fix(vertex_ai): bill Claude web search per request on deployments priced in model_info (#49160)
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Cost map pins recheck / rerun-cost-map-guard (push) Waiting to run
Deploy main to gateway-dev / dispatch (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust DB / db-queries (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
unit / rust-bridge (push) Waiting to run
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.0.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.25.0 compatibility (push) Blocked by required conditions
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run

* fix(vertex_ai): bill Claude web search per request on deployments priced in model_info

A vertex_ai deployment priced in its model_info is registered under its hashed id, so the
web search cost check on the model key never saw "claude" and billed Claude searches with the
Gemini grounding calculator: one search per call, or Gemini's $0.035 default with no search
price. The check now also reads the model the response names.

* fix(vertex_ai): bill token-priced Claude deployments at the served model's search price

A Vertex Claude deployment priced in model_info without a search price now bills each web search at the served model's cost map price, read at cost time and never written into the deployment entry. An explicit search price, zero included, still wins.

* fix(vertex_ai): price web search on the Vertex Claude Sonnet 4.5 and Haiku 4.5 rows

Google bills Claude web search at $10 per 1,000 searches on these models, so token-priced deployments of them no longer bill searches at 0

* fix(cost): bill served-model search price on token-only Anthropic and Azure AI Claude deployments

Token-only Claude deployments on anthropic and azure_ai billed web search at 0 because the deployment entry has no search price. They now fall back to the served model's cost map search price, as vertex_ai already does. Vertex routing reads the served model first, so a Gemini deployment whose id contains claude keeps per-prompt grounding

* fix(proxy): price streamed usage frames under the deployment's model so usage.cost matches the spend row

* test(proxy): give the Vertex streamRawPredict logging doubles the request's model, as a real pass-through Logging has

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
simonp9 2026-10-30 23:14:51 -07:00 • committed by GitHub
parent d1d6d55063
commit a392a39627
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 566 additions and 10 deletions

View file

@ -208,11 +208,14 @@ class StandardBuiltInToolCostTracking:
usage=usage, response_object=response_object
)
response_model: Final = _output_item_field(response_object, "model")
if model_info is not None and resolved_usage is not None and resolved_provider is not None:
result: Final = get_cost_for_web_search_request(
custom_llm_provider=resolved_provider,
usage=resolved_usage,
model_info=model_info,
response_model=response_model if isinstance(response_model, str) else None,
lookup_model_info=StandardBuiltInToolCostTracking._safe_get_model_info,
)
if result is not None:
return result

View file

@ -9,6 +9,8 @@ from litellm.types.utils import CallTypes
from . import *
if TYPE_CHECKING:
from collections.abc import Callable
from litellm.llms.base_llm.guardrail_translation.base_translation import (
BaseTranslation,
)
@ -30,7 +32,29 @@ def get_cost_for_google_maps_grounding_request(
return cost_per_google_maps_grounding_request(usage=usage, model_info=model_info)
def get_cost_for_web_search_request(custom_llm_provider: str, usage: "Usage", model_info: "ModelInfo") -> float | None:
def _with_served_model_search_pricing(
model_info: "ModelInfo",
response_model: str | None,
served_model_provider: str,
lookup_model_info: "Callable[[str, str | None], ModelInfo | None] | None",
) -> "ModelInfo":
if model_info.get("search_context_cost_per_query") is not None or response_model is None:
return model_info
served_model_info: Final = (
lookup_model_info(response_model, served_model_provider) if lookup_model_info is not None else None
)
if served_model_info is None:
return model_info
return {**model_info, "search_context_cost_per_query": served_model_info.get("search_context_cost_per_query")}
def get_cost_for_web_search_request(
custom_llm_provider: str,
usage: "Usage",
model_info: "ModelInfo",
response_model: str | None = None,
lookup_model_info: "Callable[[str, str | None], ModelInfo | None] | None" = None,
) -> float | None:
"""
Get the cost for a web search request for a given model.
@ -38,6 +62,8 @@ def get_cost_for_web_search_request(custom_llm_provider: str, usage: "Usage", mo
custom_llm_provider: The custom LLM provider.
usage: The usage object.
model_info: The model info.
response_model: The model the provider response names.
lookup_model_info: Looks up a model's cost map entry, returning None when it is not mapped.
"""
if custom_llm_provider == "gemini":
from .gemini.cost_calculator import cost_per_web_search_request
@ -46,17 +72,25 @@ def get_cost_for_web_search_request(custom_llm_provider: str, usage: "Usage", mo
elif custom_llm_provider == "anthropic":
from .anthropic.cost_calculation import get_cost_for_anthropic_web_search
return get_cost_for_anthropic_web_search(model_info=model_info, usage=usage)
return get_cost_for_anthropic_web_search(
model_info=_with_served_model_search_pricing(model_info, response_model, "anthropic", lookup_model_info),
usage=usage,
)
elif custom_llm_provider.startswith("vertex_ai"):
# Anthropic Claude models on Vertex AI populate server_tool_use.web_search_requests
# (same as the direct Anthropic API), not prompt_tokens_details.web_search_requests
# (which is the Gemini field). Route claude-* models to the Anthropic calculator.
model_key: Final[str] = model_info.get("key", "") if model_info else ""
if "claude" in model_key.lower():
if "claude" in (response_model or model_key).lower():
from .anthropic.cost_calculation import get_cost_for_anthropic_web_search
verbose_logger.debug("vertex_ai/claude model detected — routing web search cost to Anthropic calculator")
return get_cost_for_anthropic_web_search(model_info=model_info, usage=usage)
return get_cost_for_anthropic_web_search(
model_info=_with_served_model_search_pricing(
model_info, response_model, "vertex_ai", lookup_model_info
),
usage=usage,
)
from .vertex_ai.gemini.cost_calculator import (
cost_per_web_search_request as cost_per_web_search_request_vertex_ai,
@ -71,7 +105,10 @@ def get_cost_for_web_search_request(custom_llm_provider: str, usage: "Usage", mo
return None
from .anthropic.cost_calculation import get_cost_for_anthropic_web_search
return get_cost_for_anthropic_web_search(model_info=model_info, usage=usage)
return get_cost_for_anthropic_web_search(
model_info=_with_served_model_search_pricing(model_info, response_model, "azure_ai", lookup_model_info),
usage=usage,
)
elif custom_llm_provider == "perplexity":
# Perplexity handles search costs internally in its own cost calculator
# Return 0.0 to indicate costs are already accounted for

View file

@ -51387,6 +51387,11 @@
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"output_cost_per_token": 5e-06,
"output_cost_per_token_batches": 2.5e-06,
"regional_endpoint_uplift_multiplier": 1.1,
@ -51415,6 +51420,11 @@
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"output_cost_per_token": 5e-06,
"output_cost_per_token_batches": 2.5e-06,
"regional_endpoint_uplift_multiplier": 1.1,
@ -52161,6 +52171,11 @@
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"output_cost_per_token": 1.5e-05,
"output_cost_per_token_batches": 7.5e-06,
"regional_endpoint_uplift_multiplier": 1.1,
@ -52266,6 +52281,11 @@
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"output_cost_per_token": 1.5e-05,
"output_cost_per_token_batches": 7.5e-06,
"regional_endpoint_uplift_multiplier": 1.1,

View file

@ -5031,8 +5031,9 @@ class ProxyBaseLLMRequestProcessing:
if stream_usage is None:
return None
service_tier: Final = obj.get("service_tier")
served_model: Final = litellm_logging_obj.model if litellm_logging_obj is not None else None
cost_val: Final = ProxyBaseLLMRequestProcessing._streamed_usage_cost(
ModelResponse(usage=stream_usage),
ModelResponse(model=served_model, usage=stream_usage),
model_name,
service_tier if isinstance(service_tier, str) else None,
litellm_logging_obj,

View file

@ -51387,6 +51387,11 @@
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"output_cost_per_token": 5e-06,
"output_cost_per_token_batches": 2.5e-06,
"regional_endpoint_uplift_multiplier": 1.1,
@ -51415,6 +51420,11 @@
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"output_cost_per_token": 5e-06,
"output_cost_per_token_batches": 2.5e-06,
"regional_endpoint_uplift_multiplier": 1.1,
@ -52161,6 +52171,11 @@
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"output_cost_per_token": 1.5e-05,
"output_cost_per_token_batches": 7.5e-06,
"regional_endpoint_uplift_multiplier": 1.1,
@ -52266,6 +52281,11 @@
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"output_cost_per_token": 1.5e-05,
"output_cost_per_token_batches": 7.5e-06,
"regional_endpoint_uplift_multiplier": 1.1,

View file

@ -3,7 +3,7 @@
PARITY pairs the streamed case with its plain twin where both bill the same row."""
import json
from typing import Final, cast
from typing import Final, Literal, cast
from pydantic import JsonValue
@ -41,6 +41,7 @@ from integration.cost_calculation.cases.anthropic import (
from integration.cost_calculation.bridge_cost_parity.case import BridgeCostParityTestCase, bridge_pairs
from integration.cost_calculation.cost_tracking_case import (
CostTrackingTestCase,
Deployment,
ExactExpected,
JsonResponse,
RecountExpected,
@ -3387,6 +3388,232 @@ VERTEX_AI_CLAUDE_HAIKU_5_5_BRIDGE_PAIRS: Final = bridge_pairs(
)
VERTEX_AI_CLAUDE_DEPLOYMENT_RATES: Final = Deployment(
input_cost_per_token=1e-06, output_cost_per_token=5e-06, search_cost_per_query=0.01
)
def _vertex_claude_web_search_message(web_search_requests: int) -> JsonResponse:
return JsonResponse(
content_type="application/json",
body={
"id": "msg_$REQUEST_ID",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5-5",
"content": [{"type": "text", "text": "scripted answer for cost fixtures"}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 1840,
"output_tokens": 412,
"server_tool_use": {"web_search_requests": web_search_requests},
},
},
)
def _vertex_claude_web_search_stream(web_search_requests: int) -> SseResponse:
return SseResponse(
content_type="text/event-stream",
frames=sse_frames(
{
"type": "message_start",
"message": {
"id": "msg_$REQUEST_ID",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5-5",
"content": [],
"stop_reason": None,
"usage": {"input_tokens": 1840},
},
},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "scripted answer for cost fixtures"},
},
{"type": "content_block_stop", "index": 0},
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn"},
"usage": {"output_tokens": 412, "server_tool_use": {"web_search_requests": web_search_requests}},
},
{"type": "message_stop"},
),
)
def _vertex_claude_deployment_web_search_case(
name: str,
endpoint: Literal["/v1/chat/completions", "/v1/responses", "/v1/messages"],
request: dict[str, JsonValue],
response: JsonResponse | SseResponse,
tool_usage_cost: float,
deployment: Deployment = VERTEX_AI_CLAUDE_DEPLOYMENT_RATES,
) -> CostTrackingTestCase:
return CostTrackingTestCase(
name=name,
covers="quota_management.spend_tracking.cost_matrix.logs_cost",
model="vertex_ai/claude-sonnet-5-5",
endpoint=endpoint,
deployment=deployment,
request={"model": "$MODEL", **request},
response=response,
expected=ExactExpected(
spend=round(0.0039 + tool_usage_cost, 10),
input_cost=0.00184,
output_cost=0.00206,
prompt_tokens=1840,
completion_tokens=412,
tool_usage_cost=tool_usage_cost,
),
)
VERTEX_AI_CLAUDE_WEB_SEARCH_TOOL: Final[dict[str, JsonValue]] = {
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 3,
}
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_MESSAGES_WEB_SEARCH: Final = _vertex_claude_deployment_web_search_case(
"vertex_ai/claude-sonnet-5-5-deployment_rates_messages_web_search_two_requests",
"/v1/messages",
{
"max_tokens": 412,
"messages": [{"role": "user", "content": "vertex claude deployment rates messages web search"}],
"tools": [VERTEX_AI_CLAUDE_WEB_SEARCH_TOOL],
},
_vertex_claude_web_search_message(2),
0.02,
)
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_MESSAGES_WEB_SEARCH_STREAM: Final = _vertex_claude_deployment_web_search_case(
"vertex_ai/claude-sonnet-5-5-deployment_rates_messages_web_search_two_requests_stream",
"/v1/messages",
{
"max_tokens": 412,
"messages": [{"role": "user", "content": "vertex claude deployment rates messages web search stream"}],
"stream": True,
"tools": [VERTEX_AI_CLAUDE_WEB_SEARCH_TOOL],
},
_vertex_claude_web_search_stream(2),
0.02,
)
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_MESSAGES_WEB_SEARCH_ZERO_RATE: Final = (
_vertex_claude_deployment_web_search_case(
"vertex_ai/claude-sonnet-5-5-deployment_zero_search_rate_messages_two_requests",
"/v1/messages",
{
"max_tokens": 412,
"messages": [{"role": "user", "content": "vertex claude deployment zero search rate messages"}],
"tools": [VERTEX_AI_CLAUDE_WEB_SEARCH_TOOL],
},
_vertex_claude_web_search_message(2),
0.0,
Deployment(input_cost_per_token=1e-06, output_cost_per_token=5e-06, search_cost_per_query=0.0),
)
)
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_MESSAGES_WEB_SEARCH_TOKEN_RATES_ONLY: Final = (
_vertex_claude_deployment_web_search_case(
"vertex_ai/claude-sonnet-5-5-deployment_token_rates_only_messages_web_search_two_requests",
"/v1/messages",
{
"max_tokens": 412,
"messages": [{"role": "user", "content": "vertex claude deployment token rates only messages web search"}],
"tools": [VERTEX_AI_CLAUDE_WEB_SEARCH_TOOL],
},
_vertex_claude_web_search_message(2),
0.034,
Deployment(input_cost_per_token=1e-06, output_cost_per_token=5e-06),
)
)
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_CHAT_WEB_SEARCH: Final = _vertex_claude_deployment_web_search_case(
"vertex_ai/claude-sonnet-5-5-deployment_rates_chat_web_search_three_requests",
"/v1/chat/completions",
{
"messages": [{"role": "user", "content": "vertex claude deployment rates chat web search"}],
"tools": [VERTEX_AI_CLAUDE_WEB_SEARCH_TOOL],
"allowed_openai_params": ["web_search_options"],
},
_vertex_claude_web_search_message(3),
0.03,
)
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_CHAT_WEB_SEARCH_STREAM: Final = _vertex_claude_deployment_web_search_case(
"vertex_ai/claude-sonnet-5-5-deployment_rates_chat_web_search_two_requests_stream",
"/v1/chat/completions",
{
"messages": [{"role": "user", "content": "vertex claude deployment rates chat web search stream"}],
"stream": True,
"tools": [VERTEX_AI_CLAUDE_WEB_SEARCH_TOOL],
"allowed_openai_params": ["web_search_options"],
},
_vertex_claude_web_search_stream(2),
0.02,
)
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_RESPONSES_WEB_SEARCH: Final = _vertex_claude_deployment_web_search_case(
"vertex_ai/claude-sonnet-5-5-deployment_rates_responses_web_search_two_requests",
"/v1/responses",
{"input": "vertex claude deployment rates responses web search", "tools": [{"type": "web_search"}]},
_vertex_claude_web_search_message(2),
0.02,
)
GEMINI_3_1_PRO_PREVIEW_DEPLOYMENT_MESSAGES_WEB_SEARCH_STREAM: Final = CostTrackingTestCase(
name="vertex_ai/gemini-3.1-pro-preview-deployment_rates_messages_web_search_two_queries_stream",
covers="quota_management.spend_tracking.scripted_wire.logs_cost",
model="vertex_ai/gemini-3.1-pro-preview",
endpoint="/v1/messages",
deployment=Deployment(input_cost_per_token=1e-06, output_cost_per_token=5e-06, search_cost_per_query=0.03),
request={
"model": "$MODEL",
"max_tokens": 412,
"messages": [{"role": "user", "content": "vertex gemini deployment rates messages web search stream"}],
"stream": True,
"tools": [VERTEX_AI_CLAUDE_WEB_SEARCH_TOOL],
},
response=SseResponse(
content_type="text/event-stream",
frames=sse_frames(
{
"candidates": [
{
"content": {"parts": [{"text": "scripted answer for cost fixtures"}], "role": "model"},
"index": 0,
}
],
"modelVersion": "gemini-3.1-pro-preview",
},
{
"candidates": [
{
"content": {"parts": [{"text": ""}], "role": "model"},
"finishReason": "STOP",
"index": 0,
"groundingMetadata": {"webSearchQueries": ["query 0", "query 1"]},
}
],
"usageMetadata": {
"promptTokenCount": 1840,
"candidatesTokenCount": 412,
"totalTokenCount": 2252,
"promptTokensDetails": [{"modality": "TEXT", "tokenCount": 1840}],
},
"modelVersion": "gemini-3.1-pro-preview",
},
),
),
expected=ExactExpected(
spend=0.0339,
input_cost=0.00184,
output_cost=0.00206,
prompt_tokens=1840,
completion_tokens=412,
tool_usage_cost=0.03,
),
)
GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT: Final = _gemini_case(
GEMINI_3_8_FLASH_INPUT_TEXT, "vertex_ai/gemini-3.1-pro-preview-input_text", "vertex_ai/gemini-3.1-pro-preview",
ExactExpected(spend=0.0094864, input_cost=0.004048, output_cost=0.0054384, prompt_tokens=1840, completion_tokens=412),
@ -3725,6 +3952,14 @@ CASES: Final[tuple[CostTrackingTestCase, ...]] = (
VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_INPUT_TEXT,
VERTEX_AI_CLAUDE_SONNET_5_5_MESSAGES_STREAM,
*(pair.bridged for pair in VERTEX_AI_CLAUDE_SONNET_5_5_BRIDGE_PAIRS),
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_MESSAGES_WEB_SEARCH,
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_MESSAGES_WEB_SEARCH_STREAM,
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_MESSAGES_WEB_SEARCH_ZERO_RATE,
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_MESSAGES_WEB_SEARCH_TOKEN_RATES_ONLY,
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_CHAT_WEB_SEARCH,
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_CHAT_WEB_SEARCH_STREAM,
VERTEX_AI_CLAUDE_SONNET_5_5_DEPLOYMENT_RESPONSES_WEB_SEARCH,
GEMINI_3_1_PRO_PREVIEW_DEPLOYMENT_MESSAGES_WEB_SEARCH_STREAM,
GEMINI_3_1_PRO_PREVIEW_INPUT_TEXT,
GEMINI_3_1_PRO_PREVIEW_STREAM,
GEMINI_3_1_PRO_PREVIEW_CACHE_READ,

View file

@ -1,3 +1,4 @@
from collections.abc import Callable
from typing import Final
import pytest
@ -7,7 +8,7 @@ from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
StandardBuiltInToolCostTracking,
)
from litellm.types.llms.openai import FileSearchTool, ResponsesAPIResponse, WebSearchOptions
from litellm.types.utils import ModelResponse, StandardBuiltInToolsParams
from litellm.types.utils import ModelResponse, ServerToolUse, StandardBuiltInToolsParams, Usage
def test_web_search_cost_low():
@ -1052,6 +1053,175 @@ def test_azure_ai_non_claude_web_search_keeps_generic_fallback() -> None:
assert get_cost_for_web_search_request(custom_llm_provider="azure_ai", usage=usage, model_info=model_info) is None
def _register_priced_deployment(
model: str, search_cost_per_query: float | None, deployment_id: str | None = None
) -> str:
from litellm import Router
token_rates: Final = {"input_cost_per_token": 3e-6, "output_cost_per_token": 1.5e-5}
search_rates: Final = (
{}
if search_cost_per_query is None
else {
"search_context_cost_per_query": {
"search_context_size_low": search_cost_per_query,
"search_context_size_medium": search_cost_per_query,
"search_context_size_high": search_cost_per_query,
}
}
)
router: Final = Router(
model_list=[
{
"model_name": "priced-deployment",
"litellm_params": {"model": model, "api_key": "k", "vertex_project": "p", "vertex_location": "global"},
"model_info": {**token_rates, **search_rates, **({} if deployment_id is None else {"id": deployment_id})},
}
]
)
return router.model_list[0]["model_info"]["id"]
def _claude_chat_response(
web_search_requests: int, model: str = "claude-sonnet-5-5"
) -> tuple[object, Usage | None]:
usage: Final = Usage(
prompt_tokens=1840,
completion_tokens=412,
total_tokens=2252,
server_tool_use=ServerToolUse(web_search_requests=web_search_requests),
)
return ModelResponse(model=model, usage=usage), usage
def _claude_messages_response(
web_search_requests: int, model: str = "claude-sonnet-5-5"
) -> tuple[object, Usage | None]:
return {
"id": "msg_vertex_claude_web_search",
"type": "message",
"role": "assistant",
"model": model,
"content": [{"type": "text", "text": "scripted answer"}],
"usage": {
"input_tokens": 1840,
"output_tokens": 412,
"server_tool_use": {"web_search_requests": web_search_requests},
},
}, None
@pytest.mark.parametrize("build_response", [_claude_chat_response, _claude_messages_response])
@pytest.mark.parametrize(("web_search_requests", "search_cost_per_query"), [(2, 0.01), (3, 0.01), (2, 0.0)])
def test_vertex_claude_deployment_priced_web_search_billed_per_request(
local_model_cost_map: None,
build_response: Callable[[int], tuple[object, Usage | None]],
web_search_requests: int,
search_cost_per_query: float,
) -> None:
deployment_id: Final = _register_priced_deployment("vertex_ai/claude-sonnet-5-5", search_cost_per_query)
response_object, usage = build_response(web_search_requests)
cost: Final = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
model=deployment_id,
response_object=response_object,
usage=usage,
custom_llm_provider="vertex_ai",
standard_built_in_tools_params=None,
)
assert cost == pytest.approx(web_search_requests * search_cost_per_query)
@pytest.mark.parametrize("build_response", [_claude_chat_response, _claude_messages_response])
@pytest.mark.parametrize(
("deployment_model", "served_model", "custom_llm_provider"),
# Google bills Claude web search on Sonnet 4.5 and Haiku 4.5 too:
# https://cloud.google.com/model-garden/partner-models/pricing (read 2026-10-30)
[
("vertex_ai/claude-sonnet-5-5", "claude-sonnet-5-5", "vertex_ai"),
("vertex_ai/claude-sonnet-4-5@20250929", "claude-sonnet-4-5-20250929", "vertex_ai"),
("vertex_ai/claude-haiku-4-5@20251001", "claude-haiku-4-5-20251001", "vertex_ai"),
("anthropic/claude-sonnet-5-5", "claude-sonnet-5-5", "anthropic"),
("azure_ai/claude-sonnet-5-5", "claude-sonnet-5-5", "azure_ai"),
],
)
@pytest.mark.parametrize("deployment_id", [None, "claude-token-priced"], ids=["hashed_id", "claude_in_id"])
def test_claude_deployment_with_token_rates_only_bills_served_model_search_price(
local_model_cost_map: None,
build_response: Callable[[int, str], tuple[object, Usage | None]],
deployment_model: str,
served_model: str,
custom_llm_provider: str,
deployment_id: str | None,
) -> None:
served_search_pricing: Final = (
litellm.get_model_info(model=deployment_model).get("search_context_cost_per_query") or {}
)
served_search_price: Final = served_search_pricing.get("search_context_size_medium") or 0.0
registered_id: Final = _register_priced_deployment(deployment_model, None, deployment_id)
response_object, usage = build_response(2, served_model)
cost: Final = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
model=registered_id,
response_object=response_object,
usage=usage,
custom_llm_provider=custom_llm_provider,
standard_built_in_tools_params=None,
)
assert served_search_price > 0
assert cost == pytest.approx(2 * served_search_price)
def test_vertex_claude_deployment_with_token_rates_only_and_unmapped_served_model_bills_no_search(
local_model_cost_map: None,
) -> None:
deployment_id: Final = _register_priced_deployment("vertex_ai/claude-sonnet-5-5", None)
usage: Final = Usage(
prompt_tokens=1840,
completion_tokens=412,
total_tokens=2252,
server_tool_use=ServerToolUse(web_search_requests=2),
)
cost: Final = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
model=deployment_id,
response_object=ModelResponse(model="claude-unmapped-preview", usage=usage),
usage=usage,
custom_llm_provider="vertex_ai",
standard_built_in_tools_params=None,
)
assert cost == 0.0
@pytest.mark.parametrize("deployment_id", [None, "claude-code-gemini"], ids=["hashed_id", "claude_in_id"])
def test_vertex_gemini_deployment_priced_web_search_stays_per_prompt(
local_model_cost_map: None, deployment_id: str | None
) -> None:
search_cost_per_prompt: Final = 0.03
registered_id: Final = _register_priced_deployment(
"vertex_ai/gemini-2.5-flash", search_cost_per_prompt, deployment_id
)
usage: Final = Usage(
prompt_tokens=1840,
completion_tokens=412,
total_tokens=2252,
server_tool_use=ServerToolUse(web_search_requests=2),
)
cost: Final = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
model=registered_id,
response_object=ModelResponse(model="gemini-2.5-flash", usage=usage),
usage=usage,
custom_llm_provider="vertex_ai",
standard_built_in_tools_params=None,
)
assert cost == pytest.approx(search_cost_per_prompt)
@pytest.mark.parametrize(
"kwargs",
[{"tools": 5}, {"web_search_options": None}, {"web_search_options": 5}],

View file

@ -41,12 +41,13 @@ def _make_streaming_response(chunks):
return mock
def _unarmed_logging_obj():
def _unarmed_logging_obj(model: str = "unknown"):
"""Real Logging objects only carry _on_deferred_stream_complete when the
proxy arms deferred dispatch; a bare MagicMock's auto-attribute is truthy
and would spuriously trigger the deferral branch."""
obj = MagicMock()
obj._on_deferred_stream_complete = None
obj.model = model
return obj
@ -409,7 +410,7 @@ async def _collect_openai_passthrough_chunks(chunks, endpoint_type):
async for chunk in PassThroughStreamingHandler.chunk_processor(
response=response,
request_body={"model": "gpt-4o-mini", "stream": True},
litellm_logging_obj=_unarmed_logging_obj(),
litellm_logging_obj=_unarmed_logging_obj(model="gpt-4o-mini"),
endpoint_type=endpoint_type,
start_time=datetime(2026, 1, 1),
passthrough_success_handler_obj=MagicMock(),

View file

@ -346,6 +346,7 @@ async def test_vertex_ai_anthropic_streaming_cost_injection_enabled():
# Setup logging object with model info
litellm_logging_obj = MagicMock(spec=LiteLLMLoggingObj)
litellm_logging_obj.model = "claude-sonnet-4@20250514"
litellm_logging_obj.litellm_params = {}
litellm_logging_obj.model_call_details = {"model": "claude-sonnet-4@20250514"}
litellm_logging_obj.completion_start_time = None
@ -544,6 +545,7 @@ async def test_vertex_ai_anthropic_streaming_model_extraction():
response.aiter_bytes = mock_aiter_bytes
litellm_logging_obj = MagicMock(spec=LiteLLMLoggingObj)
litellm_logging_obj.model = "claude-sonnet-4@20250514"
litellm_logging_obj.litellm_params = {}
litellm_logging_obj.model_call_details = {}
litellm_logging_obj.completion_start_time = None

View file

@ -74,6 +74,7 @@ from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitErro
from litellm.proxy.common_utils.sse_keepalive import ANTHROPIC_PING_SSE_CHUNK
from litellm.proxy.dd_span_tagger import DDSpanTagger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.proxy._types import Litellm_EntityType, ProxyErrorTypes, ProxyException
from litellm.proxy._types import UserAPIKeyAuth as ProxyUserAPIKeyAuth
from litellm.proxy.auth.fallback_budget import RouterFallbackBudgetCheck
@ -10221,6 +10222,8 @@ class TestInjectCostIntoUsageDict:
discount streamed a ``usage.cost`` that disagreed with the callback's ``response_cost``."""
class _StubLoggingObj:
model = "claude-haiku-4-5"
def __init__(self, cost):
self._cost = cost
self.captured_result = None
@ -10257,6 +10260,8 @@ class TestInjectCostIntoUsageDict:
def test_message_delta_falls_back_to_model_pricing_when_the_logging_obj_returns_no_cost(self):
class _StubLoggingObj:
model = "claude-haiku-4-5"
def response_cost_calculator(self, result):
return None
@ -10282,6 +10287,8 @@ class TestInjectCostIntoUsageDict:
model-name pricing rather than propagating into the response body."""
class _StubLoggingObj:
model = "claude-haiku-4-5"
def response_cost_calculator(self, result):
raise ValueError("no pricing for this deployment")
@ -10363,6 +10370,8 @@ class TestInjectCostIntoUsageDict:
streaming /v1/chat/completions gets its negotiated price instead of sticker."""
class _StubLoggingObj:
model = "gpt-4o-mini"
def __init__(self, cost):
self._cost = cost
self.captured_result = None
@ -10391,6 +10400,8 @@ class TestInjectCostIntoUsageDict:
def test_openai_chunk_falls_back_to_model_pricing_when_the_logging_obj_returns_no_cost(self):
class _StubLoggingObj:
model = "gpt-4o-mini"
def response_cost_calculator(self, result):
return None
@ -10406,6 +10417,60 @@ class TestInjectCostIntoUsageDict:
assert result is not None
assert result["usage"]["cost"] == pytest.approx(self._expected_cost("gpt-4o-mini", 11, 4))
@pytest.mark.parametrize(
("deployment_model", "served_model", "custom_llm_provider"),
[
("vertex_ai/claude-sonnet-5-5", "claude-sonnet-5-5", "vertex_ai"),
("anthropic/claude-sonnet-5-5", "claude-sonnet-5-5", "anthropic"),
("azure_ai/claude-sonnet-5-5", "claude-sonnet-5-5", "azure_ai"),
],
)
def test_message_delta_bills_search_like_the_logged_response_on_a_token_priced_claude_deployment(
self, local_model_cost_map: None, deployment_model: str, served_model: str, custom_llm_provider: str
) -> None:
token_rates: Final = {"input_cost_per_token": 3e-6, "output_cost_per_token": 1.5e-5}
router: Final = Router(
model_list=[
{
"model_name": "priced-claude",
"litellm_params": {
"model": deployment_model,
"api_key": "k",
"vertex_project": "p",
"vertex_location": "global",
},
"model_info": token_rates,
}
]
)
deployment_id: Final = router.model_list[0]["model_info"]["id"]
logging_obj: Final = LiteLLMLoggingObj(
model=deployment_model,
messages=[{"role": "user", "content": "test"}],
stream=True,
call_type="anthropic_messages",
start_time=None,
litellm_call_id="token-priced-claude-search",
function_id="token-priced-claude-search",
)
logging_obj.update_environment_variables(
litellm_params={"metadata": {"model_info": {"id": deployment_id, **token_rates}}}, optional_params={}
)
logging_obj.model_call_details["custom_llm_provider"] = custom_llm_provider
wire_usage: Final = {"input_tokens": 1840, "output_tokens": 412, "server_tool_use": {"web_search_requests": 2}}
event: Final = {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": wire_usage}
result: Final = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict(event, "priced-claude", logging_obj)
stream_usage: Final = AnthropicConfig().calculate_usage(usage_object=wire_usage, reasoning_content=None)
logged_cost: Final = logging_obj.response_cost_calculator(
result=litellm.ModelResponse(model=served_model, usage=stream_usage)
)
token_cost: Final = 1840 * token_rates["input_cost_per_token"] + 412 * token_rates["output_cost_per_token"]
assert logged_cost is not None and logged_cost > token_cost
assert result is not None
assert result["usage"]["cost"] == pytest.approx(logged_cost)
class TestProcessChunkWithCostInjection:
@pytest.mark.parametrize("model", ["claude-opus-5-5", "vertex_ai/claude-opus-5-5"])
@ -10472,6 +10537,8 @@ class TestProcessChunkWithCostInjection:
monkeypatch.setattr(litellm, "include_cost_in_streaming_usage", True)
class _StubLoggingObj:
model = "claude-haiku-4-5"
def response_cost_calculator(self, result):
return 0.00042