mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
Merge a152ecf930 into a66dc50efb
This commit is contained in:
commit
4fd6fb2f70
14 changed files with 522 additions and 157 deletions
|
|
@ -98,6 +98,8 @@ pub enum AnthropicBeta {
|
|||
McpServers20251204,
|
||||
#[strum(serialize = "mid-conversation-output-config-2026-07-01")]
|
||||
MidConversationOutputConfig20260701,
|
||||
#[strum(serialize = "mid-conversation-system-clear-at-2026-08-21")]
|
||||
MidConversationSystemClearAt20260821,
|
||||
#[strum(serialize = "mid-conversation-tool-changes-2026-07-01")]
|
||||
MidConversationToolChanges20260701,
|
||||
#[strum(serialize = "output-128k-2025-02-19")]
|
||||
|
|
@ -129,7 +131,7 @@ pub enum AnthropicBeta {
|
|||
}
|
||||
|
||||
impl AnthropicBeta {
|
||||
pub const KNOWN: [Self; 40] = [
|
||||
pub const KNOWN: [Self; 41] = [
|
||||
Self::Oauth20250420,
|
||||
Self::WebFetch20250910,
|
||||
Self::WebSearch20250305,
|
||||
|
|
@ -157,6 +159,7 @@ impl AnthropicBeta {
|
|||
Self::McpClient20251120,
|
||||
Self::McpServers20251204,
|
||||
Self::MidConversationOutputConfig20260701,
|
||||
Self::MidConversationSystemClearAt20260821,
|
||||
Self::MidConversationToolChanges20260701,
|
||||
Self::Output128k20250219,
|
||||
Self::PromptCachingScope20260105,
|
||||
|
|
@ -257,10 +260,14 @@ impl AnthropicBeta {
|
|||
}
|
||||
Self::MidConversationOutputConfig20260701
|
||||
| Self::MidConversationToolChanges20260701 => &[
|
||||
BetaProvider::AzureAi,
|
||||
BetaProvider::BedrockConverse,
|
||||
BetaProvider::BedrockMantle,
|
||||
BetaProvider::VertexAi,
|
||||
BetaProvider::Databricks,
|
||||
],
|
||||
Self::MidConversationSystemClearAt20260821 => &[
|
||||
BetaProvider::BedrockConverse,
|
||||
BetaProvider::Bedrock,
|
||||
BetaProvider::BedrockMantle,
|
||||
BetaProvider::Databricks,
|
||||
],
|
||||
Self::Output128k20250219 | Self::TokenEfficientTools20250219 => &[
|
||||
|
|
@ -429,6 +436,7 @@ mod tests {
|
|||
AnthropicBeta::McpClient20251120,
|
||||
AnthropicBeta::McpServers20251204,
|
||||
AnthropicBeta::MidConversationOutputConfig20260701,
|
||||
AnthropicBeta::MidConversationSystemClearAt20260821,
|
||||
AnthropicBeta::MidConversationToolChanges20260701,
|
||||
AnthropicBeta::Output128k20250219,
|
||||
AnthropicBeta::PromptCachingScope20260105,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"text_editor_20241022": null,
|
||||
"text_editor_20250124": null,
|
||||
"thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01",
|
||||
"mid-conversation-system-clear-at-2026-08-21": "mid-conversation-system-clear-at-2026-08-21",
|
||||
"token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19",
|
||||
"web-fetch-2025-09-10": "web-fetch-2025-09-10",
|
||||
"web-search-2025-03-05": "web-search-2025-03-05",
|
||||
|
|
@ -68,6 +69,9 @@
|
|||
"text_editor_20241022": null,
|
||||
"text_editor_20250124": null,
|
||||
"thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01",
|
||||
"mid-conversation-output-config-2026-07-01": "mid-conversation-output-config-2026-07-01",
|
||||
"mid-conversation-tool-changes-2026-07-01": "mid-conversation-tool-changes-2026-07-01",
|
||||
"mid-conversation-system-clear-at-2026-08-21": "mid-conversation-system-clear-at-2026-08-21",
|
||||
"thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18",
|
||||
"token-efficient-tools-2025-02-19": null,
|
||||
"web-fetch-2025-09-10": "web-fetch-2025-09-10",
|
||||
|
|
@ -102,6 +106,7 @@
|
|||
"text_editor_20241022": null,
|
||||
"text_editor_20250124": null,
|
||||
"thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01",
|
||||
"mid-conversation-system-clear-at-2026-08-21": null,
|
||||
"thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18",
|
||||
"token-efficient-tools-2025-02-19": null,
|
||||
"tool-search-tool-2025-10-19": null,
|
||||
|
|
@ -137,6 +142,7 @@
|
|||
"text_editor_20241022": null,
|
||||
"text_editor_20250124": null,
|
||||
"thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01",
|
||||
"mid-conversation-system-clear-at-2026-08-21": null,
|
||||
"token-efficient-tools-2025-02-19": null,
|
||||
"tool-search-tool-2025-10-19": "tool-search-tool-2025-10-19",
|
||||
"web-fetch-2025-09-10": null,
|
||||
|
|
@ -175,6 +181,7 @@
|
|||
"text_editor_20241022": null,
|
||||
"text_editor_20250124": null,
|
||||
"thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01",
|
||||
"mid-conversation-system-clear-at-2026-08-21": null,
|
||||
"thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18",
|
||||
"token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19",
|
||||
"tool-examples-2025-10-29": "tool-examples-2025-10-29",
|
||||
|
|
@ -211,6 +218,9 @@
|
|||
"text_editor_20241022": null,
|
||||
"text_editor_20250124": null,
|
||||
"thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01",
|
||||
"mid-conversation-output-config-2026-07-01": "mid-conversation-output-config-2026-07-01",
|
||||
"mid-conversation-tool-changes-2026-07-01": "mid-conversation-tool-changes-2026-07-01",
|
||||
"mid-conversation-system-clear-at-2026-08-21": "mid-conversation-system-clear-at-2026-08-21",
|
||||
"thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18",
|
||||
"token-efficient-tools-2025-02-19": null,
|
||||
"tool-search-tool-2025-10-19": "tool-search-tool-2025-10-19",
|
||||
|
|
@ -248,6 +258,7 @@
|
|||
"text_editor_20241022": null,
|
||||
"text_editor_20250124": null,
|
||||
"thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01",
|
||||
"mid-conversation-system-clear-at-2026-08-21": null,
|
||||
"token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19",
|
||||
"web-fetch-2025-09-10": null,
|
||||
"web-search-2025-03-05": "web-search-2025-03-05"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|||
from functools import cache
|
||||
from importlib.resources import files
|
||||
from typing import Final, TypeAlias
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
|
@ -355,9 +356,33 @@ def get_provider_beta_header(
|
|||
return provider_mapping[anthropic_beta_header]
|
||||
|
||||
|
||||
FOUNDRY_API_BASE_HOST_SUFFIXES: Final[tuple[str, ...]] = ("services.ai.azure.com", "openai.azure.com")
|
||||
|
||||
|
||||
def is_foundry_api_base(api_base: str | None) -> bool:
|
||||
"""Whether ``api_base`` points at an Azure AI Foundry host, where Claude is served by Microsoft."""
|
||||
if not api_base:
|
||||
return False
|
||||
host: Final = urlparse(api_base if "://" in api_base else f"https://{api_base}").hostname or ""
|
||||
return any(host == suffix or host.endswith(f".{suffix}") for suffix in FOUNDRY_API_BASE_HOST_SUFFIXES)
|
||||
|
||||
|
||||
def resolve_beta_provider(provider: str, api_base: str | None = None) -> str:
|
||||
"""
|
||||
Pick the beta allowlist row for one deployment, not one provider string.
|
||||
|
||||
An ``anthropic`` deployment whose ``api_base`` is a Foundry host is served by Microsoft and
|
||||
gets the ``azure_ai`` row, since Foundry accepts a different set of betas than api.anthropic.com.
|
||||
"""
|
||||
if get_provider_name(provider) == "anthropic" and is_foundry_api_base(api_base):
|
||||
return "azure_ai"
|
||||
return provider
|
||||
|
||||
|
||||
def update_headers_with_filtered_beta(
|
||||
headers: dict,
|
||||
provider: str,
|
||||
api_base: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Update headers dict by filtering and transforming anthropic-beta header values.
|
||||
|
|
@ -366,6 +391,7 @@ def update_headers_with_filtered_beta(
|
|||
Args:
|
||||
headers: Request headers dict (will be modified in place)
|
||||
provider: Provider name
|
||||
api_base: Deployment api_base, used to pick the allowlist row (Foundry hosts use ``azure_ai``)
|
||||
|
||||
Returns:
|
||||
Updated headers dict
|
||||
|
|
@ -380,7 +406,7 @@ def update_headers_with_filtered_beta(
|
|||
# Filter and transform based on provider
|
||||
filtered_beta_values: Final = filter_and_transform_beta_headers(
|
||||
beta_headers=beta_values,
|
||||
provider=provider,
|
||||
provider=resolve_beta_provider(provider, api_base),
|
||||
)
|
||||
|
||||
# Update or remove the header
|
||||
|
|
@ -397,6 +423,7 @@ def update_request_with_filtered_beta(
|
|||
headers: dict,
|
||||
request_data: dict,
|
||||
provider: str,
|
||||
api_base: str | None = None,
|
||||
) -> tuple[dict, dict]:
|
||||
"""
|
||||
Update both headers and request body beta fields based on provider support.
|
||||
|
|
@ -406,11 +433,13 @@ def update_request_with_filtered_beta(
|
|||
headers: Request headers dict (will be modified in place)
|
||||
request_data: Request body dict (will be modified in place)
|
||||
provider: Provider name
|
||||
api_base: Deployment api_base, used to pick the allowlist row (Foundry hosts use ``azure_ai``)
|
||||
|
||||
Returns:
|
||||
Tuple of (updated headers, updated request_data)
|
||||
"""
|
||||
headers = update_headers_with_filtered_beta(headers=headers, provider=provider)
|
||||
row_provider: Final = resolve_beta_provider(provider, api_base)
|
||||
headers = update_headers_with_filtered_beta(headers=headers, provider=row_provider)
|
||||
|
||||
existing_body_betas: Final = request_data.get("anthropic_beta")
|
||||
if not existing_body_betas:
|
||||
|
|
@ -418,7 +447,7 @@ def update_request_with_filtered_beta(
|
|||
|
||||
filtered_body_betas: Final = filter_and_transform_beta_headers(
|
||||
beta_headers=existing_body_betas,
|
||||
provider=provider,
|
||||
provider=row_provider,
|
||||
)
|
||||
|
||||
if filtered_body_betas:
|
||||
|
|
|
|||
|
|
@ -510,6 +510,7 @@ class AnthropicChatCompletion(BaseLLM):
|
|||
headers=headers,
|
||||
request_data=request_data,
|
||||
provider=custom_llm_provider,
|
||||
api_base=api_base,
|
||||
)
|
||||
logging_obj.pre_call(
|
||||
input=messages,
|
||||
|
|
@ -1166,6 +1167,9 @@ class ModelResponseIterator:
|
|||
start_message: Final = message_start_block["message"]
|
||||
if "model" in start_message:
|
||||
self.served_model = start_message["model"]
|
||||
input_transformations: Final = start_message.get("input_transformations")
|
||||
if input_transformations is not None:
|
||||
provider_specific_fields["input_transformations"] = input_transformations
|
||||
if "usage" in message_start_block["message"]:
|
||||
usage = self._handle_usage(anthropic_usage_chunk=message_start_block["message"]["usage"])
|
||||
elif type_chunk == "error":
|
||||
|
|
|
|||
|
|
@ -2920,7 +2920,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
|
||||
def _build_provider_specific_fields(
|
||||
self,
|
||||
completion_response: dict,
|
||||
completion_response: Mapping[str, object],
|
||||
citations: Sequence[object] | None,
|
||||
thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] | None,
|
||||
web_search_results: Sequence[object] | None,
|
||||
|
|
@ -2937,6 +2937,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
if context_management is not None:
|
||||
provider_specific_fields["context_management"] = context_management
|
||||
|
||||
input_transformations: Final = completion_response.get("input_transformations")
|
||||
if input_transformations is not None:
|
||||
provider_specific_fields["input_transformations"] = input_transformations
|
||||
|
||||
if web_search_results is not None:
|
||||
provider_specific_fields["web_search_results"] = web_search_results
|
||||
provider_specific_fields["web_search_calls"] = self._build_web_search_calls(
|
||||
|
|
@ -2947,17 +2951,15 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
if tool_results is not None:
|
||||
provider_specific_fields["tool_results"] = tool_results
|
||||
|
||||
container: Final = completion_response.get("container")
|
||||
container_fields: Final[Mapping[str, object] | None] = container if isinstance(container, dict) else None
|
||||
if tool_results is not None and any(result.get("type") != "tool_search_tool_result" for result in tool_results):
|
||||
container_id: Final = (
|
||||
completion_response.get("container", {}).get("id")
|
||||
if isinstance(completion_response.get("container"), dict)
|
||||
else None
|
||||
)
|
||||
raw_container_id: Final = container_fields.get("id") if container_fields is not None else None
|
||||
container_id: Final = raw_container_id if isinstance(raw_container_id, str) else None
|
||||
code_by_id: Final = self._build_code_by_id_map(tool_calls)
|
||||
code_interpreter_results = self._build_code_interpreter_results(tool_results, code_by_id, container_id)
|
||||
provider_specific_fields["code_interpreter_results"] = code_interpreter_results
|
||||
|
||||
container: Final = completion_response.get("container")
|
||||
if container is not None:
|
||||
provider_specific_fields["container"] = container
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ import litellm.litellm_core_utils
|
|||
import litellm.types
|
||||
import litellm.types.utils
|
||||
from litellm._logging import redact_string, verbose_logger
|
||||
from litellm.anthropic_beta_headers_manager import aload_beta_headers_config, update_headers_with_filtered_beta
|
||||
from litellm.anthropic_beta_headers_manager import (
|
||||
aload_beta_headers_config,
|
||||
is_foundry_api_base,
|
||||
update_headers_with_filtered_beta,
|
||||
)
|
||||
from litellm.constants import MAX_FILE_LIST_LIMIT, REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
|
||||
from litellm.files.types import FileContentStreamingResult
|
||||
from litellm.litellm_core_utils.agentic_followup_kwargs import build_agentic_followup_kwargs
|
||||
|
|
@ -2507,14 +2511,15 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params.get("additional_drop_params") or (),
|
||||
anthropic_messages_optional_request_params,
|
||||
)
|
||||
if (
|
||||
request_params.get("safeguards") is not None
|
||||
and anthropic_messages_provider_config.should_filter_anthropic_beta_headers()
|
||||
):
|
||||
await aload_beta_headers_config()
|
||||
affinity_api_base: Final = (
|
||||
AnthropicModelInfo.get_api_base(api_base) if custom_llm_provider == "anthropic" else api_base
|
||||
)
|
||||
filters_beta_headers: Final = (
|
||||
anthropic_messages_provider_config.should_filter_anthropic_beta_headers()
|
||||
or is_foundry_api_base(affinity_api_base)
|
||||
)
|
||||
if request_params.get("safeguards") is not None and filters_beta_headers:
|
||||
await aload_beta_headers_config()
|
||||
default_affinity_header: Final = get_provider_affinity_header(affinity_api_base)
|
||||
(
|
||||
headers,
|
||||
|
|
@ -2533,8 +2538,10 @@ class BaseLLMHTTPHandler:
|
|||
api_base=api_base,
|
||||
)
|
||||
|
||||
if anthropic_messages_provider_config.should_filter_anthropic_beta_headers():
|
||||
headers = update_headers_with_filtered_beta(headers=headers, provider=custom_llm_provider)
|
||||
if filters_beta_headers:
|
||||
headers = update_headers_with_filtered_beta(
|
||||
headers=headers, provider=custom_llm_provider, api_base=affinity_api_base
|
||||
)
|
||||
|
||||
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
|
||||
|
||||
|
|
|
|||
|
|
@ -660,6 +660,7 @@ class MessageChunk(TypedDict, total=False):
|
|||
stop_sequence: str | None
|
||||
usage: UsageDelta
|
||||
safeguard_results: ReadOnly[Sequence[SafeguardResult]]
|
||||
input_transformations: ReadOnly[Sequence[Mapping[str, object]]]
|
||||
|
||||
|
||||
class MessageStartBlock(TypedDict):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
|
@ -106,3 +106,4 @@ class AnthropicMessagesResponse(TypedDict, total=False):
|
|||
usage: AnthropicUsage | None
|
||||
context_management: NotRequired[ContextManagementResponse]
|
||||
safeguard_results: NotRequired[ReadOnly[Sequence[SafeguardResult]]]
|
||||
input_transformations: NotRequired[ReadOnly[Sequence[Mapping[str, object]]]]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
"""Anthropic's ``input_transformations`` report reaches the caller on chat completions and ``/v1/messages``."""
|
||||
|
||||
import json
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support import claude_code as cc
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
_BACKEND: Final = "claude-sonnet-4-6"
|
||||
_API_KEY: Final = "scripted-anthropic-key"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_CHOICES: Final = TypeAdapter(list[dict[str, JsonValue]])
|
||||
_INPUT_TRANSFORMATIONS: Final[list[JsonValue]] = [{"type": "prefix_binding_mismatch", "index": 1}]
|
||||
_CONTENT: Final[tuple[dict[str, JsonValue], ...]] = ({"type": "text", "text": "Ok"},)
|
||||
_USAGE: Final[dict[str, JsonValue]] = {"input_tokens": 8, "output_tokens": 2}
|
||||
_IDENTITY: Final = "msg_input_transformations"
|
||||
|
||||
|
||||
def _reply_body() -> bytes:
|
||||
body: Final = _JSON_OBJECT.validate_json(cc.message_reply(_IDENTITY, _BACKEND, _CONTENT, _USAGE))
|
||||
return json.dumps({**body, "input_transformations": _INPUT_TRANSFORMATIONS}).encode()
|
||||
|
||||
|
||||
def _stream_reply() -> tuple[bytes, ...]:
|
||||
start, *rest = cc.message_stream(_IDENTITY, _BACKEND, _CONTENT, _USAGE)
|
||||
event_name, data = cc.sse_events(start.decode())[0]
|
||||
message: Final = _JSON_OBJECT.validate_python(data["message"])
|
||||
started: Final = cc.sse_frame(
|
||||
event_name, {**data, "message": {**message, "input_transformations": _INPUT_TRANSFORMATIONS}}
|
||||
)
|
||||
return (started, *rest)
|
||||
|
||||
|
||||
def _anthropic_upstream(request: Request) -> Reply:
|
||||
assert request.method == "POST" and request.target == "/v1/messages", request.target
|
||||
assert request.headers["x-api-key"] == _API_KEY, request.headers
|
||||
body: Final = _JSON_OBJECT.validate_json(request.body)
|
||||
if body.get("stream") is True:
|
||||
return Reply(content_type="text/event-stream", chunks=_stream_reply())
|
||||
return Reply(body=_reply_body())
|
||||
|
||||
|
||||
def test_chat_completion_message_carries_input_transformations(gateway: Gateway) -> None:
|
||||
with wire_server(_anthropic_upstream) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "max_tokens": 16, "messages": [{"role": "user", "content": "Say ok"}]},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
message: Final = response.json()["choices"][0]["message"]
|
||||
assert message["provider_specific_fields"]["input_transformations"] == _INPUT_TRANSFORMATIONS, response.text
|
||||
|
||||
|
||||
def test_first_chat_completion_chunk_carries_input_transformations(gateway: Gateway) -> None:
|
||||
with wire_server(_anthropic_upstream) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "max_tokens": 16, "stream": True, "messages": [{"role": "user", "content": "Say ok"}]},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
chunks: Final = [
|
||||
_JSON_OBJECT.validate_json(line[len("data: ") :])
|
||||
for line in response.text.splitlines()
|
||||
if line.startswith("data: {")
|
||||
]
|
||||
first_delta: Final = _JSON_OBJECT.validate_python(_CHOICES.validate_python(chunks[0]["choices"])[0]["delta"])
|
||||
assert first_delta["provider_specific_fields"] == {"input_transformations": _INPUT_TRANSFORMATIONS}, chunks[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"])
|
||||
def test_messages_endpoint_keeps_input_transformations(gateway: Gateway, stream: bool) -> None:
|
||||
with wire_server(_anthropic_upstream) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{"model": model, "max_tokens": 16, "stream": stream, "messages": [{"role": "user", "content": "Say ok"}]},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
if stream:
|
||||
started: Final = next(data for name, data in cc.sse_events(response.text) if name == "message_start")
|
||||
assert _JSON_OBJECT.validate_python(started["message"])["input_transformations"] == _INPUT_TRANSFORMATIONS
|
||||
else:
|
||||
assert response.json()["input_transformations"] == _INPUT_TRANSFORMATIONS, response.text
|
||||
|
|
@ -537,7 +537,11 @@ def test_streamed_thinking_blocks_accumulate_in_the_openai_sdk_and_replay_withou
|
|||
{"type": "content_block_delta", "index": 2, "delta": {"type": "thinking_delta", "thinking": "Step 3."}},
|
||||
{"type": "content_block_delta", "index": 2, "delta": {"type": "signature_delta", "signature": "sig-b"}},
|
||||
{"type": "content_block_stop", "index": 2},
|
||||
{"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 9}},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
||||
"usage": {"output_tokens": 9},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
)
|
||||
|
||||
|
|
@ -558,7 +562,11 @@ def test_streamed_thinking_blocks_accumulate_in_the_openai_sdk_and_replay_withou
|
|||
"thinking_blocks": message["thinking_blocks"],
|
||||
}
|
||||
replayed: Final = anthropic_messages_pt(
|
||||
messages=[{"role": "user", "content": "Capital of France?"}, next_turn, {"role": "user", "content": "And Spain?"}],
|
||||
messages=[
|
||||
{"role": "user", "content": "Capital of France?"},
|
||||
next_turn,
|
||||
{"role": "user", "content": "And Spain?"},
|
||||
],
|
||||
model="claude-haiku-4-5",
|
||||
llm_provider="anthropic",
|
||||
)
|
||||
|
|
@ -3650,3 +3658,37 @@ def test_streamed_end_turn_carries_no_stop_details():
|
|||
provider_fields: Final = assembled.choices[0].message.provider_specific_fields or {}
|
||||
assert "stop_details" not in provider_fields
|
||||
assert "stop_reason" not in provider_fields
|
||||
|
||||
|
||||
def test_message_start_surfaces_input_transformations_on_the_first_chunk():
|
||||
input_transformations: Final = [{"type": "thinking_block_binding", "count": 2}]
|
||||
chunk: Final = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 12, "output_tokens": 1},
|
||||
"input_transformations": input_transformations,
|
||||
},
|
||||
}
|
||||
iterator: Final = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
|
||||
|
||||
first_chunk: Final = iterator.chunk_parser(chunk=chunk)
|
||||
|
||||
assert first_chunk.choices[0].delta.provider_specific_fields == {"input_transformations": input_transformations}
|
||||
assert first_chunk.model == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
def test_message_start_without_input_transformations_has_no_provider_specific_fields():
|
||||
chunk: Final = {
|
||||
"type": "message_start",
|
||||
"message": {"id": "msg_1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-6", "content": []},
|
||||
}
|
||||
iterator: Final = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
|
||||
|
||||
assert iterator.chunk_parser(chunk=chunk).choices[0].delta.provider_specific_fields is None
|
||||
|
|
|
|||
|
|
@ -9807,3 +9807,54 @@ def test_transform_request_tool_config_point_without_tools_drops_the_key():
|
|||
assert "cache_control_injection_points" not in data
|
||||
assert "tools" not in data
|
||||
assert litellm_params["metadata"] == {}
|
||||
|
||||
|
||||
_INPUT_TRANSFORMATIONS: Final = [{"type": "thinking_block_binding", "count": 2}]
|
||||
|
||||
|
||||
def _input_transformations_response() -> dict:
|
||||
return {
|
||||
"id": "msg_input_transformations",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"content": [{"type": "text", "text": "ok"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 12, "output_tokens": 3},
|
||||
"input_transformations": _INPUT_TRANSFORMATIONS,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_response_surfaces_input_transformations_in_provider_specific_fields():
|
||||
result = AnthropicConfig().transform_response(
|
||||
model="claude-sonnet-4-6",
|
||||
raw_response=httpx.Response(200, json=_input_transformations_response()),
|
||||
model_response=ModelResponse(),
|
||||
logging_obj=MagicMock(),
|
||||
request_data={},
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
assert result.choices[0].message.provider_specific_fields["input_transformations"] == _INPUT_TRANSFORMATIONS
|
||||
|
||||
|
||||
def test_chat_response_without_input_transformations_adds_no_key():
|
||||
completion_response = _input_transformations_response()
|
||||
del completion_response["input_transformations"]
|
||||
result = AnthropicConfig().transform_response(
|
||||
model="claude-sonnet-4-6",
|
||||
raw_response=httpx.Response(200, json=completion_response),
|
||||
model_response=ModelResponse(),
|
||||
logging_obj=MagicMock(),
|
||||
request_data={},
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
assert "input_transformations" not in result.choices[0].message.provider_specific_fields
|
||||
|
|
|
|||
|
|
@ -416,3 +416,33 @@ def test_drop_params_forwards_a_non_object_message_item_as_sent_on_the_native_re
|
|||
|
||||
assert request["messages"][1] == 7
|
||||
assert request["messages"][2]["content"][0]["content"] == [{"type": "text", "text": "sunny"}]
|
||||
|
||||
|
||||
def test_native_response_keeps_input_transformations() -> None:
|
||||
input_transformations: Final[list[JsonValue]] = [{"type": "thinking_block_binding", "count": 2}]
|
||||
upstream: Final[dict[str, JsonValue]] = {
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"content": [{"type": "text", "text": "ok"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 19, "output_tokens": 42},
|
||||
"input_transformations": input_transformations,
|
||||
}
|
||||
logging_obj: Final = Logging(
|
||||
model="claude-sonnet-4-6",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="anthropic_messages",
|
||||
start_time=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
litellm_call_id="test-call",
|
||||
function_id="test-function",
|
||||
)
|
||||
|
||||
response: Final = AnthropicMessagesConfig().transform_anthropic_messages_response(
|
||||
model="claude-sonnet-4-6", raw_response=httpx.Response(200, json=upstream), logging_obj=logging_obj
|
||||
)
|
||||
|
||||
assert response["input_transformations"] == input_transformations
|
||||
|
|
|
|||
|
|
@ -419,9 +419,7 @@ def test_response_api_handler_sends_no_stream_options_when_web_search_forces_a_n
|
|||
):
|
||||
sent: Final = _SentRequestRecorder()
|
||||
recorder: Final = _AgenticLoopKwargsRecorder()
|
||||
monkeypatch.setattr(
|
||||
litellm, "callbacks", [WebSearchInterceptionLogger(enabled_providers=["azure"]), recorder]
|
||||
)
|
||||
monkeypatch.setattr(litellm, "callbacks", [WebSearchInterceptionLogger(enabled_providers=["azure"]), recorder])
|
||||
logging_obj: Final = Mock()
|
||||
logging_obj.dynamic_success_callbacks = []
|
||||
|
||||
|
|
@ -2575,7 +2573,9 @@ async def test_async_anthropic_messages_handler_passes_deployment_api_base_to_ag
|
|||
super().__init__()
|
||||
self.hook_kwargs: dict | None = None
|
||||
|
||||
async def async_should_run_agentic_loop(self, response, model, messages, tools, stream, custom_llm_provider, kwargs):
|
||||
async def async_should_run_agentic_loop(
|
||||
self, response, model, messages, tools, stream, custom_llm_provider, kwargs
|
||||
):
|
||||
self.hook_kwargs = dict(kwargs)
|
||||
return False, {}
|
||||
|
||||
|
|
@ -2935,9 +2935,7 @@ def _transform_subtitle_response(payload):
|
|||
|
||||
|
||||
def test_subtitle_synthesis_fallback_without_timings_drops_words():
|
||||
response = _transform_subtitle_response(
|
||||
{"text": "hello world", "words": [{"word": "hello"}, {"word": "world"}]}
|
||||
)
|
||||
response = _transform_subtitle_response({"text": "hello world", "words": [{"word": "hello"}, {"word": "world"}]})
|
||||
|
||||
assert response.text == "hello world"
|
||||
assert "words" not in response
|
||||
|
|
@ -3929,7 +3927,13 @@ def _capture_video_create_request(captured):
|
|||
captured["body"] = request.content
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "video_123", "object": "video", "status": "queued", "created_at": 1712697600, "model": "sora-2"},
|
||||
json={
|
||||
"id": "video_123",
|
||||
"object": "video",
|
||||
"status": "queued",
|
||||
"created_at": 1712697600,
|
||||
"model": "sora-2",
|
||||
},
|
||||
)
|
||||
|
||||
return respond
|
||||
|
|
@ -3952,7 +3956,9 @@ def test_video_generation_without_file_sends_multipart_form_data():
|
|||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(OpenAIVideoConfig()))
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(
|
||||
client=client, **_video_create_call_kwargs(OpenAIVideoConfig())
|
||||
)
|
||||
|
||||
assert captured["content_type"].startswith("multipart/form-data")
|
||||
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
|
||||
|
|
@ -3992,7 +3998,9 @@ def test_azure_video_generation_without_file_sends_multipart_form_data():
|
|||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(AzureVideoConfig()))
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(
|
||||
client=client, **_video_create_call_kwargs(AzureVideoConfig())
|
||||
)
|
||||
|
||||
assert captured["content_type"].startswith("multipart/form-data")
|
||||
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
|
||||
|
|
@ -4007,7 +4015,9 @@ def test_video_generation_json_provider_keeps_json_body():
|
|||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig()))
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(
|
||||
client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig())
|
||||
)
|
||||
|
||||
assert captured["content_type"] == "application/json"
|
||||
assert json.loads(captured["body"]) == {"model": "sora-2", "prompt": "a cat surfing", "seconds": "4"}
|
||||
|
|
@ -4037,6 +4047,7 @@ def test_video_generation_with_input_reference_keeps_file_multipart():
|
|||
AZURE_AI_BASE = "https://myfoundry.services.ai.azure.com"
|
||||
AZURE_AI_CHAT_COMPLETIONS_URL = f"{AZURE_AI_BASE}/models/chat/completions"
|
||||
|
||||
|
||||
def _a_tool_with_an_unsupported_field() -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
|
|
@ -4044,14 +4055,13 @@ def _a_tool_with_an_unsupported_field() -> dict:
|
|||
"strict": True,
|
||||
}
|
||||
|
||||
|
||||
A_COMPLETION = {
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "grok-3",
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"}
|
||||
],
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
|
||||
|
|
@ -4095,9 +4105,7 @@ def _call_azure_ai(recorder: _RecordedAzureAI, **overrides):
|
|||
|
||||
|
||||
def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried():
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)])
|
||||
|
||||
response = _call_azure_ai(recorder)
|
||||
|
||||
|
|
@ -4108,9 +4116,7 @@ def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried():
|
|||
|
||||
|
||||
def test_the_retry_changes_only_the_field_the_provider_named():
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)])
|
||||
|
||||
_call_azure_ai(recorder)
|
||||
|
||||
|
|
@ -4149,9 +4155,7 @@ def test_an_extra_input_outside_a_tool_is_not_retried_unless_dropping_params_was
|
|||
|
||||
|
||||
def test_an_extra_input_outside_a_tool_is_retried_when_dropping_params_was_asked_for():
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
recorder = _RecordedAzureAI([_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)])
|
||||
|
||||
response = _call_azure_ai(recorder, drop_params=True)
|
||||
|
||||
|
|
@ -4165,9 +4169,7 @@ async def test_a_tool_field_the_provider_rejects_is_dropped_and_retried_on_the_a
|
|||
):
|
||||
import respx
|
||||
|
||||
recorder = _RecordedAzureAI(
|
||||
[_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]
|
||||
)
|
||||
recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)])
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder)
|
||||
|
|
@ -4308,7 +4310,11 @@ async def test_create_batch_says_which_setting_is_missing_when_the_provider_reso
|
|||
|
||||
with pytest.raises(ValueError, match="api_base is required for create_batch"):
|
||||
await BaseLLMHTTPHandler().create_batch(
|
||||
create_batch_data={"input_file_id": "file_1", "endpoint": "/v1/chat/completions", "completion_window": "24h"},
|
||||
create_batch_data={
|
||||
"input_file_id": "file_1",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"completion_window": "24h",
|
||||
},
|
||||
litellm_params={},
|
||||
provider_config=provider_config,
|
||||
headers={},
|
||||
|
|
@ -4489,7 +4495,9 @@ def _start_async_completion(config, logging_obj=None):
|
|||
custom_llm_provider="openai",
|
||||
model_response=ModelResponse(),
|
||||
encoding=None,
|
||||
logging_obj=logging_obj if logging_obj is not None else Mock(dynamic_success_callbacks=None, model_call_details={}),
|
||||
logging_obj=logging_obj
|
||||
if logging_obj is not None
|
||||
else Mock(dynamic_success_callbacks=None, model_call_details={}),
|
||||
optional_params={},
|
||||
timeout=10.0,
|
||||
litellm_params={},
|
||||
|
|
@ -4877,7 +4885,9 @@ async def test_async_realtime_bridges_a_transcription_session_through_the_provid
|
|||
logging_obj.dispatch_failure_handlers = AsyncMock()
|
||||
handler = BaseLLMHTTPHandler()
|
||||
|
||||
with patch.object(handler, "_open_realtime_backend_ws", AsyncMock(side_effect=AssertionError("dialed a websocket"))) as dial:
|
||||
with patch.object(
|
||||
handler, "_open_realtime_backend_ws", AsyncMock(side_effect=AssertionError("dialed a websocket"))
|
||||
) as dial:
|
||||
await handler.async_realtime(
|
||||
model="chirp_3",
|
||||
websocket=client_ws,
|
||||
|
|
@ -5688,7 +5698,9 @@ async def test_messages_opencode_session_header(
|
|||
model="anthropic/claude-haiku-5-5",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
max_tokens=128000,
|
||||
api_base=None if environment_base and api_base == "https://opencode.ai/inference/go/anthropic" else api_base,
|
||||
api_base=None
|
||||
if environment_base and api_base == "https://opencode.ai/inference/go/anthropic"
|
||||
else api_base,
|
||||
api_key="test-key",
|
||||
stream=stream,
|
||||
litellm_session_id="conversation-123",
|
||||
|
|
@ -5749,17 +5761,27 @@ def test_vector_store_retrieve_handler_sends_id_before_azure_api_version_query()
|
|||
@pytest.mark.parametrize("is_async", (False, True))
|
||||
@pytest.mark.parametrize("provider", ("openai", "azure"))
|
||||
async def test_non_chatgpt_responses_preserves_caller_headers_after_validation(
|
||||
is_async: bool, provider: str, respx_mock: respx.MockRouter,
|
||||
is_async: bool,
|
||||
provider: str,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
route: Final = respx_mock.post(url__regex=r"https://responses\.example\.com/.*").respond(
|
||||
200,
|
||||
json={
|
||||
"id": "resp_headers", "object": "response", "created_at": 1,
|
||||
"model": "gpt-6-luna", "status": "completed", "output": [],
|
||||
"parallel_tool_calls": False, "tool_choice": "auto", "tools": [],
|
||||
"id": "resp_headers",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"model": "gpt-6-luna",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"parallel_tool_calls": False,
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
},
|
||||
)
|
||||
client: Final = AsyncHTTPHandler(transport=httpx.AsyncHTTPTransport()) if is_async else HTTPHandler(client=httpx.Client())
|
||||
client: Final = (
|
||||
AsyncHTTPHandler(transport=httpx.AsyncHTTPTransport()) if is_async else HTTPHandler(client=httpx.Client())
|
||||
)
|
||||
kwargs: Final = {
|
||||
"model": f"{provider}/gpt-6-luna",
|
||||
"input": "hello",
|
||||
|
|
@ -5784,3 +5806,49 @@ async def test_non_chatgpt_responses_preserves_caller_headers_after_validation(
|
|||
await client.close()
|
||||
else:
|
||||
client.client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_anthropic_messages_handler_filters_betas_by_the_azure_ai_row_on_a_foundry_api_base(monkeypatch):
|
||||
from litellm import anthropic_beta_headers_manager
|
||||
from litellm.llms.anthropic.messages.native.transformation import AnthropicMessagesConfig
|
||||
|
||||
monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True")
|
||||
anthropic_beta_headers_manager._BETA_HEADERS_CONFIG = None
|
||||
foundry_api_base: Final = "https://my-resource.services.ai.azure.com/anthropic"
|
||||
upstream_response: Final = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Hello!"}],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
},
|
||||
request=httpx.Request("POST", f"{foundry_api_base}/v1/messages"),
|
||||
)
|
||||
mock_client: Final = AsyncMock(spec=AsyncHTTPHandler)
|
||||
mock_client.post = AsyncMock(return_value=upstream_response)
|
||||
mock_logging_obj: Final = Mock()
|
||||
mock_logging_obj.model_call_details = {}
|
||||
mock_logging_obj.stream = False
|
||||
mock_logging_obj.dynamic_success_callbacks = None
|
||||
|
||||
await BaseLLMHTTPHandler().async_anthropic_messages_handler(
|
||||
model="claude-sonnet-4-6",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
anthropic_messages_provider_config=AnthropicMessagesConfig(),
|
||||
anthropic_messages_optional_request_params={"max_tokens": 8},
|
||||
custom_llm_provider="anthropic",
|
||||
litellm_params=GenericLiteLLMParams(api_base=foundry_api_base),
|
||||
logging_obj=mock_logging_obj,
|
||||
api_key="sk-test",
|
||||
api_base=foundry_api_base,
|
||||
client=mock_client,
|
||||
kwargs={"extra_headers": {"anthropic-beta": "advisor-tool-2026-03-01,thinking-binding-controls-2026-08-01"}},
|
||||
)
|
||||
|
||||
sent_headers: Final = mock_client.post.call_args.kwargs["headers"]
|
||||
assert sent_headers["anthropic-beta"] == "thinking-binding-controls-2026-08-01"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm import anthropic_beta_headers_manager
|
||||
from litellm.anthropic_beta_headers_manager import (
|
||||
GetAnthropicBetaHeadersConfig,
|
||||
filter_and_transform_beta_headers,
|
||||
|
|
@ -53,9 +54,7 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
def get_supported_headers(self, provider: str) -> List[str]:
|
||||
"""Get headers with non-null values for a provider."""
|
||||
provider_config = self.config.get(provider, {})
|
||||
return [
|
||||
header for header, value in provider_config.items() if value is not None
|
||||
]
|
||||
return [header for header, value in provider_config.items() if value is not None]
|
||||
|
||||
def get_unsupported_headers(self, provider: str) -> List[str]:
|
||||
"""Get headers with null values for a provider."""
|
||||
|
|
@ -65,11 +64,7 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
def get_mapped_headers(self, provider: str) -> Dict[str, str]:
|
||||
"""Get mapping of input headers to provider-specific headers."""
|
||||
provider_config = self.config.get(provider, {})
|
||||
return {
|
||||
header: value
|
||||
for header, value in provider_config.items()
|
||||
if value is not None
|
||||
}
|
||||
return {header: value for header, value in provider_config.items() if value is not None}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider",
|
||||
|
|
@ -82,24 +77,20 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
unsupported_headers = self.get_unsupported_headers(provider)
|
||||
mapped_headers = self.get_mapped_headers(provider)
|
||||
|
||||
filtered = filter_and_transform_beta_headers(
|
||||
beta_headers=all_headers, provider=provider
|
||||
)
|
||||
filtered = filter_and_transform_beta_headers(beta_headers=all_headers, provider=provider)
|
||||
assert ("compact-2026-09-04" in filtered) is (provider == "anthropic")
|
||||
|
||||
for header in unsupported_headers:
|
||||
assert (
|
||||
header not in filtered
|
||||
), f"Unsupported header '{header}' should be filtered out for {provider}"
|
||||
assert (
|
||||
mapped_headers.get(header) not in filtered
|
||||
), f"Mapped value of unsupported header '{header}' should not appear for {provider}"
|
||||
assert header not in filtered, f"Unsupported header '{header}' should be filtered out for {provider}"
|
||||
assert mapped_headers.get(header) not in filtered, (
|
||||
f"Mapped value of unsupported header '{header}' should not appear for {provider}"
|
||||
)
|
||||
|
||||
for header in supported_headers:
|
||||
expected_mapped = mapped_headers[header]
|
||||
assert (
|
||||
expected_mapped in filtered
|
||||
), f"Supported header '{header}' should be mapped to '{expected_mapped}' for {provider}"
|
||||
assert expected_mapped in filtered, (
|
||||
f"Supported header '{header}' should be mapped to '{expected_mapped}' for {provider}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider",
|
||||
|
|
@ -114,20 +105,14 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
]
|
||||
all_headers = self.get_all_beta_headers() + unknown_headers
|
||||
|
||||
filtered = filter_and_transform_beta_headers(
|
||||
beta_headers=all_headers, provider=provider
|
||||
)
|
||||
filtered = filter_and_transform_beta_headers(beta_headers=all_headers, provider=provider)
|
||||
|
||||
for unknown in unknown_headers:
|
||||
assert (
|
||||
unknown not in filtered
|
||||
), f"Unknown header '{unknown}' should be filtered out for {provider}"
|
||||
assert unknown not in filtered, f"Unknown header '{unknown}' should be filtered out for {provider}"
|
||||
|
||||
def test_update_request_with_filtered_beta_vertex_ai(self):
|
||||
"""Test combined filtering for both HTTP headers and request body betas."""
|
||||
headers = {
|
||||
"anthropic-beta": "files-api-2025-04-14,context-management-2025-06-27,code-execution-2025-05-22"
|
||||
}
|
||||
headers = {"anthropic-beta": "files-api-2025-04-14,context-management-2025-06-27,code-execution-2025-05-22"}
|
||||
request_data = {
|
||||
"anthropic_beta": [
|
||||
"files-api-2025-04-14",
|
||||
|
|
@ -143,9 +128,7 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
)
|
||||
|
||||
assert filtered_headers.get("anthropic-beta") == "context-management-2025-06-27"
|
||||
assert filtered_request_data.get("anthropic_beta") == [
|
||||
"context-management-2025-06-27"
|
||||
]
|
||||
assert filtered_request_data.get("anthropic_beta") == ["context-management-2025-06-27"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_messages_http_headers_filtering(self):
|
||||
|
|
@ -153,9 +136,7 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
all_headers = self.get_all_beta_headers()
|
||||
unsupported = self.get_unsupported_headers("anthropic")
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.get_async_httpx_client"
|
||||
) as mock_client_factory:
|
||||
with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client") as mock_client_factory:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
|
|
@ -191,9 +172,9 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
if beta_header:
|
||||
beta_values = [b.strip() for b in beta_header.split(",")]
|
||||
for unsupported_header in unsupported:
|
||||
assert (
|
||||
unsupported_header not in beta_values
|
||||
), f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Anthropic"
|
||||
assert unsupported_header not in beta_values, (
|
||||
f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Anthropic"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_azure_ai_messages_http_headers_filtering(self):
|
||||
|
|
@ -201,9 +182,7 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
all_headers = self.get_all_beta_headers()
|
||||
unsupported = self.get_unsupported_headers("azure_ai")
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.get_async_httpx_client"
|
||||
) as mock_client_factory:
|
||||
with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client") as mock_client_factory:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
|
|
@ -241,9 +220,9 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
if beta_header:
|
||||
beta_values = [b.strip() for b in beta_header.split(",")]
|
||||
for unsupported_header in unsupported:
|
||||
assert (
|
||||
unsupported_header not in beta_values
|
||||
), f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Azure AI"
|
||||
assert unsupported_header not in beta_values, (
|
||||
f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Azure AI"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bedrock_converse_headers_and_body_filtering(self):
|
||||
|
|
@ -256,9 +235,7 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"output": {
|
||||
"message": {"role": "assistant", "content": [{"text": "Hello"}]}
|
||||
},
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": "Hello"}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 10, "outputTokens": 20},
|
||||
}
|
||||
|
|
@ -290,27 +267,25 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
if beta_header:
|
||||
beta_values = [b.strip() for b in beta_header.split(",")]
|
||||
for unsupported_header in unsupported:
|
||||
assert (
|
||||
unsupported_header not in beta_values
|
||||
), f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Bedrock Converse"
|
||||
assert unsupported_header not in beta_values, (
|
||||
f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Bedrock Converse"
|
||||
)
|
||||
|
||||
data = call_kwargs.get("data")
|
||||
if data:
|
||||
body = json.loads(data)
|
||||
body_beta = body.get("additionalModelRequestFields", {}).get(
|
||||
"anthropic_beta", []
|
||||
)
|
||||
body_beta = body.get("additionalModelRequestFields", {}).get("anthropic_beta", [])
|
||||
|
||||
for unsupported_header in unsupported:
|
||||
assert (
|
||||
unsupported_header not in body_beta
|
||||
), f"Unsupported header '{unsupported_header}' should not be in request body for Bedrock Converse"
|
||||
assert unsupported_header not in body_beta, (
|
||||
f"Unsupported header '{unsupported_header}' should not be in request body for Bedrock Converse"
|
||||
)
|
||||
|
||||
for header, mapped_value in mapped_headers.items():
|
||||
if header in all_headers and mapped_value in body_beta:
|
||||
assert (
|
||||
mapped_value in body_beta
|
||||
), f"Supported header '{header}' should be mapped to '{mapped_value}' in request body for Bedrock Converse"
|
||||
assert mapped_value in body_beta, (
|
||||
f"Supported header '{header}' should be mapped to '{mapped_value}' in request body for Bedrock Converse"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vertex_ai_messages_http_headers_filtering(self):
|
||||
|
|
@ -318,9 +293,7 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
all_headers = self.get_all_beta_headers()
|
||||
unsupported = self.get_unsupported_headers("vertex_ai")
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.get_async_httpx_client"
|
||||
) as mock_client_factory:
|
||||
with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client") as mock_client_factory:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
|
|
@ -338,9 +311,7 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client_factory.return_value = mock_client
|
||||
|
||||
with patch(
|
||||
"litellm.llms.vertex_ai.vertex_llm_base.VertexBase._ensure_access_token"
|
||||
) as mock_token:
|
||||
with patch("litellm.llms.vertex_ai.vertex_llm_base.VertexBase._ensure_access_token") as mock_token:
|
||||
mock_token.return_value = ("test-token", "test-project")
|
||||
|
||||
try:
|
||||
|
|
@ -363,9 +334,9 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
if beta_header:
|
||||
beta_values = [b.strip() for b in beta_header.split(",")]
|
||||
for unsupported_header in unsupported:
|
||||
assert (
|
||||
unsupported_header not in beta_values
|
||||
), f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Vertex AI"
|
||||
assert unsupported_header not in beta_values, (
|
||||
f"Unsupported header '{unsupported_header}' should not be in HTTP headers for Vertex AI"
|
||||
)
|
||||
|
||||
def test_header_mapping_correctness(self):
|
||||
"""Test that headers are mapped correctly for providers with transformations."""
|
||||
|
|
@ -402,17 +373,15 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
beta_headers=[test_case["input"]], provider=test_case["provider"]
|
||||
)
|
||||
|
||||
assert (
|
||||
test_case["expected"] in filtered
|
||||
), f"Header '{test_case['input']}' should be mapped to '{test_case['expected']}' for {test_case['provider']}, but got: {filtered}"
|
||||
assert test_case["expected"] in filtered, (
|
||||
f"Header '{test_case['input']}' should be mapped to '{test_case['expected']}' for {test_case['provider']}, but got: {filtered}"
|
||||
)
|
||||
|
||||
def test_filter_and_transform_beta_headers_vertex_ai_keeps_compact(self):
|
||||
"""Vertex AI supports compact context edits, so the compact beta header
|
||||
must be forwarded instead of stripped (it was previously mapped to null,
|
||||
which broke compact_20260112 context edits over /v1/messages)."""
|
||||
filtered = filter_and_transform_beta_headers(
|
||||
beta_headers=["compact-2026-01-12"], provider="vertex_ai"
|
||||
)
|
||||
filtered = filter_and_transform_beta_headers(beta_headers=["compact-2026-01-12"], provider="vertex_ai")
|
||||
|
||||
assert filtered == ["compact-2026-01-12"]
|
||||
|
||||
|
|
@ -486,13 +455,11 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
unsupported = self.get_unsupported_headers(provider)
|
||||
|
||||
if unsupported:
|
||||
filtered = filter_and_transform_beta_headers(
|
||||
beta_headers=unsupported, provider=provider
|
||||
)
|
||||
filtered = filter_and_transform_beta_headers(beta_headers=unsupported, provider=provider)
|
||||
|
||||
assert (
|
||||
len(filtered) == 0
|
||||
), f"All null-value headers should be filtered out for {provider}, but got: {filtered}"
|
||||
assert len(filtered) == 0, (
|
||||
f"All null-value headers should be filtered out for {provider}, but got: {filtered}"
|
||||
)
|
||||
|
||||
def test_empty_headers_list(self):
|
||||
"""Test that empty headers list returns empty result."""
|
||||
|
|
@ -503,13 +470,9 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
"bedrock",
|
||||
"vertex_ai",
|
||||
]:
|
||||
filtered = filter_and_transform_beta_headers(
|
||||
beta_headers=[], provider=provider
|
||||
)
|
||||
filtered = filter_and_transform_beta_headers(beta_headers=[], provider=provider)
|
||||
|
||||
assert (
|
||||
len(filtered) == 0
|
||||
), f"Empty headers list should return empty result for {provider}"
|
||||
assert len(filtered) == 0, f"Empty headers list should return empty result for {provider}"
|
||||
|
||||
def test_mixed_supported_and_unsupported_headers(self):
|
||||
"""Test filtering with a mix of supported, unsupported, and unknown headers."""
|
||||
|
|
@ -529,20 +492,12 @@ class TestAnthropicBetaHeadersFiltering:
|
|||
|
||||
test_headers = [supported[0]] + [unsupported[0]] + ["unknown-header-123"]
|
||||
|
||||
filtered = filter_and_transform_beta_headers(
|
||||
beta_headers=test_headers, provider=provider
|
||||
)
|
||||
filtered = filter_and_transform_beta_headers(beta_headers=test_headers, provider=provider)
|
||||
|
||||
expected_mapped = mapped_headers[supported[0]]
|
||||
assert (
|
||||
expected_mapped in filtered
|
||||
), f"Supported header should be in result for {provider}"
|
||||
assert (
|
||||
unsupported[0] not in filtered
|
||||
), f"Unsupported header should not be in result for {provider}"
|
||||
assert (
|
||||
"unknown-header-123" not in filtered
|
||||
), f"Unknown header should not be in result for {provider}"
|
||||
assert expected_mapped in filtered, f"Supported header should be in result for {provider}"
|
||||
assert unsupported[0] not in filtered, f"Unsupported header should not be in result for {provider}"
|
||||
assert "unknown-header-123" not in filtered, f"Unknown header should not be in result for {provider}"
|
||||
|
||||
@pytest.mark.parametrize("provider", ["anthropic", "bedrock", "bedrock_mantle", "vertex_ai"])
|
||||
def test_blank_anthropic_beta_header_is_removed(self, provider):
|
||||
|
|
@ -570,3 +525,68 @@ def test_local_flags_load_bundled_beta_headers_without_fetching(monkeypatch, fla
|
|||
config = get_beta_headers_config(url="https://example.test/anthropic_beta_headers_config.json")
|
||||
remote_get.assert_not_called()
|
||||
assert config == GetAnthropicBetaHeadersConfig.load_local_beta_headers_config()
|
||||
|
||||
|
||||
FOUNDRY_API_BASE: Final = "https://my-resource.services.ai.azure.com/anthropic"
|
||||
DIRECT_API_BASE: Final = "https://api.anthropic.com"
|
||||
DIRECT_ONLY_BETA: Final = "advisor-tool-2026-03-01"
|
||||
PRESERVED_THINKING_BETAS: Final = (
|
||||
"thinking-binding-controls-2026-08-01",
|
||||
"mid-conversation-system-clear-at-2026-08-21",
|
||||
"mid-conversation-tool-changes-2026-07-01",
|
||||
"mid-conversation-output-config-2026-07-01",
|
||||
"thinking-display-updates-2026-08-18",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundled_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True")
|
||||
anthropic_beta_headers_manager._BETA_HEADERS_CONFIG = None
|
||||
|
||||
|
||||
def test_foundry_deployment_headers_follow_the_azure_ai_row(bundled_config: None) -> None:
|
||||
headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"}
|
||||
result: Final[dict[str, str]] = update_headers_with_filtered_beta(
|
||||
headers=headers, provider="anthropic", api_base=FOUNDRY_API_BASE
|
||||
)
|
||||
assert result == {"anthropic-beta": "thinking-binding-controls-2026-08-01"}
|
||||
|
||||
|
||||
def test_direct_deployment_headers_keep_every_anthropic_row_beta(bundled_config: None) -> None:
|
||||
headers: Final = {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"}
|
||||
result: Final[dict[str, str]] = update_headers_with_filtered_beta(
|
||||
headers=headers, provider="anthropic", api_base=DIRECT_API_BASE
|
||||
)
|
||||
assert result == {"anthropic-beta": f"{DIRECT_ONLY_BETA},thinking-binding-controls-2026-08-01"}
|
||||
|
||||
|
||||
def test_foundry_deployment_request_filters_header_and_body_betas_together(bundled_config: None) -> None:
|
||||
headers: dict[str, str]
|
||||
data: dict[str, object]
|
||||
headers, data = update_request_with_filtered_beta(
|
||||
headers={"anthropic-beta": DIRECT_ONLY_BETA},
|
||||
request_data={"anthropic_beta": [DIRECT_ONLY_BETA, "thinking-binding-controls-2026-08-01"], "max_tokens": 8},
|
||||
provider="anthropic",
|
||||
api_base=FOUNDRY_API_BASE,
|
||||
)
|
||||
assert headers == {}
|
||||
assert data == {"anthropic_beta": ["thinking-binding-controls-2026-08-01"], "max_tokens": 8}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", ["anthropic", "azure_ai", "vertex_ai"])
|
||||
def test_live_verified_platforms_forward_every_preserved_thinking_beta(bundled_config: None, provider: str) -> None:
|
||||
result: Final[dict[str, str]] = update_headers_with_filtered_beta(
|
||||
headers={"anthropic-beta": ",".join(PRESERVED_THINKING_BETAS)}, provider=provider
|
||||
)
|
||||
assert sorted(result["anthropic-beta"].split(",")) == sorted(PRESERVED_THINKING_BETAS), result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", ["anthropic", "azure_ai", "vertex_ai", "bedrock", "bedrock_converse"])
|
||||
def test_each_row_forwards_exactly_its_verified_preserved_thinking_cells(bundled_config: None, provider: str) -> None:
|
||||
row: Final[dict[str, str | None]] = GetAnthropicBetaHeadersConfig.load_local_beta_headers_config()[provider]
|
||||
forwarded: Final = tuple(beta for beta in PRESERVED_THINKING_BETAS if row.get(beta) is not None)
|
||||
result: Final[dict[str, str]] = update_headers_with_filtered_beta(
|
||||
headers={"anthropic-beta": ",".join(PRESERVED_THINKING_BETAS)}, provider=provider
|
||||
)
|
||||
assert sorted(result["anthropic-beta"].split(",")) == sorted(forwarded), (provider, result)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue