You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Bug Tool calls fail when assistant content is null #51661
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
Use a custom provider
Trigger any tool call
For example, I use the following prompt to reproduce: use the shell tool to call 'echo dummystring'
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.
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": [...] }
]
}
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:
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.
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.
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:
#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
Trigger: any history containing an assistant turn with tool_calls. Plain chat is unaffected.
Not endpoint- or model-specific: all three transport paths and all models tested reproduce it.
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.
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.
Activity
github-actions commentedon Sep 27, 2026
This issue might be a duplicate of existing issues. Please check:
contentis missing ornull; its report notes thatcontent: ""is accepted.picpicpic commentedon Sep 27, 2026
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:
So after that PR:
Hotragn commentedon Sep 27, 2026
The
nullis deliberate rather than accidental, which is worth knowing before anyone patches it. Onv2,packages/ai/src/protocols/openai-chat.ts:484: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_callsmay havecontent: 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 explicitnullfrom an empty string when replaying assistant turns.The file already handles this class of divergence per-provider rather than globally — the Kimi
reasoning_contentbranch 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-itsuggests a self-hosted gateway), since that identifies whether this needs a provider flag or a broader default change.picpicpic commentedon Sep 28, 2026
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.
tabenius commentedon Oct 16, 2026
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 endpointhttps://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1.Symptoms
gpt-oss-120b,qwen3.8-27b,llama-4-scout-17b).Two distinct mismatches in one request:
contentas an array on some messages andnullon the assistant tool-call turn. Cloudflare's validator accepts only strings.Diagnosis
Captured the exact outbound body on the tool-call turn:
(the request also carries
max_completion_tokens,store,stream,stream_options,tools).Single-variable replay against the same endpoint:
content: nullchanged tocontent: ""→ 200Nothing else differs. As noted above, the
nullis deliberate atpackages/ai/src/protocols/openai-chat.ts:484:#41210 fixed only the third branch (neither text nor tool calls →
""). The tool-call branch still emitsnull. 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
gpt-oss-120b,qwen3.8-27b,llama-4-scout-17b)/accounts/{id}/ai/v1/chat/completions/compat/chat/completionscloudflare-workers-aiprovider runtime/ai/v1/responsesheaders,extraBody, …)transformRequestBodyexists 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. PointbaseURLat 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
tool_calls. Plain chat is unaffected.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)
tool_callsand assert every emitted message hastypeof content === 'string'or nocontentkey. Todaynullpasses silently because nothing asserts it.openai-compatibleruntimes are tested against a validator and not a permissive stub. The current suite cannot see this class of bug.content: null; omit the key when a turn hastool_calls.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
nullcontent on an assistant message that carriestool_calls, so thenullis 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 deliberatenullon 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
opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I was able to reproduce this.
I pointed a custom
@ai-sdk/openai-compatibleprovider (gemma-4-31b-it) at a mock server that rejects messages whosecontentisn't a string or array, as yours does. Then I ranuse 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 samecontent: nullis still sent on the latestdev(86ce19d), where the error shows asInvalid 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
nullto"") is the way to go for now.Reproduction script (repro.sh)
Run it:
bash repro.sh