Skip to content

[BUG] Structured output not working for LiteLLM #743

@jtseng-godaddy

Description

@jtseng-godaddy

Checks

  • I have updated to the lastest minor and patch version of Strands
  • I have checked the documentation and this is not expected behavior
  • I have searched ./issues and there are no duplicates of my issue

Strands Version

v1.5.0

Python Version

3.13.5

Operating System

macOs 15.5

Installation Method

pip

Steps to Reproduce

  1. Use the same example: https://strandsagents.com/latest/documentation/docs/user-guide/concepts/model-providers/litellm/ Structured Output section
  2. Run the example using LiteLLM (Model I used is "claude-3-7-sonnet-20250219")
  3. Error I keep seeing No tool_calls found in response

it seems that litellm finish_reason is "tool_use" NOT "tool_calls"

Expected Behavior

Structured output should work with litellm
The original structured_output method in LiteLLMModel only handled a single response format (finish_reason == "tool_calls"), which was insufficient for handling various response formats from different models and LiteLLM's specific response structures.

Actual Behavior

  1. Use the same example: https://strandsagents.com/latest/documentation/docs/user-guide/concepts/model-providers/litellm/ Structured Output section
  2. Error I keep seeing No tool_calls found in response

{"timestamp": "2025-08-26T04:59:54.592638", "message": "❌ Error: No tool_calls found in response", "level": "ERROR", "logger": "", "module": "conversations", "function": "", "line": 490, "exception": "Traceback (most recent call last):\n File "REDACTED", line 476, in REDACTED\n result: AgentResponse = await asyncio.wait_for(\n ^^^^^^^^^^^^^^^^^^^^^^^\n ...<2 lines>...\n )\n ^\n File "/opt/homebrew/Cellar/[email protected]/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/tasks.py", line 507, in wait_for\n return await fut\n ^^^^^^^^^\n File "REDACTED/python3.13/site-packages/strands/agent/agent.py", line 461, in structured_output_async\n async for event in events:\n if "callback" in event:\n self.callback_handler(**cast(dict, event["callback"]))\n File "REDACTED/lib/python3.13/site-packages/strands/models/litellm.py", line 225, in structured_output\n raise ValueError("No tool_calls found in response")\nValueError: No tool_calls found in response"}

it seems that litellm finish_reason is "tool_use" NOT "tool_calls"

Additional Context

The original implementation was too restrictive and didn't account for:
Different response formats (tool_use vs tool_calls)
LiteLLM's specific stop_reason handling
Content arrays with structured data
Fallback parsing for structured content in regular responses

Possible Solution

File: venv/lib/python3.13/site-packages/strands/models/litellm.py
Method: structured_output
Change: Expanded from single format to multiple format support

Step 1: Enhanced Response Format Handling
File: venv/lib/python3.13/site-packages/strands/models/litellm.py
Method: structured_output
Change: Expanded from single format to multiple format support

# Before: Only handled tool_calls
if choice.finish_reason == "tool_calls":
    # Simple JSON parsing

# After: Multiple format support
# 1. Tool calls format (original)
if choice.finish_reason == "tool_calls" or choice.finish_reason == "tool_use":
    # Enhanced parsing

# 2. Content array with tool_use
if hasattr(choice.message, 'content') and isinstance(choice.message.content, list):
    for content_item in choice.message.content:
        if hasattr(content_item, 'type') and content_item.type == 'tool_use':
            # Parse tool_use from content array

# 3. LiteLLM specific stop_reason
if hasattr(choice, 'stop_reason') and choice.stop_reason == "tool_use":
    # Handle LiteLLM's stop_reason format

# 4. Fallback parsing
if choice.finish_reason == "stop" and hasattr(choice.message, 'content'):
    # Try parsing regular content as structured data

Step 2: Content Array Support
Change: Added support for LiteLLM's content array format

# New: Handle content arrays with tool_use
if hasattr(choice.message, 'content') and isinstance(choice.message.content, list):
    for content_item in choice.message.content:
        if hasattr(content_item, 'type') and content_item.type == 'tool_use':
            if hasattr(content_item, 'input') and content_item.input:
                yield {"output": output_model(**content_item.input)}

Step 3: Fallback Structured Data Parsing
Change: Added fallback for when structured data is in regular content

# New: Try parsing regular content as JSON if it looks structured
if choice.finish_reason == "stop" and hasattr(choice.message, 'content'):
    try:
        content_data = json.loads(choice.message.content)
        if isinstance(content_data, dict) and all(isinstance(k, str) for k in content_data.keys()):
            yield {"output": output_model(**content_data)}
    except (json.JSONDecodeError, TypeError, ValueError):
        pass  # Continue to next choice

Related Issues

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-structured-outputRelated to the structured output apibugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions