Skip to content

session: provider image rejection bricks session — generic 400 error, no recovery path #52042

Open
Shalin-Shah-2002/opencode
#1

Description

@felixcse

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

  1. Configure a custom OpenAI-compatible provider whose model does not accept image parts (e.g. a gateway fronting a text-only LLM)
  2. Start a chat with that model
  3. Paste an image into the chat and send
  4. 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
  5. 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
  6. Restart opencode and resume the session: draining fails the same way (Failed to drain Session in the logs)
  7. 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

  1. github-actions commented on Sep 29, 2026

    @github-actions
    Contributor

    This issue does not fully meet our contributing guidelines.

    What needs to be fixed:

    • Use the Bug Report template and provide content in its required Description field. The issue currently has a Summary section instead; you can move or reuse its existing description there.

    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.

  2. felixcse commented on Sep 29, 2026

    @felixcse
    Author

    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.

  3. github-actions commented on Sep 29, 2026

    @github-actions
    Contributor

    Thanks for updating the issue to meet the template requirements!

  4. argszero commented on Sep 29, 2026

    @argszero
    Contributor

    I'd like to work on this.

    One correction to the framing, from reading dev HEAD: 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.input when 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.ts turns message.files into media parts unconditionally, without consulting model.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

    1. reject the attachment locally, before the provider call, with a message that names the file and the missing modality (so the session stays usable and the user knows which model to switch to), or
    2. only surface the provider's error body (keeping the request as-is), or
    3. both?

    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.

  5. Veld101 commented on Sep 29, 2026

    @Veld101

    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:288 builds the outgoing request as boundImages(unsupportedParts(shaped.messages, model.capabilities)). unsupportedParts (model-request.ts:135) maps a media part for a modality the model does not declare into a text part via unsupportedMedia (model-request.ts:126):

    ERROR: Cannot read "<name>" (this model does not support image input). Inform the user.
    

    So media that the effective capabilities.input excludes should already be rewritten before the provider call, not sent. Your reading of to-llm-message.ts is right that it emits media unconditionally, but this later pass is what decides what actually goes out.

    That makes me suspect the gate is not firing because the effective capabilities.input still contains image for the failing model. packages/core/src/config/plugin/provider.ts:120 only replaces model.capabilities when config.capabilities !== undefined, so a custom provider model that declares nothing keeps whatever base the provider entry gives it. If that base includes image, 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 in to-llm-message.ts. If instead you can show the effective capabilities.input is 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.

  6. argszero commented on Sep 29, 2026

    @argszero
    Contributor

    Answering the probe, with code rather than a guess: the effective capabilities.input for an undeclared custom model does contain image, so your reading is right and mine was wrong.

    Where the default comes from. Model.Info.default seeds every model the registry has not already seen (packages/core/src/model.ts:126: draft(providerID, modelID) ?? Info.default(providerID, modelID)), and it takes Capabilities.default() (packages/schema/src/model.ts:94):

    default: () => ({ tools: true, input: ["text", "image"], output: ["text"] })

    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.ts only overwrites model.capabilities when config.capabilities !== undefined, so a custom model that declares nothing keeps input: ["text", "image"].

    Consequence. unsupportedParts (model-request.ts:135) keeps a media part whenever capabilities.input.some((item) => item.startsWith(modality)). With image present 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.ts emits media unconditionally so the gate never sees it. Wrong: prepare rewrites the shape through boundImages(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 same prepare. 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 by ERROR: 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, base v2) already reworks this plumbing (Model.mergeCapabilities, still falling back to Capabilities.default()), and #51298 covers the array/object capability-form bug on dev. 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.

  7. Dante-dan commented on Sep 29, 2026

    @Dante-dan
    Contributor

    I traced this on current v2. An undeclared custom model inherits input: ["text", "image"] from Model.Capabilities.default() (the intentional default from #38449). unsupportedParts does run on user attachments before the provider call, but it keeps an image when that effective list includes image. Declaring input: ["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: providerErrorMessage decodes APICallError.data and responseBody, but currently prefers any nonempty SDK error.message over the decoded provider message. The report gives the direct curl result, 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 and Content-Type for 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.

  8. Veld101 commented on Sep 29, 2026

    @Veld101

    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:

    return error.message.trim() !== "" ? error.message : (message ?? (code === undefined ? prefix : `${prefix}: ${code}`))

    So whenever the SDK's error.message is non-empty, the decoded provider message is 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 the prefix branch (:1058). That branch is only reached when error.message is empty and neither error.data nor error.responseBody decoded 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) requires error to be an object with message/code (ProviderErrorDetail, :1026). A very common 400 body is:

    { "error": "some-model is not a multimodal model" }

    where error is a plain string. That fails decodeProviderError, so message is undefined and the code falls through to prefix. Same for {"error":{"type":"invalid_request_error"}} without a message.

    So the two fixes are independent and both worth making:

    1. Prefer the decoded provider message over a non-empty SDK error.message, using the SDK message only as a fallback.
    2. Tolerate error as a plain string (and/or a top-level message), so the body above decodes.

    The second one is the one that would change the reported output. Also worth keeping reason.body populated with the original text so the raw body survives even when decoding fails, per the AI package's error conventions.

  9. FahadBinHussain commented on Oct 14, 2026

    @FahadBinHussain

    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 with This model does not support images. and the session wedges (full report + repair steps in #55309, closed as a duplicate here).

    two notes:

    1. the local override does work — confirmed the merge in packages/core/src/plugin/provider/opencode.ts applies capabilities on top of the built-in model. we set provider.opencode.models."mimo-v2.6-flash-free".capabilities = {tools: true, input: ["text"], output: ["text"]} and the wedge is gone.
    2. the catalog entry itself may be what needs fixing: if the zen free tier rejects images for this model, the models.dev entry is exactly what makes opencode send them in the first place. we could not find an existing issue for it in 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.

  10. devesh-m1 commented on Oct 27, 2026

    @devesh-m1

    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: capabilities is silently ignored under the deprecated provider path

    Under the deprecated top-level provider key, per-model capabilities is silently dropped — that path's schema (ProviderConfig → models.<id>) has no capabilities field; it expects modalities instead:

    {
      "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 the Model.Capabilities schema default input: ["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 providers path does accept capabilities, 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.messages is mutable at that point — and rewrites media parts plus image tool-result file entries into text placeholders whenever the resolved model's registry capabilities.input excludes "image". Vision-capable models are untouched.

    import { Plugin } from "@opencode/plugin"
    
    type AnyPart = {
      type: string
      text?: string
      filename?: string
      media?: { mediaType?: string; source?: { type?: string } }
      result?: { type?: string; value?: Array<{ type?: string; uri?: string; mime?: string; name?: string; text?: string }> }
    }
    
    const stripMedia = (messages: Array<{ content?: AnyPart[] }>): number => {
      let stripped = 0
      for (const message of messages) {
        const content = message?.content
        if (!Array.isArray(content)) continue
        for (let i = 0; i < content.length; i++) {
          const part = content[i]
          if (part.type === "media") {
            const mediaType = part.media?.mediaType ?? "unknown media"
            content[i] = {
              type: "text",
              text: `[${mediaType} omitted — the current model does not accept media input.]`,
            }
            stripped++
          } else if (
            part.type === "tool-result" &&
            part.result?.type === "content" &&
            Array.isArray(part.result.value)
          ) {
            const value = part.result.value
            for (let j = 0; j < value.length; j++) {
              const item = value[j]
              if (item.type === "file" && typeof item.mime === "string" && item.mime.startsWith("image/")) {
                value[j] = { type: "text", text: `[image file omitted: ${item.name ?? item.uri ?? "unnamed"}]` }
                stripped++
              }
            }
          }
        }
      }
      return stripped
    }
    
    const acceptsImages = async (ctx: any, model: { providerID: string; id: string }): Promise<boolean> => {
      try {
        const result: { data?: Array<{ providerID: string; id: string; capabilities?: { input?: string[] } }> } =
          await ctx.model.list()
        const info = result.data?.find((m) => m.providerID === model.providerID && m.id === model.id)
        const input = info?.capabilities?.input
        // Unknown model or undeclared input: assume it accepts images (never break vision-capable models).
        if (!input) return true
        return input.includes("image")
      } catch {
        return true
      }
    }
    
    export default Plugin.define({
      id: "no-image-crash",
      async setup(ctx) {
        const guard = (hook: string) => async (event: { model: { providerID: string; id: string }; messages: Array<{ content?: AnyPart[] }> }) => {
          if (await acceptsImages(ctx, event.model)) return
          stripMedia(event.messages)
        }
        await ctx.session.hook("context", guard("context"))
        await ctx.session.hook("generate", guard("generate"))
        await ctx.session.hook("title", guard("title"))
        await ctx.session.hook("compaction", guard("compaction"))
      },
    })

    Register globally (survives upgrades):

    { "plugins": [{ "package": "/path/to/no-image-crash" }] }

    (package.json needs "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

    1. Warn (or error) when capabilities is set under the deprecated provider path — it's silently ignored today, and it's the natural key to reach for since the new providers path documents it.
    2. Consider gating/stripping media parts for models whose capabilities.input excludes image before dispatch, instead of letting the provider 400 brick the session.
  11. Dante-dan commented on Oct 27, 2026

    @Dante-dan
    Contributor

    Thanks @devesh-m1 — this corrects my earlier guidance. I checked current dev (8697644): the legacy top-level provider model schema defines modalities, not capabilities, and migration maps modalities into runtime capabilities. The newer top-level providers config uses capabilities. For this legacy provider block, use modalities: { input: ["text"], output: ["text"] }; my earlier comment incorrectly said capabilities could be set there. Legacy schema, migration, new providers schema.

  12. opencodejr commented on Oct 28, 2026

    @opencodejr

    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 model in the chat.

    • Declaring modalities: you can already declare a custom model's input types in your config. Custom models default to accepting text and image, 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:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions