Skip to content

openrouter: Gemini fails with 400 "Requests ending with a model turn are not supported" after read tool returns images, and error hides metadata.raw #55694

Open

Description

@eknkc

Summary

When using a Google Gemini model through OpenRouter (openrouter/google/gemini-4-argon) in OpenCode V2 (2.0.29), every step immediately following a read tool call on image files (.png) fails with HTTP 400 (Requests ending with a model turn are not supported.). In addition, OpenCode surfaces only the generic wrapper message Error: Provider returned error instead of the upstream error in error.metadata.raw.

Environment

  • opencode version: 2.0.29
  • OS: Darwin 27.0.0 (darwin arm64)
  • Terminal: TERM_PROGRAM=ghostty, TERM=xterm-ghostty, COLORTERM=truecolor
  • Shell: /opt/homebrew/bin/zsh
  • Install/channel: latest
  • Active plugins: None found in config

Reproduction

  1. Select openrouter/google/gemini-4-argon (variant high).
  2. Run a task where the model executes normal text-returning tools (e.g., execute or read on a directory/text file) — these continuations succeed normally.
  3. Have the model call the read tool on one or more .png image files (which returns [{ type: "text", text: "Image read successfully" }, { type: "file", mime: "image/png", uri: "data:image/png;base64,..." }]).
  4. On the immediately following continuation step, the request fails with HTTP 400 (Error: Provider returned error / Requests ending with a model turn are not supported.), stopping the run.
  5. Sending a new user text prompt allows the session to continue until the next time read is called on an image.

Expected Behavior

  1. Tool continuations after read returns image attachments (type: "file") should lower into a valid turn sequence that OpenRouter / Google Gemini accepts without failing the run.
  2. When OpenRouter returns "message": "Provider returned error", providerErrorMessage in packages/ai/src/provider-error.ts should surface the upstream provider details from error.metadata.raw (and error.metadata.provider_name) rather than only the generic "Provider returned error" string.

Actual Behavior

The continuation step immediately following any image read tool call deterministically fails (4/4 occurrences in a single session, while 20+ text-only tool continuations in the same session succeeded) with:

{
  "type": "provider.invalid-request",
  "message": "Provider returned error",
  "status": 400,
  "response": {
    "body": "{\"error\":{\"message\":\"Provider returned error\",\"code\":400,\"metadata\":{\"raw\":\"{\\n  \\\"error\\\": {\\n    \\\"code\\\": 400,\\n    \\\"message\\\": \\\"Requests ending with a model turn are not supported.\\\",\\n    \\\"status\\\": \\\"INVALID_ARGUMENT\\\"\\n  }\\n}\\n\",\"provider_name\":\"Google\",\"is_byok\":false,\"provider_error_code\":\"400\"}},\"user_id\":\"[REDACTED]\"}"
  }
}

Server log (~/.local/share/opencode/log/opencode.log):

timestamp=2026-10-15T10:51:39.116Z level=ERROR run=0415584b message="Failed to drain Session" cause="AI.Error: Provider returned error
    at jQ (/$bunfs/root/chunk-42dcx4y2.js:6:3619)
    at <anonymous> (/$bunfs/root/chunk-42dcx4y2.js:6:3312)
    at SessionStep.attempt (/$bunfs/root/chunk-yxtj88vc.js:382:3574)
    at SessionStep.attempt (definition) (/$bunfs/root/chunk-yxtj88vc.js:367:302)
    at SessionRunner.runStep (/$bunfs/root/chunk-yxtj88vc.js:382:2672)
    at SessionRunner.runStep (definition) (/$bunfs/root/chunk-yxtj88vc.js:382:2826)
    at SessionRunner.drain (/$bunfs/root/chunk-j0gfnjyp.js:18:2552)
    at SessionRunner.drain (definition) (/$bunfs/root/chunk-yxtj88vc.js:382:516)
    at ServerProcess.start (/$bunfs/root/chunk-hjn5kn92.js:2:7501)
    at ServerProcess.start (definition) (/$bunfs/root/chunk-yxtj88vc.js:453:25628) {
  [cause]: AI.Error.InvalidRequest: Provider returned error
      at Cl (/$bunfs/root/chunk-42dcx4y2.js:6:1145)
      at jQ (/$bunfs/root/chunk-42dcx4y2.js:6:3630)
      at <anonymous> (/$bunfs/root/chunk-42dcx4y2.js:6:3312)
}" role=server sessionID=[REDACTED]

Additional Context

  • Reproducibility: Consistent (reproduced 4 out of 4 times in the session immediately after read tool calls returning ['text', 'file'] on .png images).
  • Preceding assistant message shape before each failure:
    {
      "type": "assistant",
      "model": { "id": "google/gemini-4-argon", "providerID": "openrouter", "variant": "high" },
      "content": [
        {
          "type": "reasoning",
          "text": "",
          "state": {
            "reasoningDetails": [
              {
                "type": "reasoning.encrypted",
                "data": "<REDACTED>",
                "format": "google-gemini-v1",
                "id": "call_1039747",
                "index": 0
              }
            ]
          }
        },
        {
          "type": "tool",
          "id": "call_1039747",
          "name": "read",
          "executed": false,
          "state": {
            "status": "completed",
            "input": { "path": "/path/to/render_1.png" },
            "content": [
              { "type": "text", "text": "Image read successfully" },
              { "type": "file", "mime": "image/png", "name": "/path/to/render_1.png", "uri": "data:image/png;base64,<REDACTED>" }
            ]
          }
        },
        {
          "type": "tool",
          "id": "call_1039759",
          "name": "read",
          "executed": false,
          "state": {
            "status": "completed",
            "input": { "path": "/path/to/render_2.png" },
            "content": [
              { "type": "text", "text": "Image read successfully" },
              { "type": "file", "mime": "image/png", "name": "/path/to/render_2.png", "uri": "data:image/png;base64,<REDACTED>" }
            ]
          }
        }
      ]
    }
  • Code pointers:
    • In packages/ai/src/protocols/openai-chat.ts (lowerToolMessages / flushAttachments), tool-result file attachments are split out of role: "tool" messages and flushed at the end of the turn as a synthetic { role: "user", content: [{ type: "image_url", ... }] } message with no text part (unlike packages/ai/src/protocols/gemini.ts, whose flushMedia includes { text: "Attached media from tool result:" }).
    • In packages/ai/src/provider-error.ts, decodeMessage extracts body.error.message ("Provider returned error") without checking body.error.metadata.raw for OpenRouter's upstream error message.
  • Related issues: Requests ending with a model turn are not supported #45359, gemini-3.8-flash: 400 'Requests ending with a model turn are not supported' after a model turn #47034, Gemini 400 "Requests ending with a model turn are not supported" when a step is stored out of order: Identifier.create is not monotonic across backward clock steps #55514 (which tracked V1 occurrences of "Requests ending with a model turn are not supported").

Activity

  1. opencode-agent commented on Oct 15, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and the code pointers. I reproduced both parts with a mock OpenRouter endpoint on 2.0.29, and they still happen on the latest dev (4397ee4).

    What I did: I configured openrouter/google/gemini-4-argon against a local mock. The mock makes the model call read on a PNG, then answers the next step with the same 400 body you posted.

    • Request shape: the continuation request ends with assistant (tool_calls) → tool ("Image read successfully") → user [image_url]. That last synthetic user message contains only the image, with no text part. It comes from flushAttachments in packages/ai/src/protocols/openai-chat.ts, as you pointed out.
    • Error message: OpenCode shows only Error: Provider returned error. Google's message ("Requests ending with a model turn are not supported.") is in error.metadata.raw and never reaches you.

    I couldn't confirm against the real OpenRouter/Google API that this shape is what gets rejected, because that needs a real account. Your 4/4 failures right after image reads point that way, though.

    The script below reproduces this without credentials.

    Related, but on v1 with different triggers: #55514, #47034 and #45359 (the same Gemini error), and #23665 ("Provider returned error" hiding the upstream message for a different provider).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #55694 (OpenCode v2): run a mock OpenRouter endpoint, let the model `read` a PNG, and look at
    #  (1) the continuation request that is sent (it ends with a user message containing only image_url parts), and
    #  (2) the error OpenCode shows for OpenRouter's 400 body (only "Provider returned error"; metadata.raw is dropped).
    # Needs bun, python3 and `opencode` on PATH (bun add -g @opencode/[email protected]).
    set -euo pipefail
    D=$(mktemp -d); P=$D/proj; mkdir -p $P $D/home
    export HOME=$D/home XDG_CONFIG_HOME=$D/home/.config XDG_DATA_HOME=$D/home/.local/share
    python3 - "$P/render_1.png" <<'PY'
    import sys,zlib,struct
    w=h=8; raw=b''.join(b'\x00'+b'\xff\x00\x00'*w for _ in range(h))
    c=lambda t,d: struct.pack('>I',len(d))+t+d+struct.pack('>I',zlib.crc32(t+d)&0xffffffff)
    open(sys.argv[1],'wb').write(b'\x89PNG\r\n\x1a\n'+c(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0))+c(b'IDAT',zlib.compress(raw))+c(b'IEND',b''))
    PY
    cat > $P/opencode.json <<'JSON'
    { "model": "openrouter/google/gemini-4-argon", "permissions": { "*": "allow" },
      "providers": { "openrouter": { "settings": { "baseURL": "http://127.0.0.1:4599/api/v1", "apiKey": "sk-test" },
        "models": { "google/gemini-4-argon": { "name": "Gemini 4 Argon", "capabilities": { "input": ["text","image"], "tools": true } } } } } }
    JSON
    cat > $D/mock.ts <<'TS'
    import { appendFileSync } from "fs"
    const raw = JSON.stringify({ error: { code: 400, message: "Requests ending with a model turn are not supported.", status: "INVALID_ARGUMENT" } }, null, 2) + "\n"
    const errBody = JSON.stringify({ error: { message: "Provider returned error", code: 400, metadata: { raw, provider_name: "Google", is_byok: false, provider_error_code: "400" } } })
    Bun.serve({ port: 4599, async fetch(req) {
      if (req.method !== "POST") return Response.json({ data: [] })
      const body: any = await req.json()
      appendFileSync(process.env.LOG!, JSON.stringify(body) + "\n")
      if (!body.messages?.some((m: any) => m.role === "tool")) {
        const ch = (d: any) => `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 0, model: body.model, choices: [d] })}\n\n`
        return new Response(ch({ index: 0, delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "read", arguments: JSON.stringify({ path: process.env.IMG }) } }] }, finish_reason: null })
          + ch({ index: 0, delta: {}, finish_reason: "tool_calls" }) + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } })
      }
      return new Response(errBody, { status: 400, headers: { "content-type": "application/json" } })
    }})
    TS
    LOG=$D/requests.jsonl IMG=$P/render_1.png bun $D/mock.ts & MOCK=$!; trap 'kill $MOCK' EXIT; sleep 1
    (cd $P && opencode run --standalone --auto -m openrouter/google/gemini-4-argon "Look at render_1.png" || true)
    echo "--- message roles of the last request sent to the provider:"
    tail -1 $D/requests.jsonl | python3 -c 'import json,sys
    for m in json.load(sys.stdin)["messages"]:
      c=m.get("content"); print(m["role"], [p.get("type") for p in c] if isinstance(c,list) else repr((c or "")[:40]), "tool_calls" if "tool_calls" in m else "")'

    Run it: bash repro.sh

  2. spragginsdesigns commented on Oct 15, 2026

    @spragginsdesigns

    Confirmed against the real OpenRouter API, and narrowed down to the exact cause.

    Setup: OpenCode 2.0.29 on Windows 11, openrouter/google/gemini-4-argon, a clean config (no plugins, isolated HOME). A logging pass-through proxy sat between OpenCode and openrouter.ai. Prompt: "Use your read tool to open render_1.png, then tell me in one sentence what the image shows."

    The first request returned 200 (the model calls read). The continuation request returned 400 every time:

    system | user | assistant(tool_calls, reasoning_details=[reasoning.encrypted]) | tool("Image read successfully") | user[image_url]
    -> 400 {"error":{"message":"Provider returned error","code":400,"metadata":{"raw":"{\"error\":{\"code\":400,\"message\":\"Requests ending with a model turn are not supported.\",\"status\":\"INVALID_ARGUMENT\"}}","provider_name":"Google",...}}}
    

    I then replayed that exact captured request (non-streaming), changing one thing per run:

    Variant Result
    As sent by OpenCode 400 (3 of 3 runs)
    Strip reasoning_details from the assistant message 200
    Add a text part before the image in the trailing user message 200 (2 of 2 runs)
    Move the image into the tool message content instead 400
    Drop the image message entirely 200

    So the 400 needs both of these:

    • encrypted reasoning on the tool-call turn
    • a trailing user message that contains only image_url parts

    The same shape with no reasoning_details passes, which is probably why a simple mock or a single-turn test doesn't show it. Google's error presumably comes from how OpenRouter translates an image-only user turn when there are thought signatures.

    Suggested fix: have flushAttachments in packages/ai/src/protocols/openai-chat.ts always emit a short text part with the attachments, for example [{type:"text", text:"Attachments from the read tool:"}, ...images]. In my replays that alone turns the 400 into a 200 without dropping the reasoning. Surfacing error.metadata.raw would also have made this much faster to find, since OpenCode only shows "Provider returned error".

    In real use this makes Gemini 4 Argon effectively unusable in OpenCode for any task that reads an image. I had 8 of 8 failures right after an image read over two long sessions. The successful steps around those failures still bill the full context, so a session racks up cost while getting nowhere.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions