Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
60df122
feat(gateway): document support for Gemini AI Studio
RATCHAW May 20, 2026
e5e16b3
Merge branch 'main' into RATCHAW/support-document-attachments
RATCHAW May 20, 2026
d5c6e5b
feat(playground): persist document uploads
RATCHAW May 20, 2026
8ca1691
Merge branch 'main' into RATCHAW/support-document-attachments
RATCHAW May 20, 2026
0093a47
fix(gateway): address PR review for document attachments
RATCHAW May 21, 2026
6951802
Merge branch 'main' into RATCHAW/support-document-attachments
RATCHAW May 21, 2026
2a57d87
fix(playground): render documents on shared chat page
RATCHAW May 21, 2026
06e8303
chore(admin): regenerate v1.d.ts for documents field
RATCHAW May 21, 2026
859dafc
Merge branch 'main' into RATCHAW/support-document-attachments
RATCHAW May 21, 2026
36cfcf3
Merge branch 'main' into RATCHAW/support-document-attachments
RATCHAW May 22, 2026
8222142
fix(gateway): accept RFC 2397 params in file_data data URL
RATCHAW May 25, 2026
ed33ca6
Merge branch 'main' into RATCHAW/support-document-attachments
RATCHAW May 25, 2026
a311253
fix(gateway): log client_error for typed pre-upstream input errors
RATCHAW May 26, 2026
2d8b7e3
docs: add document reading page
RATCHAW May 26, 2026
62156a2
docs: add changelog entry for document reading
RATCHAW May 26, 2026
34e5743
Merge branch 'main' into RATCHAW/support-document-attachments
RATCHAW May 26, 2026
b763ba1
Merge branch 'main' into RATCHAW/support-document-attachments
steebchen May 27, 2026
ddd37d6
Merge branch 'main' into RATCHAW/support-document-attachments
RATCHAW May 27, 2026
e32dab4
docs: add changelog image for document reading
RATCHAW May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions apps/api/src/routes/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const messageSchema = z.object({
content: z.string().nullable(),
images: z.string().nullable(), // JSON string
audios: z.string().nullable(), // JSON string of audio attachments
documents: z.string().nullable().optional(), // JSON string of document attachments
reasoning: z.string().nullable(), // Reasoning content
tools: z.string().nullable(), // JSON string of tool parts
metadata: z.record(z.unknown()).nullable(),
Expand Down Expand Up @@ -85,6 +86,7 @@ const orgShareSchema = z.object({
content: z.string().nullable(),
images: z.string().nullable(),
audios: z.string().nullable().optional(),
documents: z.string().nullable().optional(),
reasoning: z.string().nullable(),
tools: z.string().nullable(),
metadata: z.record(z.unknown()).nullable().optional(),
Expand All @@ -101,6 +103,7 @@ const sharedMessageSnapshotSchema = z.array(
content: z.string().nullable(),
images: z.string().nullable(),
audios: z.string().nullable().optional(),
documents: z.string().nullable().optional(),
reasoning: z.string().nullable(),
tools: z.string().nullable(),
metadata: z.record(z.unknown()).nullable().optional(),
Expand Down Expand Up @@ -135,6 +138,7 @@ const createMessageSchema = z
content: z.string().optional(),
images: z.string().optional(), // JSON string
audios: z.string().optional(), // JSON string of audio attachments
documents: z.string().optional(), // JSON string of document attachments
reasoning: z.string().optional(), // Reasoning content
tools: z.string().optional(), // Tool parts JSON
metadata: z.record(z.unknown()).optional(),
Expand All @@ -144,10 +148,11 @@ const createMessageSchema = z
data.content ??
data.images ??
data.audios ??
data.documents ??
data.reasoning ??
data.tools,
{
message: "Either content, images, or audios must be provided",
message: "Either content, images, audios, or documents must be provided",
},
);

Expand Down Expand Up @@ -712,7 +717,8 @@ chats.openapi(getChat, async (c) => {
role: message.role as "user" | "assistant" | "system",
content: message.content,
images: message.images,
audios: (message as any).audios ?? null,
audios: message.audios ?? null,
documents: message.documents ?? null,
reasoning: message.reasoning,
tools: message.tools ?? null,
metadata: message.metadata ?? null,
Expand Down Expand Up @@ -969,6 +975,7 @@ chats.openapi(shareChat, async (c) => {
content: tables.message.content,
images: tables.message.images,
audios: tables.message.audios,
documents: tables.message.documents,
Comment thread
RATCHAW marked this conversation as resolved.
reasoning: tables.message.reasoning,
tools: tables.message.tools,
metadata: tables.message.metadata,
Expand All @@ -993,6 +1000,7 @@ chats.openapi(shareChat, async (c) => {
content: message.content,
images: message.images,
audios: message.audios,
documents: message.documents,
reasoning: message.reasoning,
tools: message.tools,
metadata: message.metadata,
Expand Down Expand Up @@ -1433,6 +1441,7 @@ chats.openapi(forkSharedChat, async (c) => {
content: message.content,
images: message.images,
audios: message.audios ?? null,
documents: message.documents ?? null,
reasoning: message.reasoning,
tools: message.tools,
metadata: message.metadata ?? null,
Expand Down Expand Up @@ -1724,6 +1733,7 @@ chats.openapi(addMessage, async (c) => {
content: body.content ?? null,
images: body.images ?? null,
audios: body.audios ?? null,
documents: body.documents ?? null,
reasoning: body.reasoning ?? null,
tools: body.tools ?? null,
metadata: body.metadata ?? null,
Expand All @@ -1744,7 +1754,7 @@ chats.openapi(addMessage, async (c) => {
role: newMessage.role as "user" | "assistant" | "system",
content: newMessage.content,
images: newMessage.images,
audios: (newMessage as any).audios ?? null,
audios: newMessage.audios ?? null,
reasoning: newMessage.reasoning,
tools: newMessage.tools ?? null,
metadata: newMessage.metadata ?? null,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/internal-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const modelProviderMappingSchema = z.object({
streaming: z.boolean(),
vision: z.boolean().nullable(),
audio: z.boolean().nullable(),
document: z.boolean().nullable(),
reasoning: z.boolean().nullable(),
reasoningOutput: z.string().nullable(),
tools: z.boolean().nullable(),
Expand Down Expand Up @@ -214,6 +215,7 @@ internalModels.openapi(getModelsRoute, async (c) => {
...mapping,
discount: effectiveDiscount,
audio: sharedMapping?.audio ?? null,
document: sharedMapping?.document ?? null,
imageOutputPrice:
sharedMapping?.imageOutputPrice !== undefined
? String(sharedMapping.imageOutputPrice)
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/public-chat-shares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const sharedMessageSchema = z.object({
content: z.string().nullable(),
images: z.string().nullable(),
audios: z.string().nullable().optional(),
documents: z.string().nullable().optional(),
reasoning: z.string().nullable(),
tools: z.string().nullable(),
metadata: z.record(z.unknown()).nullable().optional(),
Expand Down
7 changes: 7 additions & 0 deletions apps/code/src/lib/api/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,7 @@ export interface paths {
content: string | null;
images: string | null;
audios?: string | null;
documents?: string | null;
reasoning: string | null;
tools: string | null;
metadata?: {
Expand Down Expand Up @@ -9207,6 +9208,7 @@ export interface paths {
content: string | null;
images: string | null;
audios: string | null;
documents?: string | null;
reasoning: string | null;
tools: string | null;
metadata: {
Expand Down Expand Up @@ -9476,6 +9478,7 @@ export interface paths {
content: string | null;
images: string | null;
audios?: string | null;
documents?: string | null;
reasoning: string | null;
tools: string | null;
metadata?: {
Expand Down Expand Up @@ -9674,6 +9677,7 @@ export interface paths {
content?: string;
images?: string;
audios?: string;
documents?: string;
reasoning?: string;
tools?: string;
metadata?: {
Expand All @@ -9697,6 +9701,7 @@ export interface paths {
content: string | null;
images: string | null;
audios: string | null;
documents?: string | null;
reasoning: string | null;
tools: string | null;
metadata: {
Expand Down Expand Up @@ -9764,6 +9769,7 @@ export interface paths {
content: string | null;
images: string | null;
audios: string | null;
documents?: string | null;
reasoning: string | null;
tools: string | null;
metadata: {
Expand Down Expand Up @@ -11608,6 +11614,7 @@ export interface operations {
streaming: boolean;
vision: boolean | null;
audio: boolean | null;
document: boolean | null;
reasoning: boolean | null;
reasoningOutput: string | null;
tools: boolean | null;
Expand Down
137 changes: 137 additions & 0 deletions apps/docs/content/features/documents.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
---
title: Document Reading
description: Learn how to send PDFs and other document data to document-capable models.
icon: FileText
---

import { Callout } from "fumadocs-ui/components/callout";

# Document Reading

LLMGateway supports sending documents (PDFs and other file types) to document-capable models using OpenAI's `file` content block format. The gateway forwards the document to the underlying provider so the model can read and reason over its contents.

## Document-Capable Models

Document input is currently supported on Google Gemini models via Google AI Studio. You can find document-capable models on the [models page with the document filter](https://llmgateway.io/models?filters=1&document=true).

## Sending a Document

Add a `file` content block to a user message. The `file_data` field must be a base64-encoded data URL that includes the document's MIME type.

```bash
curl -X POST "https://api.llmgateway.io/v1/chat/completions" \
-H "Authorization: Bearer $LLM_GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-2.5-flash",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Summarize this document."
},
{
"type": "file",
"file": {
"filename": "report.pdf",
"file_data": "data:application/pdf;base64,JVBERi0xLjQKJ..."
}
}
]
}
]
}'
```

### Content Block Fields

- **`type`**: must be `"file"`.
- **`file.filename`** _(optional)_: original filename, shown in the playground and forwarded for context.
- **`file.file_data`**: base64-encoded data URL of the form `data:<mime-type>;base64,<data>`.

<Callout type="info">
The `file.file_id` field (for referencing files uploaded via a provider's
Files API) is accepted by the schema but not currently supported by the Google
transform. Use `file_data` with an inline base64 data URL.
</Callout>

## Supported File Types

The accepted MIME types depend on the target model. Gemini models commonly support:

- `application/pdf`
- `text/plain`
- `text/html`
- `text/css`
- `text/javascript`
- `text/csv`
- `text/markdown`
- `text/xml`

If the upstream provider rejects the MIME type, the gateway surfaces a `400` error including the unsupported MIME type and the provider it was sent to. To use a different file type, encode the file with the matching MIME type in the data URL prefix.

## Encoding a File as a Data URL

Any tool that can produce base64 output works. For example, in a shell:

```bash
DATA=$(base64 -i report.pdf | tr -d '\n')
echo "data:application/pdf;base64,$DATA"
```

Or in JavaScript:

```javascript
import { readFileSync } from "node:fs";

const buffer = readFileSync("report.pdf");
const fileData = `data:application/pdf;base64,${buffer.toString("base64")}`;
```

Then pass `fileData` as the `file.file_data` value in your request.

## Multiple Documents

You can include multiple `file` blocks in a single message, optionally mixed with text and image content:

```bash
curl -X POST "https://api.llmgateway.io/v1/chat/completions" \
-H "Authorization: Bearer $LLM_GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-2.5-pro",
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": "Compare these two reports." },
{
"type": "file",
"file": {
"filename": "q1.pdf",
"file_data": "data:application/pdf;base64,JVBERi0x..."
}
},
{
"type": "file",
"file": {
"filename": "q2.pdf",
"file_data": "data:application/pdf;base64,JVBERi0x..."
}
}
]
}
]
}'
```

## Error Handling

The gateway returns `400` for the following document-related errors:

- The selected model does not support document input.
- The `file` block is missing both `file_data` and `file_id`.
- `file_data` is not a valid base64 data URL.
- The upstream provider rejects the document's MIME type for the selected model.
36 changes: 35 additions & 1 deletion apps/gateway/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";

import { UnsupportedAudioFormatError } from "@llmgateway/actions";
import {
InvalidFileContentError,
UnsupportedAudioFormatError,
UnsupportedDocumentFormatError,
} from "@llmgateway/actions";
import { redisClient } from "@llmgateway/cache";
import { db } from "@llmgateway/db";
import {
Expand Down Expand Up @@ -130,6 +134,36 @@ app.onError((error, c) => {
);
}

if (error instanceof InvalidFileContentError) {
logger.warn("Invalid file content", { message: error.message });
return c.json(
{
error: true,
status: 400,
message: error.message,
},
400,
);
}

if (error instanceof UnsupportedDocumentFormatError) {
logger.warn("Unsupported document format", {
message: error.message,
mimeType: error.mimeType,
providerTarget: error.providerTarget,
});
return c.json(
{
error: true,
status: 400,
message: error.message,
mimeType: error.mimeType,
providerTarget: error.providerTarget,
},
400,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (error instanceof HTTPException) {
const status = error.status;

Expand Down
Loading
Loading