diff --git a/docs/my-website/docs/proxy/jwt_key_mapping.md b/docs/my-website/docs/proxy/jwt_key_mapping.md
new file mode 100644
index 00000000000..452bf821016
--- /dev/null
+++ b/docs/my-website/docs/proxy/jwt_key_mapping.md
@@ -0,0 +1,318 @@
+# JWT → Virtual Key Mapping
+
+:::info Enterprise
+
+JWT → Virtual Key Mapping is an Enterprise feature.
+
+[Get a free trial](https://enterprise.litellm.ai/demo)
+
+:::
+
+Map JWT tokens to LiteLLM virtual keys — so every JWT client gets the same granular controls as a virtual key: model restrictions, spend limits, rate limits, guardrails, and full spend tracking.
+
+**Why this matters:** Standard JWT auth maps a JWT to a *team*. That's a shared boundary — all clients under a team share the same limits. With JWT → Virtual Key Mapping, each individual JWT client (identified by a claim like `client_id`, `azp`, or `sub`) maps to its own virtual key. You get per-client accountability without issuing API keys to your users.
+
+**Common use case:** Your company uses SSO/OIDC. Developers use Claude Code with their identity tokens. You want to enforce per-developer model access and spend limits without giving each person a LiteLLM API key.
+
+---
+
+## How It Works
+
+```mermaid
+sequenceDiagram
+ participant Client as Client (Claude Code / API)
+ participant Proxy as LiteLLM Proxy
+ participant OIDC as OIDC Provider
+ participant DB as Mapping Table
+
+ Client->>Proxy: POST /v1/chat/completions
Authorization: Bearer
+
+ Proxy->>OIDC: Verify JWT signature
+ OIDC-->>Proxy: Valid ✓
+
+ Proxy->>Proxy: Extract claim
(e.g. client_id = "alice@corp.com")
+
+ Proxy->>DB: Look up (claim_name, claim_value)
+ alt Mapping found
+ DB-->>Proxy: virtual_key_id = sk-abc123
+ Proxy->>Proxy: Apply virtual key permissions
(models, budget, rate limits)
+ Proxy-->>Client: 200 OK
+ else No mapping — fallback_team_mapping
+ Proxy->>Proxy: Fall through to team JWT auth
+ Proxy-->>Client: 200 OK
+ else No mapping — reject
+ Proxy-->>Client: 403 Forbidden
+ else No mapping — auto_register
+ Proxy->>DB: Create new virtual key + mapping
+ Proxy-->>Client: 200 OK
+ end
+```
+
+---
+
+## Setup
+
+### Prerequisites
+
+Complete [OIDC JWT Auth setup](./token_auth.md) first — you need `JWT_PUBLIC_KEY_URL` configured and `enable_jwt_auth: True` in your proxy config.
+
+### Step 1. Configure the JWT claim to map on
+
+Add `jwt_client_id_field` to your `litellm_jwtauth` config. This is the JWT claim LiteLLM uses as the lookup key:
+
+```yaml
+general_settings:
+ master_key: sk-1234
+ enable_jwt_auth: True
+ litellm_jwtauth:
+ team_id_jwt_field: "team_id" # existing team mapping (optional)
+ user_id_jwt_field: "sub"
+ jwt_client_id_field: "client_id" # 👈 claim used for key mapping
+ unregistered_jwt_client_behavior: "fallback_team_mapping" # see below
+```
+
+**`unregistered_jwt_client_behavior`** controls what happens when a JWT has no registered mapping:
+
+| Value | Behavior |
+|-------|----------|
+| `fallback_team_mapping` | Fall through to team-based JWT auth (default — backward compatible) |
+| `reject` | Return 403 if no mapping found |
+| `auto_register` | Auto-create a virtual key + mapping on first encounter |
+
+### Step 2. Register a JWT client → virtual key mapping
+
+**Option A: Single call (creates key + mapping atomically)**
+
+```bash
+curl -X POST 'http://0.0.0.0:4000/jwt_client/new' \
+ -H 'Authorization: Bearer ' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "models": ["claude-sonnet-4-5", "claude-haiku-4-5"],
+ "max_budget": 50.0,
+ "budget_duration": "30d",
+ "rpm_limit": 100,
+ "tpm_limit": 50000,
+ "team_id": "engineering"
+ }'
+```
+
+Response includes the virtual key token (only shown on creation):
+
+```json
+{
+ "key": "sk-abc123...",
+ "key_id": "key_123",
+ "mapping_id": "mapping_456",
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice"
+}
+```
+
+**Option B: Map an existing virtual key**
+
+```bash
+curl -X POST 'http://0.0.0.0:4000/jwt/key/mapping/new' \
+ -H 'Authorization: Bearer ' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "virtual_key_id": "key_123"
+ }'
+```
+
+### Step 3. Test it
+
+```bash
+# Get a JWT from your OIDC provider (must have client_id: dev-alice)
+JWT_TOKEN="eyJhbG..."
+
+curl -X POST 'http://0.0.0.0:4000/v1/chat/completions' \
+ -H "Authorization: Bearer $JWT_TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "model": "claude-sonnet-4-5",
+ "messages": [{"role": "user", "content": "Hello"}]
+ }'
+```
+
+The request is now tracked against `dev-alice`'s virtual key — spend, rate limits, and model access enforced per-client.
+
+---
+
+## Walkthrough: Admin grants granular access, team uses Claude Code
+
+This is the full flow for an engineering team using Claude Code with company SSO.
+
+### Admin setup
+
+**1. Create a team for engineering**
+
+```bash
+curl -X POST 'http://0.0.0.0:4000/team/new' \
+ -H 'Authorization: Bearer ' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "team_alias": "engineering",
+ "models": ["claude-sonnet-4-5", "claude-haiku-4-5"]
+ }'
+```
+
+**2. Register each developer with their own key and spend limit**
+
+```bash
+# Alice — senior eng, higher budget
+curl -X POST 'http://0.0.0.0:4000/jwt_client/new' \
+ -H 'Authorization: Bearer ' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "alice@corp.com",
+ "team_id": "engineering",
+ "models": ["claude-sonnet-4-5", "claude-haiku-4-5"],
+ "max_budget": 200.0,
+ "budget_duration": "30d",
+ "rpm_limit": 200
+ }'
+
+# Bob — contractor, tighter limits
+curl -X POST 'http://0.0.0.0:4000/jwt_client/new' \
+ -H 'Authorization: Bearer ' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "bob@contractor.com",
+ "team_id": "engineering",
+ "models": ["claude-haiku-4-5"],
+ "max_budget": 20.0,
+ "budget_duration": "30d",
+ "rpm_limit": 30
+ }'
+```
+
+**3. Configure Claude Code to use the proxy**
+
+Set the proxy as the API base in your team's Claude Code config:
+
+```bash
+# Point Claude Code at the LiteLLM proxy instead of Anthropic directly.
+# ANTHROPIC_API_KEY here is the bearer token sent to the proxy — set it to
+# the user's SSO/OIDC JWT token (obtained from your IdP at login).
+export ANTHROPIC_API_KEY=""
+export ANTHROPIC_BASE_URL="http://your-litellm-proxy:4000"
+```
+
+Or in `~/.claude/settings.json`:
+
+```json
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "http://your-litellm-proxy:4000"
+ }
+}
+```
+
+**4. Developers authenticate with SSO as usual**
+
+When Alice runs Claude Code, her JWT (issued by your IdP with `client_id: alice@corp.com`) goes to the proxy. LiteLLM looks up the mapping, finds her virtual key, and enforces her specific limits — her $200/month budget, 200 RPM cap, and access to Sonnet and Haiku only.
+
+Bob's token maps to his own key — $20/month, Haiku only, 30 RPM.
+
+No API keys distributed. No shared limits. Full per-developer spend visibility in the LiteLLM dashboard.
+
+---
+
+## Managing mappings
+
+**View a mapping + its key settings**
+
+```bash
+curl 'http://0.0.0.0:4000/jwt/key/mapping/info?jwt_claim_name=client_id&jwt_claim_value=alice@corp.com' \
+ -H 'Authorization: Bearer '
+```
+
+Response includes the linked key's `models`, `max_budget`, `spend`, `rpm_limit`, `expires`, etc.
+
+**Update a mapping**
+
+```bash
+curl -X POST 'http://0.0.0.0:4000/jwt_client/update' \
+ -H 'Authorization: Bearer ' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "alice@corp.com",
+ "max_budget": 300.0
+ }'
+```
+
+**Delete a mapping**
+
+```bash
+curl -X DELETE 'http://0.0.0.0:4000/jwt/key/mapping/delete' \
+ -H 'Authorization: Bearer ' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "alice@corp.com"
+ }'
+```
+
+---
+
+## Security
+
+JWT-bound keys are locked down:
+
+- Non-admin users cannot call `/key/update`, `/key/delete`, or `/key/regenerate` on a JWT-bound key. These return 403.
+- JWT-bound keys are automatically restricted to `llm_api_routes` — they can make LLM calls but cannot manage other keys or admin resources.
+- Only proxy admins can create, update, or delete mappings.
+
+---
+
+## Multi-IdP support
+
+If you have users across multiple identity providers that share the same claim values (e.g. two services both have `sub: user-123` from different issuers), set `issuer` when creating the mapping:
+
+```bash
+curl -X POST 'http://0.0.0.0:4000/jwt_client/new' \
+ -H 'Authorization: Bearer ' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "jwt_claim_name": "sub",
+ "jwt_claim_value": "user-123",
+ "issuer": "https://idp-a.corp.com",
+ "models": ["claude-sonnet-4-5"],
+ "max_budget": 50.0
+ }'
+```
+
+Mappings are unique per `(claim_name, claim_value, issuer)` — so `user-123` from IdP A and `user-123` from IdP B resolve to different virtual keys.
+
+---
+
+## What JWT clients can and can't do vs virtual keys
+
+| Capability | Virtual Key | JWT → Key Mapping |
+|---|---|---|
+| Per-client model access | ✅ | ✅ |
+| Per-client spend budget | ✅ | ✅ |
+| Per-client RPM/TPM limits | ✅ | ✅ |
+| Team membership | ✅ | ✅ |
+| Spend tracking in dashboard | ✅ | ✅ |
+| Guardrails | ✅ | ✅ |
+| Key rotation | ✅ | ✅ (admin only) |
+| Key expiry | ✅ | ✅ |
+| No API key distribution needed | ❌ | ✅ |
+| Works with existing SSO/OIDC | ❌ | ✅ |
+
+---
+
+## Related
+
+- [OIDC JWT Auth](./token_auth.md) — base JWT auth setup required before using this feature
+- [Virtual Keys](./virtual_keys.md) — full virtual key documentation
+- [Access Control](./access_control.md) — model and team access control
diff --git a/docs/my-website/docs/proxy/token_auth.md b/docs/my-website/docs/proxy/token_auth.md
index bc6fde7c840..c287ab03644 100644
--- a/docs/my-website/docs/proxy/token_auth.md
+++ b/docs/my-website/docs/proxy/token_auth.md
@@ -16,6 +16,12 @@ Use JWT's to auth admins / users / projects into the proxy.
:::
+:::tip JWT → Virtual Key Mapping
+
+Want per-user model restrictions, spend limits, and rate limits without distributing API keys? See **[JWT → Virtual Key Mapping](./jwt_key_mapping.md)** — enterprise-grade granular access control for JWT-authenticated users (e.g. Claude Code + SSO).
+
+:::
+
## Usage
### Step 1. Setup Proxy
diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js
index cc2d7800a21..b514ea2234c 100644
--- a/docs/my-website/sidebars.js
+++ b/docs/my-website/sidebars.js
@@ -452,6 +452,7 @@ const sidebars = {
items: [
"proxy/virtual_keys",
"proxy/token_auth",
+ "proxy/jwt_key_mapping",
"proxy/service_accounts",
"proxy/access_control",
"proxy/cli_sso",
diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py
index b6479a36998..9075373f1cf 100644
--- a/litellm/responses/litellm_completion_transformation/transformation.py
+++ b/litellm/responses/litellm_completion_transformation/transformation.py
@@ -1202,22 +1202,32 @@ class LiteLLMCompletionResponsesConfig:
return [chat_completion_response_message]
+ @staticmethod
+ def _resolve_file_id(item: Dict[str, Any]) -> Optional[str]:
+ """
+ Return the effective file_id for a Responses API input_file item.
+ Explicit file_id takes precedence; file_url is used as fallback so
+ downstream providers (Anthropic, Gemini) can handle the URL natively.
+ """
+ return item.get("file_id") or item.get("file_url") or None
+
@staticmethod
def _transform_input_file_item_to_file_item(item: Dict[str, Any]) -> Dict[str, Any]:
"""
Transform a Responses API input_file item to a Chat Completion file item
Args:
- item: Dictionary containing input_file type with file_id and/or file_data
+ item: Dictionary containing input_file type with file_id, file_data, and/or file_url
Returns:
Dictionary with transformed file structure for Chat Completion
"""
file_dict: Dict[str, Any] = {}
- keys = ["file_id", "file_data"]
- for key in keys:
- if item.get(key):
- file_dict[key] = item.get(key)
+ file_id = LiteLLMCompletionResponsesConfig._resolve_file_id(item)
+ if file_id:
+ file_dict["file_id"] = file_id
+ if item.get("file_data"):
+ file_dict["file_data"] = item["file_data"]
new_item: Dict[str, Any] = {"type": "file", "file": file_dict}
return new_item
@@ -2113,9 +2123,9 @@ class LiteLLMCompletionResponsesConfig:
hasattr(completion_details, "reasoning_tokens")
and completion_details.reasoning_tokens is not None
):
- output_details_dict[
- "reasoning_tokens"
- ] = completion_details.reasoning_tokens
+ output_details_dict["reasoning_tokens"] = (
+ completion_details.reasoning_tokens
+ )
else:
output_details_dict["reasoning_tokens"] = 0
diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py
index aaa131c23dc..4e44ef9e50c 100644
--- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py
+++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py
@@ -6,20 +6,20 @@ sys.path.insert(
) # Adds the parent directory to the system path
from litellm.responses.litellm_completion_transformation.transformation import (
- LiteLLMCompletionResponsesConfig,
TOOL_CALLS_CACHE,
+ LiteLLMCompletionResponsesConfig,
)
from litellm.types.llms.openai import (
ChatCompletionResponseMessage,
ChatCompletionToolMessage,
)
from litellm.types.utils import (
+ ChatCompletionMessageToolCall,
Choices,
CompletionTokensDetailsWrapper,
+ Function,
Message,
ModelResponse,
- Function,
- ChatCompletionMessageToolCall,
PromptTokensDetailsWrapper,
Usage,
)
@@ -130,6 +130,31 @@ class TestLiteLLMCompletionResponsesConfig:
assert "extra_field" not in result["file"]
assert "another_field" not in result["file"]
+ def test_transform_input_file_item_to_file_item_with_file_url(self):
+ """file_url should be mapped to file_id for downstream URL handling"""
+ result = (
+ LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(
+ {"type": "input_file", "file_url": "https://example.com/doc.pdf"}
+ )
+ )
+ assert result == {
+ "type": "file",
+ "file": {"file_id": "https://example.com/doc.pdf"},
+ }
+
+ def test_transform_input_file_item_file_id_takes_precedence_over_file_url(self):
+ """explicit file_id should not be overwritten by file_url"""
+ result = (
+ LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(
+ {
+ "type": "input_file",
+ "file_id": "file-abc123",
+ "file_url": "https://example.com/doc.pdf",
+ }
+ )
+ )
+ assert result == {"type": "file", "file": {"file_id": "file-abc123"}}
+
def test_transform_input_image_item_to_image_item_with_image_url(self):
"""Test transformation of input_image item with image_url to Chat Completion image format"""
# Setup
@@ -144,7 +169,10 @@ class TestLiteLLMCompletionResponsesConfig:
)
# Assert
- expected = {"type": "image_url", "image_url": {"url": image_url, "detail": "high"}}
+ expected = {
+ "type": "image_url",
+ "image_url": {"url": image_url, "detail": "high"},
+ }
assert result == expected
assert result["type"] == "image_url"
assert result["image_url"]["url"] == image_url
@@ -164,7 +192,10 @@ class TestLiteLLMCompletionResponsesConfig:
)
# Assert
- expected = {"type": "image_url", "image_url": {"url": image_url, "detail": "high"}}
+ expected = {
+ "type": "image_url",
+ "image_url": {"url": image_url, "detail": "high"},
+ }
assert result == expected
assert result["type"] == "image_url"
assert result["image_url"]["url"] == image_url
@@ -184,7 +215,10 @@ class TestLiteLLMCompletionResponsesConfig:
)
# Assert
- expected = {"type": "image_url", "image_url": {"url": image_url, "detail": "auto"}}
+ expected = {
+ "type": "image_url",
+ "image_url": {"url": image_url, "detail": "auto"},
+ }
assert result == expected
assert result["type"] == "image_url"
assert result["image_url"]["url"] == image_url
@@ -227,7 +261,10 @@ class TestLiteLLMCompletionResponsesConfig:
)
# Assert
- expected = {"type": "image_url", "image_url": {"url": "https://example.com/image.png", "detail": "auto"}}
+ expected = {
+ "type": "image_url",
+ "image_url": {"url": "https://example.com/image.png", "detail": "auto"},
+ }
assert result == expected
assert result["type"] == "image_url"
assert result["image_url"]["url"] == "https://example.com/image.png"
@@ -265,9 +302,7 @@ class TestLiteLLMCompletionResponsesConfig:
# Assert
assert hasattr(responses_api_response, "output")
- assert (
- len(responses_api_response.output) >= 2
- )
+ assert len(responses_api_response.output) >= 2
reasoning_items = [
item for item in responses_api_response.output if item.type == "reasoning"
@@ -277,8 +312,10 @@ class TestLiteLLMCompletionResponsesConfig:
reasoning_item = reasoning_items[0]
# Note: ID auto-generation was disabled, so reasoning items may not have IDs
# Only assert ID format if an ID is present
- if hasattr(reasoning_item, 'id') and reasoning_item.id:
- assert reasoning_item.id.startswith("rs_"), f"Expected ID to start with 'rs_', got: {reasoning_item.id}"
+ if hasattr(reasoning_item, "id") and reasoning_item.id:
+ assert reasoning_item.id.startswith(
+ "rs_"
+ ), f"Expected ID to start with 'rs_', got: {reasoning_item.id}"
assert reasoning_item.status == "completed"
assert reasoning_item.role == "assistant"
assert len(reasoning_item.content) == 1
@@ -386,7 +423,7 @@ class TestLiteLLMCompletionResponsesConfig:
"""
Test that transforming a chat completion response with 'stop' finish_reason
results in 'completed' status in the responses API response.
-
+
This is the main test case for GitHub issue #15714.
"""
chat_completion_response = ModelResponse(
@@ -406,12 +443,10 @@ class TestLiteLLMCompletionResponsesConfig:
],
)
- responses_api_response = (
- LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
- request_input="this is a test",
- responses_api_request={},
- chat_completion_response=chat_completion_response,
- )
+ responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
+ request_input="this is a test",
+ responses_api_request={},
+ chat_completion_response=chat_completion_response,
)
assert responses_api_response.status == "completed"
@@ -427,7 +462,7 @@ class TestLiteLLMCompletionResponsesConfig:
def test_transform_chat_completion_response_output_item_status(self):
"""
Test that output items in the transformed response also have valid status values.
-
+
This verifies the fix for GitHub issue #15714.
"""
chat_completion_response = ModelResponse(
@@ -447,12 +482,10 @@ class TestLiteLLMCompletionResponsesConfig:
],
)
- responses_api_response = (
- LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
- request_input="this is a test",
- responses_api_request={},
- chat_completion_response=chat_completion_response,
- )
+ responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
+ request_input="this is a test",
+ responses_api_request={},
+ chat_completion_response=chat_completion_response,
)
message_items = [
@@ -543,6 +576,7 @@ class TestLiteLLMCompletionResponsesConfig:
assert hasattr(responses_api_response, "_hidden_params")
assert responses_api_response._hidden_params == {}
+
class TestFunctionCallTransformation:
"""Test cases for function_call input transformation"""
@@ -552,30 +586,38 @@ class TestFunctionCallTransformation:
"type": "function_call",
"name": "get_weather",
"arguments": '{"location": "test"}',
- "call_id": "test_id"
+ "call_id": "test_id",
}
-
+
function_call_output_item = {
"type": "function_call_output",
"call_id": "test_id",
- "output": "result"
+ "output": "result",
}
-
- regular_message = {
- "type": "message",
- "role": "user",
- "content": "Hello"
- }
-
+
+ regular_message = {"type": "message", "role": "user", "content": "Hello"}
+
# Test function_call detection
- assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(function_call_item)
- assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call(function_call_output_item)
- assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call(regular_message)
-
+ assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(
+ function_call_item
+ )
+ assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call(
+ function_call_output_item
+ )
+ assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call(
+ regular_message
+ )
+
# Test function_call_output detection (should still work)
- assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(function_call_output_item)
- assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(function_call_item)
- assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(regular_message)
+ assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(
+ function_call_output_item
+ )
+ assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(
+ function_call_item
+ )
+ assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(
+ regular_message
+ )
def test_function_call_transformation(self):
"""Test that function_call items are correctly transformed to assistant messages with tool calls"""
@@ -585,28 +627,28 @@ class TestFunctionCallTransformation:
"arguments": '{"location": "São Paulo, Brazil"}',
"call_id": "call_123",
"id": "call_123",
- "status": "completed"
+ "status": "completed",
}
-
+
result = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
function_call=function_call_item
)
-
+
assert len(result) == 1
message = result[0]
-
+
# Should be an assistant message
assert message.get("role") == "assistant"
assert message.get("content") is None # Function calls don't have content
-
+
# Should have tool calls
tool_calls = message.get("tool_calls", [])
assert len(tool_calls) == 1
-
+
tool_call = tool_calls[0]
assert tool_call.get("id") == "call_123"
assert tool_call.get("type") == "function"
-
+
function = tool_call.get("function", {})
assert function.get("name") == "get_weather"
assert function.get("arguments") == '{"location": "São Paulo, Brazil"}'
@@ -617,7 +659,7 @@ class TestFunctionCallTransformation:
{
"type": "message",
"role": "user",
- "content": "How is the weather in São Paulo today ?"
+ "content": "How is the weather in São Paulo today ?",
},
{
"type": "function_call",
@@ -625,49 +667,51 @@ class TestFunctionCallTransformation:
"call_id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5",
"name": "get_weather",
"id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5",
- "status": "completed"
+ "status": "completed",
},
{
"type": "function_call_output",
"call_id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5",
- "output": "Rainy"
- }
+ "output": "Rainy",
+ },
]
-
+
# This should not raise an error (previously would raise "Invalid content type: ")
messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=test_input
)
-
+
assert len(messages) == 3
-
+
# First message: user message
user_msg = messages[0]
assert user_msg.get("role") == "user"
assert user_msg.get("content") == "How is the weather in São Paulo today ?"
-
+
# Second message: assistant message with tool call
assistant_msg = messages[1]
assert assistant_msg.get("role") == "assistant"
assert assistant_msg.get("tool_calls") is not None
assert len(assistant_msg.get("tool_calls", [])) == 1
-
+
tool_call = assistant_msg.get("tool_calls")[0]
assert tool_call.get("function", {}).get("name") == "get_weather"
-
+
# Third message: tool output
tool_msg = messages[2]
assert tool_msg.get("role") == "tool"
assert tool_msg.get("content") == "Rainy"
- assert tool_msg.get("tool_call_id") == "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5"
+ assert (
+ tool_msg.get("tool_call_id") == "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5"
+ )
def test_complete_request_transformation_with_function_calls(self):
"""Test the complete request transformation that would be used by the responses API"""
test_input = [
{
"type": "message",
- "role": "user",
- "content": "How is the weather in São Paulo today ?"
+ "role": "user",
+ "content": "How is the weather in São Paulo today ?",
},
{
"type": "function_call",
@@ -675,15 +719,15 @@ class TestFunctionCallTransformation:
"call_id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5",
"name": "get_weather",
"id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5",
- "status": "completed"
+ "status": "completed",
},
{
"type": "function_call_output",
"call_id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5",
- "output": "Rainy"
- }
+ "output": "Rainy",
+ },
]
-
+
tools = [
{
"type": "function",
@@ -694,44 +738,41 @@ class TestFunctionCallTransformation:
"properties": {
"location": {
"type": "string",
- "description": "City and country e.g. Bogotá, Colombia"
+ "description": "City and country e.g. Bogotá, Colombia",
}
},
"required": ["location"],
- "additionalProperties": False
- }
+ "additionalProperties": False,
+ },
}
]
-
- responses_api_request = {
- "store": False,
- "tools": tools
- }
-
+
+ responses_api_request = {"store": False, "tools": tools}
+
# This should work without errors for non-OpenAI models
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="gemini/gemini-2.0-flash",
input=test_input,
responses_api_request=responses_api_request,
- extra_headers={"X-Test-Header": "test-value"}
+ extra_headers={"X-Test-Header": "test-value"},
)
-
+
assert "messages" in result
assert "model" in result
assert "tools" in result
-
+
messages = result["messages"]
assert len(messages) == 3
assert result["model"] == "gemini/gemini-2.0-flash"
-
+
# Verify the structure is correct for chat completion
user_msg = messages[0]
assert user_msg["role"] == "user"
-
- assistant_msg = messages[1]
+
+ assistant_msg = messages[1]
assert assistant_msg["role"] == "assistant"
assert "tool_calls" in assistant_msg
-
+
tool_msg = messages[2]
assert tool_msg["role"] == "tool"
@@ -743,18 +784,18 @@ class TestFunctionCallTransformation:
"type": "function_call",
"name": "get_weather",
"arguments": '{"location": "test"}',
- "id": "fallback_id" # Only has 'id', not 'call_id'
+ "id": "fallback_id", # Only has 'id', not 'call_id'
}
-
+
result = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
function_call=function_call_item
)
-
+
assert len(result) == 1
message = result[0]
tool_calls = message.get("tool_calls", [])
assert len(tool_calls) == 1
-
+
tool_call = tool_calls[0]
assert tool_call.get("id") == "fallback_id"
@@ -778,7 +819,11 @@ class TestFunctionCallTransformation:
messages_missing_tool_calls = [
{"role": "user", "content": "Search for python bugs"},
{"role": "assistant", "content": None, "tool_calls": []},
- {"role": "tool", "content": "Found 5 results", "tool_call_id": tool_call_id},
+ {
+ "role": "tool",
+ "content": "Found 5 results",
+ "tool_call_id": tool_call_id,
+ },
]
try:
@@ -830,7 +875,11 @@ class TestFunctionCallTransformation:
messages_missing_tool_calls = [
{"role": "user", "content": "Search using attr object"},
{"role": "assistant", "content": None, "tool_calls": []},
- {"role": "tool", "content": "Found 3 results", "tool_call_id": tool_call_id},
+ {
+ "role": "tool",
+ "content": "Found 3 results",
+ "tool_call_id": tool_call_id,
+ },
]
try:
@@ -859,7 +908,9 @@ class TestToolChoiceTransformation:
Test that {"type": "tool"} is transformed to "required".
This fixes the Anthropic error: "tool_choice.tool.name: Field required"
"""
- result = LiteLLMCompletionResponsesConfig._transform_tool_choice({"type": "tool"})
+ result = LiteLLMCompletionResponsesConfig._transform_tool_choice(
+ {"type": "tool"}
+ )
assert result == "required"
def test_transform_tool_choice_preserves_function_with_name(self):
@@ -877,12 +928,20 @@ class TestContentTypeTransformation:
Test that 'tool_result' content type is transformed to 'text'.
This fixes: Invalid user message - content type 'tool_result' not valid.
"""
- result = LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type("tool_result")
+ result = (
+ LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type(
+ "tool_result"
+ )
+ )
assert result == "text"
def test_input_text_content_type_transformed_to_text(self):
"""Test that 'input_text' content type is transformed to 'text'"""
- result = LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type("input_text")
+ result = (
+ LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type(
+ "input_text"
+ )
+ )
assert result == "text"
def test_none_text_blocks_filtered_out(self):
@@ -896,7 +955,9 @@ class TestContentTypeTransformation:
{"type": "text", "text": None}, # Should be filtered out
{"type": "text", "text": "another valid"},
]
- result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content)
+ result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(
+ content
+ )
assert len(result) == 2
assert result[0]["text"] == "valid text"
assert result[1]["text"] == "another valid"
@@ -911,14 +972,16 @@ class TestToolTransformation:
# Create a Vertex AI tool using the enum value
vertex_tool = {VertexToolName.CODE_EXECUTION.value: {}}
-
+
tools = [vertex_tool]
-
+
# Execute
- result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, web_search_options = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
assert result_tools[0] == vertex_tool
@@ -930,18 +993,18 @@ class TestToolTransformation:
"type": "mcp",
"server_label": "zapier",
"server_url": "https://mcp.zapier.com/api/mcp/mcp",
- "headers": {
- "Authorization": "Bearer token123"
- },
+ "headers": {"Authorization": "Bearer token123"},
}
-
+
tools = [mcp_tool]
-
+
# Execute
- result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, web_search_options = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
assert result_tools[0] == mcp_tool
@@ -953,16 +1016,18 @@ class TestToolTransformation:
computer_use_tool = {
"type": "computer_use",
"display_width_px": 1024,
- "display_height_px": 768
+ "display_height_px": 768,
}
-
+
tools = [computer_use_tool]
-
+
# Execute
- result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, web_search_options = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
assert result_tools[0] == computer_use_tool
@@ -974,16 +1039,18 @@ class TestToolTransformation:
web_search_tool = {
"type": "web_search_preview",
"search_context_size": "medium",
- "user_location": {"country": "US"}
+ "user_location": {"country": "US"},
}
-
+
tools = [web_search_tool]
-
+
# Execute
- result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, web_search_options = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 0 # Web search is not added to tools
assert web_search_options is not None
@@ -998,24 +1065,24 @@ class TestToolTransformation:
"description": "Get weather for a location",
"parameters": {
"type": "object",
- "properties": {
- "location": {"type": "string"}
- },
- "required": ["location"]
+ "properties": {"location": {"type": "string"}},
+ "required": ["location"],
},
"cache_control": {"type": "ephemeral"},
"defer_loading": True,
"allowed_callers": ["user"],
- "input_examples": [{"location": "San Francisco"}]
+ "input_examples": [{"location": "San Francisco"}],
}
-
+
tools = [function_tool]
-
+
# Execute
- result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, web_search_options = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
result_tool = result_tools[0]
@@ -1035,16 +1102,18 @@ class TestToolTransformation:
"name": "search",
"description": "Search function",
"parameters": {"type": "object"},
- "cache_control": {"type": "ephemeral"}
+ "cache_control": {"type": "ephemeral"},
}
-
+
tools = [function_tool]
-
+
# Execute
- result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, _ = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
result_tool = result_tools[0]
@@ -1059,19 +1128,19 @@ class TestToolTransformation:
"description": "A simple function",
"parameters": {
"type": "object",
- "properties": {
- "param": {"type": "string"}
- }
- }
+ "properties": {"param": {"type": "string"}},
+ },
}
-
+
tools = [function_tool]
-
+
# Execute
- result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, _ = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
result_tool = result_tools[0]
@@ -1087,16 +1156,18 @@ class TestToolTransformation:
"""Test that code_execution tools are passed through as-is"""
code_execution_tool = {
"type": "code_execution_20250825",
- "name": "python_code_execution"
+ "name": "python_code_execution",
}
-
+
tools = [code_execution_tool]
-
+
# Execute
- result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, _ = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
assert result_tools[0]["type"] == "code_execution_20250825"
@@ -1105,21 +1176,23 @@ class TestToolTransformation:
"""Test that tool_search tools are passed through as-is"""
tool_search_regex = {
"name": "tool_search_tool_regex",
- "description": "Search tools using regex"
+ "description": "Search tools using regex",
}
-
+
tool_search_bm25 = {
"name": "tool_search_tool_bm25",
- "description": "Search tools using BM25"
+ "description": "Search tools using BM25",
}
-
+
tools = [tool_search_regex, tool_search_bm25]
-
+
# Execute
- result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, _ = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 2
assert result_tools[0]["name"] == "tool_search_tool_regex"
@@ -1128,7 +1201,7 @@ class TestToolTransformation:
def test_transform_mixed_tools_list(self):
"""Test transforming a mixed list of different tool types"""
from litellm.types.llms.vertex_ai import VertexToolName
-
+
tools = [
# Regular function tool with anthropic fields
{
@@ -1136,40 +1209,38 @@ class TestToolTransformation:
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object"},
- "cache_control": {"type": "ephemeral"}
+ "cache_control": {"type": "ephemeral"},
},
# MCP tool
- {
- "type": "mcp",
- "server_label": "zapier"
- },
+ {"type": "mcp", "server_label": "zapier"},
# Web search tool
- {
- "type": "web_search_preview",
- "search_context_size": "high"
- },
+ {"type": "web_search_preview", "search_context_size": "high"},
# Vertex AI tool
- {VertexToolName.CODE_EXECUTION.value: {}}
+ {VertexToolName.CODE_EXECUTION.value: {}},
]
-
+
# Execute
- result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, web_search_options = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
- assert len(result_tools) == 3 # function, mcp, vertex (web_search becomes options)
+ assert (
+ len(result_tools) == 3
+ ) # function, mcp, vertex (web_search becomes options)
assert web_search_options is not None
-
+
# Check function tool
func_tools = [t for t in result_tools if t.get("type") == "function"]
assert len(func_tools) == 1
assert func_tools[0]["cache_control"]["type"] == "ephemeral"
-
+
# Check MCP tool
mcp_tools = [t for t in result_tools if t.get("type") == "mcp"]
assert len(mcp_tools) == 1
-
+
# Check web search was converted to options
assert web_search_options.get("search_context_size") == "high"
@@ -1179,20 +1250,18 @@ class TestToolTransformation:
"type": "function",
"name": "test_function",
"description": "Test function",
- "parameters": {
- "properties": {
- "arg": {"type": "string"}
- }
- }
+ "parameters": {"properties": {"arg": {"type": "string"}}},
}
-
+
tools = [function_tool]
-
+
# Execute
- result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, _ = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
result_tool = result_tools[0]
@@ -1205,16 +1274,18 @@ class TestToolTransformation:
"type": "function",
"name": "test_function",
"description": "Test function",
- "parameters": {}
+ "parameters": {},
}
-
+
tools = [function_tool]
-
+
# Execute
- result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, _ = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
result_tool = result_tools[0]
@@ -1225,16 +1296,18 @@ class TestToolTransformation:
function_tool = {
"type": "function",
"name": "test_function",
- "description": "Test function"
+ "description": "Test function",
}
-
+
tools = [function_tool]
-
+
# Execute
- result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, _ = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
result_tool = result_tools[0]
@@ -1246,27 +1319,27 @@ class TestToolTransformation:
"type": "function",
"name": "test_function",
"description": "Test function",
- "parameters": {
- "type": "object",
- "properties": {
- "arg": {"type": "string"}
- }
- }
+ "parameters": {"type": "object", "properties": {"arg": {"type": "string"}}},
}
-
+
tools = [function_tool]
-
+
# Execute
- result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
- tools=tools
+ result_tools, _ = (
+ LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
+ tools=tools
+ )
)
-
+
# Assert
assert len(result_tools) == 1
result_tool = result_tools[0]
assert result_tool["function"]["parameters"]["type"] == "object"
assert "properties" in result_tool["function"]["parameters"]
- assert result_tool["function"]["parameters"]["properties"]["arg"]["type"] == "string"
+ assert (
+ result_tool["function"]["parameters"]["properties"]["arg"]["type"]
+ == "string"
+ )
class TestUsageTransformation:
@@ -1434,12 +1507,12 @@ class TestUsageTransformation:
assert response_usage.input_tokens == 13
assert response_usage.output_tokens == 100
assert response_usage.total_tokens == 113
-
+
# Verify input_tokens_details
assert response_usage.input_tokens_details is not None
assert response_usage.input_tokens_details.cached_tokens == 5
assert response_usage.input_tokens_details.text_tokens == 8
-
+
# Verify output_tokens_details
assert response_usage.output_tokens_details is not None
assert response_usage.output_tokens_details.reasoning_tokens == 50
@@ -1543,7 +1616,9 @@ class TestUsageTransformation:
Choices(
finish_reason="stop",
index=0,
- message=Message(content="Here is the generated image.", role="assistant"),
+ message=Message(
+ content="Here is the generated image.", role="assistant"
+ ),
)
],
)
@@ -1569,7 +1644,7 @@ class TestStreamingIDConsistency:
Test that all streaming events use the same item_id throughout the stream.
This fixes the issue where text-start, text-delta, and text-end events
had different IDs, breaking SDK text accumulation.
-
+
Reproduces: https://github.com/BerriAI/litellm/issues/14962
"""
from unittest.mock import Mock
@@ -1645,25 +1720,27 @@ class TestStreamingIDConsistency:
# Assert: All events should use the same item_id (from the first chunk)
assert event1 is not None, "First event should not be None"
assert event2 is not None, "Second event should not be None"
-
+
# Extract item_ids from events
item_id_1 = getattr(event1, "item_id", None)
item_id_2 = getattr(event2, "item_id", None)
-
+
assert item_id_1 is not None, "First event should have an item_id"
assert item_id_2 is not None, "Second event should have an item_id"
-
+
# The critical assertion: IDs should match across all events
assert item_id_1 == item_id_2, (
f"Item IDs should be consistent across streaming events. "
f"Got {item_id_1} and {item_id_2}. "
f"This breaks SDK text accumulation (issue #14962)."
)
-
+
# Verify the cached ID is set and matches
assert iterator._cached_item_id is not None, "Iterator should cache the item_id"
assert iterator._cached_item_id == item_id_1, "Cached ID should match event IDs"
- assert iterator._cached_item_id == "chatcmpl-first-id", "Should use the first chunk's ID"
+ assert (
+ iterator._cached_item_id == "chatcmpl-first-id"
+ ), "Should use the first chunk's ID"
def test_streaming_iterator_initial_events_use_cached_id(self):
"""
@@ -1704,7 +1781,7 @@ class TestStreamingIDConsistency:
f"Initial events should use consistent IDs. "
f"Got output_item_id={output_item_id}, content_part_id={content_part_id}"
)
-
+
# Verify it matches the cached ID
assert iterator._cached_item_id is not None
assert iterator._cached_item_id == output_item_id
@@ -1753,7 +1830,9 @@ class TestStreamingIDConsistency:
# Create done events
text_done_event = iterator.create_output_text_done_event(complete_response)
- content_done_event = iterator.create_output_content_part_done_event(complete_response)
+ content_done_event = iterator.create_output_content_part_done_event(
+ complete_response
+ )
item_done_event = iterator.create_output_item_done_event(complete_response)
# Extract IDs
@@ -1765,12 +1844,12 @@ class TestStreamingIDConsistency:
assert text_done_id is not None, "Text done event should have an item_id"
assert content_done_id is not None, "Content done event should have an item_id"
assert item_done_id is not None, "Item done event should have an id"
-
+
assert text_done_id == content_done_id == item_done_id, (
f"All done events should use consistent IDs. "
f"Got text_done={text_done_id}, content_done={content_done_id}, item_done={item_done_id}"
)
-
+
# Verify it matches the cached ID
assert iterator._cached_item_id is not None
assert iterator._cached_item_id == text_done_id
@@ -1826,13 +1905,14 @@ class TestStreamingIDConsistency:
# The single assistant message must contain BOTH tool_calls
assistant_messages = [
- m for m in messages
+ m
+ for m in messages
if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None))
== "assistant"
]
- assert len(assistant_messages) == 1, (
- f"Expected 1 assistant message, got {len(assistant_messages)}"
- )
+ assert (
+ len(assistant_messages) == 1
+ ), f"Expected 1 assistant message, got {len(assistant_messages)}"
assistant_msg = assistant_messages[0]
tool_calls = (
@@ -1840,9 +1920,9 @@ class TestStreamingIDConsistency:
if isinstance(assistant_msg, dict)
else getattr(assistant_msg, "tool_calls", None)
)
- assert tool_calls is not None and len(tool_calls) == 2, (
- f"Expected 2 tool_calls in the merged assistant message, got: {tool_calls}"
- )
+ assert (
+ tool_calls is not None and len(tool_calls) == 2
+ ), f"Expected 2 tool_calls in the merged assistant message, got: {tool_calls}"
call_ids = [
(tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None))
@@ -1853,13 +1933,14 @@ class TestStreamingIDConsistency:
# Both tool messages must be present
tool_messages = [
- m for m in messages
+ m
+ for m in messages
if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None))
== "tool"
]
- assert len(tool_messages) == 2, (
- f"Expected 2 tool messages, got {len(tool_messages)}"
- )
+ assert (
+ len(tool_messages) == 2
+ ), f"Expected 2 tool messages, got {len(tool_messages)}"
def test_single_tool_call_still_works_after_merge_fix(self):
"""
@@ -1890,7 +1971,12 @@ class TestStreamingIDConsistency:
assert "assistant" in roles
assert "tool" in roles
- assistant_messages = [m for m in messages if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "assistant"]
+ assistant_messages = [
+ m
+ for m in messages
+ if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None))
+ == "assistant"
+ ]
assert len(assistant_messages) == 1
tool_calls = (