-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
fix: normalize response images missing index + guard audio duration o… #22955
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a58d859
4740078
bc781a0
3c95588
d92bb15
cf9712d
a4fe061
b622694
a5e10f9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import Tabs from '@theme/Tabs'; | ||
| import TabItem from '@theme/TabItem'; | ||
|
|
||
| # AgentField with LiteLLM | ||
|
|
||
| Use [AgentField](https://agentfield.ai) with any LLM provider through LiteLLM. | ||
|
|
||
| AgentField is an open-source control plane for building and orchestrating autonomous AI agents, with SDKs for Python, TypeScript, and Go. AgentField's Python SDK uses LiteLLM internally for multi-provider LLM support. | ||
|
|
||
| ## Overview | ||
|
|
||
| AgentField's Python SDK uses `litellm.acompletion()` under the hood, giving you access to 100+ LLM providers out of the box: | ||
|
|
||
| - Use any LiteLLM-supported model (OpenAI, Anthropic, Azure, Bedrock, Ollama, etc.) | ||
| - Switch between providers by changing the model string | ||
| - All LiteLLM features (caching, fallbacks, routing) work automatically | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - Python 3.9+ | ||
| - API keys for your LLM providers | ||
| - AgentField control plane (optional, for orchestration features) | ||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| pip install agentfield | ||
| ``` | ||
|
|
||
| ## Quick Start | ||
|
|
||
| ### Basic Agent with OpenAI | ||
|
|
||
| ```python | ||
| from agentfield import Agent, AgentConfig | ||
|
|
||
| config = AgentConfig( | ||
| name="my-agent", | ||
| model="gpt-4o", # Any LiteLLM-supported model | ||
| instructions="You are a helpful assistant." | ||
| ) | ||
|
|
||
| agent = Agent(config) | ||
| response = await agent.run("Hello, world!") | ||
| ``` | ||
|
|
||
| ### Using Anthropic | ||
|
|
||
| ```python | ||
| config = AgentConfig( | ||
| name="claude-agent", | ||
| model="anthropic/claude-sonnet-4-20250514", # LiteLLM model format | ||
| instructions="You are a helpful assistant." | ||
| ) | ||
| ``` | ||
|
|
||
| ### Using Ollama (Local Models) | ||
|
|
||
| ```python | ||
| config = AgentConfig( | ||
| name="local-agent", | ||
| model="ollama/llama3.1", # LiteLLM's ollama/ prefix | ||
| instructions="You are a helpful assistant." | ||
| ) | ||
| ``` | ||
|
|
||
| ### Using Azure OpenAI | ||
|
|
||
| ```python | ||
| config = AgentConfig( | ||
| name="azure-agent", | ||
| model="azure/gpt-4o", # LiteLLM's azure/ prefix | ||
| instructions="You are a helpful assistant." | ||
| ) | ||
| ``` | ||
|
|
||
| ### Using with LiteLLM Proxy | ||
|
|
||
| Point AgentField to a LiteLLM Proxy for centralized model management: | ||
|
|
||
| ```python | ||
| import os | ||
|
|
||
| os.environ["OPENAI_API_BASE"] = "http://0.0.0.0:4000" # LiteLLM Proxy URL | ||
| os.environ["OPENAI_API_KEY"] = "sk-1234" # LiteLLM Proxy key | ||
|
|
||
| config = AgentConfig( | ||
| name="proxy-agent", | ||
| model="gpt-4o", # Virtual model name from proxy config | ||
| instructions="You are a helpful assistant." | ||
| ) | ||
| ``` | ||
|
|
||
| ## Multi-Agent Orchestration | ||
|
|
||
| AgentField's control plane orchestrates multiple agents, each potentially using different LLM providers: | ||
|
|
||
| ```python | ||
| from agentfield import Agent, AgentConfig, ControlPlane | ||
|
|
||
| # Create agents with different providers | ||
| researcher = Agent(AgentConfig( | ||
| name="researcher", | ||
| model="anthropic/claude-sonnet-4-20250514", | ||
| instructions="You research topics thoroughly." | ||
| )) | ||
|
|
||
| writer = Agent(AgentConfig( | ||
| name="writer", | ||
| model="gpt-4o", | ||
| instructions="You write clear, concise content." | ||
| )) | ||
|
|
||
| # Register with control plane | ||
| cp = ControlPlane(server="http://localhost:8080") | ||
| cp.register(researcher) | ||
| cp.register(writer) | ||
| ``` | ||
|
|
||
| ## Links | ||
|
|
||
| - [Documentation](https://agentfield.ai/docs) | ||
| - [GitHub](https://github.com/Agent-Field/agentfield) | ||
| - [Python SDK](https://github.com/Agent-Field/agentfield/tree/main/sdk/python) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,6 +46,20 @@ | |
|
|
||
| from .get_headers import get_response_headers | ||
|
|
||
| def _normalize_images( | ||
| images: Optional[List[Dict[str, object]]], | ||
| ) -> Optional[List[Dict[str, object]]]: | ||
| """Normalize image items to include required 'index' field if missing.""" | ||
| if images is None: | ||
| return None | ||
| normalized: List[Dict[str, object]] = [] | ||
| for i, img in enumerate(images): | ||
| if isinstance(img, dict) and "index" not in img: | ||
| img = {**img, "index": i} | ||
| normalized.append(img) | ||
| return normalized | ||
|
Comment on lines
+49
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No unit tests for This is the other primary bug fix in the PR title (OpenRouter/Gemini images missing
…would greatly increase confidence in the fix and protect against regressions.
Comment on lines
+49
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This function was added by the PR but is never invoked anywhere in the codebase. A The actual image-index fix is handled by the pre-existing images=_normalize_images_for_message(
choice["message"].get("images", None)
),The two functions are nearly identical in behavior. Because |
||
|
|
||
|
|
||
| _MESSAGE_FIELDS: frozenset = frozenset(Message.model_fields.keys()) | ||
| _CHOICES_FIELDS: frozenset = frozenset(Choices.model_fields.keys()) | ||
| _MODEL_RESPONSE_FIELDS: frozenset = frozenset(ModelResponse.model_fields.keys()) | { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,10 @@ | |
| from litellm.types.router import GenericLiteLLMParams | ||
| from litellm.utils import ProviderConfigManager, client | ||
|
|
||
| from litellm.litellm_core_utils.prompt_templates.common_utils import ( | ||
| DEFAULT_ASSISTANT_CONTINUE_MESSAGE, | ||
| ) | ||
|
|
||
| from ..adapters.handler import LiteLLMMessagesToCompletionTransformationHandler | ||
| from ..responses_adapters.handler import LiteLLMMessagesToResponsesAPIHandler | ||
| from .utils import AnthropicMessagesRequestUtils, mock_response | ||
|
|
@@ -49,6 +53,58 @@ def _should_route_to_responses_api(custom_llm_provider: Optional[str]) -> bool: | |
| ################################################# | ||
|
|
||
|
|
||
| def _sanitize_anthropic_messages(messages: List[Dict]) -> List[Dict]: | ||
| """ | ||
| Sanitize messages for the /v1/messages endpoint. | ||
|
|
||
| The Anthropic API can return assistant messages with empty text blocks | ||
| alongside tool_use blocks (e.g., {"type": "text", "text": ""}). While | ||
| the API returns these, it rejects them when sent back in subsequent | ||
| requests with "text content blocks must be non-empty". | ||
|
|
||
| This is particularly common in multi-turn tool-use conversations (e.g., | ||
| Claude Code / Agent SDK) where the model starts a text block but | ||
| immediately switches to a tool_use block. | ||
|
|
||
| The /v1/chat/completions path already handles this via | ||
| process_empty_text_blocks() in factory.py, but the /v1/messages path | ||
| was missing sanitization. | ||
| """ | ||
| for i, message in enumerate(messages): | ||
| content = message.get("content") | ||
| if not isinstance(content, list): | ||
| continue | ||
|
|
||
| # Filter out empty text blocks, keeping non-empty text and other types. | ||
| # Use `(... or "")` to guard against None text values. | ||
| filtered = [ | ||
| block | ||
| for block in content | ||
| if not ( | ||
| isinstance(block, dict) | ||
| and block.get("type") == "text" | ||
| and not (block.get("text") or "").strip() | ||
| ) | ||
| ] | ||
|
|
||
| # Only update if we actually removed something. | ||
| # Avoid mutating the caller's dicts — create a shallow copy. | ||
| if len(filtered) < len(content): | ||
| if len(filtered) > 0: | ||
| messages[i] = {**message, "content": filtered} | ||
| else: | ||
| # All blocks were empty text — replace with a continuation | ||
| # message rather than leaving empty blocks that trigger 400 | ||
| # errors. Matches behavior of process_empty_text_blocks() | ||
| # in factory.py. | ||
| messages[i] = { | ||
| **message, | ||
| "content": [{"type": "text", "text": DEFAULT_ASSISTANT_CONTINUE_MESSAGE.get("content", "Please continue.")}], | ||
|
Comment on lines
+75
to
+102
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In-place mutation of the input The function docstring comment says "Avoid mutating the caller's dicts — create a shallow copy", but the function actually mutates the input While each updated message dict is a new object ( messages[i] = {**message, "content": filtered}…write back into the original list. Any caller that holds a reference to the same list object (e.g. for retry logic, logging, or span attributes) will observe the sanitised content unexpectedly. A safe alternative is to build a new list rather than modifying in place: def _sanitize_anthropic_messages(messages: List[Dict]) -> List[Dict]:
result = []
for message in messages:
content = message.get("content")
if not isinstance(content, list):
result.append(message)
continue
filtered = [
block
for block in content
if not (
isinstance(block, dict)
and block.get("type") == "text"
and not (block.get("text") or "").strip()
)
]
if len(filtered) < len(content):
if len(filtered) > 0:
result.append({**message, "content": filtered})
else:
result.append({
**message,
"content": [{"type": "text", "text": DEFAULT_ASSISTANT_CONTINUE_MESSAGE.get("content", "Please continue.")}],
})
else:
result.append(message)
return result |
||
| } | ||
|
|
||
|
Comment on lines
+93
to
+104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The function mutates the input To ensure immutability and prevent bugs in callers that hold references to the original list, build a new list instead: def _sanitize_anthropic_messages(messages: List[Dict]) -> List[Dict]:
"""..."""
result = []
for message in messages:
content = message.get("content")
if not isinstance(content, list):
result.append(message)
continue
filtered = [
block
for block in content
if not (
isinstance(block, dict)
and block.get("type") == "text"
and not (block.get("text") or "").strip()
)
]
if len(filtered) < len(content):
if len(filtered) > 0:
result.append({**message, "content": filtered})
else:
result.append({
**message,
"content": [{"type": "text", "text": DEFAULT_ASSISTANT_CONTINUE_MESSAGE.get("content", "Please continue.")}],
})
else:
result.append(message)
return resultThe call site already reassigns ( |
||
| return messages | ||
|
|
||
|
|
||
| async def _execute_pre_request_hooks( | ||
| model: str, | ||
| messages: List[Dict], | ||
|
|
@@ -137,6 +193,10 @@ async def anthropic_messages( | |
| """ | ||
| Async: Make llm api request in Anthropic /messages API spec | ||
| """ | ||
| # Sanitize empty text blocks from messages before processing. | ||
| # See: https://github.com/BerriAI/litellm/issues/22930 | ||
| messages = _sanitize_anthropic_messages(messages) | ||
|
|
||
| # Execute pre-request hooks to allow CustomLoggers to modify request | ||
| request_kwargs = await _execute_pre_request_hooks( | ||
| model=model, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """OVHCloud Responses API support""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No unit tests for the sentinel guard and samplerate fix
The two new guards (
frames >= 2**63 - 1andsamplerate <= 0) are described as the primary bug fix in the PR title, yet no corresponding test was added intests/test_litellm/litellm_core_utils/test_audio_utils.py. The project's own pre-submission checklist states "Adding at least 1 test is a hard requirement". Without tests it's difficult to verify the sentinel-value path returnsNoneand that the samplerate guard prevents aZeroDivisionError.