mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
* feat(anthropic): workload identity federation and pluggable identity sources Backend half of #38818 (internal copy of the fork PR #38013), rebuilt as one commit on top of litellm_internal_staging without the dashboard changes. Deployments on anthropic/ without a static api_key can exchange an OIDC workload assertion for a short-lived sk-ant-oat01 token through a shared RFC 7523 JWT-bearer engine. The assertion comes from a mounted token file, an env token, a LiteLLM-signed issuer, or Keycloak, chosen per deployment, per named credential, or through ANTHROPIC_IDENTITY_SOURCE. The federation fields are server-owned: refused inline in request bodies and on POST /model/new, proxy-admin only on credentials, and the token exchange is pinned to api.anthropic.com unless LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS adds a host. GET /credentials/{name}/jwks exports the public key set of a LiteLLM-signed credential for the Claude Console. The OpenAI federation trio from #39613 rides along on the backend side with the same server-owned handling. Fixes #28607 Resolves LIT-6107 Co-authored-by: derhornspieler <15236687+derhornspieler@users.noreply.github.com> * fix(anthropic): let batch-result downloads mint from deployment params and accept host:port allowlist entries The files handler enabled workload identity on batch-result downloads but never received the deployment's litellm_params, so a deployment authenticating through a named credential could only mint from process-wide env vars. It now threads litellm_params through to the auth header the way the batch retrieve path already does. LITELLM_ANTHROPIC_WIF_ALLOWED_HOSTS entries written as host:port were read by urlsplit as a scheme, so the allowlist kept the raw entry while the exchange compared bare hostnames and refused the gateway. Entries are now parsed as network locations whether or not they carry a scheme. * fix(types): move the WIF kwargs key sets to a leaf module so the kwargs funnel imports without a cycle * test(anthropic): pin case-insensitive matching of WIF exchange-host allowlist entries * fix(anthropic): end workload identity federation errors without a period so the router suffix reads cleanly * fix(proxy): decrypt stored litellm_params before the WIF write gate * fix(proxy): hide WIF secret references from /health output * fix(proxy): keep the proxy error shape on credential endpoint refusals * fix(proxy): hide identity token file paths from /health output * fix(anthropic): rename the federation workspace param so Bedrock's anthropic_workspace_id keeps working The Bedrock Claude Platform route already reads anthropic_workspace_id from optional_params, so banning that spelling as a server-owned federation parameter broke a pre-existing client capability. The federation field is now anthropic_federation_workspace_id (env ANTHROPIC_FEDERATION_WORKSPACE_ID), which restores the base branch's behavior for Bedrock callers, drops the Bedrock-specific hint from the refusal message, and deletes the unconditional ban constant that no longer had a reader * fix(auth): share one exchanged token across workers reading the same assertion Anthropic accepts each identity assertion exactly once, so two uvicorn workers reading the same token file both minting from it means the second exchange is denied with jti_reused. Minted tokens now land in a per-user 0700 cache directory guarded by a file lock, so workers on the same host reuse one exchange until the token expires or the assertion rotates. A 401 is only retried when the re-read assertion actually differs, and the denial hint explains jti_reused. LITELLM_TOKEN_EXCHANGE_CACHE_DIR moves the cache and an empty value disables it * fix: keep anthropic federation from being shadowed or leaked An empty or whitespace-only ANTHROPIC_API_KEY counted as set, so a federated deployment sent an empty x-api-key on every call instead of minting a token. Blank values now read as unset, and a real static key on a federated deployment logs once that it outranks federation and nothing is being federated. The exchange-host allowlist matched hostnames only, so a second process on another port of an allowed host was trusted with the workload's identity token. An entry that names a port now trusts that port alone, while a bare host still trusts every port. The shared token store exists so the workers reading one projected token file do not each spend its single-use jti. A source that mints its own assertion per exchange shares nothing with another worker, so it no longer writes a live token to disk for a lookup that can never hit. * fix: unlink a staged token file a failed write leaves behind The 401 denial hint now also says federation ignores ANTHROPIC_WORKSPACE_ID, which the Bedrock Claude platform provider already reads. * refactor: move anthropic jwks derivation behind a provider-owned tagged union * fix: unlink the staged token file when its write fails at close A buffered write only reaches the disk when the handle closes, so a full disk surfaces at close and left the staging file behind holding a usable token. * fix(anthropic): close the staging descriptor before writing the shared token file * fix(wif): judge federation writes by what they set, not what is stored The admin gate read the stored deployment, so a team admin lost edit, delete and Test Connection on any deployment carrying federation params. It now returns early unless the submitted fields touch the federation surface, and a Test Connection probe that points the deployment at its own api_base is still refused, with the 403 no longer wrapped into a 500 The rest of the same review pass: POST /model/new refuses only a blocking value of `blocked`, so a client that always sends `blocked: false` is not turned away; a request body can no longer pick which federated identity to mint as by naming a stored credential; an advisory refresh the executor refuses disarms the entry instead of wedging the identity until the follower timeout; the static-key shadow warning resolves its env fallback inside the cache instead of once per request; credential writes drop nulls before storing them; the token exchange validates the endpoint URL before reading an assertion and keeps refusing redirects across a client heal; /health hides every server-owned federation field from non-admins; and the async create_file and create_batch paths say which setting is missing when the provider resolves no URL * fix(proxy): let a deployment write name a federated credential reject_federated_credential_reference runs from is_request_body_safe, which pre_db_read_auth_checks calls on every route, so it also fired on POST /model/new, /model/update, /model/{id}/update and /health/test_connection. A proxy admin could no longer attach a federated credential to a deployment over the API or the Admin UI, leaving a static config.yaml entry as the only way to configure the feature the rejection told the caller to go configure, and _reject_non_admin_wif_write never got to make the call it exists to make. is_request_body_safe now takes the route and skips only the credential-reference check on the routes that reach can_user_make_model_call. Federation fields typed inline into a body stay refused everywhere, and a call naming a federated credential still cannot pick the identity it mints as. * refactor(proxy): derive health display policy from the federation key sets The health check module hand-copied the five workload identity fields whose value is a credential, so a shared proxy surface named provider-specific parameters and a newly added secret-bearing field would have gone on being displayed until someone remembered both places WIF_SECRET_BEARING_KEYS now sits beside the key sets it splits out of, types/utils derives secret_bearing_wif_litellm_params from it, and the health layer splats that tuple the same way it already splats the admin-only one * fix(anthropic_wif): treat blank identity-source fields as unset * test(proxy): classify the federation params in the credential slot registry main's registry test (#43298) now fails the build for any credential-named deployment param without a classification. The five federation fields that carry a token, a token file path, or a signing or client secret reference are Unplanted, matching WIF_SECRET_BEARING_KEYS; the four remaining Keycloak settings name a URL, a client id, an auth method, or a scope and are NotSecret * fix(anthropic_wif): declare federation params as owned connection leaves and chart their metrics Register the 18 Anthropic and 3 OpenAI federation params as frozen ConnectionSettings leaves so the owned-kwarg registry, the kwargs funnel and the request-body ban list read one declaration. Pass the deployment api_base through to the count-tokens handler instead of a pre-suffixed URL, which doubled the /count_tokens path on main's prompt-cache predictor. Add the five litellm_anthropic_wif_* families to the all-metrics Grafana dashboard. * fix(credentials): gate PATCH on WIF fields resolved from model_id The credential PATCH handler checked server-owned workload identity federation fields only on the values the caller sent, while a body that named a deployment through model_id had its credential values resolved after that check. A non-admin could therefore copy a federated deployment's WIF fields onto an ordinary credential. Resolve the incoming values first and run the non-admin gate on them, matching the POST path * fix(anthropic): count tokens with ANTHROPIC_AUTH_TOKEN through the shared auth header Count-tokens walked its own credential ladder: a static key, else skip minting when ANTHROPIC_AUTH_TOKEN is set, else mint a federated token. With only the auth token set it forwarded nothing and the proxy silently fell back to its local tokenizer while chat on the same deployment authenticated with that token. The handler now takes the auth header that AnthropicModelInfo.aget_auth_header resolves, the same ladder chat, files, batches and skills use, and merges the oauth beta a minted or consumer token carries with the token-counting beta --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: derhornspieler <15236687+derhornspieler@users.noreply.github.com> Co-authored-by: mateo-berri <happymvw@gmail.com>
398 lines
14 KiB
Python
398 lines
14 KiB
Python
"""
|
|
Test message sanitization for Anthropic API when modify_params=True
|
|
|
|
Tests three cases:
|
|
A. Missing tool_result for tool_use (orphaned tool calls)
|
|
B. Orphaned tool_result without matching tool_use
|
|
C. Empty text content
|
|
"""
|
|
|
|
import pytest
|
|
import sys
|
|
import os
|
|
|
|
# Add the parent directory to the path so we can import litellm
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..")))
|
|
|
|
import litellm
|
|
from litellm.litellm_core_utils.prompt_templates.factory import (
|
|
sanitize_messages_for_tool_calling,
|
|
anthropic_messages_pt,
|
|
)
|
|
|
|
|
|
class TestMessageSanitization:
|
|
"""Test message sanitization for tool calling scenarios"""
|
|
|
|
def setup_method(self):
|
|
"""Setup for each test"""
|
|
# Save original modify_params value
|
|
self.original_modify_params = litellm.modify_params
|
|
litellm.modify_params = True
|
|
|
|
def teardown_method(self):
|
|
"""Cleanup after each test"""
|
|
# Restore original modify_params value
|
|
litellm.modify_params = self.original_modify_params
|
|
|
|
def test_case_a_orphaned_tool_call_single(self):
|
|
"""
|
|
Test Case A: Assistant message with tool_calls but no tool result
|
|
Should add a dummy tool result message
|
|
"""
|
|
messages = [
|
|
{"role": "user", "content": "What is the weather in Nashik?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "toolu_01Kus2cC3ydjBW7UK4GJqBP4",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": '{"location": "Nashik, India"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
]
|
|
|
|
sanitized = sanitize_messages_for_tool_calling(messages)
|
|
|
|
# Should have 3 messages: user, assistant, and dummy tool result
|
|
assert len(sanitized) == 3
|
|
assert sanitized[0]["role"] == "user"
|
|
assert sanitized[1]["role"] == "assistant"
|
|
assert sanitized[2]["role"] == "tool"
|
|
assert sanitized[2]["tool_call_id"] == "toolu_01Kus2cC3ydjBW7UK4GJqBP4"
|
|
assert "skipped" in sanitized[2]["content"].lower() or "interrupted" in sanitized[2]["content"].lower()
|
|
assert "get_weather" in sanitized[2]["content"]
|
|
|
|
def test_case_a_orphaned_tool_call_multiple(self):
|
|
"""
|
|
Test Case A: Assistant message with multiple tool_calls, some missing results
|
|
"""
|
|
messages = [
|
|
{"role": "user", "content": "Get weather for Nashik and Mumbai"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": '{"location": "Nashik"}',
|
|
},
|
|
},
|
|
{
|
|
"id": "call_2",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": '{"location": "Mumbai"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_1",
|
|
"content": "Weather in Nashik: 25°C",
|
|
},
|
|
]
|
|
|
|
sanitized = sanitize_messages_for_tool_calling(messages)
|
|
|
|
# Should have 4 messages: user, assistant, tool result for call_1, dummy for call_2
|
|
assert len(sanitized) == 4
|
|
assert sanitized[0]["role"] == "user"
|
|
assert sanitized[1]["role"] == "assistant"
|
|
assert sanitized[2]["tool_call_id"] == "call_1" # Original tool result (first in tool_calls)
|
|
assert sanitized[3]["tool_call_id"] == "call_2" # Dummy added for missing call_2
|
|
|
|
def test_case_b_orphaned_tool_result(self):
|
|
"""
|
|
Test Case B: Tool result without matching tool_call in previous assistant message
|
|
Should remove the orphaned tool result
|
|
"""
|
|
messages = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "nonexistent_id",
|
|
"content": "Some result",
|
|
},
|
|
]
|
|
|
|
sanitized = sanitize_messages_for_tool_calling(messages)
|
|
|
|
# Should have only 2 messages, orphaned tool result removed
|
|
assert len(sanitized) == 2
|
|
assert sanitized[0]["role"] == "user"
|
|
assert sanitized[1]["role"] == "assistant"
|
|
|
|
def test_case_b_valid_tool_result_preserved(self):
|
|
"""
|
|
Test Case B: Valid tool result with matching tool_call should be preserved
|
|
"""
|
|
messages = [
|
|
{"role": "user", "content": "What's the weather?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_123",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": '{"location": "Boston"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_123", "content": "Weather: 20°C"},
|
|
]
|
|
|
|
sanitized = sanitize_messages_for_tool_calling(messages)
|
|
|
|
# All messages should be preserved
|
|
assert len(sanitized) == 3
|
|
assert sanitized[2]["role"] == "tool"
|
|
assert sanitized[2]["tool_call_id"] == "call_123"
|
|
|
|
def test_case_c_empty_text_content_user(self):
|
|
"""
|
|
Test Case C: Empty text content in user message
|
|
Should replace with placeholder
|
|
"""
|
|
messages = [
|
|
{"role": "user", "content": ""},
|
|
{"role": "assistant", "content": "Hello!"},
|
|
]
|
|
|
|
sanitized = sanitize_messages_for_tool_calling(messages)
|
|
|
|
assert len(sanitized) == 2
|
|
assert sanitized[0]["role"] == "user"
|
|
assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
|
|
|
|
def test_case_c_whitespace_only_content(self):
|
|
"""
|
|
Test Case C: Whitespace-only content
|
|
Should replace with placeholder
|
|
"""
|
|
messages = [
|
|
{"role": "user", "content": " \n \t "},
|
|
{"role": "assistant", "content": " "},
|
|
]
|
|
|
|
sanitized = sanitize_messages_for_tool_calling(messages)
|
|
|
|
assert len(sanitized) == 2
|
|
assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
|
|
assert sanitized[1]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
|
|
|
|
def test_case_c_valid_content_preserved(self):
|
|
"""
|
|
Test Case C: Valid non-empty content should be preserved
|
|
"""
|
|
messages = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
]
|
|
|
|
sanitized = sanitize_messages_for_tool_calling(messages)
|
|
|
|
assert len(sanitized) == 2
|
|
assert sanitized[0]["content"] == "Hello"
|
|
assert sanitized[1]["content"] == "Hi there!"
|
|
|
|
def test_combined_cases(self):
|
|
"""
|
|
Test combination of multiple cases
|
|
"""
|
|
messages = [
|
|
{"role": "user", "content": "Get weather"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": '{"location": "NYC"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
# Missing tool result for call_1
|
|
{"role": "user", "content": ""}, # Empty content
|
|
{"role": "assistant", "content": "Response"},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "orphaned_id", # Orphaned tool result
|
|
"content": "Some data",
|
|
},
|
|
]
|
|
|
|
sanitized = sanitize_messages_for_tool_calling(messages)
|
|
|
|
# Should have: user, assistant, dummy tool result, user (sanitized), assistant
|
|
# Orphaned tool result should be removed
|
|
assert len(sanitized) == 5
|
|
assert sanitized[0]["role"] == "user"
|
|
assert sanitized[1]["role"] == "assistant"
|
|
assert sanitized[2]["role"] == "tool"
|
|
assert sanitized[2]["tool_call_id"] == "call_1" # Dummy added
|
|
assert sanitized[3]["role"] == "user"
|
|
assert sanitized[3]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
|
|
assert sanitized[4]["role"] == "assistant"
|
|
|
|
def test_modify_params_false_no_sanitization(self):
|
|
"""
|
|
Test that sanitization is skipped when modify_params=False
|
|
"""
|
|
litellm.modify_params = False
|
|
|
|
messages = [
|
|
{"role": "user", "content": ""},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "get_weather", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
]
|
|
|
|
sanitized = sanitize_messages_for_tool_calling(messages)
|
|
|
|
# Messages should be unchanged
|
|
assert len(sanitized) == 2
|
|
assert sanitized[0]["content"] == ""
|
|
assert len(sanitized[1].get("tool_calls", [])) == 1
|
|
|
|
def test_anthropic_messages_pt_integration(self):
|
|
"""
|
|
Test that sanitization is integrated into anthropic_messages_pt
|
|
"""
|
|
litellm.modify_params = True
|
|
|
|
messages = [
|
|
{"role": "user", "content": "What is the weather in Nashik?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "toolu_01Kus2cC3ydjBW7UK4GJqBP4",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": '{"location": "Nashik, India"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
]
|
|
|
|
# This should not raise an error and should add dummy tool result
|
|
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic")
|
|
|
|
# Should have at least 2 messages (user and assistant)
|
|
# The tool result will be merged into user content
|
|
assert len(result) >= 2
|
|
assert result[0]["role"] == "user"
|
|
assert result[1]["role"] == "assistant"
|
|
|
|
def test_empty_string_content_sanitized_without_modify_params(self):
|
|
"""
|
|
Regression: An empty user message ({"role": "user", "content": ""}) must
|
|
be rewritten to a non-empty placeholder *before* it reaches Anthropic,
|
|
even when litellm.modify_params is False. Otherwise Anthropic returns:
|
|
"messages: text content blocks must be non-empty"
|
|
Reproduces a real failure from the pr-review agent (pydantic-ai).
|
|
"""
|
|
litellm.modify_params = False
|
|
|
|
messages = [
|
|
{"role": "user", "content": "First message"},
|
|
{"role": "user", "content": "please review this"},
|
|
{"role": "user", "content": ""},
|
|
]
|
|
|
|
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic")
|
|
|
|
# All three user messages get merged into one user turn for Anthropic.
|
|
assert len(result) == 1
|
|
assert result[0]["role"] == "user"
|
|
text_blocks = [b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"]
|
|
assert len(text_blocks) == 3
|
|
# No text block may be empty — that's the contract Anthropic enforces.
|
|
for block in text_blocks:
|
|
assert block["text"].strip() != ""
|
|
assert text_blocks[2]["text"] == ("[System: Empty message content sanitised to satisfy protocol]")
|
|
|
|
def test_empty_text_block_in_list_content_sanitized(self):
|
|
"""
|
|
Same regression for the list-of-blocks form:
|
|
{"role": "user", "content": [{"type": "text", "text": ""}]}
|
|
Empty text *blocks* must be rewritten too, regardless of modify_params.
|
|
"""
|
|
litellm.modify_params = False
|
|
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "real content"},
|
|
{"type": "text", "text": ""},
|
|
{"type": "text", "text": " \n "},
|
|
],
|
|
},
|
|
]
|
|
|
|
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic")
|
|
|
|
assert len(result) == 1
|
|
text_blocks = [b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"]
|
|
assert len(text_blocks) == 3
|
|
assert text_blocks[0]["text"] == "real content"
|
|
for block in text_blocks[1:]:
|
|
assert block["text"].strip() != ""
|
|
|
|
def test_non_empty_content_unchanged_without_modify_params(self):
|
|
"""
|
|
Sanity check: when nothing is empty, the messages flow through unchanged
|
|
even with modify_params disabled.
|
|
"""
|
|
litellm.modify_params = False
|
|
|
|
messages = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there"},
|
|
{"role": "user", "content": "How are you?"},
|
|
]
|
|
|
|
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic")
|
|
|
|
# Two user turns + one assistant turn (alternation preserved).
|
|
assert len(result) == 3
|
|
assert result[0]["content"][0]["text"] == "Hello"
|
|
assert result[1]["content"][0]["text"] == "Hi there"
|
|
assert result[2]["content"][0]["text"] == "How are you?"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|