fix(vertex_ai,azure_ai)!: auto-add the compaction, thinking display, and tool change betas on Claude routes (#44747)

* fix(vertex_ai): forward and auto-add the compact-2026-09-04 beta on Claude routes

* test(vertex_ai): prove the compact-2026-09-04 beta reaches the wire through both Claude handlers

* fix(vertex_ai): move a client-sent body compact-2026-09-04 beta to the request header

* refactor(vertex_ai): build the Claude beta set without mutation and inject the client in the messages wire test

* fix(vertex_ai): keep client body betas in the body and answer 400 on a malformed anthropic_beta

* fix(rust): allow the compact-2026-09-04 beta on vertex_ai in the Rust beta table

The beta header config now maps compact-2026-09-04 for vertex_ai, so the Rust
table that mirrors it must stop rejecting that beta there, and the Rust Vertex
messages route adds it for a compaction request like the Python route does.

* fix(vertex_ai): keep client body betas out of the Vertex body next to litellm betas, honor drop_params, and send the compaction beta on count_tokens

Litellm's own betas replace a client's body anthropic_beta again, as on main, so a body beta
Vertex rejects no longer turns a 200 into a 400. A malformed beta drops under drop_params and
names its field otherwise. inline-tools-2026-09-15 joins compact-2026-09-04 as header-only, since
Vertex rejects both in the body. Vertex count_tokens sends compact-2026-09-04 when the request
carries compaction.

* fix(logging): log and bill Anthropic messages compaction responses on the Rust route

The Rust route hands the served Anthropic body to the logger, which validates it
against AnthropicResponse. That model had no compaction content block and no
compaction stop reason, so a compaction reply raised and wrote no spend row.

* fix(vertex_ai,azure_ai): forward compact-2026-09-04 to Azure AI Foundry, merge Vertex body betas, and redact compaction summaries

Azure AI Foundry needs the compact-2026-09-04 header for compaction, a replayed compaction block, and count tokens, so the beta map, the native compaction providers, and the Rust beta table now allow it there. Vertex keeps a client's body betas next to the ones litellm adds, as the Anthropic route does, and turn_off_message_logging now redacts compaction summaries too

* fix(vertex_ai,azure_ai): auto-add thinking display and tool change betas, redact streamed compaction summaries

Folds the header changes from #44103 into this PR. Vertex AI and Azure AI Claude
routes now add thinking-display-updates-2026-08-18 when thinking.display is
"updates" and mid-conversation-tool-changes-2026-07-01 when a tool_addition
block is present, matching the Anthropic and Bedrock routes.

With turn_off_message_logging on, compaction summaries from streamed messages
and streamed chat completions are now redacted in spend logs and callback
payloads, like the non-streamed response already was.

* fix(vertex_ai,azure_ai,rust): add tool change betas on count tokens and Rust messages, bill Rust server tool replies

Vertex and Azure count tokens now add mid-conversation-tool-changes-2026-07-01 when the history carries a tool_addition or tool_removal block, through one shared helper with the compaction beta.

The Rust /v1/messages route adds thinking-display-updates-2026-08-18 and mid-conversation-tool-changes-2026-07-01 when a request uses them, on the providers that accept each beta.

Rust-route logging parses the served body with the Anthropic response parser, so web_search, pause_turn, mcp_tool_use and container_upload replies get a spend row.

Legacy enabled thinking keeps display updates on anthropic too, and the Vertex transform reads drop_params through drops_unsupported_params.

* style(rust): rewrap beta map test line with cargo fmt

* test(rust): expect the display beta on Databricks, which main's beta list now accepts

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-30 09:28:19 -07:00 • committed by GitHub
parent d18efdfe13
commit b195d15b93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1323 additions and 84 deletions

View file

@ -199,7 +199,7 @@ impl AnthropicBeta {
&[BetaProvider::BedrockConverse, BetaProvider::Databricks]
}
Self::ContextManagement20250627 => &[BetaProvider::BedrockConverse],
Self::AdvisorTool20260301 | Self::Compact20260904 => &[
Self::AdvisorTool20260301 => &[
BetaProvider::AzureAi,
BetaProvider::BedrockConverse,
BetaProvider::Bedrock,
@ -207,6 +207,12 @@ impl AnthropicBeta {
BetaProvider::VertexAi,
BetaProvider::Databricks,
],
Self::Compact20260904 => &[
BetaProvider::BedrockConverse,
BetaProvider::Bedrock,
BetaProvider::BedrockMantle,
BetaProvider::Databricks,
],
Self::Bash20241022
| Self::Bash20250124
| Self::McpServers20251204
@ -256,14 +262,18 @@ impl AnthropicBeta {
Self::InterleavedThinking20250514 | Self::WebSearch20250305 => {
&[BetaProvider::BedrockConverse, BetaProvider::Bedrock]
}
Self::MidConversationOutputConfig20260701
| Self::MidConversationToolChanges20260701 => &[
Self::MidConversationOutputConfig20260701 => &[
BetaProvider::AzureAi,
BetaProvider::BedrockConverse,
BetaProvider::BedrockMantle,
BetaProvider::VertexAi,
BetaProvider::Databricks,
],
Self::MidConversationToolChanges20260701 => &[
BetaProvider::BedrockConverse,
BetaProvider::BedrockMantle,
BetaProvider::Databricks,
],
Self::Output128k20250219 | Self::TokenEfficientTools20250219 => &[
BetaProvider::AzureAi,
BetaProvider::BedrockConverse,
@ -483,7 +493,7 @@ mod tests {
let config = beta_headers_config();
let config_columns: Vec<String> = config
.keys()
.filter(|column| column.as_str() != "description")
.filter(|column| column.as_str() != "description" && !column.ends_with("_header_only"))
.cloned()
.collect();
let beta_provider_columns: Vec<String> = BetaProvider::VARIANTS
@ -502,12 +512,16 @@ mod tests {
let config = beta_headers_config();
for provider in BetaProvider::VARIANTS {
for beta in &AnthropicBeta::KNOWN {
let expected = config
.get(provider.as_ref())
.and_then(serde_json::Value::as_object)
.and_then(|column| column.get(beta.as_str()))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let cell = |column: &str| {
config
.get(column)
.and_then(serde_json::Value::as_object)
.and_then(|values| values.get(beta.as_str()))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
};
let expected =
cell(&format!("{provider}_header_only")).or_else(|| cell(provider.as_ref()));
let actual = beta.on(*provider).map(|name| name.to_string());
assert_eq!(actual, expected, "provider {provider}, beta {beta}");
if matches!(beta, AnthropicBeta::AdvancedToolUse20251120)

View file

@ -1,7 +1,8 @@
use litellm_auth::CredentialPlacement;
use litellm_llms_types::{
formats::messages::{
ContextEdit, ContextManagement, Message, MessagesOptionalParams, MessagesRequest, Speed,
ContentBlock, ContentBlockType, ContextEdit, ContextManagement, Message,
MessagesOptionalParams, MessagesRequest, Speed, ThinkingConfig, ThinkingDisplay,
},
providers::anthropic::{AnthropicBeta, BetaProvider, BetaSet, DEFAULT_HEADERS},
recognized::Recognized,
@ -203,6 +204,10 @@ fn feature_betas(request: &MessagesRequest) -> BetaSet {
.then_some(AnthropicBeta::FastMode20260201),
messages_carry_output_config(&request.messages)
.then_some(AnthropicBeta::PerTurnControl20260701),
uses_thinking_display_updates(params)
.then_some(AnthropicBeta::ThinkingDisplayUpdates20260818),
messages_carry_tool_changes(&request.messages)
.then_some(AnthropicBeta::MidConversationToolChanges20260701),
has_advisor_tool(tools).then_some(AnthropicBeta::AdvisorTool20260301),
is_tool_search_used(tools).then_some(AnthropicBeta::AdvancedToolUse20251120),
]
@ -253,6 +258,36 @@ fn messages_carry_output_config(messages: &[Message]) -> bool {
.any(|message| message.extra.contains_key("output_config"))
}
fn uses_thinking_display_updates(params: &MessagesOptionalParams) -> bool {
let display = match params.thinking.as_ref().and_then(Recognized::known) {
Some(ThinkingConfig::Enabled(thinking)) => thinking.display.as_ref(),
Some(ThinkingConfig::Adaptive(thinking)) => thinking.display.as_ref(),
_ => None,
};
display.and_then(Recognized::known) == Some(&ThinkingDisplay::Updates)
}
fn is_tool_reference_change(block: &ContentBlock) -> bool {
matches!(
&block.block_type,
Some(ContentBlockType::Other(block_type))
if block_type == "tool_addition" || block_type == "tool_removal"
) && block
.extra
.get("tool")
.and_then(|tool| tool.get("type"))
.and_then(Value::as_str)
== Some("tool_reference")
}
fn messages_carry_tool_changes(messages: &[Message]) -> bool {
messages
.iter()
.filter(|message| message.role == "system")
.flat_map(Message::blocks)
.any(is_tool_reference_change)
}
fn unsupported_param(model: &str, param: &str, value: &str, hint: &str) -> Error {
Error::InvalidRequest(crate::ErrorDetail::UnsupportedParameter {
model: model.into(),
@ -1071,6 +1106,41 @@ mod tests {
json!({"safeguards": [{"type": "dangerous_tool_use", "classifier_context": {"v": 1, "permission_mode": "auto"}}]}),
&[]
)]
#[case::adaptive_display_updates(
json!({"thinking": {"type": "adaptive", "display": "updates"}}),
&["thinking-display-updates-2026-08-18"]
)]
#[case::enabled_display_updates(
json!({"thinking": {"type": "enabled", "budget_tokens": 2048, "display": "updates"}}),
&["thinking-display-updates-2026-08-18"]
)]
#[case::summarized_display(json!({"thinking": {"type": "adaptive", "display": "summarized"}}), &[])]
#[case::disabled_thinking(json!({"thinking": {"type": "disabled"}}), &[])]
#[case::system_tool_addition(
json!({"messages": [
{"role": "user", "content": "Use the ping tool"},
{"role": "system", "content": [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}]},
]}),
&["mid-conversation-tool-changes-2026-07-01"]
)]
#[case::system_tool_removal(
json!({"messages": [
{"role": "system", "content": [{"type": "tool_removal", "tool": {"type": "tool_reference", "name": "ping"}}]},
]}),
&["mid-conversation-tool-changes-2026-07-01"]
)]
#[case::user_tool_addition(
json!({"messages": [
{"role": "user", "content": [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}]},
]}),
&[]
)]
#[case::tool_addition_without_a_tool_reference(
json!({"messages": [
{"role": "system", "content": [{"type": "tool_addition", "tool": {"type": "tool_definition"}}]},
]}),
&[]
)]
fn feature_betas_follow_the_request(#[case] fields: Value, #[case] expected: &[&str]) {
assert_eq!(feature_betas(&request(fields)), betas(expected));
}
@ -1093,6 +1163,33 @@ mod tests {
);
}
#[rstest]
#[case::vertex_ai(
BetaProvider::VertexAi,
&["mid-conversation-tool-changes-2026-07-01", "thinking-display-updates-2026-08-18"]
)]
#[case::azure_ai(
BetaProvider::AzureAi,
&["mid-conversation-tool-changes-2026-07-01", "thinking-display-updates-2026-08-18"]
)]
#[case::bedrock_converse(BetaProvider::BedrockConverse, &["thinking-display-updates-2026-08-18"])]
#[case::databricks(BetaProvider::Databricks, &["thinking-display-updates-2026-08-18"])]
fn tool_change_and_display_betas_reach_the_providers_that_accept_them(
#[case] provider: BetaProvider,
#[case] expected: &[&str],
) {
let fields = json!({
"thinking": {"type": "adaptive", "display": "updates"},
"messages": [
{"role": "system", "content": [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}]},
],
});
assert_eq!(
provider_feature_betas(&request(fields), provider),
betas(expected)
);
}
#[rstest]
fn hosts_without_a_beta_map_column_get_safeguards_without_a_derived_beta() {
let client = headers(&[("anthropic-beta", "claude-code-20250219")]);

View file

@ -545,6 +545,11 @@ mod tests {
&[("anthropic-beta", "web-search-2025-03-05")],
&[("x-api-key", "k"), ("anthropic-beta", "structured-outputs-2025-11-13,web-search-2025-03-05")]
)]
#[case::compaction_needs_the_compact_2026_09_04_beta(
json!({"compaction": {"type": "summarize"}}),
&[],
&[("x-api-key", "k"), ("anthropic-beta", "compact-2026-09-04")]
)]
#[case::no_feature_needs_a_beta(json!({}), &[], &[("x-api-key", "k")])]
#[case::safeguards_need_the_dangerous_tool_use_beta(
json!({"safeguards": [{"type": "dangerous_tool_use"}]}),

View file

@ -798,6 +798,18 @@ mod tests {
]}}),
&["compact-2026-01-12", "context-management-2025-06-27"]
)]
#[case::compaction(json!({"compaction": {"type": "summarize"}}), &["compact-2026-09-04"])]
#[case::thinking_display_updates(
json!({"thinking": {"type": "adaptive", "display": "updates"}}),
&["thinking-display-updates-2026-08-18"]
)]
#[case::tool_addition(
json!({"messages": [
{"role": "user", "content": "Use the ping tool"},
{"role": "system", "content": [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}]}
]}),
&["mid-conversation-tool-changes-2026-07-01"]
)]
#[case::advisor_tool_is_not_a_vertex_beta(
json!({"tools": [{"type": "advisor_20260301", "name": "advisor"}]}),
&[]

View file

@ -1,5 +1,5 @@
{
"description": "Mapping of Anthropic beta headers for each provider. Keys are input header names, values are provider-specific header names (or null if unsupported). Only headers present in mapping keys with non-null values can be forwarded.",
"description": "Mapping of Anthropic beta headers for each provider. Keys are input header names, values are provider-specific header names (or null if unsupported). Only headers present in mapping keys with non-null values can be forwarded. A <provider>_header_only column lists betas that provider accepts only as the anthropic-beta HTTP header: they are forwarded there and dropped from the anthropic_beta body field. Proxies that predate these columns ignore them and read the provider column alone.",
"anthropic": {
"advisor-tool-2026-03-01": "advisor-tool-2026-03-01",
"advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20",
@ -46,6 +46,7 @@
"bash_20250124": null,
"code-execution-2025-08-25": "code-execution-2025-08-25",
"compact-2026-01-12": null,
"compact-2026-09-04": "compact-2026-09-04",
"computer-use-2025-01-24": "computer-use-2025-01-24",
"computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07",
@ -59,6 +60,7 @@
"mcp-client-2025-11-20": "mcp-client-2025-11-20",
"mcp-client-2025-04-04": "mcp-client-2025-04-04",
"mcp-servers-2025-12-04": null,
"mid-conversation-tool-changes-2026-07-01": "mid-conversation-tool-changes-2026-07-01",
"output-128k-2025-02-19": null,
"structured-output-2024-03-01": null,
"per-turn-control-2026-07-01": "per-turn-control-2026-07-01",
@ -202,6 +204,7 @@
"mcp-client-2025-11-20": null,
"mcp-client-2025-04-04": null,
"mcp-servers-2025-12-04": null,
"mid-conversation-tool-changes-2026-07-01": "mid-conversation-tool-changes-2026-07-01",
"output-128k-2025-02-19": null,
"structured-output-2024-03-01": null,
"per-turn-control-2026-07-01": "per-turn-control-2026-07-01",
@ -218,6 +221,10 @@
"web-search-2025-03-05": "web-search-2025-03-05",
"inline-tools-2026-09-15": "inline-tools-2026-09-15"
},
"vertex_ai_header_only": {
"compact-2026-09-04": "compact-2026-09-04",
"inline-tools-2026-09-15": "inline-tools-2026-09-15"
},
"databricks": {
"advisor-tool-2026-03-01": null,
"advanced-tool-use-2025-11-20": null,

View file

@ -30,7 +30,7 @@ from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from functools import cache
from importlib.resources import files
from typing import Final, TypeAlias
from typing import Final, Literal, TypeAlias
import httpx
from pydantic import TypeAdapter
@ -47,6 +47,9 @@ BetaHeadersConfig: TypeAlias = Mapping[str, str | Mapping[str, str | None]]
_BETA_HEADERS_CONFIG_ADAPTER: Final = TypeAdapter(BetaHeadersConfig)
_DECODED_JSON: Final = TypeAdapter(object)
BetaPlacement: TypeAlias = Literal["header", "body"]
_HEADER_ONLY_COLUMN_SUFFIX: Final = "_header_only"
class GetAnthropicBetaHeadersConfig:
"""
@ -241,6 +244,31 @@ def get_provider_name(provider: str) -> str:
return aliases.get(provider, provider)
def _beta_column(name: str) -> Mapping[str, str | None]:
column: Final[Mapping[str, str | None]] = _load_beta_headers_config().get(name, {})
return column
def _header_only_column(canonical_provider: str) -> Mapping[str, str | None]:
return _beta_column(f"{canonical_provider}{_HEADER_ONLY_COLUMN_SUFFIX}")
def _provider_beta_mapping(provider: str, placement: BetaPlacement) -> Mapping[str, str | None]:
canonical_provider: Final = get_provider_name(provider)
column: Final = _beta_column(canonical_provider)
header_only: Final = _header_only_column(canonical_provider)
match placement:
case "header":
return {**column, **header_only}
case "body":
return {beta: name for beta, name in column.items() if beta not in header_only}
def get_header_only_betas(provider: str) -> frozenset[str]:
header_only: Final = _header_only_column(get_provider_name(provider))
return frozenset(beta for beta, name in header_only.items() if name is not None)
def has_beta_headers_config(provider: str) -> bool:
config: Final = _bundled_beta_headers_config()
aliases: Final = config.get("provider_aliases", {})
@ -251,6 +279,7 @@ def has_beta_headers_config(provider: str) -> bool:
def filter_and_transform_beta_headers(
beta_headers: list[str],
provider: str,
placement: BetaPlacement = "header",
) -> list[str]:
"""
Filter and transform beta headers based on provider's mapping configuration.
@ -263,6 +292,7 @@ def filter_and_transform_beta_headers(
Args:
beta_headers: List of Anthropic beta header values
provider: Provider name (e.g., "anthropic", "bedrock", "vertex_ai")
placement: Whether the betas travel as the anthropic-beta header or the anthropic_beta body field
Returns:
List of filtered and transformed beta headers for the provider
@ -270,11 +300,7 @@ def filter_and_transform_beta_headers(
if not beta_headers:
return []
config: Final = _load_beta_headers_config()
provider = get_provider_name(provider)
# Get the header mapping for this provider
provider_mapping: Final = config.get(provider, {})
provider_mapping: Final = _provider_beta_mapping(provider, placement)
filtered_headers: Final[set[str]] = set()
@ -426,6 +452,7 @@ def update_request_with_filtered_beta(
filtered_body_betas: Final = filter_and_transform_beta_headers(
beta_headers=existing_body_betas,
provider=provider,
placement="body",
)
if filtered_body_betas:

View file

@ -569,6 +569,7 @@ def _resolve_mantle_region_for_cost(
_PER_CALL_LITELLM_PARAM_DICTS: Final = frozenset({"metadata", "litellm_metadata"})
_LITELLM_PARAMS: Final = TypeAdapter(dict[str, object])
_SERVED_ANTHROPIC_BODY: Final = TypeAdapter(dict[str, object])
_REDACTED_RESULT: Final = TypeAdapter(object)
_GUARDRAIL_INFORMATION_KEY: Final = "standard_logging_guardrail_information"
@ -5067,13 +5068,15 @@ class Logging(LiteLLMLoggingBaseClass):
verbose_logger.warning("LiteLLM: the anthropic_messages stream assembled no response, logging an empty one")
return litellm.ModelResponse(model=self.model)
else:
from litellm.types.llms.anthropic import AnthropicResponse
pydantic_result: Final = AnthropicResponse.model_validate({"stop_sequence": None, **(served_body or {})})
import httpx
anthropic_body: Final = (
served_body
if served_body is not None
else _SERVED_ANTHROPIC_BODY.validate_python(
result.model_dump() if isinstance(result, BaseModel) else result
)
)
result = litellm.AnthropicConfig().transform_parsed_response(
completion_response=pydantic_result.model_dump(),
completion_response={"stop_sequence": None, **anthropic_body},
raw_response=httpx.Response(
status_code=200,
headers={},

View file

@ -249,6 +249,7 @@ def _redacted_provider_specific_fields(fields: Mapping[str, object]) -> Mapping[
"reasoningContentBlocks",
"citations",
"compaction_blocks",
"compaction_start",
"compaction_delta",
)
if key in fields

View file

@ -58,6 +58,7 @@ from litellm.types.llms.anthropic import (
ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
ANTHROPIC_TOOL_SEARCH_TOOL_TYPES,
LEGACY_THINKING_DISPLAY_UPDATES_PROVIDERS,
AllAnthropicToolsValues,
AnthropicMcpServerTool,
AnthropicMessagesToolChoice,
@ -126,6 +127,8 @@ _JSON_VALUE: Final = TypeAdapter(JsonValue)
_CLAUDE_CODE_USER_AGENT_PREFIXES: Final = ("claude-cli/", "claude-code/")
NATIVE_COMPACTION_PROVIDERS: Final = frozenset({"anthropic", "azure_ai", "vertex_ai"})
def is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool:
if custom_llm_provider == "anthropic":
@ -204,7 +207,7 @@ def requires_native_compaction_beta(
optional_params: Mapping[str, object],
messages: Sequence[object],
) -> bool:
return custom_llm_provider == "anthropic" and (
return custom_llm_provider in NATIVE_COMPACTION_PROVIDERS and (
optional_params.get("compaction") is not None or carries_signed_compaction_block(messages)
)
@ -246,6 +249,27 @@ def context_management_betas(context_management: JsonValue) -> tuple[str, ...]:
)
def count_tokens_feature_betas(
custom_llm_provider: str,
optional_params: Mapping[str, object],
messages: Sequence[object],
) -> tuple[str, ...]:
return tuple(
beta
for beta, used in (
(
ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_09_04.value,
requires_native_compaction_beta(custom_llm_provider, optional_params, messages),
),
(
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
AnthropicModelInfo().is_mid_conversation_tool_change_used(messages),
),
)
if used
)
def supports_anthropic_cache_control(model: str, custom_llm_provider: str | None) -> bool:
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.utils import supports_prompt_caching
@ -645,10 +669,12 @@ class AnthropicModelInfo(BaseLLMModelInfo):
file_ids: Final = get_file_ids_from_messages(messages)
return len(file_ids) > 0
def is_thinking_display_updates_used(self, thinking: AnthropicThinkingParam | None) -> bool:
if not isinstance(thinking, dict):
return False
return thinking.get("type") in ("adaptive", "enabled") and thinking.get("display") == "updates"
def is_thinking_display_updates_used(self, thinking: object) -> bool:
match thinking:
case {"type": "adaptive" | "enabled", "display": "updates"}:
return True
case _:
return False
def thinking_betas(self, thinking: AnthropicThinkingParam | None) -> tuple[str, ...]:
return (
@ -1130,7 +1156,10 @@ class AnthropicModelInfo(BaseLLMModelInfo):
budget_tokens: Final = _legacy_budget_tokens(thinking.get("budget_tokens", 0), drop_params=drop_params)
display: Final = thinking.get("display")
supported_display: Final = {"display": display} if display in ("summarized", "omitted") else {}
keeps_display: Final = display in ("summarized", "omitted") or (
display == "updates" and custom_llm_provider in LEGACY_THINKING_DISPLAY_UPDATES_PROVIDERS
)
supported_display: Final = {"display": display} if keeps_display else {}
carried_keys: Final = carried_thinking_keys(
thinking, rebuilt_keys=frozenset(("type", "budget_tokens", "display")), drop_params=drop_params
)

View file

@ -41,6 +41,7 @@ from litellm.types.llms.anthropic_tool_search import get_tool_search_beta_header
from litellm.types.router import GenericLiteLLMParams
from ...common_utils import (
NATIVE_COMPACTION_PROVIDERS,
OBJECT_LIST_ADAPTER,
AnthropicError,
AnthropicModelInfo,
@ -128,7 +129,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
"tool_choice",
"thinking",
"context_management",
*(("compaction",) if self._resolved_provider == "anthropic" else ()),
*(("compaction",) if self._resolved_provider in NATIVE_COMPACTION_PROVIDERS else ()),
"output_format",
"inference_geo",
"speed",

View file

@ -84,6 +84,7 @@ class AzureAIAnthropicCountTokensHandler(AzureAIAnthropicCountTokensConfig):
headers: Final = self.get_required_headers(
api_key=api_key,
litellm_params=litellm_params,
messages=messages,
)
if not {"x-api-key", "Authorization"} & headers.keys():
raise AnthropicError(

View file

@ -4,11 +4,13 @@ Azure AI Anthropic CountTokens API transformation logic.
Extends the base Anthropic CountTokens transformation with Azure authentication.
"""
from collections.abc import Sequence
from typing import Final
from urllib.parse import urlsplit
from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.anthropic.common_utils import count_tokens_feature_betas, merge_anthropic_beta_headers
from litellm.llms.anthropic.count_tokens.transformation import (
AnthropicCountTokensConfig,
)
@ -28,6 +30,7 @@ class AzureAIAnthropicCountTokensConfig(AnthropicCountTokensConfig):
self,
api_key: str | None,
litellm_params: dict[str, object] | None = None,
messages: Sequence[object] = (),
) -> dict[str, str]:
"""
Get the required headers for the Azure AI Anthropic CountTokens API.
@ -38,6 +41,7 @@ class AzureAIAnthropicCountTokensConfig(AnthropicCountTokensConfig):
Args:
api_key: The Azure AI API key
litellm_params: Optional LiteLLM parameters for additional auth config
messages: The request messages, read for betas the counted features need
Returns:
Dictionary of required headers with both x-api-key and Azure authentication
@ -46,7 +50,9 @@ class AzureAIAnthropicCountTokensConfig(AnthropicCountTokensConfig):
headers: Final = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
"anthropic-beta": ANTHROPIC_TOKEN_COUNTING_BETA_VERSION,
"anthropic-beta": merge_anthropic_beta_headers(
ANTHROPIC_TOKEN_COUNTING_BETA_VERSION, count_tokens_feature_betas("azure_ai", {}, messages)
),
**({"x-api-key": api_key} if api_key else {}),
}

View file

@ -1,9 +1,15 @@
from collections.abc import Mapping, Sequence
from typing import Final
from pydantic import TypeAdapter
from litellm.exceptions import UnsupportedParamsError
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.llms.anthropic.common_utils import AnthropicModelInfo, context_management_betas
from litellm.llms.anthropic.common_utils import (
AnthropicModelInfo,
context_management_betas,
requires_native_compaction_beta,
)
from litellm.llms.anthropic.messages.native.transformation import (
AnthropicMessagesConfig,
messages_carry_output_config,
@ -11,6 +17,7 @@ from litellm.llms.anthropic.messages.native.transformation import (
from litellm.types.llms.anthropic import (
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_HOSTED_TOOLS,
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
)
from litellm.types.llms.anthropic_tool_search import get_tool_search_beta_header
from litellm.types.llms.vertex_ai import VertexPartnerProvider
@ -19,6 +26,8 @@ from litellm.types.router import GenericLiteLLMParams
from ....vertex_llm_base import VertexBase
from ..output_params_utils import sanitize_vertex_anthropic_output_params
_OPTIONAL_PARAMS: Final = TypeAdapter(Mapping[str, object])
_VERSION_HEADER_VERTEX_READS_FROM_BODY: Final = "anthropic-version"
_VERTEX_DEFAULT_VERSION: Final = "vertex-2023-10-16"
_VERSIONS_SERVED_AS_VERTEX_DEFAULT: Final = frozenset({"", "2023-01-01", "2023-06-01"})
@ -190,14 +199,23 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert
if messages_carry_output_config(messages):
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value)
beta_values.update(
typed_params: Final = _OPTIONAL_PARAMS.validate_python(optional_params)
if requires_native_compaction_beta("vertex_ai", typed_params, messages):
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_09_04.value)
tool_change_betas: Final = (
(ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,)
if anthropic_model_info.is_mid_conversation_tool_change_used(messages)
else ()
)
all_beta_values: Final = beta_values.union(
anthropic_model_info.thinking_betas(
optional_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # optional_params is the base config's bare dict
)
),
tool_change_betas,
)
if beta_values:
request_headers["anthropic-beta"] = ",".join(beta_values)
if all_beta_values:
request_headers["anthropic-beta"] = ",".join(sorted(all_beta_values))
return request_headers, api_base

View file

@ -2,19 +2,23 @@
## Handler file for calling claude-3 on vertex ai
import base64
import json
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Final
import httpx
from pydantic import TypeAdapter
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm.anthropic_beta_headers_manager import get_header_only_betas
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.prompt_templates.image_handling import RemoteMedia, inline_remote_image_urls
from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj
from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
from ....anthropic.chat.transformation import AnthropicConfig
from ....anthropic.common_utils import AnthropicError, requires_native_compaction_beta
from .output_params_utils import sanitize_vertex_anthropic_output_params
if TYPE_CHECKING:
@ -36,6 +40,32 @@ def _vertex_labels_header(labels: object, header_names: Mapping[str, object]) ->
return base64.b64encode(json.dumps(string_labels).encode()).decode() if string_labels else None
_BETA_VALUE: Final[TypeAdapter[str | tuple[str, ...] | None]] = TypeAdapter(str | tuple[str, ...] | None)
_REQUEST_MESSAGES: Final = TypeAdapter(Sequence[object])
def _beta_names(value: object) -> frozenset[str] | None:
try:
parsed: Final = _BETA_VALUE.validate_python(value)
except ValidationError:
return None
names: Final = parsed.split(",") if isinstance(parsed, str) else parsed or ()
return frozenset(name.strip() for name in names if name.strip())
def _client_betas(value: object, field: str, drop_params: bool) -> frozenset[str]:
names: Final = _beta_names(value)
if names is not None:
return names
if drop_params:
return frozenset()
raise AnthropicError(
status_code=400,
message=f"{field} must be a string or a list of strings, got {type(value).__name__}",
)
class VertexAIError(Exception):
def __init__(self, status_code, message):
self.status_code = status_code
@ -157,21 +187,43 @@ class VertexAIAnthropicConfig(AnthropicConfig):
if context_management:
self._add_context_management_beta_headers(beta_set, context_management)
extra_headers: Final = optional_params.get("extra_headers") or {}
anthropic_beta_value: Final = extra_headers.get("anthropic-beta", "")
if isinstance(anthropic_beta_value, str) and anthropic_beta_value:
for beta in anthropic_beta_value.split(","):
beta = beta.strip()
if beta:
beta_set.add(beta)
elif isinstance(anthropic_beta_value, list):
beta_set.update(anthropic_beta_value)
request_params: Final = _NON_DEFAULT_PARAMS.validate_python(optional_params)
compaction_betas: Final = (
frozenset({ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_09_04.value})
if requires_native_compaction_beta(
"vertex_ai", request_params, _REQUEST_MESSAGES.validate_python(data["messages"])
)
else frozenset[str]()
)
drop_params: Final = drops_unsupported_params(litellm_params.get("drop_params"))
extra_headers: Final = _NON_DEFAULT_PARAMS.validate_python(request_params.get("extra_headers") or {})
data.pop("extra_headers", None)
injected_betas: Final = frozenset(beta_set) | _client_betas(
extra_headers.get("anthropic-beta"), "The anthropic-beta header", drop_params
)
client_body_betas: Final = _client_betas(
_NON_DEFAULT_PARAMS.validate_python(data).get("anthropic_beta"), "anthropic_beta", drop_params
)
if beta_set:
data["anthropic_beta"] = list(beta_set)
headers["anthropic-beta"] = ",".join(beta_set)
requested_betas: Final = injected_betas | client_body_betas
header_only_betas: Final = get_header_only_betas("vertex_ai") if requested_betas else frozenset[str]()
body_betas: Final = requested_betas - header_only_betas
if body_betas:
data["anthropic_beta"] = sorted(body_betas)
elif "anthropic_beta" in data:
del data["anthropic_beta"]
header_betas: Final = (
injected_betas
| compaction_betas
| (client_body_betas & header_only_betas)
| _client_betas(
_NON_DEFAULT_PARAMS.validate_python(headers).get("anthropic-beta"),
"The anthropic-beta header",
drop_params,
)
)
if header_betas:
headers["anthropic-beta"] = ",".join(sorted(header_betas))
return data

View file

@ -6,12 +6,19 @@ Unlike Gemini models which use Google's token counting API, partner models use
their respective publisher-specific count-tokens endpoints.
"""
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from typing import Any, Final
from pydantic import TypeAdapter
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.anthropic.common_utils import OBJECT_LIST_ADAPTER, is_claude_model_target, tools_without_custom_field
from litellm.llms.anthropic.common_utils import (
OBJECT_LIST_ADAPTER,
count_tokens_feature_betas,
is_claude_model_target,
tools_without_custom_field,
)
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.llms.vertex_ai.common_utils import get_vertex_base_url
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
@ -23,6 +30,9 @@ _COUNT_TOKENS_PUBLISHERS: Final = (
)
_REQUEST_MESSAGES: Final = TypeAdapter(Sequence[object])
def count_tokens_request_body(
request_data: Mapping[str, object], model: str, drop_params: object
) -> Mapping[str, object]:
@ -43,6 +53,14 @@ def count_tokens_publisher(model: str) -> str | None:
return next((publisher for marker, publisher in _COUNT_TOKENS_PUBLISHERS if marker in model), None)
def count_tokens_headers(access_token: str, request_data: Mapping[str, object]) -> Mapping[str, str]:
authorization: Final = {"Authorization": f"Bearer {access_token}"}
betas: Final = count_tokens_feature_betas(
"vertex_ai", request_data, _REQUEST_MESSAGES.validate_python(request_data["messages"])
)
return {**authorization, "anthropic-beta": ",".join(betas)} if betas else authorization
class VertexAIPartnerModelsTokenCounter(VertexBase):
"""
Token counter for Vertex AI Partner Models.
@ -175,8 +193,7 @@ class VertexAIPartnerModelsTokenCounter(VertexBase):
api_base=litellm_params.get("api_base"),
)
# Prepare headers
headers: Final = {"Authorization": f"Bearer {access_token}"}
headers: Final = dict(count_tokens_headers(access_token, request_data))
# Get async HTTP client
from litellm import LlmProviders

View file

@ -713,6 +713,14 @@ class AnthropicResponseContentBlockRedactedThinking(LiteLLMBaseModel):
data: str
class AnthropicResponseContentBlockCompaction(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
type: Literal["compaction"]
content: str | None = None
signature: str | None = None
class AnthropicResponseContentBlockServerToolUse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
@ -743,7 +751,7 @@ class AnthropicOutputTokensDetails(LiteLLMBaseModel):
thinking_tokens: int | None = None
AnthropicFinishReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use", "refusal"]
AnthropicFinishReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use", "refusal", "compaction"]
class AnthropicResponse(LiteLLMBaseModel):
@ -761,6 +769,7 @@ class AnthropicResponse(LiteLLMBaseModel):
| AnthropicResponseContentBlockToolUse
| AnthropicResponseContentBlockThinking
| AnthropicResponseContentBlockRedactedThinking
| AnthropicResponseContentBlockCompaction
| AnthropicResponseContentBlockServerToolUse
| AnthropicResponseContentBlockWebSearchToolResult
]
@ -808,6 +817,9 @@ class AnthropicThinkingParam(TypedDict, total=False):
block_binding: ReadOnly[AnthropicThinkingBlockBinding]
LEGACY_THINKING_DISPLAY_UPDATES_PROVIDERS: Final = frozenset(("anthropic", "azure_ai", "vertex_ai"))
class ANTHROPIC_HOSTED_TOOLS(str, Enum):
WEB_SEARCH = "web_search"
BASH = "bash"
@ -828,6 +840,7 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum):
CONTEXT_MANAGEMENT_2025_06_27 = "context-management-2025-06-27"
COMPACT_2026_01_12 = "compact-2026-01-12"
COMPACT_2026_09_04 = "compact-2026-09-04"
INLINE_TOOLS_2026_09_15 = "inline-tools-2026-09-15"
STRUCTURED_OUTPUT_2025_09_25 = "structured-outputs-2025-11-13"
ADVANCED_TOOL_USE_2025_11_20 = "advanced-tool-use-2025-11-20"
FAST_MODE_2026_02_01 = "fast-mode-2026-02-01"

View file

@ -6956,6 +6956,95 @@ def test_handle_anthropic_messages_response_logging_reparses_the_served_body_wit
assert logged.usage.completion_tokens == 7 # type: ignore[attr-defined]
@pytest.mark.parametrize(
"compaction_block",
[
{"type": "compaction", "content": "Summary of the chat so far.", "signature": "sig-abc"},
{"type": "compaction", "signature": "sig-abc"},
],
ids=["with_summary", "without_content"],
)
def test_handle_anthropic_messages_response_logging_reparses_a_served_compaction_response(
compaction_block: dict[str, str],
):
logging_obj = _anthropic_messages_logging_obj()
logging_obj.optional_params = {}
logged = logging_obj._handle_anthropic_messages_response_logging(
result={
"id": "msg_compacted",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5-5",
"content": [compaction_block],
"stop_reason": "compaction",
"stop_sequence": None,
"usage": {"input_tokens": 410, "output_tokens": 95},
}
)
assert isinstance(logged, ModelResponse)
assert logged.id == "msg_compacted"
assert logged.usage.prompt_tokens == 410
assert logged.usage.completion_tokens == 95
assert logged.choices[0].message.provider_specific_fields["compaction_blocks"] == [compaction_block]
_WEB_SEARCH_REPLY: Final = (
{"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "weather"}},
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_1",
"content": [{"type": "web_search_result", "url": "https://example.com", "title": "Weather"}],
},
{"type": "text", "text": "It is sunny."},
)
@pytest.mark.parametrize(
("content", "stop_reason"),
[
(list(_WEB_SEARCH_REPLY), "end_turn"),
(list(_WEB_SEARCH_REPLY), "pause_turn"),
([{"type": "text", "text": "The context window is full."}], "model_context_window_exceeded"),
(
[
{"type": "mcp_tool_use", "id": "mcptoolu_1", "name": "lookup", "server_name": "docs", "input": {}},
{"type": "mcp_tool_result", "tool_use_id": "mcptoolu_1", "is_error": False, "content": [{"type": "text", "text": "ok"}]},
{"type": "text", "text": "Found it."},
],
"end_turn",
),
([{"type": "container_upload", "file_id": "file_1"}, {"type": "text", "text": "Uploaded."}], "end_turn"),
],
ids=["web_search", "pause_turn", "model_context_window_exceeded", "mcp_tool_use", "container_upload"],
)
def test_handle_anthropic_messages_response_logging_bills_a_served_server_tool_response(
content: list[dict[str, object]], stop_reason: str
):
logging_obj = _anthropic_messages_logging_obj()
logging_obj.optional_params = {}
logged = logging_obj._handle_anthropic_messages_response_logging(
result={
"id": "msg_server_tools",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5-5",
"content": content,
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": {"input_tokens": 1200, "output_tokens": 80},
}
)
assert isinstance(logged, ModelResponse)
assert logged.id == "msg_server_tools"
assert logged.usage.prompt_tokens == 1200
assert logged.usage.completion_tokens == 80
assert logged.choices[0].message.content == content[-1]["text"]
def test_handle_anthropic_messages_response_logging_passes_model_response_through():
"""Anthropic-native path already yields a ModelResponse; it must be returned unchanged."""
logging_obj = _anthropic_messages_logging_obj()

View file

@ -57,9 +57,10 @@ def _provider_fields() -> dict[str, object]:
"thinking_blocks": [{"type": "thinking", "thinking": SECRET}],
"reasoningContentBlocks": [{"reasoningText": {"text": SECRET}}],
"citations": [{"cited_text": SECRET}],
"thought_signatures": [SIGNATURE],
"compaction_blocks": [{"type": "compaction", "content": SECRET}],
"compaction_blocks": [{"type": "compaction", "content": SECRET, "signature": "compaction-signature"}],
"compaction_start": {"type": "compaction", "content": SECRET},
"compaction_delta": {"type": "compaction_delta", "content": SECRET},
"thought_signatures": [SIGNATURE],
}

View file

@ -3544,12 +3544,11 @@ class TestWifProviderAllowlist:
monkeypatch.setenv("WIF_TEST_JWT", "jwt-assertion-value")
def test_vertex_anthropic_never_mints_or_sends_the_assertion(self, monkeypatch, wif_engine):
import litellm
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import (
VertexAIAnthropicConfig,
)
import litellm
poster, calls = wif_engine
self._env_only_wif(monkeypatch)
@ -3775,7 +3774,7 @@ class TestModelDiscovery:
)
monkeypatch.setattr("litellm.module_level_client", client)
with pytest.raises(Exception, match="invalid x-api-key") as exc_info: # noqa: B017, PT011 # the callee raises a bare Exception; match pins the sanitized text
with pytest.raises(Exception, match="invalid x-api-key") as exc_info: # noqa: B017 # the callee raises a bare Exception; match pins the sanitized text
AnthropicModelInfo().get_models(api_base="https://api.anthropic.com")
assert "invalid x-api-key" in str(exc_info.value)
@ -4238,15 +4237,18 @@ def test_thinking_display_beta_requires_active_thinking(thinking: object, expect
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize(
("display", "expected_thinking"),
("provider", "display", "expected_thinking"),
(
("summarized", {"type": "adaptive", "display": "summarized"}),
("omitted", {"type": "adaptive", "display": "omitted"}),
("updates", {"type": "adaptive"}),
("azure_ai", "summarized", {"type": "adaptive", "display": "summarized"}),
("azure_ai", "omitted", {"type": "adaptive", "display": "omitted"}),
("azure_ai", "updates", {"type": "adaptive", "display": "updates"}),
("vertex_ai", "updates", {"type": "adaptive", "display": "updates"}),
("anthropic", "updates", {"type": "adaptive", "display": "updates"}),
("databricks", "updates", {"type": "adaptive"}),
),
)
def test_shared_legacy_thinking_translation_preserves_supported_display(
display: str, expected_thinking: dict[str, str]
provider: str, display: str, expected_thinking: dict[str, str]
) -> None:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
@ -4257,7 +4259,7 @@ def test_shared_legacy_thinking_translation_preserves_supported_display(
AnthropicModelInfo.translate_legacy_thinking_for_adaptive_model(
model="claude-opus-5",
optional_params=optional_params,
custom_llm_provider="azure_ai",
custom_llm_provider=provider,
drop_params=False,
)
@ -4552,6 +4554,47 @@ def test_shared_legacy_thinking_translation_drops_a_non_numeric_budget_under_dro
)
_SIGNED_COMPACTION_BLOCK: Final = {"type": "compaction", "content": "summary so far", "signature": "sig"}
@pytest.mark.parametrize(
"provider,expected",
[("anthropic", True), ("vertex_ai", True), ("azure_ai", True), ("bedrock", False), ("bedrock_converse", False)],
)
@pytest.mark.parametrize(
"optional_params,messages",
[
({"compaction": {"type": "summarize"}}, [{"role": "user", "content": "hi"}]),
({}, [{"role": "assistant", "content": [_SIGNED_COMPACTION_BLOCK]}, {"role": "user", "content": "go on"}]),
],
ids=["compaction_param", "signed_compaction_block"],
)
def test_requires_native_compaction_beta_per_provider(
provider: str, expected: bool, optional_params: dict[str, object], messages: list[object]
) -> None:
from litellm.llms.anthropic.common_utils import requires_native_compaction_beta
assert requires_native_compaction_beta(provider, optional_params, messages) is expected
@pytest.mark.parametrize("provider", ["anthropic", "azure_ai", "vertex_ai"])
@pytest.mark.parametrize(
"optional_params,messages",
[
({"max_tokens": 64}, [{"role": "user", "content": "hi"}]),
({}, [{"role": "assistant", "content": [{"type": "compaction", "content": "unsigned"}]}]),
({"compaction": None}, [{"role": "user", "content": "hi"}]),
],
ids=["no_signal", "unsigned_block", "compaction_none"],
)
def test_requires_native_compaction_beta_is_false_without_a_signal(
provider: str, optional_params: dict[str, object], messages: list[object]
) -> None:
from litellm.llms.anthropic.common_utils import requires_native_compaction_beta
assert requires_native_compaction_beta(provider, optional_params, messages) is False
@pytest.mark.parametrize(
("custom_llm_provider", "model", "expected"),
[

View file

@ -215,3 +215,56 @@ async def test_count_uses_the_shared_api_base_chat_would_use(monkeypatch: pytest
assert result is not None
assert result.total_tokens == 6
assert route.called
@pytest.mark.asyncio
@pytest.mark.parametrize(
("assistant_content", "expected_beta"),
[
(
[{"type": "compaction", "content": "Summary of the chat so far.", "signature": "sig-abc"}],
"compact-2026-09-04,token-counting-2024-11-01",
),
("Use Recipe and Step tables.", "token-counting-2024-11-01"),
],
ids=("signed_compaction_block", "plain_turn"),
)
async def test_count_sends_the_compact_beta_only_when_the_history_replays_a_compaction_block(
monkeypatch: pytest.MonkeyPatch, assistant_content: object, expected_beta: str
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
with respx.mock:
route: Final = respx.post(COUNT_URL).mock(return_value=httpx.Response(200, json={"input_tokens": 376}))
result: Final = await AzureAIAnthropicTokenCounter().count_tokens(
model_to_use="claude-sonnet-5-5",
messages=[
{"role": "assistant", "content": assistant_content},
{"role": "user", "content": "What tables did we pick?"},
],
contents=None,
deployment={"litellm_params": {"api_key": "azure-key", "api_base": API_BASE}},
request_model="azure_ai/claude-sonnet-5-5",
)
assert result is not None
assert result.total_tokens == 376
assert route.calls.last.request.headers["anthropic-beta"] == expected_beta
@pytest.mark.asyncio
async def test_count_sends_the_tool_change_beta_when_the_history_adds_a_tool(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
with respx.mock:
route: Final = respx.post(COUNT_URL).mock(return_value=httpx.Response(200, json={"input_tokens": 541}))
result: Final = await AzureAIAnthropicTokenCounter().count_tokens(
model_to_use="claude-sonnet-5-5",
messages=[
{"role": "user", "content": "Use the ping tool once."},
{"role": "system", "content": [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}]},
],
contents=None,
deployment={"litellm_params": {"api_key": "azure-key", "api_base": API_BASE}},
request_model="azure_ai/claude-sonnet-5-5",
)
assert result is not None
assert result.total_tokens == 541
assert route.calls.last.request.headers["anthropic-beta"] == "mid-conversation-tool-changes-2026-07-01,token-counting-2024-11-01"

View file

@ -607,3 +607,91 @@ async def test_outgoing_azure_request_keeps_an_object_output_format_under_drop_p
body: Final = await _azure_request_body_for_output_format(output_format, drop_params=True)
assert body["output_format"] == output_format
_SIGNED_COMPACTION_BLOCK: Final = {"type": "compaction", "content": "Summary of the chat so far.", "signature": "sig"}
@pytest.mark.asyncio
@pytest.mark.usefixtures("local_beta_headers_config")
@pytest.mark.parametrize(
("messages", "params"),
[
([{"role": "user", "content": "Summarize the chat so far."}], {"compaction": {"type": "summarize"}}),
(
[{"role": "assistant", "content": [_SIGNED_COMPACTION_BLOCK]}, {"role": "user", "content": "Go on"}],
{},
),
],
ids=["compaction_param", "signed_compaction_block"],
)
async def test_outgoing_azure_compaction_request_carries_the_compact_2026_09_04_beta(
messages: list[object], params: dict[str, object]
) -> None:
"""Foundry answers 400 "compaction: this parameter requires anthropic-beta: compact-2026-09-04" for the param
and 400 "Input tag 'compaction' ... does not match" for a replayed block without the header, and 200 with it
(live, claude-sonnet-5-5 on Azure AI Foundry, 2026-10-20)."""
received: Final[list[httpx.Request]] = []
def foundry_answers(request: httpx.Request) -> httpx.Response:
received.append(request)
return _foundry_answer(request)
upstream: Final = AsyncHTTPHandler(transport=httpx.MockTransport(foundry_answers))
try:
await litellm.anthropic.messages.acreate(
max_tokens=256,
messages=messages,
model="azure_ai/claude-sonnet-5-5",
api_key="test-api-key",
api_base="https://test-resource.services.ai.azure.com",
client=upstream,
**params,
)
finally:
await upstream.close()
assert len(received) == 1, received
assert received[0].headers["anthropic-beta"].split(",").count("compact-2026-09-04") == 1, dict(received[0].headers)
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize("feature", ("thinking", "output_config", "tool_addition", "tool_removal", "none"))
@pytest.mark.parametrize("explicit_beta", (False, True))
def test_messages_feature_beta_headers(feature: str, explicit_beta: bool) -> None:
from typing import Final
from litellm.llms.azure_ai.anthropic.messages_transformation import AzureAnthropicMessagesConfig
from litellm.types.llms.anthropic import (
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
)
beta: Final = (
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER
if feature == "thinking"
else ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value
if feature == "output_config"
else ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
)
message: Final = (
{"role": "system", "content": [], "output_config": {"effort": "high"}}
if feature == "output_config"
else {"role": "system", "content": [{"type": feature, "tool": {"type": "tool_reference", "name": "ping"}}]}
if feature in ("tool_addition", "tool_removal")
else {"role": "user", "content": "Reply with OK"}
)
headers, _ = AzureAnthropicMessagesConfig().validate_anthropic_messages_environment(
headers={"anthropic-beta": f"existing-beta,{beta}" if explicit_beta else "existing-beta"},
model="claude-fable-5-1",
messages=[{"role": "user", "content": "Hello"}, message],
optional_params={"thinking": {"type": "adaptive", "display": "updates"}} if feature == "thinking" else {},
litellm_params={"vertex_ai_project": "test-project", "vertex_ai_location": "us-east5"},
api_key="test-key",
api_base="https://example.com/anthropic",
)
betas: Final = headers["anthropic-beta"].split(",")
assert betas.count(beta) == int(feature != "none" or explicit_beta)
assert betas.count("existing-beta") == 1

View file

@ -5,10 +5,12 @@ sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../.."))
)
from typing import Final
from unittest.mock import MagicMock, patch
import pytest
from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta
from litellm.llms.azure_ai.anthropic.transformation import AzureAnthropicConfig
from litellm.types.router import GenericLiteLLMParams
@ -484,3 +486,85 @@ def test_chat_flagged_model_keeps_mid_conversation_system_role_in_place(local_mo
"role": "system",
"content": [{"type": "text", "text": "<system-reminder>Answer with exactly one word.</system-reminder>"}],
}
@pytest.mark.parametrize(
("optional_params", "messages", "expected"),
[
({"compaction": {"type": "summarize"}}, [{"role": "user", "content": "Hello"}], True),
(
{},
[
{"role": "assistant", "content": [{"type": "compaction", "content": "Summary.", "signature": "sig"}]},
{"role": "user", "content": "Go on"},
],
True,
),
({}, [{"role": "user", "content": "Hello"}], False),
],
ids=["compaction_param", "signed_compaction_block", "no_compaction"],
)
def test_azure_chat_compaction_sends_the_compact_2026_09_04_beta(
local_beta_headers_config: None, optional_params: dict[str, object], messages: list[object], expected: bool
) -> None:
headers: Final = AzureAnthropicConfig().update_headers_with_optional_anthropic_beta(
headers={}, optional_params=optional_params, messages=messages
)
filtered: Final = update_headers_with_filtered_beta(headers=headers, provider="azure_ai")
assert ("compact-2026-09-04" in filtered.get("anthropic-beta", "").split(",")) is expected
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize("thinking_type", ("adaptive", "enabled"))
@pytest.mark.parametrize("display", ("summarized", "omitted", "updates"))
def test_chat_preserves_thinking_display_and_beta(thinking_type: str, display: str) -> None:
from typing import Final
from litellm.llms.azure_ai.anthropic.transformation import AzureAnthropicConfig
from litellm.types.llms.anthropic import ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER
config: Final = AzureAnthropicConfig()
optional_params: Final = config.map_openai_params(
non_default_params={"thinking": {"type": thinking_type, "display": display, "budget_tokens": 2048}},
optional_params={},
model="claude-fable-5-1",
drop_params=False,
)
headers: Final = config.validate_environment(
headers={"anthropic-beta": "existing-beta"},
model="claude-fable-5-1",
messages=[{"role": "user", "content": "Reply with OK"}],
optional_params=optional_params,
litellm_params={},
api_key="test-key",
)
result: Final = config.transform_request(
model="claude-fable-5-1",
messages=[{"role": "user", "content": "Reply with OK"}],
optional_params=optional_params,
litellm_params={},
headers=headers,
)
assert result["thinking"]["type"] == "adaptive"
assert result["thinking"]["display"] == display
assert (ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER in headers["anthropic-beta"].split(",")) == (display == "updates")
assert "existing-beta" in headers["anthropic-beta"].split(",")
@pytest.mark.parametrize("thinking", ("enabled", {"type": "bogus"}, {"type": "adaptive", "display": "future-mode"}))
def test_beta_detection_preserves_unrecognized_thinking(thinking: object) -> None:
from typing import Final
from litellm.types.llms.anthropic import ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER
params: Final = {"thinking": thinking}
headers: Final = AzureAnthropicConfig().validate_environment(
headers={}, model="claude-fable-5-1", messages=[], optional_params=params,
litellm_params={}, api_key="test-key",
)
assert params["thinking"] == thinking
assert ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER not in headers.get("anthropic-beta", "").split(",")

View file

@ -8,11 +8,13 @@ from threading import get_ident
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import requests
import responses
import litellm
from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.messages.transformation import (
VertexAIPartnerModelsAnthropicMessagesConfig,
@ -115,7 +117,11 @@ def _vertex_client() -> MagicMock:
async def _send_to_vertex(client: MagicMock, caller_headers: Mapping[str, str], drop_params: bool) -> None:
with responses.RequestsMock(assert_all_requests_are_fired=False) as oauth:
oauth.add(responses.POST, "https://oauth2.googleapis.com/token", json={"access_token": "vertex-token", "expires_in": 3600})
oauth.add(
responses.POST,
"https://oauth2.googleapis.com/token",
json={"access_token": "vertex-token", "expires_in": 3600},
)
await litellm.anthropic.messages.acreate(
model="vertex_ai/claude-sonnet-5",
messages=[{"role": "user", "content": "hi"}],
@ -289,7 +295,12 @@ def test_no_safeguards_leaves_dangerous_tool_use_beta_header_out():
assert "dangerous-tool-use-2026-09-03" not in updated_headers.get("anthropic-beta", "")
def _validate_vertex_headers(client_headers, messages, thinking: Mapping[str, object] | None = None):
def _validate_vertex_headers(
client_headers: dict[str, str],
messages: list[object],
optional_params: dict[str, object] | None = None,
thinking: Mapping[str, object] | None = None,
) -> dict[str, str]:
config = VertexAIPartnerModelsAnthropicMessagesConfig()
litellm_params = {
"vertex_ai_project": "test-project",
@ -305,7 +316,10 @@ def _validate_vertex_headers(client_headers, messages, thinking: Mapping[str, ob
headers=client_headers,
model="claude-opus-5-5",
messages=messages,
optional_params={"max_tokens": 64, **({"thinking": thinking} if thinking is not None else {})},
optional_params={
**(optional_params or {"max_tokens": 64}),
**({"thinking": thinking} if thinking is not None else {}),
},
litellm_params=litellm_params,
api_base=None,
)
@ -1040,3 +1054,178 @@ def test_vertex_claude_4_8_plus_cost_map_entries_carry_mid_conversation_system_f
and info.get("supports_mid_conversation_system") is not True
]
assert missing == []
_SIGNED_COMPACTION_BLOCK: Final = {"type": "compaction", "content": "summary so far", "signature": "sig"}
@pytest.mark.parametrize(
"client_headers",
[{"anthropic-beta": "compact-2026-09-04"}, {}],
ids=["client_sends_beta", "client_omits_beta"],
)
@pytest.mark.parametrize(
"optional_params,messages",
[
({"max_tokens": 64, "compaction": {"type": "summarize"}}, [{"role": "user", "content": "Hello"}]),
(
{"max_tokens": 64},
[{"role": "assistant", "content": [_SIGNED_COMPACTION_BLOCK]}, {"role": "user", "content": "Go on"}],
),
],
ids=["compaction_param", "signed_compaction_block"],
)
def test_native_compaction_reaches_vertex_with_compact_2026_09_04_beta(
local_beta_headers_config: None,
client_headers: dict[str, str],
optional_params: dict[str, object],
messages: list[object],
) -> None:
"""Vertex serves on-demand compaction only behind compact-2026-09-04 (Google Cloud: beta on
https://platform.claude.com/docs/en/build-with-claude/compaction-on-demand, read 2026-10-05)
and answers 400 "compaction: Extra inputs are not permitted" without it, so the beta rides
along with the `compaction` param or a signed compaction block exactly once, whether or not
the client sent it, and survives the Vertex beta filter."""
headers: Final = _validate_vertex_headers(client_headers, messages, optional_params)
filtered: Final = update_headers_with_filtered_beta(headers=headers, provider="vertex_ai")
assert filtered["anthropic-beta"].split(",").count("compact-2026-09-04") == 1
def test_no_compaction_signal_leaves_compact_2026_09_04_beta_out() -> None:
headers: Final = _validate_vertex_headers({}, [{"role": "user", "content": "Hello"}])
assert "compact-2026-09-04" not in headers.get("anthropic-beta", "")
_VERTEX_MESSAGE_RESPONSE: Final = {
"id": "msg_vrtx_test",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "pong"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 5, "output_tokens": 1},
}
@pytest.mark.asyncio
async def test_vertex_messages_request_sends_compact_2026_09_04_beta_on_the_wire(
local_beta_headers_config: None,
) -> None:
"""A /v1/messages compaction request to a Vertex Claude deployment reaches Vertex with the beta in the
anthropic-beta request header and never in the body, the shape Vertex accepts (live, 2026-10-05)."""
mock_response: Final = MagicMock()
mock_response.status_code = 200
mock_response.text = json.dumps(_VERTEX_MESSAGE_RESPONSE)
mock_response.headers = httpx.Headers({})
mock_response.json.return_value = _VERTEX_MESSAGE_RESPONSE
client: Final = MagicMock(spec=AsyncHTTPHandler)
client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.llms.vertex_ai.vertex_llm_base.VertexBase._ensure_access_token_async",
AsyncMock(return_value=("token", "test-project")),
):
await litellm.anthropic.messages.acreate(
model="vertex_ai/claude-sonnet-4-6",
max_tokens=64,
messages=[{"role": "user", "content": "Reply with the single word: pong"}],
compaction={"type": "summarize"},
vertex_project="test-project",
vertex_location="us-east5",
client=client,
)
sent: Final = client.post.call_args.kwargs
sent_body: Final = sent["json"] if "json" in sent else json.loads(sent["data"])
assert sent["headers"]["anthropic-beta"].split(",").count("compact-2026-09-04") == 1
assert sent_body["compaction"] == {"type": "summarize"}
assert "anthropic_beta" not in sent_body
@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config")
@pytest.mark.parametrize("feature", ("thinking", "output_config", "tool_addition", "tool_removal", "none"))
@pytest.mark.parametrize("explicit_beta", (False, True))
def test_messages_feature_beta_headers(feature: str, explicit_beta: bool) -> None:
from typing import Final
from unittest.mock import patch
from google.oauth2.credentials import Credentials
from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.messages.transformation import (
VertexAIPartnerModelsAnthropicMessagesConfig,
)
from litellm.types.llms.anthropic import (
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
)
beta: Final = (
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER
if feature == "thinking"
else ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value
if feature == "output_config"
else ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
)
message: Final = (
{"role": "system", "content": [], "output_config": {"effort": "high"}}
if feature == "output_config"
else {"role": "system", "content": [{"type": feature, "tool": {"type": "tool_reference", "name": "ping"}}]}
if feature in ("tool_addition", "tool_removal")
else {"role": "user", "content": "Reply with OK"}
)
with patch(
"google.auth.default",
return_value=(MagicMock(spec=Credentials, token="test-token", expired=False), "test-project"),
):
headers, _ = VertexAIPartnerModelsAnthropicMessagesConfig().validate_anthropic_messages_environment(
headers={"anthropic-beta": f"existing-beta,{beta}" if explicit_beta else "existing-beta"},
model="claude-fable-5-1",
messages=[{"role": "user", "content": "Hello"}, message],
optional_params={"thinking": {"type": "adaptive", "display": "updates"}} if feature == "thinking" else {},
litellm_params={"vertex_ai_project": "test-project", "vertex_ai_location": "us-east5"},
api_key="test-key",
api_base="https://example.com/anthropic",
)
betas: Final = headers["anthropic-beta"].split(",")
assert betas.count(beta) == int(feature != "none" or explicit_beta)
assert betas.count("existing-beta") == 1
if feature in ("tool_addition", "tool_removal"):
filtered_headers: Final = update_headers_with_filtered_beta(headers=headers, provider="vertex_ai")
assert filtered_headers.get("anthropic-beta", "").split(",").count(beta) == 1
@pytest.mark.parametrize("thinking", ("enabled", {"type": "bogus"}, {"type": "adaptive", "display": "future-mode"}))
def test_beta_detection_preserves_unrecognized_thinking(thinking: object) -> None:
from typing import Final
from unittest.mock import patch
from google.oauth2.credentials import Credentials
from litellm.types.llms.anthropic import ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER
params: Final = {"thinking": thinking}
with patch(
"google.auth.default",
return_value=(MagicMock(spec=Credentials, token="test-token", expired=False), "test-project"),
):
headers, _ = VertexAIPartnerModelsAnthropicMessagesConfig().validate_anthropic_messages_environment(
headers={},
model="claude-fable-5-1",
messages=[],
optional_params=params,
litellm_params={"vertex_ai_project": "test-project", "vertex_ai_location": "us-east5"},
api_key="test-key",
api_base="https://example.com/anthropic",
)
assert params["thinking"] == thinking
assert ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER not in headers.get("anthropic-beta", "").split(",")

View file

@ -4,7 +4,7 @@ import copy
import json
from collections.abc import Mapping
from typing import Final
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import httpx
import pytest
@ -12,10 +12,12 @@ import respx
from google.oauth2.credentials import Credentials
import litellm
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.anthropic_beta_headers_manager import (
update_headers_with_filtered_beta,
update_request_with_filtered_beta,
)
from litellm.llms.anthropic.common_utils import AnthropicError
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import (
VertexAIAnthropicConfig,
)
@ -475,8 +477,10 @@ def test_vertex_ai_anthropic_extra_headers_beta_propagation():
def test_vertex_ai_anthropic_inline_tools_beta_survives_the_chat_beta_filter(local_beta_headers_config: None) -> None:
"""The chat path runs the Vertex beta filter over both the header and the `anthropic_beta` body field right
before the request goes out, so inline-tools-2026-09-15 must survive both (source and date in
tests/unit/test_anthropic_beta_headers_filtering.py)."""
before the request goes out, so inline-tools-2026-09-15 must survive it as the header (source and date in
tests/unit/test_anthropic_beta_headers_filtering.py) and stay out of the body field, which Vertex rejects
with 400 "Unexpected value(s) `inline-tools-2026-09-15` for the `anthropic_beta` parameter" (live, global,
claude-sonnet-5-5, 2026-10-20)."""
config: Final = VertexAIAnthropicConfig()
headers: Final[dict[str, str]] = {}
optional_params: Final[dict[str, object]] = {
@ -497,7 +501,7 @@ def test_vertex_ai_anthropic_inline_tools_beta_survives_the_chat_beta_filter(loc
)
assert filtered_headers["anthropic-beta"].split(",").count("inline-tools-2026-09-15") == 1
assert "inline-tools-2026-09-15" in filtered_request["anthropic_beta"]
assert "inline-tools-2026-09-15" not in filtered_request.get("anthropic_beta", [])
def test_vertex_ai_anthropic_extra_headers_beta_merged_with_auto_betas():
@ -1232,3 +1236,293 @@ def test_refusal_stop_details_kept_through_vertex_anthropic_transform():
provider_fields: Final = result.choices[0].message.provider_specific_fields
assert provider_fields["stop_reason"] == "refusal"
assert provider_fields["stop_details"] == ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"]
_SIGNED_COMPACTION_BLOCK: Final = {"type": "compaction", "content": "summary so far", "signature": "sig"}
_CLEAR_TOOL_USES: Final = {"edits": [{"type": "clear_tool_uses_20250919"}]}
def _vertex_request(optional_params: dict[str, object], headers: dict[str, str], litellm_params: dict[str, object] | None = None) -> dict:
return VertexAIAnthropicConfig().transform_request(
model="claude-opus-4-6",
messages=[{"role": "user", "content": "Hello"}],
optional_params={"max_tokens": 100, "is_vertex_request": True, **optional_params},
litellm_params=litellm_params or {},
headers=headers,
)
@pytest.mark.parametrize(
"optional_params,messages",
[
(
{"max_tokens": 100, "is_vertex_request": True, "compaction": {"type": "summarize"}},
[{"role": "user", "content": "Hello"}],
),
(
{"max_tokens": 100, "is_vertex_request": True},
[
{
"role": "assistant",
"content": "",
"provider_specific_fields": {"compaction_blocks": [_SIGNED_COMPACTION_BLOCK]},
},
{"role": "user", "content": "Go on"},
],
),
],
ids=["compaction_param", "signed_compaction_block_sent_back"],
)
def test_vertex_ai_anthropic_native_compaction_adds_compact_2026_09_04_beta(
local_beta_headers_config: None, optional_params: dict[str, object], messages: list[dict[str, object]]
) -> None:
"""The chat path skips the Anthropic beta injection for Vertex requests, so the Vertex config
adds compact-2026-09-04 itself (Google Cloud: beta on
https://platform.claude.com/docs/en/build-with-claude/compaction-on-demand, read 2026-10-05)
as the HTTP header only, since Vertex answered 400 "Unexpected value(s) `compact-2026-09-04`
for the `anthropic_beta` parameter" with the name in the body field (us-east5,
claude-sonnet-4-6, 2026-10-05), and the header must survive the Vertex beta filter."""
headers: Final[dict[str, str]] = {}
result: Final = VertexAIAnthropicConfig().transform_request(
model="claude-opus-4-6",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers=headers,
)
filtered_headers, filtered_body = update_request_with_filtered_beta(
headers=headers, request_data=result, provider="vertex_ai"
)
assert filtered_headers["anthropic-beta"].split(",").count("compact-2026-09-04") == 1
assert "compact-2026-09-04" not in filtered_body.get("anthropic_beta", [])
def test_vertex_ai_anthropic_no_compaction_signal_leaves_compact_2026_09_04_beta_out() -> None:
headers: Final[dict[str, str]] = {}
result: Final = _vertex_request({}, headers)
assert "compact-2026-09-04" not in result.get("anthropic_beta", [])
assert "compact-2026-09-04" not in headers.get("anthropic-beta", "")
def test_vertex_ai_anthropic_client_sent_compact_2026_09_04_stays_out_of_the_body_field(
local_beta_headers_config: None,
) -> None:
headers: Final[dict[str, str]] = {}
result: Final = _vertex_request(
{
"compaction": {"type": "summarize"},
"extra_headers": {"anthropic-beta": "compact-2026-09-04,interleaved-thinking-2025-05-14"},
},
headers,
)
assert headers["anthropic-beta"].split(",").count("compact-2026-09-04") == 1
assert result["anthropic_beta"] == ["interleaved-thinking-2025-05-14"]
def test_vertex_ai_anthropic_compaction_keeps_the_betas_the_client_sent_as_headers(
local_beta_headers_config: None,
) -> None:
headers: Final = {"anthropic-beta": "context-1m-2025-08-07"}
result: Final = _vertex_request({"compaction": {"type": "summarize"}}, headers)
assert sorted(headers["anthropic-beta"].split(",")) == ["compact-2026-09-04", "context-1m-2025-08-07"]
assert "anthropic_beta" not in result
@pytest.mark.parametrize("header_only_beta", ["compact-2026-09-04", "inline-tools-2026-09-15"])
def test_vertex_ai_anthropic_client_sent_body_header_only_beta_moves_to_the_header(
local_beta_headers_config: None, header_only_beta: str
) -> None:
"""Vertex rejects these betas inside the body `anthropic_beta` field with 400 "Unexpected value(s)" and
accepts them as the header (live, global, claude-sonnet-5-5, 2026-10-20), so a client that opts in through that field still
gets the beta as a header and keeps its other betas in the body."""
headers: Final[dict[str, str]] = {}
result: Final = _vertex_request(
{"anthropic_beta": [header_only_beta, "interleaved-thinking-2025-05-14"]}, headers
)
assert headers["anthropic-beta"] == header_only_beta
assert result["anthropic_beta"] == ["interleaved-thinking-2025-05-14"]
@pytest.mark.parametrize(
"client_beta,body,header",
[
(
"context-1m-2025-08-07",
["context-1m-2025-08-07", "context-management-2025-06-27"],
"context-management-2025-06-27",
),
(
"inline-tools-2026-09-15",
["context-management-2025-06-27"],
"context-management-2025-06-27,inline-tools-2026-09-15",
),
],
ids=["body_beta", "header_only_beta"],
)
def test_vertex_ai_anthropic_litellm_betas_merge_with_the_client_body_betas(
local_beta_headers_config: None, client_beta: str, body: list[str], header: str
) -> None:
headers: Final[dict[str, str]] = {}
result: Final = _vertex_request({"context_management": _CLEAR_TOOL_USES, "anthropic_beta": [client_beta]}, headers)
assert result["anthropic_beta"] == body
assert headers["anthropic-beta"] == header
@pytest.mark.parametrize(
"anthropic_beta",
[["interleaved-thinking-2025-05-14"], ("interleaved-thinking-2025-05-14",)],
ids=["list", "sdk_tuple"],
)
def test_vertex_ai_anthropic_client_sent_body_betas_stay_out_of_the_header(anthropic_beta: object) -> None:
headers: Final[dict[str, str]] = {}
result: Final = _vertex_request({"anthropic_beta": anthropic_beta}, headers)
assert result["anthropic_beta"] == ["interleaved-thinking-2025-05-14"]
assert "anthropic-beta" not in headers
_MALFORMED_BETAS: Final = pytest.mark.parametrize(
"malformed", [5, [1, 2], {"beta": "x"}], ids=["int", "list_of_ints", "dict"]
)
_MALFORMED_BETA_SOURCES: Final = pytest.mark.parametrize(
"source,field",
[("anthropic_beta", "anthropic_beta"), ("extra_headers", "The anthropic-beta header")],
ids=["body_field", "extra_header"],
)
def _malformed_beta_params(source: str, malformed: object) -> dict[str, object]:
if source == "extra_headers":
return {"extra_headers": {"anthropic-beta": malformed}}
return {"anthropic_beta": malformed}
@_MALFORMED_BETAS
@_MALFORMED_BETA_SOURCES
def test_vertex_ai_anthropic_malformed_anthropic_beta_answers_400_naming_its_field(
malformed: object, source: str, field: str
) -> None:
with pytest.raises(AnthropicError) as excinfo:
_vertex_request(_malformed_beta_params(source, malformed), {})
assert excinfo.value.status_code == 400
assert excinfo.value.message.startswith(f"{field} must be")
@_MALFORMED_BETAS
@_MALFORMED_BETA_SOURCES
@pytest.mark.parametrize("auto_beta", [False, True], ids=["alone", "next_to_litellm_beta"])
def test_vertex_ai_anthropic_malformed_anthropic_beta_is_dropped_under_drop_params(
local_beta_headers_config: None, malformed: object, source: str, field: str, auto_beta: bool
) -> None:
headers: Final[dict[str, str]] = {}
params: Final = {**_malformed_beta_params(source, malformed), **({"context_management": _CLEAR_TOOL_USES} if auto_beta else {})}
result: Final = _vertex_request(params, headers, litellm_params={"drop_params": True})
assert result.get("anthropic_beta") == (["context-management-2025-06-27"] if auto_beta else None)
assert headers.get("anthropic-beta") == ("context-management-2025-06-27" if auto_beta else None)
_VERTEX_MESSAGE_RESPONSE: Final = {
"id": "msg_vrtx_test",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "pong"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 5, "output_tokens": 1},
}
def test_vertex_ai_anthropic_completion_sends_compact_2026_09_04_beta_on_the_wire(local_beta_headers_config: None) -> None:
"""A chat completion with `compaction` on a Vertex Claude deployment reaches Vertex with the beta in the
anthropic-beta request header and never in the body field, the shape Vertex accepts (live, 2026-10-05)."""
mock_response: Final = MagicMock()
mock_response.status_code = 200
mock_response.text = json.dumps(_VERTEX_MESSAGE_RESPONSE)
mock_response.headers = {}
mock_response.json.return_value = _VERTEX_MESSAGE_RESPONSE
client: Final = MagicMock(spec=HTTPHandler)
client.post.return_value = mock_response
with patch(
"litellm.llms.vertex_ai.vertex_llm_base.VertexBase._ensure_access_token",
return_value=("token", "test-project"),
):
litellm.completion(
model="vertex_ai/claude-sonnet-4-6",
messages=[{"role": "user", "content": "Reply with the single word: pong"}],
compaction={"type": "summarize"},
vertex_project="test-project",
vertex_location="us-east5",
client=client,
)
sent: Final = client.post.call_args.kwargs
sent_body: Final = sent["json"] if "json" in sent else json.loads(sent["data"])
assert sent["headers"]["anthropic-beta"].split(",").count("compact-2026-09-04") == 1
assert sent_body["compaction"] == {"type": "summarize"}
assert "anthropic_beta" not in sent_body
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize("thinking_type", ("adaptive", "enabled"))
@pytest.mark.parametrize("display", ("summarized", "omitted", "updates"))
def test_chat_preserves_thinking_display_and_beta(thinking_type: str, display: str) -> None:
from typing import Final
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import VertexAIAnthropicConfig
from litellm.types.llms.anthropic import ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER
config: Final = VertexAIAnthropicConfig()
optional_params: Final = config.map_openai_params(
non_default_params={"thinking": {"type": thinking_type, "display": display, "budget_tokens": 2048}},
optional_params={},
model="claude-fable-5-1",
drop_params=False,
)
optional_params["extra_headers"] = {"anthropic-beta": "existing-beta"}
headers: Final = {}
result: Final = config.transform_request(
model="claude-fable-5-1",
messages=[{"role": "user", "content": "Reply with OK"}],
optional_params=optional_params,
litellm_params={},
headers=headers,
)
assert result["thinking"]["type"] == "adaptive"
assert result["thinking"]["display"] == display
assert (ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER in headers["anthropic-beta"].split(",")) == (display == "updates")
assert "existing-beta" in headers["anthropic-beta"].split(",")
@pytest.mark.parametrize("thinking", ("enabled", {"type": "bogus"}, {"type": "adaptive", "display": "future-mode"}))
def test_beta_detection_preserves_unrecognized_thinking(thinking: object) -> None:
from typing import Final
from litellm.types.llms.anthropic import ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER
headers: Final = {}
result: Final = VertexAIAnthropicConfig().transform_request(
model="claude-fable-5-1", messages=[{"role": "user", "content": "Hello"}],
optional_params={"thinking": thinking}, litellm_params={}, headers=headers,
)
assert result["thinking"] == thinking
assert ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER not in headers.get("anthropic-beta", "").split(",")

View file

@ -0,0 +1,60 @@
from typing import Final
import pytest
from litellm.llms.vertex_ai.vertex_ai_partner_models.count_tokens.handler import count_tokens_headers
_HISTORY: Final = ({"role": "user", "content": "Hello"},)
@pytest.mark.parametrize(
"request_data",
[
{"messages": [{"role": "assistant", "content": [{"type": "compaction", "content": "so far", "signature": "sig"}]}]},
{"messages": list(_HISTORY), "compaction": {"type": "summarize"}},
],
ids=["signed_compaction_block", "compaction_param"],
)
def test_count_tokens_on_a_compaction_request_sends_the_compact_beta(request_data: dict[str, object]) -> None:
assert count_tokens_headers("token", request_data) == {
"Authorization": "Bearer token",
"anthropic-beta": "compact-2026-09-04",
}
@pytest.mark.parametrize(
"request_data",
[
{"messages": list(_HISTORY)},
{"messages": [{"role": "assistant", "content": [{"type": "compaction", "content": "so far", "signature": ""}]}]},
],
ids=["plain", "unsigned_compaction_block"],
)
def test_count_tokens_without_compaction_sends_no_beta(request_data: dict[str, object]) -> None:
assert count_tokens_headers("token", request_data) == {"Authorization": "Bearer token"}
_TOOL_ADDITION: Final = {
"role": "system",
"content": [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}],
}
@pytest.mark.parametrize(
("request_data", "expected_beta"),
[
({"messages": [*_HISTORY, _TOOL_ADDITION]}, "mid-conversation-tool-changes-2026-07-01"),
(
{"messages": [*_HISTORY, _TOOL_ADDITION], "compaction": {"type": "summarize"}},
"compact-2026-09-04,mid-conversation-tool-changes-2026-07-01",
),
],
ids=["tool_addition", "tool_addition_with_compaction"],
)
def test_count_tokens_on_a_tool_change_request_sends_the_tool_change_beta(
request_data: dict[str, object], expected_beta: str
) -> None:
assert count_tokens_headers("token", request_data) == {
"Authorization": "Bearer token",
"anthropic-beta": expected_beta,
}

View file

@ -85,7 +85,8 @@ class TestAnthropicBetaHeadersFiltering:
filtered = filter_and_transform_beta_headers(
beta_headers=all_headers, provider=provider
)
assert ("compact-2026-09-04" in filtered) is (provider == "anthropic")
assert ("compact-2026-09-04" in filtered) is (provider in ("anthropic", "azure_ai", "vertex_ai"))
assert ("mid-conversation-tool-changes-2026-07-01" in filtered) is (provider != "bedrock_converse")
for header in unsupported_headers:
assert (
@ -406,15 +407,49 @@ class TestAnthropicBetaHeadersFiltering:
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"
@pytest.mark.parametrize("beta", ["compact-2026-09-04", "inline-tools-2026-09-15"])
def test_vertex_ai_header_only_beta_is_forwarded_as_the_header_and_dropped_from_the_body(
self, beta: str
) -> None:
headers, body = update_request_with_filtered_beta(
headers={"anthropic-beta": f"{beta},context-1m-2025-08-07"},
request_data={"anthropic_beta": [beta, "context-1m-2025-08-07"]},
provider="vertex_ai",
)
assert filtered == ["compact-2026-01-12"]
assert set(headers["anthropic-beta"].split(",")) == {beta, "context-1m-2025-08-07"}
assert body["anthropic_beta"] == ["context-1m-2025-08-07"]
def test_header_only_betas_stay_out_of_the_provider_columns_older_proxies_read(self) -> None:
"""Proxies released before header-only columns existed fetch this map from main, read only the
provider column, and copy its betas into the body field too, where Vertex answers 400. So a
header-only beta may sit in its provider column only if older proxies already forwarded it."""
header_only_columns: Final = {
column.removesuffix("_header_only"): betas
for column, betas in self.config.items()
if column.endswith("_header_only")
}
forwarded_by_older_proxies: Final = {
provider: {beta for beta in betas if self.config[provider].get(beta) is not None}
for provider, betas in header_only_columns.items()
}
assert "vertex_ai" in header_only_columns
assert {provider: betas for provider, betas in forwarded_by_older_proxies.items() if betas} == {
"vertex_ai": {"inline-tools-2026-09-15"}
}
def test_filter_and_transform_beta_headers_vertex_ai_keeps_compact(self):
"""Vertex AI serves both compaction betas (threshold compaction behind compact-2026-01-12
and on-demand compaction behind compact-2026-09-04, Google Cloud: beta on
https://platform.claude.com/docs/en/build-with-claude/compaction-on-demand, read 2026-10-05),
so the Vertex filter forwards both instead of stripping them: a dropped header makes
Vertex answer 400 "compaction: Extra inputs are not permitted"."""
filtered = filter_and_transform_beta_headers(
beta_headers=["compact-2026-01-12", "compact-2026-09-04"], provider="vertex_ai"
)
assert filtered == ["compact-2026-01-12", "compact-2026-09-04"]
@pytest.mark.parametrize("provider", ["bedrock_converse", "bedrock"])
def test_fine_grained_tool_streaming_forwarded_for_bedrock(self, provider):