Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/my-website/docs/integrations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
This section covers integrations with various tools and services that can be used with LiteLLM (either Proxy or SDK).

## AI Agent Frameworks
- **[AgentField](../tutorials/agentfield.md)** - Open-source control plane for building and orchestrating autonomous AI agents
- **[Letta](./letta.md)** - Build stateful LLM agents with persistent memory using LiteLLM Proxy

## Development Tools
Expand All @@ -15,4 +16,4 @@ This section covers integrations with various tools and services that can be use
- **[Datadog](../observability/datadog.md)**


Click into each section to learn more about the integrations.
Click into each section to learn more about the integrations.
124 changes: 124 additions & 0 deletions docs/my-website/docs/tutorials/agentfield.md
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)
1 change: 1 addition & 0 deletions docs/my-website/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ const sidebars = {
slug: "/agent_sdks"
},
items: [
"tutorials/agentfield",
"tutorials/openai_agents_sdk",
"tutorials/claude_agent_sdk",
"tutorials/copilotkit_sdk",
Expand Down
1 change: 1 addition & 0 deletions litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,7 @@ def set_global_gitlab_config(config: Dict[str, Any]) -> None:
from .llms.perplexity.responses.transformation import PerplexityResponsesConfig as PerplexityResponsesConfig
from .llms.databricks.responses.transformation import DatabricksResponsesAPIConfig as DatabricksResponsesAPIConfig
from .llms.openrouter.responses.transformation import OpenRouterResponsesAPIConfig as OpenRouterResponsesAPIConfig
from .llms.ovhcloud.responses.transformation import OVHCloudResponsesAPIConfig as OVHCloudResponsesAPIConfig
from .llms.gemini.interactions.transformation import GoogleAIStudioInteractionsConfig as GoogleAIStudioInteractionsConfig
from .llms.openai.chat.o_series_transformation import OpenAIOSeriesConfig as OpenAIOSeriesConfig, OpenAIOSeriesConfig as OpenAIO1Config
from .llms.anthropic.skills.transformation import AnthropicSkillsConfig as AnthropicSkillsConfig
Expand Down
5 changes: 5 additions & 0 deletions litellm/_lazy_imports_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@
"PerplexityResponsesConfig",
"DatabricksResponsesAPIConfig",
"OpenRouterResponsesAPIConfig",
"OVHCloudResponsesAPIConfig",
"GoogleAIStudioInteractionsConfig",
"OpenAIOSeriesConfig",
"AnthropicSkillsConfig",
Expand Down Expand Up @@ -930,6 +931,10 @@
".llms.openrouter.responses.transformation",
"OpenRouterResponsesAPIConfig",
),
"OVHCloudResponsesAPIConfig": (
".llms.ovhcloud.responses.transformation",
"OVHCloudResponsesAPIConfig",
),
"GoogleAIStudioInteractionsConfig": (
".llms.gemini.interactions.transformation",
"GoogleAIStudioInteractionsConfig",
Expand Down
20 changes: 17 additions & 3 deletions litellm/cost_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,19 @@ def completion_cost( # noqa: PLR0915
router_model_id=router_model_id,
)

# When base_model overrides model and carries its own provider prefix
# (e.g. base_model="gemini/gemini-2.0-flash" on an anthropic deployment),
# align custom_llm_provider so cost_per_token builds the correct key.
# Skip when custom_pricing is True (base_model is ignored in that path).
_provider_overridden = False
if base_model is not None and selected_model is not None and not custom_pricing:
_parts = selected_model.split("/", 1)
if len(_parts) > 1 and _parts[0] in LlmProvidersSet:
extracted = _parts[0]
if extracted != custom_llm_provider:
custom_llm_provider = extracted
_provider_overridden = True

potential_model_names = [
selected_model,
_get_response_model(completion_response),
Expand Down Expand Up @@ -1176,9 +1189,10 @@ def completion_cost( # noqa: PLR0915

hidden_params = getattr(completion_response, "_hidden_params", None)
if hidden_params is not None:
custom_llm_provider = hidden_params.get(
"custom_llm_provider", custom_llm_provider or None
)
if not _provider_overridden:
custom_llm_provider = hidden_params.get(
"custom_llm_provider", custom_llm_provider or None
)
region_name = hidden_params.get("region_name", region_name)

# For Gemini/Vertex AI responses, trafficType is stored in
Expand Down
11 changes: 10 additions & 1 deletion litellm/litellm_core_utils/audio_utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,16 @@ def calculate_request_duration(file: FileTypes) -> Optional[float]:
# Extract duration using soundfile
file_object = io.BytesIO(file_content)
with sf.SoundFile(file_object) as audio:
duration = len(audio) / audio.samplerate
frames = len(audio)
# Guard against sentinel/invalid frame counts (e.g., 2^63-1 from libsndfile)
if frames <= 0 or frames >= 2**63 - 1:
return None
if audio.samplerate <= 0:
return None
duration = frames / audio.samplerate
# Reject implausible durations (> 24 hours)
if duration > 86400:
return None
Comment on lines +266 to +275
Copy link
Contributor

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 - 1 and samplerate <= 0) are described as the primary bug fix in the PR title, yet no corresponding test was added in tests/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 returns None and that the samplerate guard prevents a ZeroDivisionError.

return duration

except Exception:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No unit tests for _normalize_images

This is the other primary bug fix in the PR title (OpenRouter/Gemini images missing index causing pydantic ValidationError), yet no test was added for _normalize_images in tests/test_litellm/. A simple test asserting that:

  • a list of dicts without index gets sequential indices backfilled
  • a list of dicts already having index is returned unchanged
  • None is returned as-is

…would greatly increase confidence in the fix and protect against regressions.

Comment on lines +49 to +60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_normalize_images is dead code — never called

This function was added by the PR but is never invoked anywhere in the codebase. A grep of the whole repo confirms the only occurrence is its own definition at line 49.

The actual image-index fix is handled by the pre-existing _normalize_images_for_message() (lines 70–85), which was already present before this PR and is already called at line 627:

images=_normalize_images_for_message(
    choice["message"].get("images", None)
),

The two functions are nearly identical in behavior. Because _normalize_images is never reached by any call path, the stated bug fix is entirely carried by the pre-existing helper — not by this new function. Either remove this function or wire it up to an actual call site.



_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()) | {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In-place mutation of the input messages list

The function docstring comment says "Avoid mutating the caller's dicts — create a shallow copy", but the function actually mutates the input messages list itself via messages[i] = .... Only the individual message dicts get shallow copies — the outer list is still modified in-place.

While each updated message dict is a new object ({**message, ...}), lines like:

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function mutates the input messages list in-place via messages[i] = ..., despite the comment at line 91 suggesting immutability ("Avoid mutating the caller's dicts"). While each dict is shallow-copied, the list structure itself is modified, which violates the documented contract.

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 result

The call site already reassigns (messages = _sanitize_anthropic_messages(messages)), so this change is safe.

return messages


async def _execute_pre_request_hooks(
model: str,
messages: List[Dict],
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions litellm/llms/ollama/completion/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ def get_model_info(
or get_secret_str("OLLAMA_API_BASE")
or "http://localhost:11434"
)
# Strip any endpoint paths that may have been appended by get_complete_url()
# to avoid malformed URLs like /api/generate/api/show
for endpoint in ["/api/generate", "/api/chat", "/api/embed"]:
if api_base.endswith(endpoint):
api_base = api_base[: -len(endpoint)]
break
api_key = self.get_api_key()
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}

Expand Down
1 change: 1 addition & 0 deletions litellm/llms/ovhcloud/responses/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""OVHCloud Responses API support"""
Loading
Loading