anomalyco / anomalyco/opencode

zai: PDF attachments always fail with 400 [1210] — opencode sends `file_data`, Z.AI accepts PDF only via `file_url`/`file_id`

Open
#49,237 2 comments 0 reactions 1 assignee View on GitHub

@kitlangton is already working on this.

Since Sep 15, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Summary

Reading any PDF with the read tool permanently fails a zai session. OpenCode maps application/pdf attachments to the OpenAI-compatible file.file_data (base64 data-URI) transport, which is the one PDF transport Z.AI's /paas/v4/chat/completions does not accept. The request is rejected with HTTP 400 code 1210 and the turn dies.

glm-5.3-flash reads PDFs fine — via file_url or file_id. Only file_data is rejected. So this is a transport-mapping defect, not a provider limitation.

Same class as #44486 (OpenAI Responses, input_file.file_data in tool-result replay), which was fixed.

Environment

  • opencode version: 1.18.31 (Homebrew, /usr/local/Cellar/opencode/1.18.31)
  • OS: macOS 15.7.9, x86_64, Darwin 24.6.0
  • Terminal: WarpTerminal v0.2026.09.09.08.26.stable_02, shell /bin/zsh
  • Provider / model: zai / glm-5.3-flash (variant high)
  • Active plugins: none (~/.config/opencode/plugin does not exist; config is {"$schema": ...} only)
  • Provider mapping (models.json): npm: @ai-sdk/openai-compatible, api: https://api.z.ai/api/paas/v4

Reproduction

  1. opencode with zai/glm-5.3-flash.
  2. Ask it to read any local PDF, so the read tool attaches it.
  3. The next assistant turn fails:
level=ERROR message="stream error" providerID=zai modelID=glm-5.3-flash
  error.error="AI_APICallError: Failed to parse the file. Please check its accessibility and format."

The tool part itself succeeds (output: "PDF read successfully", attachments[0].mime = application/pdf). The failure happens when that attachment is hoisted into the synthetic user message and converted for the provider.

Root cause

MessageV2.toModelMessages hoists tool-result media into a synthetic user message. The H() allow-predicate only returns true for @ai-sdk/anthropic, @ai-sdk/openai, @ai-sdk/amazon-bedrock*, @ai-sdk/google-vertex/anthropic, @ai-sdk/xai (images) and @ai-sdk/google (gemini-3). For @ai-sdk/openai-compatible it returns false, so the PDF becomes a file part on a user message.

@ai-sdk/openai-compatible then hardcodes:

if (part.mediaType === "application/pdf") {
  return { type: "file", file: {
    filename: part.filename ?? "document.pdf",
    file_data: `data:application/pdf;base64,${convert(part.data)}`
  }}
}

Z.AI rejects exactly that shape for PDFs.

Evidence — transport matrix

Direct calls to https://api.z.ai/api/paas/v4/chat/completions, model: glm-5.3-flash, no opencode in the circuit:

Transport Result
file.file_data = data:application/pdf;base64,… (what opencode sends) 400 1210 "Failed to parse the file."
file.file_data, raw base64, no data-URI prefix 400 1210
file.file_data, with/without filename 400 1210
file.file_url = https://cdn.bigmodel.cn/static/demo/demo1.pdf 200 — PDF read correctly
file.file_id from POST /paas/v4/files purpose=user_data 200 — PDF read correctly
file.file_id from purpose=agent 400 1210
same bytes rendered to PNG via image_url 200

Controls that rule out alternative explanations:

  • Not the file. A 615-byte PDF I generated locally fails identically; the same bytes as image_url return 200.
  • Not the account/key/network. Success and failure alternate on the same key, endpoint and TLS path within the same minute.
  • Not thinking. Adding thinking:{type:"enabled"} with reasoning_effort low/high/max does not change the outcome. (Note: thinking:disabled does return 1210, with a different message — "This model always engages in thinking and cannot be disabled" — see #47872. 1210 is a generic "invalid parameter" bucket; the discriminator is the message field.)
  • Not max_tokens/temperature. image_url + max_tokens:512 + temperature:0.6 returns 200.

What the spec says

Z.AI's OpenAPI for POST /paas/v4/chat/completions (VisionMultimodalContentItemFile) documents three members of file, and attaches the supported-format list only to file_url:

  • file_url"Only GLM-5.3-Flash, GLM-4.6V, GLM-4.5V supported. Supports formats such as pdf, txt, word, jsonl, xlsx, pptx"
  • file_id"The ID returned by the File Upload API, only GLM-5.3-Flash supported"
  • file_data"Base64 file content in the format data:<MIME>;base64,<BASE64_DATA>"no format list

The docs' own "File Visual Example" uses file_url with a .pdf on glm-5.3-flash. So PDF-over-file_data was never promised; opencode's JSON is schema-conformant but picks the wrong transport.

Upload endpoint: POST /paas/v4/files, multipart, purpose enum user_data | agent. Only user_data produces a file_id the chat endpoint accepts for PDF.

Minimal repro

export ZAI_API_KEY=...   # https://z.ai/manage-apikey/apikey-list
python3 - <<'EOF'
import base64, json, os, urllib.request
KEY = os.environ["ZAI_API_KEY"]
# any valid PDF
pdf = base64.b64encode(open("small.pdf","rb").read()).decode()
body = {"model":"glm-5.3-flash","stream":False,"messages":[{"role":"user","content":[
    {"type":"file","file":{"filename":"document.pdf",
                           "file_data":"data:application/pdf;base64,"+pdf}},
    {"type":"text","text":"What is this?"}]}]}
req = urllib.request.Request("https://api.z.ai/api/paas/v4/chat/completions",
    data=json.dumps(body).encode(),
    headers={"Authorization":"Bearer "+KEY,"Content-Type":"application/json"})
try:
    print(urllib.request.urlopen(req, timeout=120).status)
except urllib.error.HTTPError as e:
    print(e.code, e.read().decode())
# -> 400 {"error":{"code":"1210","message":"Failed to parse the file. ..."}}
EOF

Swap the content part for {"type":"file","file":{"file_url":"https://cdn.bigmodel.cn/static/demo/demo1.pdf"}} to get a 200 against the same key and model.

Suggested fix

For zai (and any provider whose API is api.z.ai/api/paas/v4), don't emit file_data for application/pdf. Either:

  1. Upload the attachment to POST /paas/v4/files with purpose=user_data and send {"type":"file","file":{"file_id": "<id>"}} — works for local files, which is the read-tool case; or
  2. Send file_url when the attachment already has a reachable URL.

Option 1 needs a small cache keyed by content hash so a PDF is not re-uploaded on every step of the agent loop.

Worth noting for whoever picks this up: the file/image_url mutual exclusion in the same message — "Not support passing both the file and image_url or video_url parameters at the same time" — means a turn mixing a PDF and a screenshot will fail regardless of transport.

I'm happy to send a PR if the approach sounds right.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.