Repository navigation
Description
Summary
When a model is configured as text-only (modalities.input: ["text"], i.e. capabilities.input.image === false), image attachments inside tool results are still sent to the API as base64 data URLs. The API then rejects the entire request with a 400 error, which permanently breaks the session — every subsequent message replays the same image-bearing tool result.
Root cause
There are two code paths that should strip unsupported media, but only one of them checks the model's input capabilities, and the other is where tool-result images actually live:
-
unsupportedParts(packages/opencode/src/provider/transform.ts) — correctly checksmodel.capabilities.input[modality]and replaces unsupportedimage/fileparts with a text error. But it only inspects top-level parts ofusermessages. It does not look insidetool-resultparts. -
toModelMessagesEffect(packages/opencode/src/session/message-v2.ts) — decides whether to keep media embedded in a tool result or extract it into a synthetic user message, viasupportsMediaInToolResult. That function returnstruefor@ai-sdk/openai(and others), so the image stays embedded in the tool-result part — exactly whereunsupportedPartsnever looks.
The result: for providers where supportsMediaInToolResult is true, images in tool results bypass the modality gate entirely.
Reproduction
-
Configure a text-only model (e.g. via a custom OpenAI-compatible provider whose endpoint rejects image input):
"glm-5.2": { "modalities": { "input": ["text"], "output": ["text"] } }
Provider npm:
@ai-sdk/openai. -
In a session, have the agent use the
readtool on a PNG file (e.g. a screenshot). The tool result carries the image aspart.state.attachments[].url = "data:image/png;base64,...". -
The next LLM call fails with an API 400, e.g.:
Model only support text input statusCode: 400 isRetryable: false -
Every subsequent message in the session replays the same tool-result history and fails identically — the session is bricked.
Expected behaviour
capabilities.input.image === false should be honored regardless of where the image sits in the message history. Two possible fixes:
- Option A — extend
unsupportedPartsto also walk the content oftool-resultparts and strip/replace unsupported media there. - Option B — make
supportsMediaInToolResultcapability-aware: returnfalsewhenmodel.capabilities.input.imageisfalse, so the image gets extracted into a synthetic user message whereunsupportedPartscan catch it.
Option B is the smaller change and reuses the existing extraction + filtering pipeline.
Environment
- opencode 1.18.5 (Homebrew)
- Provider: custom,
npm: "@ai-sdk/openai", baseURL pointing at a Volcano Engine (Ark) Responses-API endpoint - Model:
glm-5.2,modalities.input: ["text"]
Workaround
Manually edit the session database (~/.local/share/opencode/opencode.db, part table) to strip the data:image/...;base64,... URL from the offending state.attachments entry, replacing it with a data:text/plain;stripped,... placeholder.
Activity
github-actions commentedon Jul 31, 2026
This issue might be a duplicate of existing issues. Please check:
If these don't fully cover your report (your issue adds useful root-cause analysis of the
unsupportedParts+supportsMediaInToolResultinteraction), it may still be worth linking them for context.ivaNov-0042 commentedon Aug 10, 2026
Fix: gate
supportsMediaInToolResulton model capabilitiesI ran into the same breakage (text-only model receiving base64 images from tool results, 400'ing the session). The root cause described above is spot-on. Here's a minimal fix that makes
supportsMediaInToolResulthonormodel.capabilities.inputso unsupported media gets extracted into a syntheticusermessage whereunsupportedPartscan catch it — exactly what the function's own comment already says it should do:Why it's npm-specific
supportsMediaInToolResultdecides whether media in a tool result stays in thetool-role message or is extracted into a syntheticusermessage. The decision is based only onmodel.api.npm, never onmodel.capabilities.input. For@ai-sdk/openaiit returnstrueunconditionally, so the image stays in thetoolmessage — andunsupportedPartsonly inspectsuser-role messages (if (msg.role !== "user") return msg), so it never gets filtered.With the default
@ai-sdk/openai-compatible, the function returnsfalse, the image is extracted to ausermessage, andunsupportedPartscorrectly replaces it with an error. That's why the bug only surfaces whennpm: "@ai-sdk/openai"is set explicitly.npmsupportsMediaInToolResult(image)unsupportedPartscatches it?@ai-sdk/openaitrue(unconditional)toolmsg@ai-sdk/openai-compatible(default)falseusermsgChange 1 —
packages/opencode/src/provider/transform.tsExport the existing
mimeToModalityhelper so it can be reused:Change 2 —
packages/opencode/src/session/message-v2.tsAdd the capability check at the top of
supportsMediaInToolResult:import { isMedia } from "@/util/media" + import { mimeToModality } from "@/provider/transform" ... const supportsMediaInToolResult = (attachment: { mime: string }) => { + // If the model lacks this modality, extract it so unsupportedParts() turns it + // into a user-visible error instead of sending unsupported media to the model. + const modality = mimeToModality(attachment.mime) + if (modality && !model.capabilities.input[modality]) return false if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true ... }Result
Decision order becomes: model capabilities first, then SDK package name.
capabilities.input[modality]@ai-sdk/openai+ text-only model + imageimagefalsefalseusermsg →unsupportedPartsturns into error ✓@ai-sdk/openai+ image-capable model + imageimagetruetrue@ai-sdk/openai-compatible(default) + text-only + imageimagefalsefalseundefinedThis also covers the other unconditional-
truebranches (@ai-sdk/anthropic,@ai-sdk/amazon-bedrock/mantle,@ai-sdk/google-vertex/anthropic) which had the same latent issue.Type safety
mimeToModalityreturnsModality | undefinedwhereModality = "text"|"audio"|"image"|"video"|"pdf"(frommodels-dev.ts).model.capabilities.inputisProviderModalities(provider.ts) with exactly those keys, somodel.capabilities.input[modality]typechecks asboolean. The@/provider/transformimport adds no circular dependency (transform.ts only depends onai,remeda, provider and models-dev types).Happy to open a PR with this if it's useful.
github-actions commentedon Oct 11, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.