Skip to content

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

@wattmto

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

Metadata

Metadata

Assignees

Labels

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

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions