Skip to content

Bug Tool calls fail when assistant content is null #51661

Open

Description

@picpicpic

Description

I am encountering an issue in OpenCode v2 (version 2.0.18) where tool calls trigger a validation error from the provider.
This behavior is not present in OpenCode v1 with the same provider.

Plugins

none

OpenCode version

2.0.18

Steps to reproduce

  1. Use a custom provider
  2. Trigger any tool call
    For example, I use the following prompt to reproduce: use the shell tool to call 'echo dummystring'
  3. The request sent to the provider contains "content": null for the assistant message that initiates the tool call

Actual behavior:
The provider returns a ValidationError because it expects a string or an array for the content field, but receives null.

Failing request { "model": "gemma-4-31b-it", "messages": [ { "role": "assistant", "content": null, "tool_calls": [...] } ] }
Error response { "errors": [{ "errorMessage": "Request doesn't match the schema", "message": "Invalid input: expected string, received null" }] }

Expected behavior:
The content field should be an empty string "" instead of null when a tool call is made without accompanying text.

Workaround / Proof of concept:
I created a small plugin that intercepts the request and replaces "content": null with "content": "".
With this change, the tool calls work perfectly.

Fixed request which is working { "model": "gemma-4-31b-it", "messages": [ { "role": "assistant", "content": "", "tool_calls": [...] } ] }

Screenshot and/or share link

No response

Operating System

macOS

Terminal

Kitty, VSCode terminal, MacOS Terminal

Activity

  1. github-actions commented on Sep 27, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. picpicpic commented on Sep 27, 2026

    @picpicpic
    Author

    This is not a duplicate of #41165, even though the symptoms look similar.

    #41165 was fixed by #41210 ("fix(ai): align chat assistant content"), which is indeed included in 2.0.18.
    However, #41210 only covers the case where an assistant message has neither text nor tool calls.
    The current code in packages/ai/src/protocols/openai-chat.ts (line 484) is:

    content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",

    So after that PR:

  3. Hotragn commented on Sep 27, 2026

    @Hotragn

    The null is deliberate rather than accidental, which is worth knowing before anyone patches it. On v2, packages/ai/src/protocols/openai-chat.ts:484:

    content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",

    So the three cases are explicit: text present → the joined text; no text but tool calls → null; neither → "". The schema agrees — content: Schema.NullOr(Schema.String) on the assistant message (:149).

    That matches the OpenAI Chat Completions spec, where an assistant message carrying tool_calls may have content: null, so the request isn't malformed — the provider here is stricter than the spec. Which makes "send "" instead" a behaviour change for every OpenAI-compatible provider, not just the strict ones, and some do distinguish an explicit null from an empty string when replaying assistant turns.

    The file already handles this class of divergence per-provider rather than globally — the Kimi reasoning_content branch a few lines above (:470) is the closest example, and there's similar handling for Moonshot, Gemini and Alibaba elsewhere. A narrow option along those lines looks like the shape that fits, but which providers should opt in isn't something I can determine from outside.

    @picpicpic — it would help to know which server you're running (the model name gemma-4-31b-it suggests a self-hosted gateway), since that identifies whether this needs a provider flag or a broader default change.

  4. picpicpic commented on Sep 28, 2026

    @picpicpic
    Author

    Hi @Hotragn
    Thank you for all this information.
    I reached out to our internal provider, but unfortunately I don't have any technical details about the server being used...
    They just wanted to let me know that a new version would be available soon.
    I will retest it at that point.

  5. tabenius commented on Oct 16, 2026

    @tabenius

    Confirming this on v2.0.30 against a first-party provider, with a minimal proof, so the fix does not depend on anyone's custom server changing.

    Environment: Linux x86_64, opencode v2.0.30, node v24.21.0. Provider: Cloudflare Workers AI over the OpenAI-compatible endpoint https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1.

    Symptoms

    • Plain chat works, streaming included, for every model tried (gpt-oss-120b, qwen3.8-27b, llama-4-scout-17b).
    • The first turn where the model calls a tool returns HTTP 400 and the turn cannot proceed, so agentic use of the provider is impossible.
    • Vendor error, verbatim:
    AiError: Bad input: Error: oneOf at '/' not met, 0 matches: required properties at '/' are 'prompt',
    Type mismatch of '/messages/0/content', 'array' not in 'string',
    Type mismatch of '/messages/1/content', 'array' not in 'string',
    Type mismatch of '/messages/2/content', 'string' not in 'null',
    required properties at '/messages/2' are 'role,content', ...
    

    Two distinct mismatches in one request: content as an array on some messages and null on the assistant tool-call turn. Cloudflare's validator accepts only strings.

    Diagnosis

    Captured the exact outbound body on the tool-call turn:

    system(str), user(str),
    assistant(content: null, tool_calls: [...], reasoning_content: "..."),
    tool(str, tool_call_id), user(str)
    

    (the request also carries max_completion_tokens, store, stream, stream_options, tools).

    Single-variable replay against the same endpoint:

    • verbatim body → 400
    • only content: null changed to content: "" → 200

    Nothing else differs. As noted above, the null is deliberate at packages/ai/src/protocols/openai-chat.ts:484:

    content: content.length > 0 ? content.map(p => p.text).join("") : toolCalls.length > 0 ? null : "",

    #41210 fixed only the third branch (neither text nor tool calls → ""). The tool-call branch still emits null. The array-vs-string mismatch is a second, separate shape problem (Anthropic-style parts mixed with OpenAI strings) — the subject of #30381, which the staleness bot auto-closed while it still reproduced.

    Interventions tried

    Intervention Result
    three models (gpt-oss-120b, qwen3.8-27b, llama-4-scout-17b) chat 200, tools 400
    direct /accounts/{id}/ai/v1/chat/completions 400
    AI Gateway /compat/chat/completions 400
    bundled cloudflare-workers-ai provider runtime 400
    /ai/v1/responses GPT-OSS-only and non-streaming per docs; the runtime sends chat-shaped tools → rejected
    provider config (headers, extraBody, …) no request-body hook exposed
    plugin no request-body hook in v2.0.30
    external shim (below) works

    transformRequestBody exists as an internal AI SDK provider property but is not in the OpenAI-compatible provider settings schema, so a user has no supported way to change outgoing messages.

    The shim trick. A one-file loopback HTTP proxy in front of the endpoint that (a) rewrites content: null/absent to "" and normalises array content to a string, and (b) injects the provider credential from a file on every request, so the client config holds no secret. Point baseURL at the shim; tool calling then works end to end for every model, streaming included, and upstream 4xx is relayed unchanged rather than masked as a 502. It is a workaround, not a fix.

    Scope

    Side effects of the shim solution

    Good: unblocks the whole provider for agentic work; streaming preserved; errors relayed faithfully; no OpenCode patch and no dependency change; the credential lives in one file, so rotation needs no restart.

    Bad: one more always-on process to run and monitor; it is an unauthenticated credential-injecting proxy and must bind loopback only, since anything that can reach it can spend the account's quota; it hides the defect so a future regression goes unnoticed; it must be retired once the client or the provider is fixed; and null → "" rewrites message bytes, harmless here but an accommodation the provider should not require.

    Upstream ops (tests, gates, CI)

    • Unit: serialise a history containing an assistant turn with tool_calls and assert every emitted message has typeof content === 'string' or no content key. Today null passes silently because nothing asserts it.
    • Contract: add a strict-validator fixture (or a recorded real provider) to the provider matrix, so openai-compatible runtimes are tested against a validator and not a permissive stub. The current suite cannot see this class of bug.
    • Gate: refuse to emit content: null; omit the key when a turn has tool_calls.
    • Change control: expose a documented request-body transform (config or plugin hook) so adapting a strict provider does not require a sidecar.

    Affected users (estimate)

    Every OpenCode user doing tool/agentic work against a strict OpenAI-compatible provider; for Cloudflare Workers AI that is 100% of tool calls for every model — a hard blocker, not a degradation. The intersection is small in absolute terms but non-trivial: Workers AI is a documented OpenAI-compatible target and this is its second report (#30381) plus a relay report (#41165). Order of tens to low hundreds; a tracker search for 'string' not in 'null' would refine it.

    Discussion

    Read strictly, OpenAI tolerates null content on an assistant message that carries tool_calls, so the null is not wrong per spec — but validators that require a string are common, every other client we compared emits "" or omits the key, and "" satisfies both, so the cheap non-breaking fix is client-side. Two community PRs already normalised Cloudflare's content shapes (#30589, #36850) and were closed unmerged; #41210 fixed only the empty-content branch. The remaining defect is the deliberate null on the tool-call branch, and note that on Cloudflare this cannot be fixed by the user at all — it belongs in the client. The array/string mix in the same error also means the request is not even self-consistent, so normalising message content once at serialisation would fix both.

    References

  6. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I was able to reproduce this.

    I pointed a custom @ai-sdk/openai-compatible provider (gemma-4-31b-it) at a mock server that rejects messages whose content isn't a string or array, as yours does. Then I ran use the shell tool to call 'echo dummystring'. On 2.0.18 the shell tool runs, but the follow-up request replays the assistant message as {"role":"assistant","content":null,"tool_calls":[…]} and is rejected with HTTP 400. The same content: null is still sent on the latest dev (86ce19d), where the error shows as Invalid input: expected string, received null.

    It comes from how the OpenAI Chat protocol builds assistant messages that have tool calls but no text. The script below reproduces it end to end. Your plugin workaround (rewriting null to "") is the way to go for now.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #51661: assistant tool-call message replayed with "content": null.
    # A mock OpenAI-compatible server returns one shell tool call, then on the next
    # request validates messages[].content strictly (string or array), as the
    # reporter's provider does. Usage: ./repro.sh            (opencode on PATH)
    #        OC_DEV=/workspace/repo ./repro.sh                (run from source)
    set -u
    DIR=$(mktemp -d /tmp/opencode/repro51661.XXXX); cd "$DIR"; git init -q
    cat > server.ts <<'TS'
    let n = 0
    Bun.serve({ port: 18751, async fetch(req) {
      const url = new URL(req.url)
      if (url.pathname.endsWith("/models")) return Response.json({ data: [{ id: "gemma-4-31b-it", object: "model" }] })
      const body = await req.json()
      await Bun.write(`req-${++n}.json`, JSON.stringify(body, null, 2))
      const bad = body.messages.find((m: any) => !(typeof m.content === "string" || Array.isArray(m.content)))
      if (bad) {
        console.log(`REQUEST ${n}: REJECTED role=${bad.role} content=${JSON.stringify(bad.content)}`)
        return Response.json({ errors: [{ errorMessage: "Request doesn't match the schema", message: "Invalid input: expected string, received null" }] }, { status: 400 })
      }
      const hasTool = body.messages.some((m: any) => m.role === "tool")
      console.log(`REQUEST ${n}: OK (tool result present: ${hasTool})`)
      const id = "chatcmpl-1", created = 1, model = body.model
      const chunks = hasTool
        ? [{ choices: [{ index: 0, delta: { role: "assistant", content: "dummystring printed." } }] },
           { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }]
        : [{ choices: [{ index: 0, delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "shell", arguments: "" } }] } }] },
           { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ command: "echo dummystring", description: "echo" }) } }] } }] },
           { choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }]
      const sse = chunks.map(c => `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, ...c })}\n\n`).join("") + "data: [DONE]\n\n"
      return new Response(sse, { headers: { "content-type": "text/event-stream" } })
    } })
    TS
    cat > opencode.json <<'JSON'
    { "$schema": "https://opencode.ai/config.json",
      "provider": { "strict": { "npm": "@ai-sdk/openai-compatible", "name": "Strict",
        "options": { "baseURL": "http://127.0.0.1:18751/v1", "apiKey": "x" },
        "models": { "gemma-4-31b-it": { "name": "Gemma", "tool_call": true } } } },
      "permission": "allow" }
    JSON
    bun server.ts > server.log 2>&1 & SP=$!; sleep 1
    export XDG_CONFIG_HOME="$DIR/xdg" XDG_DATA_HOME="$DIR/data" OPENCODE_PASSWORD=repro51661
    if [ -n "${OC_DEV:-}" ]; then
      # From source: start the server ourselves (its self-spawned child lacks the JSX preload)
      SRC=(bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts")
      "${SRC[@]}" serve --hostname 127.0.0.1 --port 18752 > serve.log 2>&1 & OSP=$!; sleep 8
      OC=("${SRC[@]}" run --server http://127.0.0.1:18752)
      echo "opencode: $("${SRC[@]}" --version)"
    else OC=(opencode run --standalone); OSP=; echo "opencode: $(opencode --version)"; fi
    timeout 120 "${OC[@]}" -m strict/gemma-4-31b-it "use the shell tool to call 'echo dummystring'" 2>&1 | tail -20
    [ -n "$OSP" ] && kill $OSP
    kill $SP
    echo "--- server log"; cat server.log
    echo "--- assistant messages in last request:"; jq -c ".messages[] | select(.role==\"assistant\")" $(ls req-*.json | sort -V | tail -1)

    Run it: bash repro.sh

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions