diff --git a/litellm/constants.py b/litellm/constants.py index 3a49e8a7fc4..6cca43f8c8b 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1533,6 +1533,7 @@ BEDROCK_CONVERSE_MODELS: Final = [ "openai.gpt-oss-120b-1:0", "anthropic.claude-haiku-4-5-20251001-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", + "anthropic.claude-fable-5-2", "anthropic.claude-fable-5-1", "anthropic.claude-fable-5", "anthropic.claude-sonnet-5", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index cc920c5fe25..0d35c643561 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1970,7 +1970,7 @@ "source": "https://aws.amazon.com/bedrock/pricing/" }, "bedrock/claude-fable-latest": { - "alias_of": "global.anthropic.claude-fable-5-1" + "alias_of": "global.anthropic.claude-fable-5-2" }, "us.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -69958,6 +69958,44 @@ "region": "us-gov-west-1" } }, + "bedrock/us-gov-west-1/anthropic.claude-fable-5-2": { + "cache_creation_input_token_cost": 1.2e-05, + "cache_creation_input_token_cost_above_1hr": 1.92e-05, + "cache_read_input_token_cost": 2.4e-07, + "input_cost_per_token": 9.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4.8e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512, + "token_count_model": { + "model": "anthropic.claude-fable-5-2", + "litellm_provider": "bedrock_mantle", + "region": "us-gov-west-1" + } + }, "bedrock/us-gov-east-1/nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, "input_cost_per_token_batches": 3.6e-08, @@ -70266,6 +70304,39 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "bedrock/us-gov-east-1/anthropic.claude-fable-5-2": { + "cache_creation_input_token_cost": 1.2e-05, + "cache_creation_input_token_cost_above_1hr": 1.92e-05, + "cache_read_input_token_cost": 2.4e-07, + "input_cost_per_token": 9.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4.8e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "bedrock_mantle/us-gov-west-1/openai.gpt-5.6-terra": { "litellm_provider": "bedrock_mantle", "max_input_tokens": 922000, @@ -87903,74 +87974,6 @@ "supports_xhigh_reasoning_effort": true, "thinking_always_on": true }, - "bedrock/us-gov-east-1/anthropic.claude-fable-5-2": { - "bedrock_output_config_effort_ceiling": "xhigh", - "cache_creation_input_token_cost": 1.2e-05, - "cache_creation_input_token_cost_above_1hr": 1.92e-05, - "cache_read_input_token_cost": 2.4e-07, - "input_cost_per_token": 9.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 1000000, - "max_output_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 4.8e-05, - "prompt_cache_min_tokens": 512.0, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", - "supports_adaptive_thinking": true, - "supports_assistant_prefill": false, - "supports_bedrock_native_structured_output": false, - "supports_computer_use": true, - "supports_forced_tool_use": false, - "supports_function_calling": true, - "supports_max_reasoning_effort": true, - "supports_mid_conversation_system": true, - "supports_native_structured_output": false, - "supports_output_config": true, - "supports_parallel_tool_use_config": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_sampling_params": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_xhigh_reasoning_effort": true, - "thinking_always_on": true - }, - "bedrock/us-gov-west-1/anthropic.claude-fable-5-2": { - "bedrock_output_config_effort_ceiling": "xhigh", - "cache_creation_input_token_cost": 1.2e-05, - "cache_creation_input_token_cost_above_1hr": 1.92e-05, - "cache_read_input_token_cost": 2.4e-07, - "input_cost_per_token": 9.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 1000000, - "max_output_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 4.8e-05, - "prompt_cache_min_tokens": 512.0, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", - "supports_adaptive_thinking": true, - "supports_assistant_prefill": false, - "supports_bedrock_native_structured_output": false, - "supports_computer_use": true, - "supports_forced_tool_use": false, - "supports_function_calling": true, - "supports_max_reasoning_effort": true, - "supports_mid_conversation_system": true, - "supports_native_structured_output": false, - "supports_output_config": true, - "supports_parallel_tool_use_config": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_sampling_params": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_xhigh_reasoning_effort": true, - "thinking_always_on": true - }, "bedrock/us-gov-east-1/anthropic.claude-mythos-5-2": { "bedrock_output_config_effort_ceiling": "xhigh", "cache_creation_input_token_cost": 1.2e-05, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index cc920c5fe25..0d35c643561 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1970,7 +1970,7 @@ "source": "https://aws.amazon.com/bedrock/pricing/" }, "bedrock/claude-fable-latest": { - "alias_of": "global.anthropic.claude-fable-5-1" + "alias_of": "global.anthropic.claude-fable-5-2" }, "us.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -69958,6 +69958,44 @@ "region": "us-gov-west-1" } }, + "bedrock/us-gov-west-1/anthropic.claude-fable-5-2": { + "cache_creation_input_token_cost": 1.2e-05, + "cache_creation_input_token_cost_above_1hr": 1.92e-05, + "cache_read_input_token_cost": 2.4e-07, + "input_cost_per_token": 9.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4.8e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512, + "token_count_model": { + "model": "anthropic.claude-fable-5-2", + "litellm_provider": "bedrock_mantle", + "region": "us-gov-west-1" + } + }, "bedrock/us-gov-east-1/nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, "input_cost_per_token_batches": 3.6e-08, @@ -70266,6 +70304,39 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 512 }, + "bedrock/us-gov-east-1/anthropic.claude-fable-5-2": { + "cache_creation_input_token_cost": 1.2e-05, + "cache_creation_input_token_cost_above_1hr": 1.92e-05, + "cache_read_input_token_cost": 2.4e-07, + "input_cost_per_token": 9.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4.8e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_bedrock_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "bedrock_mantle/us-gov-west-1/openai.gpt-5.6-terra": { "litellm_provider": "bedrock_mantle", "max_input_tokens": 922000, @@ -87903,74 +87974,6 @@ "supports_xhigh_reasoning_effort": true, "thinking_always_on": true }, - "bedrock/us-gov-east-1/anthropic.claude-fable-5-2": { - "bedrock_output_config_effort_ceiling": "xhigh", - "cache_creation_input_token_cost": 1.2e-05, - "cache_creation_input_token_cost_above_1hr": 1.92e-05, - "cache_read_input_token_cost": 2.4e-07, - "input_cost_per_token": 9.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 1000000, - "max_output_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 4.8e-05, - "prompt_cache_min_tokens": 512.0, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", - "supports_adaptive_thinking": true, - "supports_assistant_prefill": false, - "supports_bedrock_native_structured_output": false, - "supports_computer_use": true, - "supports_forced_tool_use": false, - "supports_function_calling": true, - "supports_max_reasoning_effort": true, - "supports_mid_conversation_system": true, - "supports_native_structured_output": false, - "supports_output_config": true, - "supports_parallel_tool_use_config": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_sampling_params": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_xhigh_reasoning_effort": true, - "thinking_always_on": true - }, - "bedrock/us-gov-west-1/anthropic.claude-fable-5-2": { - "bedrock_output_config_effort_ceiling": "xhigh", - "cache_creation_input_token_cost": 1.2e-05, - "cache_creation_input_token_cost_above_1hr": 1.92e-05, - "cache_read_input_token_cost": 2.4e-07, - "input_cost_per_token": 9.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 1000000, - "max_output_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 4.8e-05, - "prompt_cache_min_tokens": 512.0, - "source": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json", - "supports_adaptive_thinking": true, - "supports_assistant_prefill": false, - "supports_bedrock_native_structured_output": false, - "supports_computer_use": true, - "supports_forced_tool_use": false, - "supports_function_calling": true, - "supports_max_reasoning_effort": true, - "supports_mid_conversation_system": true, - "supports_native_structured_output": false, - "supports_output_config": true, - "supports_parallel_tool_use_config": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_sampling_params": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_xhigh_reasoning_effort": true, - "thinking_always_on": true - }, "bedrock/us-gov-east-1/anthropic.claude-mythos-5-2": { "bedrock_output_config_effort_ceiling": "xhigh", "cache_creation_input_token_cost": 1.2e-05, diff --git a/tests/integration/cost_calculation/cases/bedrock_converse.py b/tests/integration/cost_calculation/cases/bedrock_converse.py index 858db06cafc..96033a8b49a 100644 --- a/tests/integration/cost_calculation/cases/bedrock_converse.py +++ b/tests/integration/cost_calculation/cases/bedrock_converse.py @@ -4672,6 +4672,57 @@ US_ANTHROPIC_CLAUDE_FABLE_5_1_INPUT_TEXT: Final = CostTrackingTestCase( spend=0.0702, input_cost=0.03312, output_cost=0.03708, prompt_tokens=1840, completion_tokens=412 ), ) +US_ANTHROPIC_CLAUDE_FABLE_5_2_INPUT_TEXT: Final = CostTrackingTestCase( + name="us.anthropic.claude-fable-5-2-input_text", + covers="quota_management.spend_tracking.cost_matrix.logs_cost", + model="us.anthropic.claude-fable-5-2", + request={ + "model": "$MODEL", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line.", + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "a5b0ca93d2e1 summarize the attached material in one line and name the city weather", + } + ], + }, + ], + "stream": False, + "allowed_openai_params": [], + }, + response=JsonResponse( + content_type="application/json", + body={ + "metrics": {"latencyMs": 878}, + "output": {"message": {"content": [{"text": "scripted answer a5b0ca93d2e1"}], "role": "assistant"}}, + "stopReason": "end_turn", + "usage": { + "cacheReadInputTokenCount": 0, + "cacheReadInputTokens": 0, + "cacheWriteInputTokenCount": 0, + "cacheWriteInputTokens": 0, + "inputTokens": 1840, + "outputTokens": 412, + "serverToolUsage": {}, + "totalTokens": 2252, + }, + }, + ), + expected=ExactExpected( + spend=0.05616, input_cost=0.026496, output_cost=0.029664, prompt_tokens=1840, completion_tokens=412 + ), +) US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM: Final = CostTrackingTestCase( name="us.anthropic.claude-fable-5-1-stream", @@ -4726,6 +4777,59 @@ US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM: Final = CostTrackingTestCase( spend=0.0702, input_cost=0.03312, output_cost=0.03708, prompt_tokens=1840, completion_tokens=412 ), ) +US_ANTHROPIC_CLAUDE_FABLE_5_2_STREAM: Final = CostTrackingTestCase( + name="us.anthropic.claude-fable-5-2-stream", + covers="quota_management.spend_tracking.scripted_wire.logs_cost", + model="us.anthropic.claude-fable-5-2", + request={ + "model": "$MODEL", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line.", + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "d62b383256ff summarize the attached material in one line and name the city weather", + } + ], + }, + ], + "stream": True, + "stream_options": {"include_usage": True}, + "allowed_openai_params": [], + }, + response=EventStreamResponse( + content_type="application/vnd.amazon.eventstream", + events=( + EventStreamEvent(event_type="messageStart", payload={"role": "assistant"}), + EventStreamEvent( + event_type="contentBlockDelta", + payload={"contentBlockIndex": 0, "delta": {"text": "scripted answer d62b383256ff"}}, + ), + EventStreamEvent(event_type="contentBlockStop", payload={"contentBlockIndex": 0}), + EventStreamEvent(event_type="messageStop", payload={"stopReason": "end_turn"}), + EventStreamEvent( + event_type="metadata", + payload={ + "metrics": {"latencyMs": 883}, + "usage": {"inputTokens": 1840, "outputTokens": 412, "serverToolUsage": {}, "totalTokens": 2252}, + }, + ), + ), + ), + expected=ExactExpected( + spend=0.05616, input_cost=0.026496, output_cost=0.029664, prompt_tokens=1840, completion_tokens=412 + ), +) US_ANTHROPIC_CLAUDE_FABLE_5_1_CACHE_READ: Final = CostTrackingTestCase( name="us.anthropic.claude-fable-5-1-cache_read", @@ -4779,6 +4883,58 @@ US_ANTHROPIC_CLAUDE_FABLE_5_1_CACHE_READ: Final = CostTrackingTestCase( spend=0.0678384, input_cost=0.0336384, output_cost=0.0342, prompt_tokens=12928, completion_tokens=380 ), ) +US_ANTHROPIC_CLAUDE_FABLE_5_2_CACHE_READ: Final = CostTrackingTestCase( + name="us.anthropic.claude-fable-5-2-cache_read", + covers="quota_management.spend_tracking.cost_matrix.logs_cost", + model="us.anthropic.claude-fable-5-2", + request={ + "model": "$MODEL", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line.", + "cache_control": {"type": "ephemeral"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "1a5a397f1134 summarize the attached material in one line and name the city weather", + } + ], + }, + ], + "stream": False, + "allowed_openai_params": [], + }, + response=JsonResponse( + content_type="application/json", + body={ + "metrics": {"latencyMs": 878}, + "output": {"message": {"content": [{"text": "scripted answer 1a5a397f1134"}], "role": "assistant"}}, + "stopReason": "end_turn", + "usage": { + "cacheReadInputTokenCount": 12288, + "cacheReadInputTokens": 12288, + "cacheWriteInputTokenCount": 0, + "cacheWriteInputTokens": 0, + "inputTokens": 640, + "outputTokens": 380, + "serverToolUsage": {}, + "totalTokens": 13308, + }, + }, + ), + expected=ExactExpected( + spend=0.05427072, input_cost=0.02691072, output_cost=0.02736, prompt_tokens=12928, completion_tokens=380 + ), +) US_ANTHROPIC_CLAUDE_FABLE_5_1_CACHE_WRITE_5M: Final = CostTrackingTestCase( name="us.anthropic.claude-fable-5-1-cache_write_5m", @@ -4830,6 +4986,56 @@ US_ANTHROPIC_CLAUDE_FABLE_5_1_CACHE_WRITE_5M: Final = CostTrackingTestCase( spend=0.248076, input_cost=0.216576, output_cost=0.0315, prompt_tokens=9728, completion_tokens=350 ), ) +US_ANTHROPIC_CLAUDE_FABLE_5_2_CACHE_WRITE_5M: Final = CostTrackingTestCase( + name="us.anthropic.claude-fable-5-2-cache_write_5m", + covers="quota_management.spend_tracking.cost_matrix.logs_cost", + model="us.anthropic.claude-fable-5-2", + request={ + "model": "$MODEL", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line.", + "cache_control": {"type": "ephemeral"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "3481bca67e1a summarize the attached material in one line and name the city weather", + } + ], + }, + ], + "stream": False, + "allowed_openai_params": [], + }, + response=JsonResponse( + content_type="application/json", + body={ + "metrics": {"latencyMs": 878}, + "output": {"message": {"content": [{"text": "scripted answer 3481bca67e1a"}], "role": "assistant"}}, + "stopReason": "end_turn", + "usage": { + "inputTokens": 512, + "outputTokens": 350, + "totalTokens": 10078, + "cacheReadInputTokens": 0, + "cacheWriteInputTokens": 9216, + "cacheDetails": [{"ttl": "5m", "inputTokens": 9216}], + }, + }, + ), + expected=ExactExpected( + spend=0.1984608, input_cost=0.1732608, output_cost=0.0252, prompt_tokens=9728, completion_tokens=350 + ), +) US_ANTHROPIC_CLAUDE_FABLE_5_1_CACHE_WRITE_1H: Final = CostTrackingTestCase( name="us.anthropic.claude-fable-5-1-cache_write_1h", @@ -4881,6 +5087,56 @@ US_ANTHROPIC_CLAUDE_FABLE_5_1_CACHE_WRITE_1H: Final = CostTrackingTestCase( spend=0.372492, input_cost=0.340992, output_cost=0.0315, prompt_tokens=9728, completion_tokens=350 ), ) +US_ANTHROPIC_CLAUDE_FABLE_5_2_CACHE_WRITE_1H: Final = CostTrackingTestCase( + name="us.anthropic.claude-fable-5-2-cache_write_1h", + covers="quota_management.spend_tracking.cost_matrix.logs_cost", + model="us.anthropic.claude-fable-5-2", + request={ + "model": "$MODEL", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line.", + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "3f037d5317b0 summarize the attached material in one line and name the city weather", + } + ], + }, + ], + "stream": False, + "allowed_openai_params": [], + }, + response=JsonResponse( + content_type="application/json", + body={ + "metrics": {"latencyMs": 878}, + "output": {"message": {"content": [{"text": "scripted answer 3f037d5317b0"}], "role": "assistant"}}, + "stopReason": "end_turn", + "usage": { + "inputTokens": 512, + "outputTokens": 350, + "totalTokens": 10078, + "cacheReadInputTokens": 0, + "cacheWriteInputTokens": 9216, + "cacheDetails": [{"ttl": "1h", "inputTokens": 9216}], + }, + }, + ), + expected=ExactExpected( + spend=0.2979936, input_cost=0.2727936, output_cost=0.0252, prompt_tokens=9728, completion_tokens=350 + ), +) US_ANTHROPIC_CLAUDE_SONNET_5_TOOL_CALL: Final = CostTrackingTestCase( name="us.anthropic.claude-sonnet-5-tool_call", @@ -5832,6 +6088,87 @@ US_ANTHROPIC_CLAUDE_FABLE_5_1_TOOL_CALL: Final = CostTrackingTestCase( spend=0.0702, input_cost=0.03312, output_cost=0.03708, prompt_tokens=1840, completion_tokens=412 ), ) +US_ANTHROPIC_CLAUDE_FABLE_5_2_TOOL_CALL: Final = CostTrackingTestCase( + name="us.anthropic.claude-fable-5-2-tool_call", + covers="quota_management.spend_tracking.cost_matrix.logs_cost", + model="us.anthropic.claude-fable-5-2", + request={ + "model": "$MODEL", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line.", + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "7f97ab74e808 summarize the attached material in one line and name the city weather", + } + ], + }, + ], + "stream": False, + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather and a short forecast for a city.", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "City name"}, + "days": {"type": "integer", "description": "Forecast horizon in days"}, + "units": {"type": "string", "enum": ["metric", "imperial"]}, + }, + "required": ["city"], + }, + }, + } + ], + "tool_choice": "auto", + "allowed_openai_params": ["tool_choice"], + }, + response=JsonResponse( + content_type="application/json", + body={ + "output": { + "message": { + "role": "assistant", + "content": [ + { + "toolUse": { + "toolUseId": "tooluse_$REQUEST_ID", + "name": "get_weather", + "input": {"city": "Berlin", "days": 7, "units": "metric"}, + "type": "tool_use", + } + } + ], + } + }, + "stopReason": "tool_use", + "usage": { + "inputTokens": 1840, + "outputTokens": 412, + "totalTokens": 2252, + "cacheReadInputTokens": 0, + "cacheWriteInputTokens": 0, + }, + "metrics": {"latencyMs": 2071}, + }, + ), + expected=ExactExpected( + spend=0.05616, input_cost=0.026496, output_cost=0.029664, prompt_tokens=1840, completion_tokens=412 + ), +) US_ANTHROPIC_CLAUDE_FABLE_5_1_THINKING: Final = CostTrackingTestCase( name="us.anthropic.claude-fable-5-1-thinking", @@ -5885,6 +6222,58 @@ US_ANTHROPIC_CLAUDE_FABLE_5_1_THINKING: Final = CostTrackingTestCase( spend=0.0594, input_cost=0.02232, output_cost=0.03708, prompt_tokens=1240, completion_tokens=412 ), ) +US_ANTHROPIC_CLAUDE_FABLE_5_2_THINKING: Final = CostTrackingTestCase( + name="us.anthropic.claude-fable-5-2-thinking", + covers="quota_management.spend_tracking.cost_matrix.logs_cost", + model="us.anthropic.claude-fable-5-2", + request={ + "model": "$MODEL", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line.", + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "bca700fd52a2 summarize the attached material in one line and name the city weather", + } + ], + }, + ], + "stream": False, + "reasoning_effort": "medium", + "allowed_openai_params": ["reasoning_effort"], + }, + response=JsonResponse( + content_type="application/json", + body={ + "output": {"message": {"role": "assistant", "content": [{"text": "scripted answer bca700fd52a2"}]}}, + "stopReason": "end_turn", + "usage": { + "cacheReadInputTokenCount": 0, + "cacheReadInputTokens": 0, + "cacheWriteInputTokenCount": 0, + "cacheWriteInputTokens": 0, + "inputTokens": 1240, + "outputTokens": 412, + "serverToolUsage": {}, + "totalTokens": 1652, + }, + "metrics": {"latencyMs": 2071}, + }, + ), + expected=ExactExpected( + spend=0.04752, input_cost=0.017856, output_cost=0.029664, prompt_tokens=1240, completion_tokens=412 + ), +) US_ANTHROPIC_CLAUDE_OPUS_5_5_UPSTREAM_400_ZERO_SPEND: Final = CostTrackingTestCase( name="us.anthropic.claude-opus-5-5-upstream_400_zero_spend", @@ -6014,6 +6403,13 @@ US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON: Final = US_ANTHROPIC_CLAUD "request": {"model": "$MODEL", "input": "summarize the attached material in one line"}, } ) +US_ANTHROPIC_CLAUDE_FABLE_5_2_RESPONSES_BRIDGED_JSON: Final = US_ANTHROPIC_CLAUDE_FABLE_5_2_INPUT_TEXT.model_copy( + update={ + "name": "us.anthropic.claude-fable-5-2-responses_bridged_json", + "endpoint": "/v1/responses", + "request": {"model": "$MODEL", "input": "summarize the attached material in one line"}, + } +) US_ANTHROPIC_CLAUDE_HAIKU_5_5_TIERED_INPUT_ABOVE_100K: Final = with_usage( US_ANTHROPIC_CLAUDE_HAIKU_5_5_INPUT_TEXT, @@ -6088,6 +6484,7 @@ US_ANTHROPIC_CLAUDE_HAIKU_5_5_TIERED_CACHE_WRITE_1H_ABOVE_100K: Final = with_usa ), ) + def _us_anthropic_messages_bridge_pairs( input_case: CostTrackingTestCase, stream_case: CostTrackingTestCase ) -> tuple[BridgeCostParityTestCase, ...]: @@ -6125,6 +6522,11 @@ US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM: Final = _us_anthropic_re US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM, "us.anthropic.claude-fable-5-1-responses_bridged_stream", ) +US_ANTHROPIC_CLAUDE_FABLE_5_2_RESPONSES_BRIDGED_STREAM: Final = _us_anthropic_responses_stream_case( + US_ANTHROPIC_CLAUDE_FABLE_5_2_RESPONSES_BRIDGED_JSON, + US_ANTHROPIC_CLAUDE_FABLE_5_2_STREAM, + "us.anthropic.claude-fable-5-2-responses_bridged_stream", +) US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM: Final = _us_anthropic_responses_stream_case( US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_JSON, US_ANTHROPIC_CLAUDE_HAIKU_5_5_STREAM, @@ -6143,6 +6545,9 @@ US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM: Final = _us_anthropic_r _US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS: Final = _us_anthropic_messages_bridge_pairs( US_ANTHROPIC_CLAUDE_FABLE_5_1_INPUT_TEXT, US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM ) +_US_ANTHROPIC_FABLE_5_2_MESSAGES_PAIRS: Final = _us_anthropic_messages_bridge_pairs( + US_ANTHROPIC_CLAUDE_FABLE_5_2_INPUT_TEXT, US_ANTHROPIC_CLAUDE_FABLE_5_2_STREAM +) _US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS: Final = _us_anthropic_messages_bridge_pairs( US_ANTHROPIC_CLAUDE_HAIKU_5_5_INPUT_TEXT, US_ANTHROPIC_CLAUDE_HAIKU_5_5_STREAM ) @@ -6161,6 +6566,7 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_JSON, US_ANTHROPIC_CLAUDE_FABLE_5_RESPONSES_BRIDGED_JSON, US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON, + US_ANTHROPIC_CLAUDE_FABLE_5_2_RESPONSES_BRIDGED_JSON, US_ANTHROPIC_CLAUDE_HAIKU_5_5_TIERED_INPUT_ABOVE_100K, US_ANTHROPIC_CLAUDE_HAIKU_5_5_TIERED_AT_100K_BOUNDARY, US_ANTHROPIC_CLAUDE_HAIKU_5_5_TIERED_CACHE_READ_ABOVE_100K, @@ -6255,10 +6661,15 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( US_ANTHROPIC_CLAUDE_FABLE_5_CACHE_WRITE_5M, US_ANTHROPIC_CLAUDE_FABLE_5_CACHE_WRITE_1H, US_ANTHROPIC_CLAUDE_FABLE_5_1_INPUT_TEXT, + US_ANTHROPIC_CLAUDE_FABLE_5_2_INPUT_TEXT, US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM, + US_ANTHROPIC_CLAUDE_FABLE_5_2_STREAM, US_ANTHROPIC_CLAUDE_FABLE_5_1_CACHE_READ, + US_ANTHROPIC_CLAUDE_FABLE_5_2_CACHE_READ, US_ANTHROPIC_CLAUDE_FABLE_5_1_CACHE_WRITE_5M, + US_ANTHROPIC_CLAUDE_FABLE_5_2_CACHE_WRITE_5M, US_ANTHROPIC_CLAUDE_FABLE_5_1_CACHE_WRITE_1H, + US_ANTHROPIC_CLAUDE_FABLE_5_2_CACHE_WRITE_1H, US_ANTHROPIC_CLAUDE_SONNET_5_TOOL_CALL, US_ANTHROPIC_CLAUDE_SONNET_5_THINKING, US_ANTHROPIC_CLAUDE_SONNET_5_5_TOOL_CALL, @@ -6272,14 +6683,18 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( US_ANTHROPIC_CLAUDE_FABLE_5_TOOL_CALL, US_ANTHROPIC_CLAUDE_FABLE_5_THINKING, US_ANTHROPIC_CLAUDE_FABLE_5_1_TOOL_CALL, + US_ANTHROPIC_CLAUDE_FABLE_5_2_TOOL_CALL, US_ANTHROPIC_CLAUDE_FABLE_5_1_THINKING, + US_ANTHROPIC_CLAUDE_FABLE_5_2_THINKING, US_ANTHROPIC_CLAUDE_OPUS_5_5_UPSTREAM_400_ZERO_SPEND, US_ANTHROPIC_CLAUDE_OPUS_5_5_INPUT_TEXT_ROLLUPS, US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM, + US_ANTHROPIC_CLAUDE_FABLE_5_2_RESPONSES_BRIDGED_STREAM, US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM, US_ANTHROPIC_CLAUDE_OPUS_5_5_RESPONSES_BRIDGED_STREAM, US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM, *tuple(pair.bridged for pair in _US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS), + *tuple(pair.bridged for pair in _US_ANTHROPIC_FABLE_5_2_MESSAGES_PAIRS), *tuple(pair.bridged for pair in _US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS), *tuple(pair.bridged for pair in _US_ANTHROPIC_OPUS_5_5_MESSAGES_PAIRS), *tuple(pair.bridged for pair in _US_ANTHROPIC_SONNET_5_5_MESSAGES_PAIRS), @@ -6308,10 +6723,15 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( StreamParityTestCase(plain=US_ANTHROPIC_CLAUDE_HAIKU_5_5_INPUT_TEXT, streamed=US_ANTHROPIC_CLAUDE_HAIKU_5_5_STREAM), StreamParityTestCase(plain=US_ANTHROPIC_CLAUDE_FABLE_5_INPUT_TEXT, streamed=US_ANTHROPIC_CLAUDE_FABLE_5_STREAM), StreamParityTestCase(plain=US_ANTHROPIC_CLAUDE_FABLE_5_1_INPUT_TEXT, streamed=US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM), + StreamParityTestCase(plain=US_ANTHROPIC_CLAUDE_FABLE_5_2_INPUT_TEXT, streamed=US_ANTHROPIC_CLAUDE_FABLE_5_2_STREAM), StreamParityTestCase( plain=US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON, streamed=US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM, ), + StreamParityTestCase( + plain=US_ANTHROPIC_CLAUDE_FABLE_5_2_RESPONSES_BRIDGED_JSON, + streamed=US_ANTHROPIC_CLAUDE_FABLE_5_2_RESPONSES_BRIDGED_STREAM, + ), StreamParityTestCase( plain=US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_JSON, streamed=US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM, @@ -6328,6 +6748,10 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( plain=_US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS[0].bridged, streamed=_US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS[1].bridged, ), + StreamParityTestCase( + plain=_US_ANTHROPIC_FABLE_5_2_MESSAGES_PAIRS[0].bridged, + streamed=_US_ANTHROPIC_FABLE_5_2_MESSAGES_PAIRS[1].bridged, + ), StreamParityTestCase( plain=_US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS[0].bridged, streamed=_US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS[1].bridged, @@ -6372,10 +6796,17 @@ BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( BridgeCostParityTestCase( native=US_ANTHROPIC_CLAUDE_FABLE_5_1_INPUT_TEXT, bridged=US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_JSON ), + BridgeCostParityTestCase( + native=US_ANTHROPIC_CLAUDE_FABLE_5_2_INPUT_TEXT, bridged=US_ANTHROPIC_CLAUDE_FABLE_5_2_RESPONSES_BRIDGED_JSON + ), BridgeCostParityTestCase( native=US_ANTHROPIC_CLAUDE_FABLE_5_1_STREAM, bridged=US_ANTHROPIC_CLAUDE_FABLE_5_1_RESPONSES_BRIDGED_STREAM, ), + BridgeCostParityTestCase( + native=US_ANTHROPIC_CLAUDE_FABLE_5_2_STREAM, + bridged=US_ANTHROPIC_CLAUDE_FABLE_5_2_RESPONSES_BRIDGED_STREAM, + ), BridgeCostParityTestCase( native=US_ANTHROPIC_CLAUDE_HAIKU_5_5_STREAM, bridged=US_ANTHROPIC_CLAUDE_HAIKU_5_5_RESPONSES_BRIDGED_STREAM, @@ -6389,6 +6820,7 @@ BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( bridged=US_ANTHROPIC_CLAUDE_SONNET_5_5_RESPONSES_BRIDGED_STREAM, ), *_US_ANTHROPIC_FABLE_5_1_MESSAGES_PAIRS, + *_US_ANTHROPIC_FABLE_5_2_MESSAGES_PAIRS, *_US_ANTHROPIC_HAIKU_5_5_MESSAGES_PAIRS, *_US_ANTHROPIC_OPUS_5_5_MESSAGES_PAIRS, *_US_ANTHROPIC_SONNET_5_5_MESSAGES_PAIRS, diff --git a/tests/integration/cost_calculation/cases/vertex_ai.py b/tests/integration/cost_calculation/cases/vertex_ai.py index 8590a19646b..47a484e972b 100644 --- a/tests/integration/cost_calculation/cases/vertex_ai.py +++ b/tests/integration/cost_calculation/cases/vertex_ai.py @@ -5,8 +5,7 @@ PARITY pairs the streamed case with its plain twin where both bill the same row. import json from typing import Final, Literal, cast -from pydantic import JsonValue - +from integration.cost_calculation.bridge_cost_parity.case import BridgeCostParityTestCase, bridge_pairs from integration.cost_calculation.cases.anthropic import ( CLAUDE_FABLE_5_1_CACHE_READ, CLAUDE_FABLE_5_1_CACHE_WRITE_1H, @@ -17,6 +16,15 @@ from integration.cost_calculation.cases.anthropic import ( CLAUDE_FABLE_5_1_STREAM, CLAUDE_FABLE_5_1_THINKING, CLAUDE_FABLE_5_1_TOOL_CALL, + CLAUDE_FABLE_5_2_CACHE_READ, + CLAUDE_FABLE_5_2_CACHE_WRITE_1H, + CLAUDE_FABLE_5_2_CACHE_WRITE_5M, + CLAUDE_FABLE_5_2_INPUT_TEXT, + CLAUDE_FABLE_5_2_MESSAGES_INPUT_TEXT, + CLAUDE_FABLE_5_2_MESSAGES_STREAM, + CLAUDE_FABLE_5_2_STREAM, + CLAUDE_FABLE_5_2_THINKING, + CLAUDE_FABLE_5_2_TOOL_CALL, CLAUDE_HAIKU_5_5_MESSAGES_INPUT_TEXT, CLAUDE_HAIKU_5_5_MESSAGES_STREAM, CLAUDE_OPUS_5_5_CACHE_READ, @@ -38,7 +46,6 @@ from integration.cost_calculation.cases.anthropic import ( CLAUDE_SONNET_5_5_THINKING, CLAUDE_SONNET_5_5_TOOL_CALL, ) -from integration.cost_calculation.bridge_cost_parity.case import BridgeCostParityTestCase, bridge_pairs from integration.cost_calculation.cost_tracking_case import ( CostTrackingTestCase, Deployment, @@ -49,6 +56,7 @@ from integration.cost_calculation.cost_tracking_case import ( SseResponse, ) from integration.cost_calculation.stream_parity.case import StreamParityTestCase, sse_frames +from pydantic import JsonValue def _gemini_case_response( @@ -3009,6 +3017,37 @@ VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT: Final = CLAUDE_FABLE_5_1_INPUT_TEXT.model ), } ) +VERTEX_AI_CLAUDE_FABLE_5_2_INPUT_TEXT: Final = CLAUDE_FABLE_5_2_INPUT_TEXT.model_copy( + update={ + "name": "vertex_ai/claude-fable-5-2-input_text", + "model": "vertex_ai/claude-fable-5-2", + "response": JsonResponse( + content_type="application/json", + body={ + "model": "claude-fable-5-2", + "id": "msg_$REQUEST_ID", + "type": "message", + "role": "assistant", + "content": [{"type": "thinking", "thinking": "scripted thought", "signature": "scripted signature"}], + "container": None, + "stop_reason": "max_tokens", + "stop_sequence": None, + "stop_details": None, + "usage": { + "input_tokens": 1840, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 412, + "output_tokens_details": {"thinking_tokens": 0}, + }, + }, + ), + "expected": ExactExpected( + spend=0.05928, input_cost=0.027968, output_cost=0.031312, prompt_tokens=1840, completion_tokens=412 + ), + } +) VERTEX_AI_CLAUDE_FABLE_5_1_STREAM: Final = CLAUDE_FABLE_5_1_STREAM.model_copy( update={ "name": "vertex_ai/claude-fable-5-1-stream", @@ -3017,6 +3056,14 @@ VERTEX_AI_CLAUDE_FABLE_5_1_STREAM: Final = CLAUDE_FABLE_5_1_STREAM.model_copy( "expected": VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT.expected, } ) +VERTEX_AI_CLAUDE_FABLE_5_2_STREAM: Final = CLAUDE_FABLE_5_2_STREAM.model_copy( + update={ + "name": "vertex_ai/claude-fable-5-2-stream", + "model": "vertex_ai/claude-fable-5-2", + "response": _vertex_claude_stream_response("claude-fable-5-2", "max_tokens", ()), + "expected": VERTEX_AI_CLAUDE_FABLE_5_2_INPUT_TEXT.expected, + } +) VERTEX_AI_CLAUDE_SONNET_5_5_CACHE_READ: Final = _vertex_claude_shape_case( CLAUDE_SONNET_5_5_CACHE_READ, @@ -3135,6 +3182,15 @@ VERTEX_AI_CLAUDE_FABLE_5_1_CACHE_READ: Final = _vertex_claude_shape_case( spend=0.013567425, input_cost=0.013092425, output_cost=0.000475, prompt_tokens=27017, completion_tokens=5 ), ) +VERTEX_AI_CLAUDE_FABLE_5_2_CACHE_READ: Final = _vertex_claude_shape_case( + CLAUDE_FABLE_5_2_CACHE_READ, + "vertex_ai/claude-fable-5-2-cache_read", + "vertex_ai/claude-fable-5-2", + _vertex_claude_usage(input_tokens=14, output_tokens=5, cache_read_input_tokens=27003), + ExactExpected( + spend=0.01085394, input_cost=0.01047394, output_cost=0.00038, prompt_tokens=27017, completion_tokens=5 + ), +) VERTEX_AI_CLAUDE_FABLE_5_1_CACHE_WRITE_5M: Final = _vertex_claude_shape_case( CLAUDE_FABLE_5_1_CACHE_WRITE_5M, "vertex_ai/claude-fable-5-1-cache_write_5m", @@ -3146,6 +3202,17 @@ VERTEX_AI_CLAUDE_FABLE_5_1_CACHE_WRITE_5M: Final = _vertex_claude_shape_case( spend=0.64225225, input_cost=0.64177725, output_cost=0.000475, prompt_tokens=27025, completion_tokens=5 ), ) +VERTEX_AI_CLAUDE_FABLE_5_2_CACHE_WRITE_5M: Final = _vertex_claude_shape_case( + CLAUDE_FABLE_5_2_CACHE_WRITE_5M, + "vertex_ai/claude-fable-5-2-cache_write_5m", + "vertex_ai/claude-fable-5-2", + _vertex_claude_usage( + input_tokens=14, output_tokens=5, cache_creation_input_tokens=27011, ephemeral_5m_input_tokens=27011 + ), + ExactExpected( + spend=0.5138018, input_cost=0.5134218, output_cost=0.00038, prompt_tokens=27025, completion_tokens=5 + ), +) VERTEX_AI_CLAUDE_FABLE_5_1_CACHE_WRITE_1H: Final = _vertex_claude_shape_case( CLAUDE_FABLE_5_1_CACHE_WRITE_1H, "vertex_ai/claude-fable-5-1-cache_write_1h", @@ -3157,6 +3224,17 @@ VERTEX_AI_CLAUDE_FABLE_5_1_CACHE_WRITE_1H: Final = _vertex_claude_shape_case( spend=1.027159, input_cost=1.026684, output_cost=0.000475, prompt_tokens=27025, completion_tokens=5 ), ) +VERTEX_AI_CLAUDE_FABLE_5_2_CACHE_WRITE_1H: Final = _vertex_claude_shape_case( + CLAUDE_FABLE_5_2_CACHE_WRITE_1H, + "vertex_ai/claude-fable-5-2-cache_write_1h", + "vertex_ai/claude-fable-5-2", + _vertex_claude_usage( + input_tokens=14, output_tokens=5, cache_creation_input_tokens=27011, ephemeral_1h_input_tokens=27011 + ), + ExactExpected( + spend=0.8217272, input_cost=0.8213472, output_cost=0.00038, prompt_tokens=27025, completion_tokens=5 + ), +) VERTEX_AI_CLAUDE_FABLE_5_1_THINKING: Final = _vertex_claude_shape_case( CLAUDE_FABLE_5_1_THINKING, "vertex_ai/claude-fable-5-1-thinking", @@ -3171,6 +3249,20 @@ VERTEX_AI_CLAUDE_FABLE_5_1_THINKING: Final = _vertex_claude_shape_case( ], adaptive_thinking=True, ) +VERTEX_AI_CLAUDE_FABLE_5_2_THINKING: Final = _vertex_claude_shape_case( + CLAUDE_FABLE_5_2_THINKING, + "vertex_ai/claude-fable-5-2-thinking", + "vertex_ai/claude-fable-5-2", + _vertex_claude_usage(input_tokens=18, output_tokens=98, thinking_tokens=33), + ExactExpected( + spend=0.0077216, input_cost=0.0002736, output_cost=0.007448, prompt_tokens=18, completion_tokens=98 + ), + [ + {"type": "thinking", "thinking": "scripted thought", "signature": "scripted signature"}, + {"type": "text", "text": "scripted answer for cost fixtures"}, + ], + adaptive_thinking=True, +) VERTEX_AI_CLAUDE_FABLE_5_1_TOOL_CALL: Final = _vertex_claude_shape_case( CLAUDE_FABLE_5_1_TOOL_CALL, "vertex_ai/claude-fable-5-1-tool_call", @@ -3180,6 +3272,15 @@ VERTEX_AI_CLAUDE_FABLE_5_1_TOOL_CALL: Final = _vertex_claude_shape_case( spend=0.012065, input_cost=0.007315, output_cost=0.00475, prompt_tokens=385, completion_tokens=50 ), ) +VERTEX_AI_CLAUDE_FABLE_5_2_TOOL_CALL: Final = _vertex_claude_shape_case( + CLAUDE_FABLE_5_2_TOOL_CALL, + "vertex_ai/claude-fable-5-2-tool_call", + "vertex_ai/claude-fable-5-2", + _vertex_claude_usage(input_tokens=385, output_tokens=50), + ExactExpected( + spend=0.009652, input_cost=0.005852, output_cost=0.0038, prompt_tokens=385, completion_tokens=50 + ), +) VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT: Final = _vertex_claude_shape_case( VERTEX_AI_CLAUDE_SONNET_5_5_INPUT_TEXT, @@ -3325,6 +3426,15 @@ VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT: Final = CLAUDE_FABLE_5_1_MESSAGE "expected": VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT.expected, } ) +VERTEX_AI_CLAUDE_FABLE_5_2_MESSAGES_INPUT_TEXT: Final = CLAUDE_FABLE_5_2_MESSAGES_INPUT_TEXT.model_copy( + update={ + "name": "vertex_ai/claude-fable-5-2-messages_input_text", + "model": "vertex_ai/claude-fable-5-2", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_FABLE_5_2_INPUT_TEXT.response, + "expected": VERTEX_AI_CLAUDE_FABLE_5_2_INPUT_TEXT.expected, + } +) VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM: Final = CLAUDE_FABLE_5_1_MESSAGES_STREAM.model_copy( update={ "name": "vertex_ai/claude-fable-5-1-messages_stream", @@ -3334,10 +3444,23 @@ VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM: Final = CLAUDE_FABLE_5_1_MESSAGES_ST "expected": VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT.expected, } ) +VERTEX_AI_CLAUDE_FABLE_5_2_MESSAGES_STREAM: Final = CLAUDE_FABLE_5_2_MESSAGES_STREAM.model_copy( + update={ + "name": "vertex_ai/claude-fable-5-2-messages_stream", + "model": "vertex_ai/claude-fable-5-2", + "endpoint": "/v1/messages", + "response": VERTEX_AI_CLAUDE_FABLE_5_2_STREAM.response, + "expected": VERTEX_AI_CLAUDE_FABLE_5_2_INPUT_TEXT.expected, + } +) VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS: Final = bridge_pairs( (VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM), ("/v1/responses",), ) +VERTEX_AI_CLAUDE_FABLE_5_2_BRIDGE_PAIRS: Final = bridge_pairs( + (VERTEX_AI_CLAUDE_FABLE_5_2_MESSAGES_INPUT_TEXT, VERTEX_AI_CLAUDE_FABLE_5_2_MESSAGES_STREAM), + ("/v1/responses",), +) VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT: Final = CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT.model_copy( update={ "name": "vertex_ai/claude-opus-5-5-messages_input_text", @@ -3915,7 +4038,9 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( VERTEX_AI_CLAUDE_OPUS_5_5_INPUT_TEXT, VERTEX_AI_CLAUDE_OPUS_5_5_STREAM, VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT, + VERTEX_AI_CLAUDE_FABLE_5_2_INPUT_TEXT, VERTEX_AI_CLAUDE_FABLE_5_1_STREAM, + VERTEX_AI_CLAUDE_FABLE_5_2_STREAM, VERTEX_AI_CLAUDE_SONNET_5_5_CACHE_READ, VERTEX_AI_CLAUDE_SONNET_5_5_CACHE_WRITE_5M, VERTEX_AI_CLAUDE_SONNET_5_5_CACHE_WRITE_1H, @@ -3927,10 +4052,15 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( VERTEX_AI_CLAUDE_OPUS_5_5_THINKING, VERTEX_AI_CLAUDE_OPUS_5_5_TOOL_CALL, VERTEX_AI_CLAUDE_FABLE_5_1_CACHE_READ, + VERTEX_AI_CLAUDE_FABLE_5_2_CACHE_READ, VERTEX_AI_CLAUDE_FABLE_5_1_CACHE_WRITE_5M, + VERTEX_AI_CLAUDE_FABLE_5_2_CACHE_WRITE_5M, VERTEX_AI_CLAUDE_FABLE_5_1_CACHE_WRITE_1H, + VERTEX_AI_CLAUDE_FABLE_5_2_CACHE_WRITE_1H, VERTEX_AI_CLAUDE_FABLE_5_1_THINKING, + VERTEX_AI_CLAUDE_FABLE_5_2_THINKING, VERTEX_AI_CLAUDE_FABLE_5_1_TOOL_CALL, + VERTEX_AI_CLAUDE_FABLE_5_2_TOOL_CALL, VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT, VERTEX_AI_CLAUDE_HAIKU_5_5_STREAM, VERTEX_AI_CLAUDE_HAIKU_5_5_CACHE_READ, @@ -3944,8 +4074,11 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = ( VERTEX_AI_CLAUDE_HAIKU_5_5_MESSAGES_STREAM, *(pair.bridged for pair in VERTEX_AI_CLAUDE_HAIKU_5_5_BRIDGE_PAIRS), VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, + VERTEX_AI_CLAUDE_FABLE_5_2_MESSAGES_INPUT_TEXT, VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM, + VERTEX_AI_CLAUDE_FABLE_5_2_MESSAGES_STREAM, *(pair.bridged for pair in VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS), + *(pair.bridged for pair in VERTEX_AI_CLAUDE_FABLE_5_2_BRIDGE_PAIRS), VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT, VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_STREAM, *(pair.bridged for pair in VERTEX_AI_CLAUDE_OPUS_5_5_BRIDGE_PAIRS), @@ -3995,6 +4128,7 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( ), StreamParityTestCase(plain=VERTEX_AI_CLAUDE_OPUS_5_5_INPUT_TEXT, streamed=VERTEX_AI_CLAUDE_OPUS_5_5_STREAM), StreamParityTestCase(plain=VERTEX_AI_CLAUDE_FABLE_5_1_INPUT_TEXT, streamed=VERTEX_AI_CLAUDE_FABLE_5_1_STREAM), + StreamParityTestCase(plain=VERTEX_AI_CLAUDE_FABLE_5_2_INPUT_TEXT, streamed=VERTEX_AI_CLAUDE_FABLE_5_2_STREAM), StreamParityTestCase( plain=VERTEX_AI_CLAUDE_HAIKU_5_5_INPUT_TEXT, streamed=VERTEX_AI_CLAUDE_HAIKU_5_5_STREAM ), @@ -4010,10 +4144,18 @@ PARITY: Final[tuple[StreamParityTestCase, ...]] = ( plain=VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_INPUT_TEXT, streamed=VERTEX_AI_CLAUDE_FABLE_5_1_MESSAGES_STREAM, ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_FABLE_5_2_MESSAGES_INPUT_TEXT, + streamed=VERTEX_AI_CLAUDE_FABLE_5_2_MESSAGES_STREAM, + ), StreamParityTestCase( plain=VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS[0].bridged, streamed=VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS[1].bridged, ), + StreamParityTestCase( + plain=VERTEX_AI_CLAUDE_FABLE_5_2_BRIDGE_PAIRS[0].bridged, + streamed=VERTEX_AI_CLAUDE_FABLE_5_2_BRIDGE_PAIRS[1].bridged, + ), StreamParityTestCase( plain=VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_INPUT_TEXT, streamed=VERTEX_AI_CLAUDE_OPUS_5_5_MESSAGES_STREAM, @@ -4068,6 +4210,7 @@ BRIDGE_COST_PARITY: Final[tuple[BridgeCostParityTestCase, ...]] = ( *GEMINI_4_ARGON_ROUTE_BRIDGES, *VERTEX_AI_CLAUDE_HAIKU_5_5_BRIDGE_PAIRS, *VERTEX_AI_CLAUDE_FABLE_5_1_BRIDGE_PAIRS, + *VERTEX_AI_CLAUDE_FABLE_5_2_BRIDGE_PAIRS, *VERTEX_AI_CLAUDE_OPUS_5_5_BRIDGE_PAIRS, *VERTEX_AI_CLAUDE_SONNET_5_5_BRIDGE_PAIRS, ) diff --git a/tests/integration/cost_calculation/cost_map.py b/tests/integration/cost_calculation/cost_map.py index 37dc86e2601..4f87caf7854 100644 --- a/tests/integration/cost_calculation/cost_map.py +++ b/tests/integration/cost_calculation/cost_map.py @@ -906,6 +906,28 @@ COST_MAP_ENTRIES: Final[dict[str, dict[str, JsonValue]]] = { "max_output_tokens": 128000, "max_tokens": 128000, }, + "vertex_ai/claude-fable-5-2": { + "litellm_provider": "vertex_ai-anthropic_models", + "mode": "chat", + "input_cost_per_token": 1.52e-05, + "input_cost_per_token_batches": 7.6e-06, + "output_cost_per_token": 7.6e-05, + "output_cost_per_token_batches": 3.8e-05, + "cache_creation_input_token_cost": 1.9e-05, + "cache_creation_input_token_cost_above_1hr": 3.04e-05, + "cache_read_input_token_cost": 3.8e-07, + "search_context_cost_per_query": { + "search_context_size_high": 0.019, + "search_context_size_low": 0.019, + "search_context_size_medium": 0.019, + }, + "supports_function_calling": True, + "supports_prompt_caching": True, + "supports_reasoning": True, + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + }, "vertex_ai/claude-haiku-5-5": { "litellm_provider": "vertex_ai-anthropic_models", "mode": "chat", @@ -1494,6 +1516,19 @@ COST_MAP_ENTRIES: Final[dict[str, dict[str, JsonValue]]] = { "output_cost_per_token": 9e-05, "supports_function_calling": True, }, + "us.anthropic.claude-fable-5-2": { + "cache_creation_input_token_cost": 1.8e-05, + "cache_creation_input_token_cost_above_1hr": 2.88e-05, + "cache_read_input_token_cost": 1.44e-06, + "input_cost_per_token": 1.44e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-05, + "supports_function_calling": True, + }, "gpt-6-sol": { "cache_read_input_token_cost": 2.2e-07, "input_cost_per_token": 2.2e-06, diff --git a/tests/integration/providers/test_bedrock_converse_fable_capability_flags_wire.py b/tests/integration/providers/test_bedrock_converse_fable_capability_flags_wire.py new file mode 100644 index 00000000000..815c5ba750f --- /dev/null +++ b/tests/integration/providers/test_bedrock_converse_fable_capability_flags_wire.py @@ -0,0 +1,234 @@ +"""us.anthropic.claude-fable-5-2 on Bedrock Converse: the row's capability flags shape the Converse body. + +Fable rejects sampling params and forced tool choice and always thinks, so the proxy must strip +or refuse those before the request reaches Bedrock, the same way it does on the Anthropic API. +""" + +import json +from collections.abc import Callable +from typing import Final + +from integration._support.client import Gateway, Scenario +from integration._support.wire import Reply, Request, wire_server +from pydantic import JsonValue, TypeAdapter + +_MODEL_ID: Final = "us.anthropic.claude-fable-5-2" +_TARGET: Final = f"/model/{_MODEL_ID}/converse" +_TOKEN: Final = "synthetic-bedrock-bearer" +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_FORCED_TOOL_CHOICES: Final[tuple[JsonValue, ...]] = ("required", {"type": "function", "function": {"name": "lookup"}}) +_TOOLS: Final[list[JsonValue]] = [ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Look up a synthetic record.", + "parameters": { + "type": "object", + "properties": {"record_id": {"type": "string"}}, + "required": ["record_id"], + }, + }, + } +] + + +def _reply(usage: dict[str, JsonValue] | None = None) -> bytes: + return json.dumps( + { + "output": { + "message": { + "role": "assistant", + "content": [ + { + "reasoningContent": { + "reasoningText": {"text": "scripted thought", "signature": "scripted-signature"} + } + }, + {"text": "done"}, + ], + } + }, + "stopReason": "end_turn", + "usage": usage or {"inputTokens": 12, "outputTokens": 3, "totalTokens": 15}, + "metrics": {"latencyMs": 1}, + } + ).encode() + + +def _deployment(scenario: Scenario, api_base: str, *, drop_params: bool = False) -> str: + if drop_params: + return scenario.model( + model=f"bedrock/converse/{_MODEL_ID}", + api_key=_TOKEN, + aws_region_name="us-east-1", + aws_bedrock_runtime_endpoint=api_base, + num_retries=0, + drop_params=True, + ) + return scenario.model( + model=f"bedrock/converse/{_MODEL_ID}", + api_key=_TOKEN, + aws_region_name="us-east-1", + aws_bedrock_runtime_endpoint=api_base, + num_retries=0, + ) + + +def _peer(on_body: Callable[[dict[str, JsonValue]], bytes]) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + assert request.method == "POST" and request.target == _TARGET, (request.method, request.target) + assert request.headers["authorization"] == f"Bearer {_TOKEN}", request.headers + return Reply(body=on_body(_JSON_OBJECT.validate_json(request.body))) + + return respond + + +def _chat_body(model: str, **extra: JsonValue) -> dict[str, JsonValue]: + return { + "model": model, + "messages": [{"role": "user", "content": "synthetic fable request"}], + "max_tokens": 64, + **extra, + } + + +def test_drop_params_removes_temperature_and_top_p_before_bedrock(gateway: Gateway) -> None: + def on_body(body: dict[str, JsonValue]) -> bytes: + assert body["inferenceConfig"] == {"maxTokens": 64}, body + return _reply() + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url, drop_params=True) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, temperature=0.2, top_p=0.9)) + assert response.status_code == 200, response.text + assert response.json()["choices"][0]["message"]["content"] == "done", response.text + assert len(wire.drain()) == 1 + + +def test_sampling_params_are_refused_before_reaching_bedrock_without_drop_params(gateway: Gateway) -> None: + def on_body(body: dict[str, JsonValue]) -> bytes: + raise AssertionError(f"temperature must not reach Bedrock: {body}") + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, temperature=0.2)) + assert response.status_code == 400, response.text + assert "temperature" in response.text and "drop_params" in response.text, response.text + assert len(wire.drain()) == 0 + + +def test_drop_params_downgrades_forced_and_named_tool_choice_to_auto(gateway: Gateway) -> None: + def on_body(body: dict[str, JsonValue]) -> bytes: + tool_config: Final = body["toolConfig"] + assert isinstance(tool_config, dict), body + assert tool_config.get("toolChoice") in (None, {"auto": {}}), body + tools: Final = tool_config["tools"] + assert isinstance(tools, list) and len(tools) == 1, body + return _reply() + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url, drop_params=True) + responses: Final = tuple( + gateway.request("POST", "/v1/chat/completions", _chat_body(model, tools=_TOOLS, tool_choice=tool_choice)) + for tool_choice in _FORCED_TOOL_CHOICES + ) + assert [response.status_code for response in responses] == [200, 200], [response.text for response in responses] + assert len(wire.drain()) == 2 + + +def test_forced_tool_choice_is_refused_before_reaching_bedrock_without_drop_params(gateway: Gateway) -> None: + def on_body(body: dict[str, JsonValue]) -> bytes: + raise AssertionError(f"forced tool_choice must not reach Bedrock: {body}") + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request( + "POST", "/v1/chat/completions", _chat_body(model, tools=_TOOLS, tool_choice="required") + ) + assert response.status_code == 400, response.text + assert "tool_choice" in response.text, response.text + assert len(wire.drain()) == 0 + + +def test_thinking_disabled_is_dropped_for_an_always_on_model(gateway: Gateway) -> None: + def on_body(body: dict[str, JsonValue]) -> bytes: + assert "thinking" not in json.dumps(body.get("additionalModelRequestFields", {})), body + return _reply() + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request( + "POST", "/v1/chat/completions", _chat_body(model, thinking={"type": "disabled"}) + ) + assert response.status_code == 200, response.text + assert response.json()["choices"][0]["message"]["reasoning_content"] == "scripted thought", response.text + assert len(wire.drain()) == 1 + + +def test_reasoning_effort_becomes_adaptive_thinking_with_output_config_effort(gateway: Gateway) -> None: + def on_body(body: dict[str, JsonValue]) -> bytes: + extra: Final = body["additionalModelRequestFields"] + assert isinstance(extra, dict), body + assert extra["thinking"] == {"type": "adaptive", "display": "summarized"}, body + assert extra["output_config"] == {"effort": "max"}, body + assert "budget_tokens" not in json.dumps(body), body + return _reply() + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, reasoning_effort="max")) + assert response.status_code == 200, response.text + assert len(wire.drain()) == 1 + + +def test_one_hour_cache_point_reaches_bedrock_and_its_usage_comes_back(gateway: Gateway) -> None: + usage: Final[dict[str, JsonValue]] = { + "inputTokens": 9, + "outputTokens": 3, + "totalTokens": 7237, + "cacheReadInputTokens": 0, + "cacheWriteInputTokens": 7225, + "cacheDetails": [{"ttl": "1h", "inputTokens": 7225}], + } + + def on_body(body: dict[str, JsonValue]) -> bytes: + assert body["system"] == [ + {"text": "synthetic cached system prompt"}, + {"cachePoint": {"type": "default", "ttl": "1h"}}, + ], body + return _reply(usage) + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + _chat_body( + model, + messages=[ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "synthetic cached system prompt", + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ], + }, + {"role": "user", "content": "synthetic fable request"}, + ], + ), + ) + assert response.status_code == 200, response.text + usage_out: Final = _JSON_OBJECT.validate_json(response.text)["usage"] + assert isinstance(usage_out, dict), response.text + prompt_details: Final = usage_out["prompt_tokens_details"] + assert isinstance(prompt_details, dict), response.text + assert prompt_details["cache_creation_token_details"] == { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 7225, + }, response.text + assert usage_out["cache_creation_input_tokens"] == 7225, response.text + assert len(wire.drain()) == 1 diff --git a/tests/integration/providers/test_vertex_anthropic_fable_capability_flags_wire.py b/tests/integration/providers/test_vertex_anthropic_fable_capability_flags_wire.py new file mode 100644 index 00000000000..8376cd92de7 --- /dev/null +++ b/tests/integration/providers/test_vertex_anthropic_fable_capability_flags_wire.py @@ -0,0 +1,242 @@ +"""claude-fable-5-2 on Vertex AI: the row's capability flags shape the rawPredict body the proxy sends. + +Fable rejects sampling params and forced tool choice and always thinks, so the proxy must strip +or refuse those before the request reaches Vertex, the same way it does on the Anthropic API. +""" + +import json +import uuid +from collections.abc import Callable +from typing import Final +from urllib.parse import parse_qs + +from integration._support.client import Gateway, Scenario +from integration._support.vertex import service_account_json +from integration._support.wire import Reply, Request, wire_server +from pydantic import JsonValue, TypeAdapter + +_MODEL: Final = "claude-fable-5-2" +_PROJECT: Final = "scripted-project" +_LOCATION: Final = "global" +_TARGET: Final = f"/v1/projects/{_PROJECT}/locations/{_LOCATION}/publishers/anthropic/models/{_MODEL}:rawPredict" +_TOKEN: Final = "scripted-vertex-access-token" +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_FORCED_TOOL_CHOICES: Final[tuple[JsonValue, ...]] = ("required", {"type": "function", "function": {"name": "lookup"}}) +_TOOLS: Final[list[JsonValue]] = [ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Look up a synthetic record.", + "parameters": { + "type": "object", + "properties": {"record_id": {"type": "string"}}, + "required": ["record_id"], + }, + }, + } +] + + +def _reply(identity: str, usage: dict[str, JsonValue] | None = None) -> bytes: + return json.dumps( + { + "id": identity, + "type": "message", + "role": "assistant", + "model": _MODEL, + "content": [ + {"type": "thinking", "thinking": "scripted thought", "signature": "scripted-signature"}, + {"type": "text", "text": "done"}, + ], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": usage or {"input_tokens": 12, "output_tokens": 3}, + } + ).encode() + + +def _deployment(scenario: Scenario, api_base: str, *, drop_params: bool = False) -> str: + credentials: Final = service_account_json(_PROJECT, api_base) + if drop_params: + return scenario.model( + model=f"vertex_ai/{_MODEL}", + api_base=api_base, + api_key=None, + vertex_project=_PROJECT, + vertex_location=_LOCATION, + vertex_credentials=credentials, + num_retries=0, + drop_params=True, + ) + return scenario.model( + model=f"vertex_ai/{_MODEL}", + api_base=api_base, + api_key=None, + vertex_project=_PROJECT, + vertex_location=_LOCATION, + vertex_credentials=credentials, + num_retries=0, + ) + + +def _peer(on_body: Callable[[dict[str, JsonValue]], bytes]) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + if request.target == "/_oauth/token": + form: Final = parse_qs(request.body.decode()) + assert form["grant_type"] == ["urn:ietf:params:oauth:grant-type:jwt-bearer"], form + return Reply(body=json.dumps({"access_token": _TOKEN, "token_type": "Bearer", "expires_in": 3600}).encode()) + assert request.method == "POST" and request.target == _TARGET, (request.method, request.target) + assert request.headers["authorization"] == f"Bearer {_TOKEN}", request.headers + body: Final = _JSON_OBJECT.validate_json(request.body) + assert body["anthropic_version"] == "vertex-2023-10-16", body + return Reply(body=on_body(body)) + + return respond + + +def _chat_body(model: str, **extra: JsonValue) -> dict[str, JsonValue]: + return { + "model": model, + "messages": [{"role": "user", "content": "synthetic fable request"}], + "max_tokens": 64, + **extra, + } + + +def _model_calls(requests: tuple[Request, ...]) -> int: + return sum(request.target == _TARGET for request in requests) + + +def test_drop_params_removes_temperature_and_top_p_before_vertex(gateway: Gateway) -> None: + identity: Final = f"msg_{uuid.uuid4().hex}" + + def on_body(body: dict[str, JsonValue]) -> bytes: + assert "temperature" not in body and "top_p" not in body, body + assert body["max_tokens"] == 64, body + return _reply(identity) + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url, drop_params=True) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, temperature=0.2, top_p=0.9)) + assert response.status_code == 200, response.text + assert response.json()["choices"][0]["message"]["content"] == "done", response.text + assert _model_calls(wire.drain()) == 1 + + +def test_sampling_params_are_refused_before_reaching_vertex_without_drop_params(gateway: Gateway) -> None: + def on_body(body: dict[str, JsonValue]) -> bytes: + raise AssertionError(f"temperature must not reach Vertex: {body}") + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, temperature=0.2)) + assert response.status_code == 400, response.text + assert "temperature" in response.text and "drop_params" in response.text, response.text + assert _model_calls(wire.drain()) == 0 + + +def test_drop_params_downgrades_forced_and_named_tool_choice_to_auto(gateway: Gateway) -> None: + identity: Final = f"msg_{uuid.uuid4().hex}" + + def on_body(body: dict[str, JsonValue]) -> bytes: + assert body["tool_choice"] == {"type": "auto"}, body + tools: Final = body["tools"] + assert isinstance(tools, list) and [tool["name"] for tool in tools if isinstance(tool, dict)] == ["lookup"], ( + body + ) + return _reply(identity) + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url, drop_params=True) + responses: Final = tuple( + gateway.request("POST", "/v1/chat/completions", _chat_body(model, tools=_TOOLS, tool_choice=tool_choice)) + for tool_choice in _FORCED_TOOL_CHOICES + ) + assert [response.status_code for response in responses] == [200, 200], [response.text for response in responses] + assert _model_calls(wire.drain()) == 2 + + +def test_forced_tool_choice_is_refused_before_reaching_vertex_without_drop_params(gateway: Gateway) -> None: + def on_body(body: dict[str, JsonValue]) -> bytes: + raise AssertionError(f"forced tool_choice must not reach Vertex: {body}") + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request( + "POST", "/v1/chat/completions", _chat_body(model, tools=_TOOLS, tool_choice="required") + ) + assert response.status_code == 400, response.text + assert "tool_choice" in response.text, response.text + assert _model_calls(wire.drain()) == 0 + + +def test_thinking_disabled_is_dropped_for_an_always_on_model(gateway: Gateway) -> None: + identity: Final = f"msg_{uuid.uuid4().hex}" + + def on_body(body: dict[str, JsonValue]) -> bytes: + assert "thinking" not in body, body + return _reply(identity) + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request( + "POST", "/v1/chat/completions", _chat_body(model, thinking={"type": "disabled"}) + ) + assert response.status_code == 200, response.text + assert response.json()["choices"][0]["message"]["reasoning_content"] == "scripted thought", response.text + assert _model_calls(wire.drain()) == 1 + + +def test_reasoning_effort_becomes_adaptive_thinking_with_output_config_effort(gateway: Gateway) -> None: + identity: Final = f"msg_{uuid.uuid4().hex}" + + def on_body(body: dict[str, JsonValue]) -> bytes: + assert body["thinking"] == {"type": "adaptive", "display": "summarized"}, body + assert body["output_config"] == {"effort": "max"}, body + assert "budget_tokens" not in json.dumps(body), body + return _reply(identity) + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, reasoning_effort="max")) + assert response.status_code == 200, response.text + assert _model_calls(wire.drain()) == 1 + + +def test_messages_one_hour_cache_control_reaches_vertex_and_its_usage_comes_back(gateway: Gateway) -> None: + identity: Final = f"msg_{uuid.uuid4().hex}" + system: Final[list[JsonValue]] = [ + {"type": "text", "text": "synthetic cached system prompt", "cache_control": {"type": "ephemeral", "ttl": "1h"}} + ] + usage: Final[dict[str, JsonValue]] = { + "input_tokens": 9, + "output_tokens": 3, + "cache_creation_input_tokens": 7225, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 7225}, + } + + def on_body(body: dict[str, JsonValue]) -> bytes: + assert body["system"] == system, body + return _reply(identity, usage) + + with wire_server(_peer(on_body)) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire.url) + response: Final = gateway.request( + "POST", + "/v1/messages", + { + "model": model, + "max_tokens": 64, + "system": system, + "messages": [{"role": "user", "content": "synthetic fable request"}], + }, + ) + assert response.status_code == 200, response.text + assert response.json()["usage"]["cache_creation"] == { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 7225, + }, response.text + assert response.json()["usage"]["cache_creation_input_tokens"] == 7225, response.text + assert _model_calls(wire.drain()) == 1 diff --git a/tests/unit/test_claude_fable_5_config.py b/tests/unit/test_claude_fable_5_config.py index c9e46dd6887..2157364efb3 100644 --- a/tests/unit/test_claude_fable_5_config.py +++ b/tests/unit/test_claude_fable_5_config.py @@ -98,6 +98,13 @@ def test_adaptive_thinking_detected_for_fable_5_1(local_model_cost_map, model): FABLE_5_2_VARIANTS = ( "claude-fable-5-2", + "anthropic.claude-fable-5-2", + "global.anthropic.claude-fable-5-2", + "us.anthropic.claude-fable-5-2", + "eu.anthropic.claude-fable-5-2", + "us-gov.anthropic.claude-fable-5-2", + "bedrock/us-gov-west-1/anthropic.claude-fable-5-2", + "bedrock/us-gov-east-1/anthropic.claude-fable-5-2", "vertex_ai/claude-fable-5-2", "vertex_ai/claude-fable-5-2@default", "bedrock/claude_platform/claude-fable-5-2", @@ -118,6 +125,11 @@ def test_fable_5_2_present_in_bundled_backup() -> None: "claude-fable-5-2", "anthropic/claude-fable-5-2", "claude-fable-latest", + "anthropic.claude-fable-5-2", + "bedrock/us.anthropic.claude-fable-5-2", + "bedrock/invoke/eu.anthropic.claude-fable-5-2", + "bedrock/global.anthropic.claude-fable-5-2", + "bedrock/claude-fable-latest", "vertex_ai/claude-fable-5-2", "vertex_ai/claude-fable-5-2@default", "bedrock/claude_platform/claude-fable-5-2", @@ -129,6 +141,10 @@ def test_adaptive_thinking_detected_for_fable_5_2(local_model_cost_map, model: s assert AnthropicModelInfo.is_adaptive_thinking_model(model, "anthropic") is True +def test_fable_5_2_registered_for_bedrock_converse() -> None: + assert "anthropic.claude-fable-5-2" in BEDROCK_CONVERSE_MODELS + + def test_fable_5_2_fast_mode_doubles_every_token_price_on_the_anthropic_api(local_model_cost_map) -> None: """Anthropic bills fast mode at 2x the standard rate on every token type (platform.claude.com/docs/en/about-claude/pricing, read 2026-11-09), and the