Skip to content

feat(responses): fetch and inline input_file file_url for document inputs - #1168

Open
JoshC8C7 wants to merge 2 commits into
mainfrom
josh/cor-427-responses-input-file-dwctl
Open

feat(responses): fetch and inline input_file file_url for document inputs#1168
JoshC8C7 wants to merge 2 commits into
mainfrom
josh/cor-427-responses-input-file-dwctl

Conversation

@JoshC8C7

Copy link
Copy Markdown
Contributor

feat(responses): fetch and inline input_file file_url for document inputs

Summary

This is the dwctl half of COR-427 (PDF / input_file support on
/ai/v1/responses). The onwards half makes the gateway able to carry and map
input_file content; this half makes a file_url actually work end to end in
strict mode.

A Chat Completions file content part can only carry inline bytes
(file_data) or a provider-owned file_id - it has no URL form (unlike
image_url). So an input_file.file_url cannot survive the
Responses-to-Chat-Completions conversion as a URL: the bytes have to be fetched
and inlined first. dwctl is the only layer that can do that fetch (onwards is a
pure transform with no outbound I/O).

A new middleware rewrites each input_file content part in a /responses
request body before it reaches onwards:

  • file_url -> fetch through the hardened fetcher (the same SSRF-protected
    fetcher used for image URLs: DNS pinning, IP deny-list, redirect
    re-validation, MIME / size caps) and inline the bytes as a base64 file_data
    data URI, dropping file_url.
  • file_data -> left untouched; onwards maps it to a Chat Completions file
    part.
  • file_id (with no file_data) -> rejected with a clear 400. dwctl customers
    never upload to the upstream provider, so a bare file_id always refers to
    dwctl's own file store, which is not yet wired to resolve Responses inputs.
    Returning a documented error beats forwarding an id the provider can never
    resolve.

Changes

  • New crate::file_input module: FileInputConfig (enabled flag + a
    document-MIME fetcher policy, default application/pdf) and
    normalize_input_files, which walks input[*].content[*] and rewrites
    input_file parts. The fetch is injected as a callback (mirroring
    image_normalizer::walker::substitute_with) so the rewrite logic is testable
    without a live fetch.
  • New inference::file_input_middleware: reads the body once, runs
    normalize_input_files with the real hardened fetcher, restores / reserialises
    the body, and maps failures to file-specific 4xx/5xx codes
    (input_file_rejected, input_file_url_unfetchable, ...). A raw-body
    substring fast-path skips the JSON parse entirely unless the body actually
    mentions input_file, and a body with nothing to rewrite is restored
    byte-for-byte (no reserialise, no Content-Length change).
  • Reuses image_normalizer::fetcher::ImageFetcher and NormalizeError
    verbatim - only the MIME allow-list differs (documents instead of images).
  • Config gains a file_input section; the middleware is layered onto the
    onwards router next to the image normaliser.

Behaviour by config

  • file_input.enabled = false (default): the middleware is a pass-through. An
    input_file.file_url flows to onwards untouched and onwards returns a clear
    strict-mode error rather than silently dropping it. Outbound fetch of
    user-supplied URLs is opt-in, mirroring the image normaliser.
  • file_input.enabled = true: file_url is fetched and inlined, so a PDF by
    URL works end to end through strict mode.

Tests

crate::file_input (rewrite logic): fetch+inline file_url to base64
file_data, file_data passthrough (asserts no fetch), bare file_id
rejected, no-input_file no-op, and a real-fetcher SSRF rejection
(link-local). inference::file_input_middleware (HTTP layer): file_id 400,
link-local file_url 400, inline file_data passthrough, no-input_file
verbatim passthrough, disabled passthrough, and non-/responses paths ignored.
cargo test -p dwctl --lib file_input passes (11 tests).

Note on test strategy: the SSRF guard hard-denies loopback, so a local mock
server cannot be fetched. The success path is covered by injecting a stub fetch
(the production fetcher is exercised separately against a link-local address to
prove the guard fires).

Dependency / deploy ordering

This change has no compile-time dependency on the onwards half: dwctl rewrites
the JSON body (producing input_file.file_data) and forwards it, so it builds
against the current released onwards. The runtime dependency is that onwards
must understand input_file.file_data for the inlined document to reach the
model - that is the onwards half of COR-427.

So this is safe to merge independently: the feature is off by default
(file_input.enabled = false), and it is dormant until both halves are
deployed and the flag is enabled. Do not enable file_input in an environment
until the onwards input_file change is live there.

(Locally this branch is tested end to end against the onwards branch via a
[patch.crates-io] override in the workspace Cargo.toml. That override is
intentionally NOT part of this PR.)

Follow-up

dwctl-owned file_id resolution: intercept a dwctl-issued file_id, load the
bytes from dwctl's file store, and inline them as file_data (the same shape
this middleware already produces for file_url). That turns the file_id 400
into a working path and is the remaining piece of COR-427.

…puts

Chat Completions has no URL form for files, so an input_file.file_url cannot survive the strict Responses to Chat Completions conversion as a URL. dwctl fetches the bytes and inlines them so the document reaches the model.

New file_input module + inference::file_input_middleware: for /responses requests, file_url is fetched through the hardened SSRF-protected fetcher and inlined as base64 file_data; inline file_data passes through; a bare dwctl-owned file_id is rejected with a clear 400 (it can never resolve upstream). A raw-body fast-path skips the JSON parse unless the body mentions input_file.

Off by default (file_input.enabled = false); dormant until the onwards half of COR-427 is deployed.

COR-427.
Copilot AI review requested due to automatic review settings June 22, 2026 16:44
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 22, 2026

Copy link
Copy Markdown

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

Latest commit: 91f18c1
Status: ✅  Deploy successful!
Preview URL: https://41ec3338.control-layer.pages.dev
Branch Preview URL: https://josh-cor-427-responses-input.control-layer.pages.dev

View logs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds opt-in /responses request-body normalisation in dwctl to support input_file.file_url by fetching document bytes via the existing hardened SSRF-protected fetcher and inlining them as base64 file_data, plus config wiring and tests.

Changes:

  • Introduces crate::file_input (FileInputConfig, normalize_input_files) to rewrite input[*].content[*] input_file parts (file_url → fetched+inlined file_data; bare file_id → 400).
  • Adds inference::file_input_middleware to apply that rewrite on /responses requests and map failures to file-specific 4xx/5xx error codes, with a substring fast-path to avoid JSON parsing when possible.
  • Wires the middleware into the onwards router and extends Config / test configs to include the new file_input section (default disabled).

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
dwctl/src/test/utils.rs Extends test config builder to include file_input defaults.
dwctl/src/lib.rs Layers the new file_input middleware onto the onwards router.
dwctl/src/inference/mod.rs Exposes the new file_input_middleware module.
dwctl/src/inference/file_input_middleware.rs Implements /responses body rewriting + error mapping + tests.
dwctl/src/file_input/mod.rs Adds core input_file rewrite logic + config + tests.
dwctl/src/db/handlers/api_keys.rs Updates test-only config construction to include file_input.
dwctl/src/config.rs Adds file_input section to global config with defaults.

Comment thread dwctl/src/lib.rs
Comment on lines +1570 to +1574
let onwards_router = {
let cfg = state.current_config();
let fetcher = std::sync::Arc::new(crate::image_normalizer::fetcher::ImageFetcher::new(cfg.file_input.fetcher.clone()));
let file_input_state = crate::inference::file_input_middleware::FileInputMiddlewareState {
enabled: cfg.file_input.enabled,
Comment on lines +187 to +191
/// Cheap pre-check on the raw body so we only pay for a JSON parse when an
/// `input_file` content part might be present. A false positive (the substring
/// appears elsewhere, e.g. in user text) just falls through to the normal walk,
/// which is a no-op; a false negative is impossible because every `input_file`
/// part carries the literal `"input_file"` type discriminator.
Comment on lines +68 to +72
fn default_file_fetcher() -> FetcherConfig {
FetcherConfig {
allowed_mime: vec!["application/pdf".to_string()],
..FetcherConfig::default()
}

@doubleword-code doubleword-code Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR introduces middleware to fetch and inline input_file.file_url as base64 file_data for /responses requests, enabling document inputs to survive the Responses→Chat Completions conversion. The feature is gated behind file_input.enabled = false by default.

Verdict: Needs significant changes before approval.

The implementation is well-structured with appropriate SSRF protections via the hardened ImageFetcher, but there are critical gaps that would cause document inputs to be dropped in production:

  1. transition.rs unconditionally drops ALL input_file parts (lines 349-351) - even after the middleware converts file_urlfile_data, the multi-step engine's transition function still discards these parts because it checks only type == "input_file", not whether file_data is present.

  2. Daemon path lacks file_input normalization - Flex requests processed by DwctlRequestProcessor don't go through HTTP middleware; the processor only handles image token signing, not input_file.file_url fetching.

  3. Incomplete Chat Completions conversion - Per OpenAI docs, Chat Completions uses type: "file" with nested file: {file_data | file_id}, not type: "input_file". The transition code needs updating to perform this conversion.

Research notes

  • OpenAI File Inputs docs: Shows Chat Completions shape is {type: "file", file: {file_data | file_id}}, while Responses API uses {type: "input_file", file_data | file_id | file_url}. The comment in transition.rs:881 stating "input_file has no chat-completions representation" is outdated.
  • SSRF protections in ImageFetcher (dwctl/src/image_normalizer/fetcher.rs) include DNS pinning, IP deny-list, redirect re-validation, MIME allow-list, and size caps - these are correctly reused for document fetching.

Suggested next steps

  1. Update transition.rs to convert input_file with file_data or file_id to Chat Completions format: {type: "file", file: {...}}
  2. Add file_input normalization to DwctlRequestProcessor for flex request handling (similar to image JIT signing)
  3. Ensure both middleware and daemon paths normalize consistently before considering this feature complete

General findings

Architecture concern: Middleware vs. Engine coverage

The file_input_middleware only applies to HTTP requests flowing through onwards_router. Multi-step requests handled by DwctlRequestProcessor::process() bypass HTTP middleware entirely. This creates inconsistent behavior:

Request Type Path file_input Handling
Realtime non-background middleware → onwards ✅ Processed
Realtime background middleware → spawned task → onwards ✅ Processed
Flex tool-free daemon → HTTP loopback → middleware → onwards ✅ Processed
Flex with tools daemon → ResponseLoopHttpClient → internal loop ❌ Dropped by transition.rs

General findings (auto-demoted from inline due to pre-validation)

  • Blocking dwctl/src/inference/engine/transition.rs:349 — This branch unconditionally drops ALL input_file content parts, regardless of whether they contain file_url (unfetchable) or file_data (inline bytes).
    • (demoted: path "dwctl/src/inference/engine/transition.rs" is not in the PR diff)
  • Blocking dwctl/src/inference/engine/transition.rs:881 — This comment is factually incorrect and misleading. Per OpenAI's File Inputs documentation, Chat Completions DOES support file inputs via:
    • (demoted: path "dwctl/src/inference/engine/transition.rs" is not in the PR diff)
  • Blocking dwctl/src/inference/engine/processor.rs:191 — The daemon's DwctlRequestProcessor::process() only performs JIT image signing for dw-img:// tokens. It does NOT handle input_file.file_url normalization.
    • (demoted: path "dwctl/src/inference/engine/processor.rs" is not in the PR diff)
  • Non-blocking dwctl/src/inference/file_input_middleware.rs:183 — The path matching only checks for /responses suffix. This works for the current routing where onwards_router is nested at /ai/v1 (so paths like /ai/v1/responses become /responses in the middleware), but consider adding a comment referencing how the router nesting affects path matching.
    • (demoted: code self-check failed at dwctl/src/inference/file_input_middleware.rs:183: diff has fn path_accepts_files(path: &str) -> bool {, model claimed path.ends_with("/responses"))

/// appears elsewhere, e.g. in user text) just falls through to the normal walk,
/// which is a no-op; a false negative is impossible because every `input_file`
/// part carries the literal `"input_file"` type discriminator.
fn contains_input_file(body: &[u8]) -> bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The byte-scan optimization is clever, but windows().any() allocates an iterator. For the common case (no input_file), this is fine. However, consider that memchr::memmem::find() or even body.contains(b"input_file") (if available on [u8]) would be more efficient.

That said, this is a minor performance consideration and the current implementation is correct and readable. The false-positive tolerance (substring appearing in user text) is correctly documented.

No action required unless profiling shows this is on a hot path.

/// The image fetcher's defaults are sound for documents too (size cap,
/// timeouts, redirect re-validation, IP deny-list); only the accepted MIME
/// types differ.
fn default_file_fetcher() -> FetcherConfig {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: The default MIME allow-list only includes application/pdf. This is reasonable for documents, but consider whether other common document formats should be supported:

  • text/plain (.txt files)
  • application/vnd.openxmlformats-officedocument.wordprocessingml.document (.docx)
  • text/csv (.csv files)

Per OpenAI's docs, they support many file types including spreadsheets, presentations, and code files. The current restriction to PDF-only may be intentional for the initial rollout, but it's worth documenting this design choice.

Suggested fix: Add a comment explaining why only PDF is enabled by default, or expand the list if broader support is intended.

Comment thread dwctl/src/file_input/mod.rs Outdated
if let Some(url) = part.get("file_url").and_then(Value::as_str).map(str::to_string) {
let (mime, bytes) = fetch(url).await?;
let data_uri = format!("data:{};base64,{}", mime, B64.encode(&bytes));
let obj = part.as_object_mut().expect("input_file content part is an object");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: Using .expect() here assumes the JSON structure is valid after checking part.get("type"). This is generally safe since malformed JSON would fail earlier parsing, but consider using .ok_or_else() to return a proper NormalizeError::BadInput instead of panicking.

In practice, if part isn't an object at this point, the request body is corrupted and a panic may be acceptable. However, consistent error handling throughout the module would improve robustness.

Suggested fix:

let obj = part.as_object_mut().ok_or_else(|| {
    NormalizeError::BadInput("input_file content part is not a JSON object".to_string())
})?;

Comment thread dwctl/src/config.rs
@@ -200,6 +200,13 @@ pub struct Config {
/// shape and defaults.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Minor wording issue - this doc comment fragment seems incomplete. The full context is:

/// `image_normalizer` configuration section. See
/// `crate::image_normalizer::ImageNormalizerConfig` for the full
/// shape and defaults.

The new file_input config's doc comment follows the same pattern but could be slightly clearer.

No action required - the meaning is clear from context.

Addresses PR review. input_file previously only worked on the single-step proxy path: the multi-step warm path and flex daemon bypass the file_input layer, and the multi-step translator dropped input_file.

- transition.rs: map input_file{file_data|file_id} to a Chat Completions {type: file, file: {...}} part instead of dropping it.
- inference middleware: inline input_file.file_url (via the hardened fetcher) on the warm-path and flex branches before dispatch/persist, so the document survives to the model on every path. Realtime stays on the file_input layer.
- file_input: return a NormalizeError instead of panicking on a malformed part; document the PDF-only MIME default and base64 size inflation; soften the fast-path comment (a JSON-escaped discriminator is best-effort, not impossible).
@JoshC8C7

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review - addressed the coverage gap and the nits.

Coverage (the main one)

input_file now works on all paths, not just single-step:

  • transition.rs: maps input_file{file_data|file_id} to a Chat Completions {"type":"file","file":{...}} part instead of dropping it.
  • inference middleware: inlines input_file.file_url (via the hardened SSRF fetcher) on the warm-path (multi-step / tools) and flex branches, before the request is dispatched / persisted. The flex job is persisted with file_data already inlined, so the daemon needs no extra fetching. Realtime continues to use the file_input layer.

So multi-step and flex now carry the document end to end.

Nits

  • file_input/mod.rs: .expect() -> returns NormalizeError::BadInput on a malformed part.
  • Documented the PDF-only MIME default (operators can widen allowed_mime) and the ~33% base64 body inflation (keep max_bytes aligned with upstream limits).
  • Softened the fast-path comment: it is best-effort, not a correctness boundary. A JSON-escaped discriminator skips normalisation and the file_url flows to onwards, which returns a clear error (never a silent drop).
  • memchr micro-opt and the config doc-wording: left as-is (no functional change).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants