llamaIndex - ✅(Solved) Fix [Bug]: to_openai_message_dict doesn't JSON-serialize ToolCallBlock.tool_kwargs and breaks cross-provider agent workflows [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
run-llama/llama_index#21378Fetched 2026-04-15 06:20:01
View on GitHub
Comments
1
Participants
2
Timeline
6
Reactions
0
Timeline (top)
labeled ×2referenced ×2commented ×1cross-referenced ×1

Error Message

BadRequestError: Error code: 400 - {'error': {'message': "Invalid type for 'messages[3].tool_calls[0].function.arguments': expected a string, but got an object instead.", 'type': 'invalid_request_error', 'param': 'messages[3].tool_calls[0].function.arguments', 'code': 'invalid_type'}}

Root Cause

When using AgentWorkflow with mixed LLM providers (e.g., Anthropic orchestrator handing off to an OpenAI sub-agent), the OpenAI Chat Completions API returns a 400 BadRequestError because function.arguments is sent as a JSON object instead of a JSON string.

PR fix notes

PR #21389: fix: JSON-serialize ToolCallBlock.tool_kwargs in to_openai_message_dict

Description (problem / solution / changelog)

Fixes #21378

Problem

When using AgentWorkflow with mixed LLM providers (e.g., Anthropic orchestrator handing off to an OpenAI sub-agent), the OpenAI Chat Completions API returns a 400 BadRequestError because function.arguments was sent as a JSON object instead of a JSON string.

BadRequestError: Error code: 400 - {'error': {'message': "Invalid type for 'messages[3].tool_calls[0].function.arguments': expected a string, but got an object instead.", ...}}

Root Cause

In llama_index/llms/openai/utils.py, the to_openai_message_dict function placed block.tool_kwargs directly into "arguments" without JSON serialization. The OpenAI Chat Completions API expects function.arguments to be a JSON string.

Fix

  • Serialize dict tool_kwargs to JSON string before assigning to function.arguments
  • String tool_kwargs are passed through unchanged (backward compatible)

Changes

  • Modified to_openai_message_dict() in llama_index/llms/openai/utils.py
  • Added 2 tests in tests/test_openai_utils.py:
    • test_chat_completions_tool_kwargs_serialized_to_json_string() - verifies dict serialization
    • test_chat_completions_tool_kwargs_string_passthrough() - verifies string passthrough

Testing

cd llama-index-integrations/llms/llama-index-llms-openai
pytest tests/test_openai_utils.py::test_chat_completions_tool_kwargs_serialized_to_json_string -v
pytest tests/test_openai_utils.py::test_chat_completions_tool_kwargs_string_passthrough -v

Changed files

  • llama-index-integrations/llms/llama-index-llms-openai/llama_index/llms/openai/utils.py (modified, +5/-1)
  • llama-index-integrations/llms/llama-index-llms-openai/tests/test_openai_utils.py (modified, +51/-0)

PR #21404: fix(openai): serialize ToolCallBlock.tool_kwargs to JSON string in Chat Completions API

Description (problem / solution / changelog)

Summary

  • to_openai_message_dict passes ToolCallBlock.tool_kwargs directly as a dict into function.arguments, but the OpenAI Chat Completions API requires it to be a JSON string
  • This causes BadRequestError: 400 in cross-provider agent workflows (e.g. Anthropic orchestrator → OpenAI sub-agent) since Anthropic stores tool input as a Python dict
  • Applied the same json.dumps() serialization pattern already used in to_openai_responses_message_dict (lines 666-668)

Fixes #21378

Test plan

  • Added test_chat_completions_tool_kwargs_serialized_to_json_string — verifies dict kwargs are JSON-serialized
  • Added test_chat_completions_tool_kwargs_string_passthrough — verifies string kwargs pass through unchanged
  • Existing responses API tests continue to pass
  • All 4 tool_kwargs-related tests pass

Changed files

  • llama-index-integrations/llms/llama-index-llms-openai/llama_index/llms/openai/utils.py (modified, +4/-1)
  • llama-index-integrations/llms/llama-index-llms-openai/tests/test_openai_utils.py (modified, +49/-0)

Code Example

BadRequestError: Error code: 400 - {'error': {'message': "Invalid type for 
'messages[3].tool_calls[0].function.arguments': expected a string, but got an 
object instead.", 'type': 'invalid_request_error', 'param': 
'messages[3].tool_calls[0].function.arguments', 'code': 'invalid_type'}}

---

# to_openai_message_dict (Chat Completions API)BROKEN
elif isinstance(block, ToolCallBlock):
    function_dict = {
        "type": "function",
        "function": {
            "name": block.tool_name,
            "arguments": block.tool_kwargs,   # <-- dict when from Anthropic, OpenAI expects string
        },
        "id": block.tool_call_id,
    }

---

# to_openai_responses_message_dict (Responses API)FIXED
elif isinstance(block, ToolCallBlock):
    arguments = block.tool_kwargs
    if not isinstance(arguments, str):
        arguments = json.dumps(arguments)      # <-- correctly serialized
    tool_calls.extend([...])

---

elif isinstance(block, ToolCallBlock):
    try:
        arguments = block.tool_kwargs
        if not isinstance(arguments, str):
            arguments = json.dumps(arguments)
        
        function_dict = {
            "type": "function",
            "function": {
                "name": block.tool_name,
                "arguments": arguments,
            },
            "id": block.tool_call_id,
        }

---

from llama_index.core.agent.workflow import AgentWorkflow, FunctionAgent
from llama_index.llms.anthropic import Anthropic
from llama_index.llms.openai import OpenAI

