Skip to content

OpenAI-compatible streaming tool calls fail when function.name arrives in a later chunk #24137

Closed

Description

@wattmto

Description

OpenAI-compatible streaming tool calls can fail when a provider/model sends the first tool_calls delta with an id and function.arguments, but without function.name, and then sends function.name in a later delta.

OpenCode currently treats the first partial tool-call delta as invalid and throws:

AI_InvalidResponseDataError: Expected 'function.name' to be a string.

This appears to be a streaming parser robustness issue. OpenAI-compatible streaming responses may split tool-call fields across deltas, so a partial chunk should be accumulated until enough data exists to emit the tool call.

This is similar in spirit to vercel/ai#6687, where provider-specific OpenAI-compatible tool-call chunks were valid after accumulation but failed because the parser expected a more complete shape too early.

Expected Behavior

The following streamed chunks should be accumulated into a single valid tool call:

{
  "id": "call_123",
  "type": "function",
  "function": {
    "name": "bash",
    "arguments": "{\"command\": \"ls -la\"}"
  }
}

OpenCode should execute the bash tool after the function.name and complete JSON arguments have been received.

Actual Behavior

OpenCode throws before the later chunk containing function.name is processed:

AI_InvalidResponseDataError: Expected 'function.name' to be a string.

Minimal Reproduction

This mock OpenAI-compatible SSE stream reproduces the chunk ordering. The key part is the second chunk: it contains id, type, and function.arguments, but no function.name. The third chunk then provides function.name.

const chunks = [
  `data: {"choices":[{"finish_reason":null,"index":0,"delta":{"role":"assistant","content":null}}]}`,
  `data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"arguments":""}}]}}]}`,
  `data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"bash","arguments":"{"}}]}}]}`,
  `data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\u0022command\\u0022"}}]}}]}`,
  `data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":": \\u0022ls -la\\u0022}"}}]}}]}`,
  `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{}}]}`,
  `data: [DONE]`,
]

const stream = new ReadableStream({
  start(controller) {
    for (const chunk of chunks) {
      controller.enqueue(new TextEncoder().encode(`${chunk}\n\n`))
    }
    controller.close()
  },
})

const response = new Response(stream, {
  status: 200,
  headers: { "Content-Type": "text/event-stream" },
})

A parser that consumes this response should delay validation/emission of the tool call until function.name has arrived and the accumulated arguments are complete.

Raw SSE Stream

data: {"choices":[{"finish_reason":null,"index":0,"delta":{"role":"assistant","content":null}}]}

data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"arguments":""}}]}}]}

data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"bash","arguments":"{"}}]}}]}

data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\u0022command\\u0022"}}]}}]}

data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":": \\u0022ls -la\\u0022}"}}]}}]}

data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{}}]}

data: [DONE]

Suggested Fix Direction

Accumulate partial tool-call deltas by index/id and only emit/validate the tool call once function.name is available. If the stream finishes and a pending tool call still has no function.name, then it should remain an invalid response.

Related

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Apr 24, 2026
  2. github-actions commented on Apr 24, 2026

    @github-actions
    Contributor

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

    • Weird error with toolcalling on local model #6649: Same root cause and identical error message (AI_InvalidResponseDataError: Expected 'function.name' to be a string.) with the same chunk ordering pattern — function.name arriving in a later delta than function.arguments. You referenced this issue yourself; it may be worth consolidating there.
  3. wattmto commented on Apr 24, 2026

    @wattmto
    Author

    Closing because this minimum reproduction targets the upstream OpenAI-compatible streaming parser behavior and should be filed against vercel/ai instead.

  4. darkhipo commented on Jun 17, 2026

    @darkhipo

    Can confirm: bug appears fixed in v1.17.8

    We reproduced this exact error (AI_InvalidResponseDataError: Expected 'function.name' to be a string.) on opencode v1.17.5 with dashscope/qwen3.7-max-2026-06-08 — an OpenAI-compatible streaming provider.

    After upgrading to v1.17.8 (installed via curl -fsSL https://opencode.ai/install | bash), the bug no longer reproduces. Ran three consecutive reproduction tests with tool-calling prompts — all tool calls (ls, grep, file reads) executed cleanly with zero instances of the error, even under --log-level DEBUG.

    Environment:

    • opencode: v1.17.8 (via install script, binary at ~/.opencode/bin/opencode)
    • Model: dashscope/qwen3.7-max-2026-06-08 (OpenAI-compatible streaming)
    • macOS ARM64 (Apple Silicon)

    Note: brew formula (anomalyco/tap) is not yet updated past v1.17.5; the install script gets v1.17.8 directly.

    Thanks for the fix — this was causing significant tool-call reliability issues on our setup.

  5. nazerim commented on Oct 1, 2026

    @nazerim

    Still reproducible on 1.18.34. Root cause confirmed by grepping published tarballs: the throw string Expected 'function.name' ships in @ai-sdk/openai-compatible up to 2.0.7 and is gone from 3.0.0 onward (vercel/ai#18440 / PR #18445 fixed it upstream) — but opencode's compiled binary still bundles a pre-3.0.0 copy, so every closed tracker reads "fixed/not-ours" while users keep hitting it.

    Filed with evidence + a deterministic synthetic-SSE reproduction (no real API key needed): #52462

    Interim fix (MIT, plugin-only, no core changes): https://github.com/nazerim/toolcall-guard — repairs/buffers malformed tool_calls deltas at the fetch layer, scrubs replayed ChatML tokens from outbound history (they re-arm the provider's parser every turn), and auto-continues turns the provider silently truncates at a clean finish_reason:"stop".

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

Metadata

Metadata

Assignees

Labels

acpcoreAnything pertaining to core functionality of the application (opencode server stuff)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions