Sane file handling with OpenRouter

OpenRouter is a model inference router: you send one LLM request, and it can route that request to a model through different upstream providers depending on availability, throughput, price, and other operational constraints.

That abstraction is useful. It is also where multimodal file handling gets weird.

For text-only requests, most provider differences are annoying but tractable. For PDF and image inputs, the differences can be request-breaking:

  1. Different upstream providers publish different file-size, page-count, and transport limits.
  2. OpenRouter’s PDF API documents the shape of the request, but not an obvious end-to-end file-size ceiling for each route.
  3. In practice, I suspect there may be an OpenRouter-side threshold around 10 MB, but I want evidence rather than vibes.

So this post is an experiment log. The goal is to build a small, repeatable test matrix for (model × provider × file type) and publish the boring details I wish were easier to find.

Starting cell

The first test cell is intentionally narrow:

DimensionValue
Modelgoogle/gemini-3.1-pro-preview
ProviderGoogle AI Studio
File typePDF
OpenRouter PDF enginenative
File transportPublic URL

Then I will change one variable at a time: first Google Vertex, then other providers, then other models and file types.

What upstream says

Google’s Gemini document-understanding docs list Gemini 3.1 Pro as supporting PDFs with:

Those are upstream limits. The question here is whether the same practical limit survives the OpenRouter route.

OpenRouter’s PDF docs support two ways to send PDFs:

They also expose a file-parser plugin with native, cloudflare-ai, and mistral-ocr engines. For this test, I want native, because the model/provider route should receive the PDF directly when native file input is available.

Method

I generated a ladder of valid PDFs at known byte sizes:

1 MiB
5 MiB
9 MiB
10 MiB
11 MiB
15 MiB
20 MiB
30 MiB
50 MiB

Each PDF contains one readable page with a marker instruction, then padding after %%EOF. That means the file is large on the wire, but the document content is tiny. This is deliberate: I am trying to isolate file transport limits from token/context limits.

Each request asks the model to read the PDF and reply with exactly:

OPENROUTER_PDF_OK

A passing test is not a benchmark of document quality. It only means: OpenRouter accepted the request, fetched the PDF, routed it to the requested provider, and the model saw enough of the PDF to read the marker.

Request shape

The OpenRouter request is roughly:

{
  "model": "google/gemini-3.1-pro-preview",
  "provider": { "only": ["Google AI Studio"] },
  "messages": [
    {
      "role": "user",
      "content": [
        {
          "type": "file",
          "file": {
            "filename": "openrouter-pdf-limit-10mib.pdf",
            "file_data": "https://.../openrouter-pdf-limit-10mib.pdf"
          }
        },
        {
          "type": "text",
          "text": "Read the attached PDF. If it is readable, reply with exactly OPENROUTER_PDF_OK and nothing else."
        }
      ]
    }
  ],
  "plugins": [
    { "id": "file-parser", "pdf": { "engine": "native" } }
  ],
  "max_tokens": 32,
  "temperature": 0
}

Reproduction

The scripts for this experiment live in:

experiments/openrouter-file-limits/

Generate PDFs:

bun experiments/openrouter-file-limits/scripts/generate-pdfs.ts

Upload them to a public R2 prefix, then run:

export OPENROUTER_API_KEY=...
export PUBLIC_BASE_URL="https://pub-xxxx.r2.dev/openrouter-file-limits"
export OPENROUTER_MODEL="google/gemini-3.1-pro-preview"
export OPENROUTER_PROVIDER="Google AI Studio"
export OPENROUTER_PDF_ENGINE="native"

bun experiments/openrouter-file-limits/scripts/run-openrouter-tests.ts

The runner writes raw JSON results under:

experiments/openrouter-file-limits/data/results/

Results so far

Two early mistakes mattered:

  1. I initially had a bad PUBLIC_BASE_URL, so OpenRouter/Google really could not fetch the file.
  2. My first generated PDFs placed the secret phrase too far to the right, so the visible page was cropped. I regenerated the probes with the marker on its own visible line.

After fixing both, google-vertex looks good through 50 MiB.

Direct OpenRouter HTTP → Google Vertex

Result file:

experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T00-57-11-787Z.json
SizeHTTP statusOpenRouter providerResult
1 MiB200Googlepass
5 MiB200Googlepass
9 MiB200Googlepass
10 MiB200Googlepass
11 MiB200Googlepass
15 MiB200Googlepass
20 MiB200Googlepass
30 MiB200Googlepass
50 MiB200Googlepass

Vercel AI SDK → OpenRouter → Google Vertex

Result file:

experiments/openrouter-file-limits/data/results/vercel-ai-sdk-2026-04-24T00-55-26-578Z.json

This tests an extra translation layer: AI SDK ModelMessage FilePart@openrouter/ai-sdk-provider → OpenRouter → Google Vertex.

SizeResult
1 MiBpass
5 MiBpass
9 MiBpass
10 MiBpass
11 MiBpass
15 MiBpass
20 MiBpass
30 MiBpass
50 MiBpass

Above 50 MiB: Google Vertex still passed, but this needs caveats

I then tried 51, 55, 60, 75, and 100 MiB through direct OpenRouter HTTP → google-vertex.

Result file:

experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-03-01-452Z.json
SizeResult
51 MiBpass
55 MiBpass
60 MiBpass
75 MiBpass
100 MiBpass

This is surprising because Google’s published Gemini PDF API limit is 50 MB. The synthetic PDFs are intentionally padded after %%EOF, so this result may mean one of several things:

So I am treating this as: “100 MiB padded transport object passed,” not “Gemini supports arbitrary real 100 MiB PDFs.”

Google AI Studio comparison is noisy

With the same fixed PDFs and provider.only = ["google-ai-studio"], Google AI Studio produced a mix of successes, 429 upstream rate limits, and occasional 400 Cannot fetch content from the provided URL errors.

Result files:

experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-03-32-973Z.json
experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-04-45-589Z.json
experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-05-34-025Z.json

Important finding: when it does run, I observed successful Google AI Studio responses above 50 MiB, including 75 MiB and 100 MiB. But the route is currently too rate-limited/flaky for a clean matrix.

Also, passing a local Google key via the request did not appear to activate BYOK in OpenRouter’s response metadata; failures still reported is_byok: false.

Other model families: early probes

I tried two non-Gemini routes with smaller targeted ladders.

Anthropic Claude Sonnet 4.5

Route:

model: anthropic/claude-sonnet-4.5
provider.only: ["anthropic"]
pdf engine: native

Result files:

experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-05-56-767Z.json
experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-06-50-775Z.json
SizeResult
1 MiBpass
10 MiBpass
20 MiBpass
25 MiBpass
30 MiBpass
32 MiBfail: The file exceeds the maximum allowed size.
33 MiBfail: The file exceeds the maximum allowed size.
40 MiBfail: The file exceeds the maximum allowed size.
50 MiBfail: The file exceeds the maximum allowed size.

This looks like a real upstream/provider file-size ceiling around 32 MiB, with 30 MiB accepted and 32 MiB rejected.

OpenAI GPT-5.4 Nano

Route:

model: openai/gpt-5.4-nano
provider.only: ["openai"]
pdf engine: native

Result files:

experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-06-10-030Z.json
experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-07-27-446Z.json
SizeResult
1 MiBpass
10 MiBpass
50 MiBpass
75 MiBfail: File urls cannot be larger than 32MB.
100 MiBfail: File urls cannot be larger than 32MB.

The 50 MiB pass plus 75/100 MiB “32MB” failures are inconsistent enough that this needs a tighter follow-up around 31–60 MiB and probably real non-padded PDFs. It may be an OpenRouter preprocessing/native handoff difference rather than a simple provider limit.

The big surprise: “file size” is not one thing

The first probes used PDFs with a tiny valid one-page PDF body and extra bytes appended after %%EOF. That was useful for testing object transport and URL fetching, but it was not a realistic test of a large PDF’s internal structure.

So I tried two more synthetic-but-more-real constructions:

  1. Large page content stream: the extra bytes live inside the page’s /Contents stream as PDF comments.
  2. Large embedded image XObject: the PDF contains a large referenced image object, while the secret phrase remains visible text on the page.

The difference was dramatic.

Google Vertex with post-EOF padded PDFs

SizeResult
50 MiBpass
51 MiBpass
75 MiBpass
100 MiBpass

This says: Google Vertex can fetch a large object and still parse the small valid PDF body at the front.

Google Vertex with large content-stream PDFs

Result file:

experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-12-36-183Z.json
SizeResult
1 MiBpass
30 MiBfail: The document has no pages.
50 MiBfail: The document has no pages.
51 MiBfail: The document has no pages.
75 MiBfail: The document has no pages.
100 MiBfail: The document has no pages.

Google Vertex with embedded-image PDFs

Result file:

experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T01-15-21-866Z.json
SizeResult
2 MiBpass
5 MiBpass
10 MiBpass
15 MiBfail: The document has no pages.
20 MiBfail: The document has no pages.
25 MiBfail: The document has no pages.

That is the most important finding so far: the practical limit is not just HTTP object size. It depends on what the PDF bytes are.

A padded 100 MiB PDF can pass because the extra bytes are irrelevant to the parser. A 15 MiB PDF with a large embedded image can fail because those bytes are part of the document graph the parser/model ingestion path actually needs to process.

The error message is also misleading. The document has no pages sounds like a malformed or empty PDF, but the same generator produces smaller PDFs that pass. So this may be a parser failure mode rather than a literal page-count problem.

Standards-compliant pdf-lib PDFs

To get away from hand-written PDF structure, I added a generator using pdf-lib. These PDFs are boring many-page text documents with normal pages, fonts, metadata, and content streams. The secret phrase appears near the top of every page.

Generator:

experiments/openrouter-file-limits/scripts/generate-pdflib-pdfs.ts

Google Vertex results:

experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T06-22-31-102Z.json
experiments/openrouter-file-limits/data/results/openrouter-2026-04-24T06-23-30-533Z.json
SizePagesResult
1 MiB50pass
10 MiB450pass
15 MiB675fail: The document has no pages.
20 MiB900fail: The document has no pages.
25 MiB1,125fail: The document has no pages.
30 MiB1,350fail: The document has no pages.
50 MiB2,250fail: The document has no pages.

This strengthens the finding. The earlier 100 MiB padded-PDF success does not mean real 100 MiB PDFs work. For normal many-page generated PDFs, Google Vertex passed 10 MiB and failed at 15 MiB in this run.

The failure message is still odd. These PDFs do have pages, and the smaller PDFs from the same generator pass. So The document has no pages appears to be a generic/incorrect upstream parser failure at this size/complexity boundary.

Gemini Files API: upload succeeds, OpenRouter cannot reuse the URI

Given the pdf-lib results, the next question was whether Gemini’s Files API changes the failure mode. The Files API decouples file upload from generation and is recommended by Google for larger PDFs and reusable documents.

I added a Files API runner:

experiments/openrouter-file-limits/scripts/run-gemini-files-api-tests.ts

It uploads a generated PDF with GOOGLE_AI_STUDIO_API_KEY, waits for the file to become ACTIVE, then tries two paths:

  1. direct Gemini generateContent using the returned https://generativelanguage.googleapis.com/v1beta/files/... URI;
  2. OpenRouter using that same Files API URI as the PDF file_data.

The AI Studio key works for Files API

A 1 MiB pdf-lib PDF uploaded successfully:

state=ACTIVE
uri=https://generativelanguage.googleapis.com/v1beta/files/...
mime=application/pdf

So GOOGLE_AI_STUDIO_API_KEY is sufficient for the Gemini Files API.

Direct Gemini Files API works for small PDFs

With gemini-3-flash-preview, the 1 MiB pdf-lib PDF passed through direct Gemini Files API:

direct PASS text="OPENROUTER_PDF_OK"

Result file:

experiments/openrouter-file-limits/data/results/gemini-files-api-2026-04-24T06-39-43-741Z.json

OpenRouter cannot read a file uploaded with my Gemini key

Passing the Gemini Files API URI to OpenRouter failed:

403 PERMISSION_DENIED
You do not have permission to access the File ... or it may not exist.

This happened even when I attempted to pass the Google API key through the OpenRouter request body. The response still indicated OpenRouter’s Google AI Studio route was not using my uploaded-file context.

My interpretation: Gemini Files API URIs are scoped to the API key/project that uploaded them. OpenRouter’s upstream Google request cannot access a file uploaded by my local key unless OpenRouter is actually authenticated to Google with that same key in a way that applies to Files API access.

So this does not appear to be a viable OpenRouter workaround today:

Upload PDF to Gemini Files API locally → pass generativelanguage.googleapis.com/v1beta/files/... URI to OpenRouter

At least not with the request shape I tested.

Files API separates upload limits from model context limits

I also uploaded larger pdf-lib PDFs directly to Gemini Files API. The 10 MiB and 15 MiB PDFs both uploaded and became ACTIVE, but direct generation failed with:

The input token count exceeds the maximum number of tokens allowed (1048576).

Result file:

experiments/openrouter-file-limits/data/results/gemini-files-api-2026-04-24T06-41-13-196Z.json

This is another important distinction:

So the Files API helps separate upload/admission from generation. It does not bypass context limits.

This makes the overall model clearer:

LayerQuestionExample failure
TransportCan OpenRouter/provider fetch or receive the bytes?URL fetch error, request too large
File storage/admissionCan the provider store/process the file object?Files API upload failure
Parser/tokenizerCan the provider turn the PDF into model input?The document has no pages
Model contextCan the resulting document fit in the model context?input token count exceeds the maximum
GenerationCan the model answer from the processed document?empty/length-limited answer

Download the PDF artifacts

The generated PDFs are public in R2 so readers can rerun the tests. The most useful standards-compliant pdf-lib artifacts are:

ArtifactSizePagesURL
openrouter-pdflib-pdf-limit-1mib.pdf~1 MiB50download
openrouter-pdflib-pdf-limit-10mib.pdf~10 MiB450download
openrouter-pdflib-pdf-limit-15mib.pdf~15 MiB675download
openrouter-pdflib-pdf-limit-20mib.pdf~20 MiB900download
openrouter-pdflib-pdf-limit-25mib.pdf~25 MiB1,125download
openrouter-pdflib-pdf-limit-30mib.pdf~30 MiB1,350download
openrouter-pdflib-pdf-limit-50mib.pdf~50 MiB2,250download

For comparison, the original padded transport probes are also available at the same prefix with names like openrouter-pdf-limit-50mib.pdf, and the embedded-image probes use names like openrouter-image-pdf-limit-10mib.pdf.

What I am looking for

There are a few possible outcomes:

Once I have the first result set, I will rerun the exact same ladder against Google Vertex and expand the matrix from there.