orchestrator = FunctionAgent(
    name="orchestrator",
    llm=Anthropic(model="claude-sonnet-4-6"),
    can_handoff_to=["worker"],
    system_prompt="Delegate all queries to the worker agent.",
)

worker = FunctionAgent(
    name="worker",
    llm=AzureOpenAI(model="gpt-5.2",
    system_prompt="Answer the user's question.",
    tools=[...],
)

workflow = AgentWorkflow(agents=[orchestrator, worker], root_agent="orchestrator")

# This will fail when the orchestrator hands off to the worker
result = await workflow.run(user_msg="Hello")

---
RAW_BUFFERClick to expand / collapse

Bug Description

When using AgentWorkflow with mixed LLM providers (e.g., Anthropic orchestrator handing off to an OpenAI sub-agent), the OpenAI Chat Completions API returns a 400 BadRequestError because function.arguments is sent as a JSON object instead of a JSON string.

BadRequestError: Error code: 400 - {'error': {'message': "Invalid type for 
'messages[3].tool_calls[0].function.arguments': expected a string, but got an 
object instead.", 'type': 'invalid_request_error', 'param': 
'messages[3].tool_calls[0].function.arguments', 'code': 'invalid_type'}}

Root Cause

In llama_index/llms/openai/utils.py, the to_openai_message_dict function places block.tool_kwargs directly into "arguments" without serialization:

# to_openai_message_dict (Chat Completions API) — BROKEN
elif isinstance(block, ToolCallBlock):
    function_dict = {
        "type": "function",
        "function": {
            "name": block.tool_name,
            "arguments": block.tool_kwargs,   # <-- dict when from Anthropic, OpenAI expects string
        },
        "id": block.tool_call_id,
    }

The Anthropic provider stores tool_kwargs as a Python dict (since the Anthropic API returns tool input as an object). The OpenAI Chat Completions API requires function.arguments to be a JSON string.

This is already fixed in to_openai_responses_message_dict (Responses API path) but not in to_openai_message_dict (Chat Completions API path):

# to_openai_responses_message_dict (Responses API) — FIXED
elif isinstance(block, ToolCallBlock):
    arguments = block.tool_kwargs
    if not isinstance(arguments, str):
        arguments = json.dumps(arguments)      # <-- correctly serialized
    tool_calls.extend([...])

Suggested Fix

In to_openai_message_dict, apply the same pattern used in to_openai_responses_message_dict:

elif isinstance(block, ToolCallBlock):
    try:
        arguments = block.tool_kwargs
        if not isinstance(arguments, str):
            arguments = json.dumps(arguments)
        
        function_dict = {
            "type": "function",
            "function": {
                "name": block.tool_name,
                "arguments": arguments,
            },
            "id": block.tool_call_id,
        }

This is a one-line fix that ensures cross-provider compatibility while remaining backward-compatible (if tool_kwargs is already a string, it's passed through unchanged).

Version

0.14.8

Steps to Reproduce

Create an AgentWorkflow with two agents:

Agent A using an Anthropic LLM (e.g., claude-sonnet-4-6) Agent B using an AzureOpenAI LLM (e.g., gpt-5.2) Agent A is the root orchestrator with can_handoff_to=["Agent B"] Send a user query that causes Agent A to hand off to Agent B Agent B's LLM call fails with the 400 BadRequestError

from llama_index.core.agent.workflow import AgentWorkflow, FunctionAgent
from llama_index.llms.anthropic import Anthropic
from llama_index.llms.openai import OpenAI

orchestrator = FunctionAgent(
    name="orchestrator",
    llm=Anthropic(model="claude-sonnet-4-6"),
    can_handoff_to=["worker"],
    system_prompt="Delegate all queries to the worker agent.",
)

worker = FunctionAgent(
    name="worker",
    llm=AzureOpenAI(model="gpt-5.2",
    system_prompt="Answer the user's question.",
    tools=[...],
)

workflow = AgentWorkflow(agents=[orchestrator, worker], root_agent="orchestrator")

# This will fail when the orchestrator hands off to the worker
result = await workflow.run(user_msg="Hello")

Expected Behavior

tool_kwargs should be JSON-serialized to a string before being placed into function.arguments when converting messages for the OpenAI Chat Completions API, consistent with how it's already handled in to_openai_responses_message_dict.

Relevant Logs/Tracebacks

extent analysis

TL;DR

The most likely fix is to serialize tool_kwargs to a JSON string in the to_openai_message_dict function before passing it to the OpenAI Chat Completions API.

Guidance

  • The issue arises from the difference in how Anthropic and OpenAI APIs handle tool_kwargs. Anthropic returns it as a Python dict, while OpenAI expects a JSON string.
  • To fix this, apply the same serialization pattern used in to_openai_responses_message_dict to to_openai_message_dict.
  • Specifically, check if arguments is not a string and serialize it using json.dumps(arguments) if necessary.
  • This ensures cross-provider compatibility without breaking backward compatibility for cases where tool_kwargs is already a string.

Example

elif isinstance(block, ToolCallBlock):
    try:
        arguments = block.tool_kwargs
        if not isinstance(arguments, str):
            arguments = json.dumps(arguments)
        
        function_dict = {
            "type": "function",
            "function": {
                "name": block.tool_name,
                "arguments": arguments,
            },
            "id": block.tool_call_id,
        }

Notes

This fix assumes that the json module is available and imported. The provided code snippet directly addresses the identified issue and should be applied to the to_openai_message_dict function.

Recommendation

Apply the workaround by serializing tool_kwargs to a JSON string in the to_openai_message_dict function, as this directly addresses the compatibility issue between Anthropic and OpenAI APIs.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING