Skip to content

Tool call arguments may contain invalid JSON #17750

Closed as not planned
Closed as not planned

Description

@citiboy

Summary

When using SiliconFlow's Qwen3-8B model, tool call arguments may contain invalid JSON due to the </tool_call> XML tag being included in the function.arguments field. This affects both streaming and non-streaming modes because neither validates JSON before emitting tool-call events.

Environment

  • OpenCode Version: v1.2.20+
  • Provider: SiliconFlow (China)
  • Model: Qwen/Qwen3-8B
  • File: packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts

Problem Description

Root Cause

Qwen3-8B internally uses <tool_call>...</tool_call> XML format to represent tool calls. When SiliconFlow's API converts this to OpenAI-compatible format, the </tool_call> tag may be included in function.arguments.

Code Analysis

Non-streaming mode (doGenerate) - Line 245-256:

if (choice.message.tool_calls != null) {
  for (const toolCall of choice.message.tool_calls) {
    content.push({
      type: "tool-call",
      toolCallId: toolCall.id ?? generateId(),
      toolName: toolCall.function.name,
      input: toolCall.function.arguments!,  // No JSON validation!
      // ...
    })
  }
}

Streaming mode (flush) - Line 640-653:

flush(controller) {
  // ...
  for (const toolCall of toolCalls.filter((toolCall) => !toolCall.hasFinished)) {
    controller.enqueue({
      type: "tool-call",
      toolCallId: toolCall.id ?? generateId(),
      toolName: toolCall.function.name,
      input: toolCall.function.arguments,  // No JSON validation!
    })
  }
}

Note: In streaming mode, there is JSON validation during chunk processing (line 603-606), but unfinished tool calls in flush bypass this check.

Error Message

Invalid input for tool question: JSON parsing failed: Text: {"questions": [...], "multiple": true}}
</tool_call>.
Error message: JSON Parse error: Expected ']'

Steps to Reproduce

  1. Configure OpenCode to use SiliconFlow with Qwen3-8B
  2. Send a message that triggers a tool call (e.g., ambiguous request that needs clarification)
  3. Observe that the tool call arguments contain </tool_call> tag

Minimal reproduction with curl:

curl -s -N https://api.siliconflow.cn/v1/chat/completions \
  -H "Authorization: Bearer $SILICONFLOW_CN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen/Qwen3-8B",
    "messages": [{"role": "user", "content": "帮我写一个Python爬虫"}],
    "tools": [{"type": "function", "function": {"name": "question", "description": "Ask user questions", "parameters": {"type": "object", "properties": {"questions": {"type": "array", "items": {"type": "object", "properties": {"question": {"type": "string"}, "options": {"type": "array", "items": {"type": "string"}}, "multiple": {"type": "boolean"}}}}}, "required": ["questions"]}}}],
    "tool_choice": "auto",
    "stream": true
  }'

Expected Behavior

Tool call arguments should always be valid JSON in both streaming and non-streaming modes.

Suggested Fix

Option 1: Add JSON validation in both modes

For non-streaming (doGenerate):

if (choice.message.tool_calls != null) {
  for (const toolCall of choice.message.tool_calls) {
    let args = toolCall.function.arguments!
    // Strip XML artifacts from providers like SiliconFlow
    args = args.replace(/<\/?tool_call>/g, '').trim()

    if (!isParsableJson(args)) {
      console.warn(`Invalid tool call arguments: ${args}`)
      continue
    }

    content.push({
      type: "tool-call",
      toolCallId: toolCall.id ?? generateId(),
      toolName: toolCall.function.name,
      input: args,
      // ...
    })
  }
}

For streaming (flush):

flush(controller) {
  // ...
  for (const toolCall of toolCalls.filter((toolCall) => !toolCall.hasFinished)) {
    let args = toolCall.function.arguments
    // Strip XML artifacts from providers like SiliconFlow
    args = args.replace(/<\/?tool_call>/g, '').trim()

    if (!isParsableJson(args)) {
      console.warn(`Invalid tool call arguments: ${args}`)
      continue
    }

    controller.enqueue({
      type: "tool-call",
      toolCallId: toolCall.id ?? generateId(),
      toolName: toolCall.function.name,
      input: args,
    })
  }
}

Option 2: Strip XML tags during chunk accumulation (streaming only)

if (toolCallDelta.function?.arguments != null) {
  let args = toolCallDelta.function?.arguments ?? ""
  // Strip XML tags that some providers include
  args = args.replace(/<\/?tool_call>/g, '')
  toolCall.function!.arguments += args
}

Note: Option 2 only fixes streaming mode. Non-streaming mode still needs separate handling.

Related Code

  • packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts:245-256 - doGenerate tool_calls handling (non-streaming)
  • packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts:640-653 - flush function (streaming)
  • packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts:591-592 - arguments accumulation (streaming)
  • packages/opencode/src/session/llm.ts:178-197 - experimental_repairToolCall (current workaround redirects to invalid tool)

Activity

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

    @github-actions
    Contributor

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

  3. changed the title [-]Tool call arguments may contain invalid JSON in streaming mode[/-] [+]Tool call arguments may contain invalid JSON[/+] on Mar 16, 2026
  4. removed
    bugSomething isn't working
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  5. jaykayenn commented on May 24, 2026

    @jaykayenn

    Deepseek via Huggingface also results in "</" being appended to tool calls in Opencode, breaking tool usage.

  6. github-actions commented on Jul 24, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions