Skip to content

feat(gateway): support PDF file attachments - #2336

Closed
steebchen wants to merge 3 commits into
mainfrom
pdf-attachment-support
Closed

steebchen wants to merge 3 commits into
mainfrom
pdf-attachment-support

Conversation

@steebchen

@steebchen steebchen commented May 19, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Accept OpenAI-style { type: "file", file: { file_data, filename } } content parts on /v1/chat/completions
  • Anthropic: emit native document blocks
  • Google (AI Studio / Vertex): emit inline_data with mime_type: application/pdf
  • OpenAI: pass through unchanged for chat completions; convert to input_file for the Responses API
  • New processFileUrl / processFileContent helpers (data URL + https) with a 32 MB cap, allowlisted to application/pdf
  • Added e2e test (apps/gateway/src/files.e2e.ts) that sends a small canary PDF fixture to Claude, Gemini, and GPT and asserts the model echoes the canary string

Test plan

  • pnpm test:e2e --filter gateway -- files.e2e with provider keys configured for anthropic, google-ai-studio, and openai
  • Manual: send a PDF via data:application/pdf;base64,... to each of the three providers through /v1/chat/completions
  • Confirm a non-PDF MIME (e.g. text/plain) is rejected by processFileUrl

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • PDF file upload support in chat messages (include filename, file ID, or base64 data) with MIME/type checks and size limits; supported across multiple model integrations.
    • File content is transformed for provider APIs so models that accept documents can process uploaded PDFs.
  • Tests

    • New end-to-end tests validating PDF upload, processing, and expected response content.

Review Change Stack

Accept OpenAI-style `{type: "file", file: {file_data, filename}}` content
parts on /v1/chat/completions and forward to Anthropic (document blocks),
Google (inline_data application/pdf), and OpenAI (pass-through + Responses
API input_file). Adds an e2e test that asks Claude, Gemini, and GPT to read
a canary PDF fixture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 19, 2026 20:21
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented May 19, 2026 •

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 29d7e660-7f6a-4799-a7dd-0782880598af

📥 Commits

Reviewing files that changed from the base of the PR and between a51624a and 59abe19.

📒 Files selected for processing (1)
  • apps/gateway/src/files.e2e.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/gateway/src/files.e2e.ts

Walkthrough

Adds PDF file attachment support end-to-end: new message types, file URL parsing/validation, provider transformers (Anthropic/Google/Responses), gateway schema update to accept file content, response conversion, and an E2E test exercising PDF uploads.

Changes

File upload feature

Layer / File(s) Summary
Message content type system
packages/models/src/types.ts
New FileContent and DocumentContent interfaces; MessageContent union extended; isFileContent type guard added.
File URL processing and validation
packages/actions/src/process-file-url.ts, packages/actions/src/index.ts
Implements processFileUrl (data: and network URLs) with PDF-only MIME checks, size limits, base64 encoding, and plan-aware error messages; adds processFileContent wrapper and re-exports from package root.
Gateway completions schema update
apps/gateway/src/chat/schemas/completions.ts
Adds type: "file" message content to completionsRequestSchema with optional file_data, filename, and file_id.
Provider message transformations
packages/actions/src/transform-anthropic-messages.ts, packages/actions/src/transform-google-messages.ts, packages/actions/src/prepare-request-body.ts
Anthropic transformer maps FileContent to document blocks; Google transformer maps to inline_data; Responses API mapping emits input_file. Transformers call processFileContent to obtain base64 data and MIME type.
Response conversion from Responses API
apps/gateway/src/responses/tools/convert-responses-to-chat.ts
Converts Responses API input_file items back to chat file content, preserving available file_data/file_url/file_id/filename.
E2E test for file uploads
apps/gateway/src/files.e2e.ts, .gitignore
Adds E2E tests that read a local PDF fixture (ignored via .gitignore), seed test project/API key, post completions with file attachment across PDF-capable models, and assert 200 plus canary string in responses.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Gateway as /v1/chat/completions
  participant Transformer
  participant FileProcessor as processFileContent
  participant Provider as LLM Provider
  Client->>Gateway: POST with FileContent (file_data or URL)
  activate Gateway
  Gateway->>Gateway: Validate schema (file variant)
  Gateway->>Transformer: Transform message content
  activate Transformer
  Transformer->>FileProcessor: processFileContent(file)
  activate FileProcessor
  alt data: URL
    FileProcessor->>FileProcessor: Parse MIME & extract base64
    FileProcessor->>FileProcessor: Validate MIME (PDF-only)
  else network URL
    FileProcessor->>FileProcessor: Fetch URL (HTTPS if prod)
    FileProcessor->>FileProcessor: Validate content-type & size
    FileProcessor->>FileProcessor: Convert bytes to base64
  end
  FileProcessor-->>Transformer: Return {data, mimeType}
  deactivate FileProcessor
  Transformer->>Transformer: Convert to provider format (document/inline_data/input_file)
  Transformer-->>Gateway: Provider-formatted message
  deactivate Transformer
  Gateway->>Provider: Send formatted request
  activate Provider
  Provider-->>Gateway: Response
  deactivate Provider
  Gateway-->>Client: 200 OK
  deactivate Gateway
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(gateway): support PDF file attachments' accurately summarizes the main change—adding PDF file attachment support to the gateway's chat completions endpoint across multiple providers (Anthropic, Google, OpenAI).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pdf-attachment-support

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI 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.

Pull request overview

This PR adds end-to-end support for PDF “file” content parts in /v1/chat/completions, transforming OpenAI-style { type: "file", file: { file_data, filename } } inputs into provider-native formats for Anthropic and Google, and adding Responses API bridging logic plus an E2E test/fixture support.

Changes:

  • Extend shared message content types to include file input parts and an Anthropic document block representation.
  • Add processFileUrl / processFileContent helpers (data URL + fetched URL → base64 + MIME) and wire them into Anthropic/Google transformers.
  • Add Responses API mapping (file ↔ input_file) and an E2E test that exercises PDF input across providers.

Reviewed changes

Copilot reviewed 9 out of 11 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/models/src/types.ts Introduces FileContent/DocumentContent types and isFileContent guard.
packages/actions/src/transform-google-messages.ts Adds support for converting file parts into Google inline_data blocks.
packages/actions/src/transform-anthropic-messages.ts Adds support for converting file parts into Anthropic document blocks.
packages/actions/src/process-file-url.ts New helper to validate/fetch/size-limit and base64-encode PDF attachments.
packages/actions/src/prepare-request-body.ts Adds file → input_file transformation for OpenAI Responses API payloads.
packages/actions/src/index.ts Re-exports the new file-processing helper.
apps/gateway/src/responses/tools/convert-responses-to-chat.ts Adds input_file → file conversion when converting Responses input to chat messages.
apps/gateway/src/files.e2e.ts Adds an E2E test that sends a small PDF fixture and asserts the canary is echoed.
apps/gateway/src/chat/schemas/completions.ts Extends request validation schema to allow file content parts.
.gitignore Allows committing the PDF test fixture despite a global *.pdf ignore.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to +29
const ALLOWED_FILE_MIME_PREFIXES = ["application/pdf"];

function isAllowedFileMime(mimeType: string): boolean {
return ALLOWED_FILE_MIME_PREFIXES.some(
(prefix) => mimeType === prefix || mimeType.startsWith(prefix),
);
Comment on lines +44 to +59
const dataUrlMatch = url.match(/^data:([^;,]+)(?:;base64)?,(.*)$/);
if (!dataUrlMatch) {
logger.warn("Invalid file data URL format provided");
throw new Error("Invalid file data URL format");
}

const [, mimeType, data] = dataUrlMatch;

if (!isAllowedFileMime(mimeType)) {
logger.warn("Unsupported MIME type in file data URL", { mimeType });
throw new Error(`Unsupported file type: ${mimeType}`);
}

const isBase64 = url.includes(";base64,");
const base64Data = isBase64 ? data : btoa(data);

Comment on lines +58 to +62
file: z.object({
file_data: z.string().optional(),
filename: z.string().optional(),
file_id: z.string().optional(),
}),
type: "file",
file: {
...(item.file_data ? { file_data: item.file_data } : {}),
...(item.file_url ? { file_data: item.file_url } : {}),
Comment on lines +292 to +304
} else if (isFileContent(content)) {
const { data, mimeType } = await processFileContent(
content.file,
isProd,
32,
userPlan,
);
parts.push({
inline_data: {
mime_type: mimeType,
data,
},
});

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/gateway/src/chat/schemas/completions.ts`:
- Around line 58-63: Update the "file" object schema (the "file: z.object({
file_data, filename, file_id })" block) so it rejects empty objects at
validation time: add a refine/superRefine to that z.object to require at least
one of file_data, filename, or file_id be present and non-empty (e.g., not
undefined/empty string), returning a validation error when all three are
missing/empty; this ensures requests like { type: "file", file: {} } fail with a
400 instead of being deferred to downstream transformers.

In `@packages/actions/src/process-file-url.ts`:
- Around line 72-80: The fetch of user-supplied url in process-file-url must be
hardened: before calling fetch(url) (in the block that uses url, isProd and
logger), validate and sanitize the URL by parsing it and rejecting non-http(s)
schemes, explicit ports outside 80/443, localhost/127.0.0.0/8, ::1, link-local
(169.254/16), private RFC1918 ranges and other non-public IPs — perform DNS
resolution of the hostname and check the resolved IP(s) are public; log the
rejected host via logger.warn and throw a clear Error. Also add a bounded
timeout and cancelation around fetch (use an AbortController with a configurable
timeout), limit redirects and response body size to avoid hanging/DoS, and
ensure errors from timeouts or blocked hosts are surfaced consistently where
response is used.
- Around line 26-29: The isAllowedFileMime function currently treats any MIME
starting with a prefix as allowed, which lets values like
"application/pdf-malicious" slip through; update isAllowedFileMime to treat the
PDF prefix specially by comparing only the base media type (strip any parameters
after ';' and trim) for exact equality with "application/pdf", while retaining
the existing startsWith behavior for other prefixes in
ALLOWED_FILE_MIME_PREFIXES; modify the logic inside isAllowedFileMime to derive
baseType = mimeType.split(';')[0].trim() and match baseType ===
'application/pdf' for the PDF entry and prefix-matching for the rest.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fa554bc1-9d83-436a-8cd7-19b1ccc11c6b

📥 Commits

Reviewing files that changed from the base of the PR and between 7fd21c9 and a51624a.

⛔ Files ignored due to path filters (1)
  • apps/gateway/src/test-fixtures/test-document.pdf is excluded by !**/*.pdf
📒 Files selected for processing (10)
  • .gitignore
  • apps/gateway/src/chat/schemas/completions.ts
  • apps/gateway/src/files.e2e.ts
  • apps/gateway/src/responses/tools/convert-responses-to-chat.ts
  • packages/actions/src/index.ts
  • packages/actions/src/prepare-request-body.ts
  • packages/actions/src/process-file-url.ts
  • packages/actions/src/transform-anthropic-messages.ts
  • packages/actions/src/transform-google-messages.ts
  • packages/models/src/types.ts

Comment on lines +58 to +63
file: z.object({
file_data: z.string().optional(),
filename: z.string().optional(),
file_id: z.string().optional(),
}),
}),

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject empty file objects at schema validation

Line 58-63 allows { type: "file", file: {} }. That defers failure to downstream transformers instead of returning a clean 400 at request validation time.

Suggested fix
 							z.object({
 								type: z.literal("file"),
-								file: z.object({
-									file_data: z.string().optional(),
-									filename: z.string().optional(),
-									file_id: z.string().optional(),
-								}),
+								file: z
+									.object({
+										file_data: z.string().optional(),
+										filename: z.string().optional(),
+										file_id: z.string().optional(),
+									})
+									.refine(
+										(f) => Boolean(f.file_data || f.file_id),
+										"file requires file_data or file_id",
+									),
 							}),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
file: z.object({
file_data: z.string().optional(),
filename: z.string().optional(),
file_id: z.string().optional(),
}),
}),
file: z
.object({
file_data: z.string().optional(),
filename: z.string().optional(),
file_id: z.string().optional(),
})
.refine(
(f) => Boolean(f.file_data || f.file_id),
"file requires file_data or file_id",
),
}),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/chat/schemas/completions.ts` around lines 58 - 63, Update
the "file" object schema (the "file: z.object({ file_data, filename, file_id })"
block) so it rejects empty objects at validation time: add a refine/superRefine
to that z.object to require at least one of file_data, filename, or file_id be
present and non-empty (e.g., not undefined/empty string), returning a validation
error when all three are missing/empty; this ensures requests like { type:
"file", file: {} } fail with a 400 instead of being deferred to downstream
transformers.

Comment on lines +26 to +29
function isAllowedFileMime(mimeType: string): boolean {
return ALLOWED_FILE_MIME_PREFIXES.some(
(prefix) => mimeType === prefix || mimeType.startsWith(prefix),
);

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tighten MIME allowlist match to exact PDF type

Line 28 currently accepts any MIME that starts with application/pdf (e.g., application/pdf-malicious), which bypasses the intended strict PDF-only gate.

Suggested fix
 function isAllowedFileMime(mimeType: string): boolean {
-	return ALLOWED_FILE_MIME_PREFIXES.some(
-		(prefix) => mimeType === prefix || mimeType.startsWith(prefix),
-	);
+	return ALLOWED_FILE_MIME_PREFIXES.includes(mimeType.toLowerCase());
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/actions/src/process-file-url.ts` around lines 26 - 29, The
isAllowedFileMime function currently treats any MIME starting with a prefix as
allowed, which lets values like "application/pdf-malicious" slip through; update
isAllowedFileMime to treat the PDF prefix specially by comparing only the base
media type (strip any parameters after ';' and trim) for exact equality with
"application/pdf", while retaining the existing startsWith behavior for other
prefixes in ALLOWED_FILE_MIME_PREFIXES; modify the logic inside
isAllowedFileMime to derive baseType = mimeType.split(';')[0].trim() and match
baseType === 'application/pdf' for the PDF entry and prefix-matching for the
rest.

Comment on lines +72 to +80
if (!url.startsWith("https://") && isProd) {
logger.warn("Non-HTTPS URL provided for file fetch in production", {
url: url.substring(0, 20) + "...",
});
throw new Error("File URLs must use HTTPS protocol in production");
}

try {
const response = await fetch(url);

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Harden remote file fetch against SSRF and hung upstream calls

Line 80 fetches user-supplied URLs directly. HTTPS-only in production is not sufficient against SSRF (private/internal targets over HTTPS), and there is no request timeout to bound latency.

Suggested hardening direction
+// Before fetch(url):
+// 1) Resolve hostname and reject private/link-local/loopback IPs.
+// 2) Optionally enforce an outbound allowlist.
+// 3) Apply an explicit timeout.
+
-const response = await fetch(url);
+const response = await fetch(url, {
+	signal: AbortSignal.timeout(10_000),
+});

Also applies to: 79-131

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/actions/src/process-file-url.ts` around lines 72 - 80, The fetch of
user-supplied url in process-file-url must be hardened: before calling
fetch(url) (in the block that uses url, isProd and logger), validate and
sanitize the URL by parsing it and rejecting non-http(s) schemes, explicit ports
outside 80/443, localhost/127.0.0.0/8, ::1, link-local (169.254/16), private
RFC1918 ranges and other non-public IPs — perform DNS resolution of the hostname
and check the resolved IP(s) are public; log the rejected host via logger.warn
and throw a clear Error. Also add a bounded timeout and cancelation around fetch
(use an AbortController with a configurable timeout), limit redirects and
response body size to avoid hanging/DoS, and ensure errors from timeouts or
blocked hosts are surfaced consistently where response is used.

@steebchen

Copy link
Copy Markdown
Member Author

superseded by #2354

@steebchen steebchen closed this May 24, 2026
@steebchen
steebchen deleted the pdf-attachment-support branch May 24, 2026 15:41
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