Repository navigation
Description
Description
When a custom OpenAI-compatible provider's model rejects image input, a pasted image bricks the session. Every subsequent request replays the image with the full session history and fails with the same generic Error: Provider request failed with HTTP 400. The provider's actual error body is never surfaced, nothing identifies the image as the cause, and there is no in-app recovery. Switching the session to a model that accepts images revives it (undocumented, undiscoverable workaround); switching back re-breaks it.
Distinct from the tool-result path in #39824, #43388, #41163, and the closed #51873: here the image is a user attachment on a custom provider, so the model capability gate never fires — custom models carry no modality metadata, and the image is sent through to a provider that rejects it. A config field to declare input modalities for custom provider models would let the existing gate handle this client-side, and would also cover the inverse case in #43392.
The provider rejection was verified independently of OpenCode — a direct curl to the endpoint with a standard OpenAI image_url content part (both a real screenshot and a 1×1 control pixel) returns 400 with <model> is not a multimodal model. So the request format is fine; the issue is OpenCode's handling of the rejection: an opaque repeated error with no guided recovery.
Plugins
None
OpenCode version
2.0.19 — behavior verified identical on 2.0.16 and 2.0.19
Steps to reproduce
- Configure a custom OpenAI-compatible provider whose model does not accept image parts (e.g. a gateway fronting a text-only LLM)
- Start a chat with that model
- Paste an image into the chat and send
- The request fails: the chat shows only "Error: Provider request failed with HTTP 400". The provider's response body — which names the actual cause — is not shown
- Send any new message, even plain text like "hey": it fails with the same generic error, because the image is replayed with the full session history on every request
- Restart opencode and resume the session: draining fails the same way (
Failed to drain Sessionin the logs) - Switch the session's model to one that accepts images: the session revives and works. Switch back to the text-only model: it fails again
Screenshot and/or share link
Server log, repeated on every resume attempt:
ERROR message="Failed to drain Session" cause="AI.Error: <model> is not a multimodal model"
ERROR message="Failed to drain Session" cause="AI.Error: Provider request failed with HTTP 400"
The failed assistant message persists with finish: "error" and error.type: "provider.invalid-request" — the error data exists, it is just not surfaced usefully in the chat UI.
Operating System
macOS (Darwin 25.6.0, arm64)
Terminal
VS Code integrated terminal (zsh)
Activity
github-actions commentedon Sep 29, 2026
This issue does not fully meet our contributing guidelines.
What needs to be fixed:
Please edit this issue to address the above within 2 hours, or it will be automatically closed.
This issue might be a duplicate of existing issues. Please check:
If you believe this was flagged incorrectly, please let a maintainer know.
felixcse commentedon Sep 29, 2026
Edited to follow the bug report template (content moved from Summary into Description).
On the duplicate flag: related, but a different entry path. #51873 (and the open #39824 / #43388 / #41163) cover images from tool results bypassing the capability gate. This report covers a user-pasted attachment on a custom provider: the gate never fires because custom models carry no modality metadata, so the image is sent through to a provider that rejects it (verified via direct curl to the endpoint, bypassing OpenCode). The suggested fix — a config field declaring input modalities for custom provider models — is complementary to the tool-result fixes and would also cover #43392's inverse case.
Happy to close as a duplicate if maintainers see it otherwise — the symptom (images in history → repeated 400s → unusable session) is indeed the same.
github-actions commentedon Sep 29, 2026
Thanks for updating the issue to meet the template requirements!
argszero commentedon Sep 29, 2026
I'd like to work on this.
One correction to the framing, from reading
devHEAD: the config side already exists. A custom provider model can declare its input modalities today —{ "provider": { "my-openai-compatible": { "models": { "some-model": { "capabilities": { "input": ["text"], "output": ["text"] } } } } } }— and that value is written straight onto
model.capabilities.inputwhen the provider entry is loaded (packages/core/src/config/plugin/provider.ts). So declaring "text only" is possible, but it does not protect this path.The gap is the user-attachment path:
packages/core/src/session/runner/to-llm-message.tsturnsmessage.filesintomediaparts unconditionally, without consultingmodel.capabilities.input— so the gate that covers tool-result media never sees a pasted image, and the request goes out regardless of what the model says it accepts.Before I start: would you prefer the fix to
I'd default to (1), scoped to the input modalities the model actually declares, and leave the generic-400 surfacing as a separate concern. Happy to be pointed elsewhere if the maintainers see the root cause differently.
Veld101 commentedon Sep 29, 2026
One thing worth pinning before choosing between (1), (2) and (3), because there is already a gate on the primary path.
packages/core/src/session/model-request.ts:288builds the outgoing request asboundImages(unsupportedParts(shaped.messages, model.capabilities)).unsupportedParts(model-request.ts:135) maps amediapart for a modality the model does not declare into a text part viaunsupportedMedia(model-request.ts:126):So media that the effective
capabilities.inputexcludes should already be rewritten before the provider call, not sent. Your reading ofto-llm-message.tsis right that it emitsmediaunconditionally, but this later pass is what decides what actually goes out.That makes me suspect the gate is not firing because the effective
capabilities.inputstill containsimagefor the failing model.packages/core/src/config/plugin/provider.ts:120only replacesmodel.capabilitieswhenconfig.capabilities !== undefined, so a custom provider model that declares nothing keeps whatever base the provider entry gives it. If that base includesimage, a pasted image passes the gate and the provider 400s exactly as reported.If that is the case, the fix is closer to what you ruled out as "already exists": the missing piece is enforcing
input: ["text"]as the default (or making the capability plumbing land for custom models), not adding a second gate into-llm-message.ts. If instead you can show the effectivecapabilities.inputis text-only and the image still reaches the provider, then your reading is correct and the assembly path for user attachments differs from the above.Worth a one-line probe of the effective capabilities for the failing model before deciding, and it also tells us whether option (1) is enough on its own.
argszero commentedon Sep 29, 2026
Answering the probe, with code rather than a guess: the effective
capabilities.inputfor an undeclared custom model does containimage, so your reading is right and mine was wrong.Where the default comes from.
Model.Info.defaultseeds every model the registry has not already seen (packages/core/src/model.ts:126:draft(providerID, modelID) ?? Info.default(providerID, modelID)), and it takesCapabilities.default()(packages/schema/src/model.ts:94):That value is deliberate, not an accident: it came from #38449 (“fix(core): default custom model capabilities”, requested by @rekram1-node), which replaced the previous
{ tools: false, input: [], output: [] }.packages/core/src/config/plugin/provider.tsonly overwritesmodel.capabilitieswhenconfig.capabilities !== undefined, so a custom model that declares nothing keepsinput: ["text", "image"].Consequence.
unsupportedParts(model-request.ts:135) keeps a media part whenevercapabilities.input.some((item) => item.startsWith(modality)). Withimagepresent in that list, the pasted image is not rewritten, the request goes out, and the provider 400s exactly as reported. So the gate firing correctly is a matter of what the capability list says — no second gate is needed.Correcting my earlier comment. I said
to-llm-message.tsemitsmediaunconditionally so the gate never sees it. Wrong:preparerewrites the shape throughboundImages(unsupportedParts(shaped.messages, model.capabilities))(model-request.ts:288), and that covers every session request kind — primary, compaction and title/generate all go through the sameprepare. My mistake was reading one producer and stopping before the assembly step.What this means for the report. The config field the report asks for already exists and already drives that gate — declaring it is enough to make the session survive:
{ "provider": { "my-openai-compatible": { "models": { "some-model": { "capabilities": { "input": ["text"], "output": ["text"] } } } } } }With
input: ["text"], the image is replaced before the provider call byERROR: Cannot read "…" (this model does not support image input). Inform the user., so the turn still completes and the session is not bricked.What is actually left, and why I am not opening a PR for it. Tightening
Capabilities.default()to text-only would fix the reported case, but it is a trade-off rather than a defect: the same permissive default is what keeps #43392 (an image-capable model wrongly having its images stripped) from being the common complaint, and #38449 chose it on purpose. It also overlaps live work — #51365 (@jlongster, basev2) already reworks this plumbing (Model.mergeCapabilities, still falling back toCapabilities.default()), and #51298 covers the array/object capability-form bug ondev. Adding a third change in that area from the outside would be noise, so I am stepping back from the claim I made above.If a maintainer does want the default tightened (or wants the recovery message to name the model that dropped the image), say so and I will write it. Otherwise the remaining gap is a product decision, not something I should decide unilaterally.
Dante-dan commentedon Sep 29, 2026
I traced this on current
v2. An undeclared custom model inheritsinput: ["text", "image"]fromModel.Capabilities.default()(the intentional default from #38449).unsupportedPartsdoes run on user attachments before the provider call, but it keeps an image when that effective list includesimage. Declaringinput: ["text"]lets the existing conversion keep the session usable. #51365 addresses partial capability config decoding for #51252; it retains the undeclared-model default.There is a separate possible cause for the opaque 400:
providerErrorMessagedecodesAPICallError.dataandresponseBody, but currently prefers any nonempty SDKerror.messageover the decoded provider message. The report gives the directcurlresult, but not the raw response shape, so I cannot yet tell whether this masking happens in the failing OpenCode call. Could you share a sanitized response body andContent-Typefor that 400? If it contains a structured message while the SDK message is generic, I can make a narrow error-reporting fix.For the capability behavior, I suggest preserving the current default unless maintainers want to change the policy for undeclared custom models. Should those models continue to allow images by default, or should they default to text-only? A text-only default would reverse #38449's deliberate choice and affect image-capable custom models.
Veld101 commentedon Sep 29, 2026
The masking question can be answered from the code, without the raw body - and there are actually two separate things in
providerErrorMessage(packages/core/src/aisdk.ts:1051).The preference order:
So whenever the SDK's
error.messageis non-empty, the decoded providermessageis discarded, even though it was extracted a few lines earlier. That is a real masking bug and is deterministic from the source; it does not depend on the response shape.But the reported text is
Provider request failed with HTTP 400, which is theprefixbranch (:1058). That branch is only reached whenerror.messageis empty and neithererror.datanorerror.responseBodydecoded to a message. So for the reported case the preference order is not what hides the cause - the body simply did not decode.The likely reason is shape.
ProviderErrorBody(:1030) requireserrorto be an object withmessage/code(ProviderErrorDetail,:1026). A very common 400 body is:{ "error": "some-model is not a multimodal model" }where
erroris a plain string. That failsdecodeProviderError, somessageisundefinedand the code falls through toprefix. Same for{"error":{"type":"invalid_request_error"}}without amessage.So the two fixes are independent and both worth making:
error.message, using the SDK message only as a fallback.erroras a plain string (and/or a top-levelmessage), so the body above decodes.The second one is the one that would change the reported output. Also worth keeping
reason.bodypopulated with the original text so the raw body survives even when decoding fails, per the AI package's error conventions.fix(llm): surface provider error message from long JSON error bodies
fix(core): stop replaying attachments the provider rejected
fix(core): gate V2 attachments on declared model input modalities
FahadBinHussain commentedon Oct 14, 2026
adding a case from our side: it is not only custom-provider models. the built-in
opencode/mimo-v2.6-flash-free(zen free tier) has no capabilities declared in config, and the models.dev catalog declares its input as["text","image","audio","video"], so opencode sends tool-result screenshots on purpose — but the provider rejects them every turn withThis model does not support images.and the session wedges (full report + repair steps in #55309, closed as a duplicate here).two notes:
packages/core/src/plugin/provider/opencode.tsapplies capabilities on top of the built-in model. we setprovider.opencode.models."mimo-v2.6-flash-free".capabilities = {tools: true, input: ["text"], output: ["text"]}and the wedge is gone.sst/models.dev.odd detail from the same debugging session: pasting a single PNG into a fresh session with this model worked once (#54538), while the embedded tool-result screenshots were rejected consistently — so it may be size or part-shape dependent rather than a plain no-images tier.
devesh-m1 commentedon Oct 27, 2026
TL;DR: Found a config-level root cause that likely triggers most reports here, plus a plugin workaround that makes sessions crash-proof regardless of config. Both verified on v2.1.4.
1. Config footgun:
capabilitiesis silently ignored under the deprecatedproviderpathUnder the deprecated top-level
providerkey, per-modelcapabilitiesis silently dropped — that path's schema (ProviderConfig→models.<id>) has nocapabilitiesfield; it expectsmodalitiesinstead:{ "provider": { "my-provider": { "npm": "@ai-sdk/openai-compatible", "models": { "my-text-model": { "modalities": { "input": ["text"], "output": ["text"] }, "limit": { "context": 128000, "output": 16000 } } } } } }If you write the natural-looking
capabilities: { "input": ["text"] }there instead, the registry falls back to theModel.Capabilitiesschema defaultinput: ["text","image"]. The client-side "model accepts media" gate then never fires, the image is dispatched anyway, and the provider 400s — bricking the session exactly as described in this issue.After switching the key to
modalities, the model registry correctly reports the model as text-only.(The new non-deprecated top-level
providerspath does acceptcapabilities, so the same block is valid there — it's only the deprecated path that renames it.)2. Belt-and-braces plugin: strip media before dispatch for text-only models
Works regardless of how capabilities are declared. A global plugin that hooks
session.hook("context" | "generate" | "title" | "compaction")—event.messagesis mutable at that point — and rewritesmediaparts plus image tool-resultfileentries into text placeholders whenever the resolved model's registrycapabilities.inputexcludes"image". Vision-capable models are untouched.Register globally (survives upgrades):
{ "plugins": [{ "package": "/path/to/no-image-crash" }] }(
package.jsonneeds"exports": { ".": "./index.ts" }and a dependency on@opencode/plugin.)Verified end-to-end on 2.1.4: a text-only-model subagent that reads a PNG now survives (receives a text placeholder instead of the image), while a vision-capable model subagent still receives the actual image.
Suggestions for the OpenCode side
capabilitiesis set under the deprecatedproviderpath — it's silently ignored today, and it's the natural key to reach for since the newproviderspath documents it.capabilities.inputexcludesimagebefore dispatch, instead of letting the provider 400 brick the session.Dante-dan commentedon Oct 27, 2026
Thanks @devesh-m1 — this corrects my earlier guidance. I checked current
dev(8697644): the legacy top-levelprovidermodel schema definesmodalities, notcapabilities, and migration mapsmodalitiesinto runtime capabilities. The newer top-levelprovidersconfig usescapabilities. For this legacyproviderblock, usemodalities: { input: ["text"], output: ["text"] }; my earlier comment incorrectly saidcapabilitiescould be set there. Legacy schema, migration, new providers schema.opencodejr commentedon Oct 28, 2026
Thanks for the detailed report and for checking the provider directly with
curl.As I understand it, you're asking for OpenCode to recover on its own when a provider rejects an image it assumed the model could read, instead of the session failing on every later turn. Two parts of this are already covered in newer v2 releases:
Error message: since 2.0.20, OpenCode shows the provider's own error instead of the bare
Provider request failed with HTTP 400(fix(ai): show provider error bodies when no message is recognized #51978, fix(ai): read provider error messages from common body layouts #52014). On a current version you should see<model> is not a multimodal modelin the chat.Declaring modalities: you can already declare a custom model's input types in your config. Custom models default to accepting
textandimage, which is why the image was sent through. For example:{ "provider": { "my-gateway": { "models": { "my-text-model": { "capabilities": { "input": ["text"] } } } } } }OpenCode should then replace images with a short text note before sending. This check runs over the whole history on each request, so it should also make the sessions that are already stuck usable again.
What's left is the recovery itself: handling the rejection when it happens, e.g. offering to drop the image and retry. Maintainers will consider that as a feature request.
Related issues: