From f74c72eedbfba7444ddbe5576a460495b279b073 Mon Sep 17 00:00:00 2001 From: Marty Sullivan Date: Sun, 16 Aug 2026 03:49:31 -0400 Subject: [PATCH 01/11] fix(batches): price a retrieved batch from its deployment's model and rates Retrieving a completed batch computed its cost with no model identity: neither the deployment's model nor its configured pricing reached the batch cost calculation. For bedrock that left the cost model falling back to the provider's own response model (e.g. "claude-sonnet-4-6"), which does not resolve under a bedrock provider, so the lookup missed and cost silently became $0 while usage stayed correct. Dropping the deployment's model info separately discarded any rates configured on that deployment, billing a zero-cost deployment at the public rate instead. Both are the same omission at the call site, so both are fixed by passing the logging object's own model and the pricing the router registered for the deployment. --- litellm/batches/batch_utils.py | 5 + litellm/litellm_core_utils/litellm_logging.py | 17 ++++ .../test_litellm/batches/test_batch_utils.py | 95 ++++++++++++++++++ .../test_litellm_logging.py | 97 +++++++++++++++++++ 4 files changed, 214 insertions(+) diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index 8bb3a0ab1ee..b811dc0f6ee 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -48,6 +48,7 @@ async def _handle_completed_batch( custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"], model_name: str | None = None, litellm_params: dict | None = None, + model_info: ModelInfo | None = None, ) -> tuple[float, Usage, list[str]]: """Fetch a completed batch's output file and aggregate its cost, usage, and models in a single pass over the JSONL lines, so the parsed file content is @@ -58,6 +59,9 @@ async def _handle_completed_batch( custom_llm_provider: The LLM provider model_name: Optional model name litellm_params: Optional litellm parameters containing credentials (api_key, api_base, etc.) + model_info: Optional deployment-level model info with custom pricing, + threaded through so a deployment's configured rates win over the + global cost map. """ # A completed batch whose request lines all failed has no output file - the # results are written to a separate error_file_id and output_file_id is None. @@ -86,6 +90,7 @@ async def _handle_completed_batch( entries=_iter_batch_input_entries(file_content), custom_llm_provider=custom_llm_provider, model_name=model_name, + model_info=model_info, ) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 4a4a97b1d85..662ce38f746 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -108,6 +108,7 @@ from litellm.types.utils import ( LiteLLMBatch, LiteLLMLoggingBaseClass, LiteLLMRealtimeStreamLoggingObject, + ModelInfo, ModelResponse, ModelResponseStream, RawRequestTypedDict, @@ -579,6 +580,20 @@ class Logging(LiteLLMLoggingBaseClass): return model_id return None + def get_router_deployment_model_info(self) -> ModelInfo | None: + """Pricing the router registered under this deployment's model_info.id. + + Returns None when the deployment declares no pricing of its own, so the + caller falls back to the global cost map. + """ + model_id: Final = self.get_router_model_id() + if model_id is None: + return None + try: + return litellm.get_model_info(model=model_id) + except Exception: # noqa: BLE001 # get_model_info raises for any id with no registered pricing + return None + def update_environment_variables( self, litellm_params: dict, @@ -2600,7 +2615,9 @@ class Logging(LiteLLMLoggingBaseClass): ) = await _handle_completed_batch( batch=result, custom_llm_provider=self.custom_llm_provider, + model_name=self.model, litellm_params=self.litellm_params, + model_info=self.get_router_deployment_model_info(), ) result._hidden_params["response_cost"] = response_cost diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py index 70d4ce2cebd..033febabd45 100644 --- a/tests/test_litellm/batches/test_batch_utils.py +++ b/tests/test_litellm/batches/test_batch_utils.py @@ -1320,3 +1320,98 @@ async def test_output_file_content_bedrock_reads_with_deployment_aws_credentials assert captured["aws_region_name"] == "us-west-2" assert captured["_litellm_internal_model_credentials"] is snapshot assert "model" not in captured + + +# =========================================================================== # +# _handle_completed_batch threads the deployment's model identity + pricing +# +# Regression: the retrieve path called _handle_completed_batch with neither +# model_name nor model_info. For bedrock that left cost_model falling back to +# the provider's own response model ("claude-sonnet-4-6"), which does not +# resolve under custom_llm_provider="bedrock", so cost silently became $0 while +# usage stayed correct. Dropping model_info separately discarded a deployment's +# configured rates, billing a zero-cost deployment at the public rate. +# =========================================================================== # + + +def _bedrock_row(model, input_tokens, output_tokens): + return { + "modelInput": {"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]}, + "modelOutput": { + "model": model, + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + }, + }, + "recordId": "r", + } + + +@pytest.mark.asyncio +async def test_handle_completed_bedrock_batch_prices_from_deployment_model(monkeypatch): + """A bedrock batch must price from the deployment model, not the response model.""" + rows = [_bedrock_row("claude-sonnet-4-6", 18, 10)] * 100 + + async def fake_fetch(batch, custom_llm_provider, litellm_params=None): + return _vertex_jsonl(rows) + + monkeypatch.setattr(bu, "_fetch_batch_output_file_content", fake_fetch) + + cost, usage, _ = await bu._handle_completed_batch( + _batch("of"), + custom_llm_provider="bedrock", + model_name="bedrock/global.anthropic.claude-sonnet-4-6", + ) + + assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (1800, 1000, 2800) + # 3e-06 / 1.5e-05 on-demand, halved for batch. + assert cost == pytest.approx(1800 * 3e-06 / 2 + 1000 * 1.5e-05 / 2) + + # The response model alone cannot price a bedrock batch: this is the $0 bug. + zero_cost, zero_usage, _ = await bu._handle_completed_batch( + _batch("of"), + custom_llm_provider="bedrock", + model_name=None, + ) + assert zero_cost == 0.0 + assert zero_usage.total_tokens == 2800 + + +@pytest.mark.asyncio +async def test_handle_completed_batch_honors_deployment_pricing(monkeypatch): + """A deployment's configured rates must win over the global cost map.""" + rows = [_success_row(model="gemini-2.5-flash", usage=_usage(60, 75))] + + async def fake_fetch(batch, custom_llm_provider, litellm_params=None): + return _vertex_jsonl(rows) + + monkeypatch.setattr(bu, "_fetch_batch_output_file_content", fake_fetch) + + free_cost, _, _ = await bu._handle_completed_batch( + _batch("of"), + custom_llm_provider="vertex_ai", + model_name="vertex_ai/gemini-2.5-flash", + model_info={ + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "input_cost_per_token_batches": 0.0, + "output_cost_per_token_batches": 0.0, + }, + ) + assert free_cost == 0.0 + + billed_cost, _, _ = await bu._handle_completed_batch( + _batch("of"), + custom_llm_provider="vertex_ai", + model_name="vertex_ai/gemini-2.5-flash", + model_info=None, + ) + assert billed_cost > 0.0 diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 28a6c8dd18d..e9dc65e526d 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -1,3 +1,4 @@ +import contextlib import os import sys import asyncio @@ -340,6 +341,102 @@ class TestGetRouterModelId: assert obj.get_router_model_id() is None +class TestGetRouterDeploymentModelInfo: + """Pricing a deployment registered under its own model_info.id.""" + + def test_returns_registered_deployment_pricing(self, logging_obj): + deployment_id = "deploy-zero-cost-1" + litellm.model_cost[deployment_id] = { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "input_cost_per_token_batches": 0.0, + "output_cost_per_token_batches": 0.0, + "litellm_provider": "vertex_ai", + "mode": "chat", + } + logging_obj.litellm_params = {"litellm_metadata": {"model_info": {"id": deployment_id}}} + try: + info = logging_obj.get_router_deployment_model_info() + assert info is not None + assert info["input_cost_per_token"] == 0.0 + assert info["output_cost_per_token_batches"] == 0.0 + finally: + litellm.model_cost.pop(deployment_id, None) + + def test_returns_none_for_unregistered_deployment(self, logging_obj): + logging_obj.litellm_params = {"litellm_metadata": {"model_info": {"id": "deploy-never-registered"}}} + assert logging_obj.get_router_deployment_model_info() is None + + def test_returns_none_without_a_deployment_id(self, logging_obj): + logging_obj.litellm_params = {"api_base": ""} + assert logging_obj.get_router_deployment_model_info() is None + + +class TestRetrieveBatchCostPassesModelIdentity: + """Regression: retrieving a batch priced it with no model identity at all. + + _handle_completed_batch was called without model_name or model_info, so a + bedrock batch fell back to the provider's own response model (unresolvable + under custom_llm_provider="bedrock") and silently cost $0, and a deployment's + configured rates were ignored entirely. + """ + + @pytest.mark.asyncio + async def test_forwards_deployment_model_and_pricing(self, monkeypatch): + from litellm.litellm_core_utils import litellm_logging as logging_module + from litellm.types.utils import LiteLLMBatch, Usage + + deployment_id = "deploy-batch-pricing-1" + litellm.model_cost[deployment_id] = { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "bedrock", + "mode": "chat", + } + + captured: dict = {} + + async def fake_handle_completed_batch(**kwargs): + captured.update(kwargs) + return 1.25, Usage(prompt_tokens=1800, completion_tokens=1000, total_tokens=2800), ["m"] + + monkeypatch.setattr(logging_module, "_handle_completed_batch", fake_handle_completed_batch) + + obj = LitellmLogging( + model="bedrock/global.anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "Hey"}], + stream=False, + call_type="aretrieve_batch", + start_time=time.time(), + litellm_call_id="batch-call-1", + function_id="f", + ) + obj.custom_llm_provider = "bedrock" + obj.litellm_params = {"litellm_metadata": {"model_info": {"id": deployment_id}}} + + batch = LiteLLMBatch( + id="batch_abc", + completion_window="24h", + created_at=1, + endpoint="/v1/chat/completions", + input_file_id="file-in", + object="batch", + status="completed", + output_file_id="file-out", + ) + + try: + with contextlib.suppress(Exception): + await obj._async_success_handler_body(result=batch, start_time=None, end_time=None) + finally: + litellm.model_cost.pop(deployment_id, None) + + assert captured, "_handle_completed_batch was never called" + assert captured["model_name"] == "bedrock/global.anthropic.claude-sonnet-4-6" + assert captured["model_info"] is not None + assert captured["model_info"]["input_cost_per_token"] == 0.0 + + class TestAnthropicPassthroughCustomPricing: """Verify the Anthropic pass-through handler forwards custom pricing.""" From 727905dfc31b67484cc8f3650ff3495aa02936e5 Mon Sep 17 00:00:00 2001 From: Marty Sullivan Date: Sun, 16 Aug 2026 16:43:56 -0400 Subject: [PATCH 02/11] fix(batches): only use deployment pricing when the deployment declares it The router registers a model_info entry for every deployment, priced or not, and get_model_info fills absent costs with 0. Resolving deployment pricing through it therefore reported a free deployment for any ordinary one, which priced its batches at $0 while usage stayed correct: the same silent under-count this branch set out to remove, widened from bedrock to every provider. Caught by a live batch run, where four vertex batches that price correctly today came back at $0. The raw registration is now what decides: pricing is used only when the deployment actually declares one of the batch cost fields, so ordinary deployments fall back to the global cost map exactly as before. The earlier test missed this by using a deployment id that was never registered, where get_model_info does raise; a real deployment is always registered. --- litellm/litellm_core_utils/litellm_logging.py | 16 ++++++++++++++-- .../litellm_core_utils/test_litellm_logging.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 662ce38f746..f6f3885bff2 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -584,14 +584,26 @@ class Logging(LiteLLMLoggingBaseClass): """Pricing the router registered under this deployment's model_info.id. Returns None when the deployment declares no pricing of its own, so the - caller falls back to the global cost map. + caller falls back to the global cost map. The raw registration is what + decides that: the router registers an entry for every deployment, and + get_model_info fills absent costs with 0, so asking it directly cannot + tell "configured as free" apart from "no pricing configured". """ + pricing_keys: Final = ( + "input_cost_per_token", + "output_cost_per_token", + "input_cost_per_token_batches", + "output_cost_per_token_batches", + ) model_id: Final = self.get_router_model_id() if model_id is None: return None + registered: Final = litellm.model_cost.get(model_id) + if not isinstance(registered, dict) or not any(registered.get(key) is not None for key in pricing_keys): + return None try: return litellm.get_model_info(model=model_id) - except Exception: # noqa: BLE001 # get_model_info raises for any id with no registered pricing + except Exception: # noqa: BLE001 # get_model_info raises for ids it cannot resolve a provider for return None def update_environment_variables( diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index e9dc65e526d..3b5e8aaf1f8 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -367,6 +367,24 @@ class TestGetRouterDeploymentModelInfo: logging_obj.litellm_params = {"litellm_metadata": {"model_info": {"id": "deploy-never-registered"}}} assert logging_obj.get_router_deployment_model_info() is None + def test_returns_none_when_deployment_registered_without_pricing(self, logging_obj): + """The router registers an entry for EVERY deployment, priced or not. + + get_model_info fills absent costs with 0, so consulting it directly would + hand back free pricing for an ordinary deployment and bill its batches $0. + """ + deployment_id = "deploy-no-pricing-1" + litellm.register_model( + model_cost={deployment_id: {"id": deployment_id, "access_groups": ["x"]}}, + persist_across_reloads=False, + ) + logging_obj.litellm_params = {"litellm_metadata": {"model_info": {"id": deployment_id}}} + try: + assert litellm.get_model_info(model=deployment_id)["input_cost_per_token"] == 0 + assert logging_obj.get_router_deployment_model_info() is None + finally: + litellm.model_cost.pop(deployment_id, None) + def test_returns_none_without_a_deployment_id(self, logging_obj): logging_obj.litellm_params = {"api_base": ""} assert logging_obj.get_router_deployment_model_info() is None From 0797e266cd4814e995e080e9fd57c12d1509dafc Mon Sep 17 00:00:00 2001 From: Marty Sullivan Date: Sun, 16 Aug 2026 17:00:12 -0400 Subject: [PATCH 03/11] fix(batches): price against the deployment model, not the router alias self.model can carry the router's model_group alias, which no cost map resolves, so a bedrock batch still priced at $0 after the model name started being passed. The deployment's own litellm_params model is used when present. Verified against the local (image-bound) cost map that dev and prod both force: alias 'claude-opus-4-5' prices $0.000000 while 'bedrock/global.anthropic.claude-opus-4-5-20251101-v1:0' prices $0.017000 --- litellm/litellm_core_utils/litellm_logging.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index f6f3885bff2..5d65288faa2 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -580,6 +580,17 @@ class Logging(LiteLLMLoggingBaseClass): return model_id return None + def get_deployment_model_for_cost(self) -> str | None: + """The provider-qualified model to price against. + + self.model can be the router's model_group alias, which no cost map + resolves, so the deployment's own litellm_params model wins when present. + """ + deployment_model: Final = self.litellm_params.get("model") if hasattr(self, "litellm_params") else None + if isinstance(deployment_model, str) and deployment_model: + return deployment_model + return self.model + def get_router_deployment_model_info(self) -> ModelInfo | None: """Pricing the router registered under this deployment's model_info.id. @@ -2627,7 +2638,7 @@ class Logging(LiteLLMLoggingBaseClass): ) = await _handle_completed_batch( batch=result, custom_llm_provider=self.custom_llm_provider, - model_name=self.model, + model_name=self.get_deployment_model_for_cost(), litellm_params=self.litellm_params, model_info=self.get_router_deployment_model_info(), ) From 964c9a3ca298fc96aa6daa7cda3b914712c23f18 Mon Sep 17 00:00:00 2001 From: Marty Sullivan Date: Sun, 16 Aug 2026 17:15:37 -0400 Subject: [PATCH 04/11] fix(batches): resolve the deployment model from model_call_details On a batch retrieve both self.model and litellm_params[model] come back None, so the cost model fell through to the provider's own response model (an Anthropic id like claude-opus-4-5-20251101) which does not resolve under a bedrock provider, leaving bedrock batches at $0 with correct usage. model_call_details carries the deployment's provider-qualified model (bedrock/global.anthropic.claude-opus-4-5-20251101-v1:0), confirmed by instrumenting a live retrieve, so it is preferred with the previous two sources kept as fallbacks. --- litellm/litellm_core_utils/litellm_logging.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 5d65288faa2..32143899125 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -583,13 +583,17 @@ class Logging(LiteLLMLoggingBaseClass): def get_deployment_model_for_cost(self) -> str | None: """The provider-qualified model to price against. - self.model can be the router's model_group alias, which no cost map - resolves, so the deployment's own litellm_params model wins when present. + On a batch retrieve both self.model and litellm_params["model"] can be + unset, and self.model can otherwise carry the router's model_group alias, + which no cost map resolves. model_call_details holds the deployment's own + provider-qualified model, so it is preferred. """ - deployment_model: Final = self.litellm_params.get("model") if hasattr(self, "litellm_params") else None - if isinstance(deployment_model, str) and deployment_model: - return deployment_model - return self.model + candidates: Final = ( + (self.model_call_details or {}).get("model") if hasattr(self, "model_call_details") else None, + self.litellm_params.get("model") if hasattr(self, "litellm_params") else None, + self.model, + ) + return next((candidate for candidate in candidates if isinstance(candidate, str) and candidate), None) def get_router_deployment_model_info(self) -> ModelInfo | None: """Pricing the router registered under this deployment's model_info.id. From b593cef7588338cca936c1e7c368305de32e8cfe Mon Sep 17 00:00:00 2001 From: Marty Sullivan Date: Sun, 16 Aug 2026 22:17:03 -0400 Subject: [PATCH 05/11] fix(batches): keep published rates for a side the deployment leaves unset Substituting a deployment's pricing wholesale billed the token direction it did not configure at zero: get_model_info fills an absent cost with 0, and any non-None pricing field suppressed the global fallback. A deployment declaring only input_cost_per_token therefore billed output at nothing. Each of the four batch cost fields now falls back to the model's published rate when the deployment leaves it unset, so a one-sided override applies to the side it configures and only that side. Adds a parametrized regression over input-only, output-only, and both-zero, plus coverage for a deployment whose model has no published entry. Annotates the new test helpers per the repo's type-coverage rule and drops the narrative banner comment from the batch tests. --- litellm/litellm_core_utils/litellm_logging.py | 46 ++++++++--- .../test_litellm/batches/test_batch_utils.py | 17 ++-- .../test_litellm_logging.py | 78 +++++++++++++++++-- 3 files changed, 113 insertions(+), 28 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 32143899125..c22ac9eb4c9 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -308,6 +308,14 @@ def _get_cached_prometheus_logger(): return _PrometheusLogger +_DEPLOYMENT_PRICING_KEYS: Final = ( + "input_cost_per_token", + "output_cost_per_token", + "input_cost_per_token_batches", + "output_cost_per_token_batches", +) + + class Logging(LiteLLMLoggingBaseClass): global \ supabaseClient, \ @@ -602,24 +610,44 @@ class Logging(LiteLLMLoggingBaseClass): caller falls back to the global cost map. The raw registration is what decides that: the router registers an entry for every deployment, and get_model_info fills absent costs with 0, so asking it directly cannot - tell "configured as free" apart from "no pricing configured". + tell "configured as free" apart from "no pricing configured". A deployment + may declare only one side of its pricing, so a rate it leaves unset keeps + the model's published value instead of billing as zero. """ - pricing_keys: Final = ( - "input_cost_per_token", - "output_cost_per_token", - "input_cost_per_token_batches", - "output_cost_per_token_batches", - ) model_id: Final = self.get_router_model_id() if model_id is None: return None registered: Final = litellm.model_cost.get(model_id) - if not isinstance(registered, dict) or not any(registered.get(key) is not None for key in pricing_keys): + if not isinstance(registered, dict) or not any( + registered.get(key) is not None for key in _DEPLOYMENT_PRICING_KEYS + ): return None try: - return litellm.get_model_info(model=model_id) + merged: Final = litellm.get_model_info(model=model_id) except Exception: # noqa: BLE001 # get_model_info raises for ids it cannot resolve a provider for return None + published: Final = self._published_model_info() + if published is None: + return merged + if registered.get("input_cost_per_token") is None: + merged["input_cost_per_token"] = published.get("input_cost_per_token") + if registered.get("output_cost_per_token") is None: + merged["output_cost_per_token"] = published.get("output_cost_per_token") + if registered.get("input_cost_per_token_batches") is None: + merged["input_cost_per_token_batches"] = published.get("input_cost_per_token_batches") + if registered.get("output_cost_per_token_batches") is None: + merged["output_cost_per_token_batches"] = published.get("output_cost_per_token_batches") + return merged + + def _published_model_info(self) -> ModelInfo | None: + """The cost map's own entry for this deployment's model, when it resolves.""" + deployment_model: Final = self.get_deployment_model_for_cost() + if deployment_model is None: + return None + try: + return litellm.get_model_info(model=deployment_model) + except Exception: # noqa: BLE001 # no published entry to layer the declared rates over + return None def update_environment_variables( self, diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py index 033febabd45..177a4e354ca 100644 --- a/tests/test_litellm/batches/test_batch_utils.py +++ b/tests/test_litellm/batches/test_batch_utils.py @@ -1324,17 +1324,10 @@ async def test_output_file_content_bedrock_reads_with_deployment_aws_credentials # =========================================================================== # # _handle_completed_batch threads the deployment's model identity + pricing -# -# Regression: the retrieve path called _handle_completed_batch with neither -# model_name nor model_info. For bedrock that left cost_model falling back to -# the provider's own response model ("claude-sonnet-4-6"), which does not -# resolve under custom_llm_provider="bedrock", so cost silently became $0 while -# usage stayed correct. Dropping model_info separately discarded a deployment's -# configured rates, billing a zero-cost deployment at the public rate. # =========================================================================== # -def _bedrock_row(model, input_tokens, output_tokens): +def _bedrock_row(model: str, input_tokens: int, output_tokens: int) -> dict[str, object]: return { "modelInput": {"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]}, "modelOutput": { @@ -1356,11 +1349,11 @@ def _bedrock_row(model, input_tokens, output_tokens): @pytest.mark.asyncio -async def test_handle_completed_bedrock_batch_prices_from_deployment_model(monkeypatch): +async def test_handle_completed_bedrock_batch_prices_from_deployment_model(monkeypatch) -> None: """A bedrock batch must price from the deployment model, not the response model.""" rows = [_bedrock_row("claude-sonnet-4-6", 18, 10)] * 100 - async def fake_fetch(batch, custom_llm_provider, litellm_params=None): + async def fake_fetch(batch: object, custom_llm_provider: str, litellm_params: dict | None = None) -> bytes: return _vertex_jsonl(rows) monkeypatch.setattr(bu, "_fetch_batch_output_file_content", fake_fetch) @@ -1386,11 +1379,11 @@ async def test_handle_completed_bedrock_batch_prices_from_deployment_model(monke @pytest.mark.asyncio -async def test_handle_completed_batch_honors_deployment_pricing(monkeypatch): +async def test_handle_completed_batch_honors_deployment_pricing(monkeypatch) -> None: """A deployment's configured rates must win over the global cost map.""" rows = [_success_row(model="gemini-2.5-flash", usage=_usage(60, 75))] - async def fake_fetch(batch, custom_llm_provider, litellm_params=None): + async def fake_fetch(batch: object, custom_llm_provider: str, litellm_params: dict | None = None) -> bytes: return _vertex_jsonl(rows) monkeypatch.setattr(bu, "_fetch_batch_output_file_content", fake_fetch) diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 3b5e8aaf1f8..4acbe276cf5 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -344,7 +344,7 @@ class TestGetRouterModelId: class TestGetRouterDeploymentModelInfo: """Pricing a deployment registered under its own model_info.id.""" - def test_returns_registered_deployment_pricing(self, logging_obj): + def test_returns_registered_deployment_pricing(self, logging_obj) -> None: deployment_id = "deploy-zero-cost-1" litellm.model_cost[deployment_id] = { "input_cost_per_token": 0.0, @@ -363,11 +363,11 @@ class TestGetRouterDeploymentModelInfo: finally: litellm.model_cost.pop(deployment_id, None) - def test_returns_none_for_unregistered_deployment(self, logging_obj): + def test_returns_none_for_unregistered_deployment(self, logging_obj) -> None: logging_obj.litellm_params = {"litellm_metadata": {"model_info": {"id": "deploy-never-registered"}}} assert logging_obj.get_router_deployment_model_info() is None - def test_returns_none_when_deployment_registered_without_pricing(self, logging_obj): + def test_returns_none_when_deployment_registered_without_pricing(self, logging_obj) -> None: """The router registers an entry for EVERY deployment, priced or not. get_model_info fills absent costs with 0, so consulting it directly would @@ -385,10 +385,74 @@ class TestGetRouterDeploymentModelInfo: finally: litellm.model_cost.pop(deployment_id, None) - def test_returns_none_without_a_deployment_id(self, logging_obj): + def test_returns_none_without_a_deployment_id(self, logging_obj) -> None: logging_obj.litellm_params = {"api_base": ""} assert logging_obj.get_router_deployment_model_info() is None + @pytest.mark.parametrize( + "declared,expected_input,expected_output", + [ + ({"input_cost_per_token": 1e-06}, 1e-06, 1.5e-05), + ({"output_cost_per_token": 5e-06}, 3e-06, 5e-06), + ({"input_cost_per_token": 0.0, "output_cost_per_token": 0.0}, 0.0, 0.0), + ], + ids=["input-only", "output-only", "both-zero"], + ) + def test_one_sided_override_keeps_the_published_rate_for_the_other_side( + self, + declared: dict[str, float], + expected_input: float, + expected_output: float, + ) -> None: + """A deployment may configure one direction only. + + Substituting its pricing wholesale billed the direction it left unset at + zero, because get_model_info fills an absent cost with 0 and that + suppressed the global fallback. + """ + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + + model = "bedrock/global.anthropic.claude-sonnet-4-6" + published = litellm.get_model_info(model=model) + assert (published["input_cost_per_token"], published["output_cost_per_token"]) == (3e-06, 1.5e-05) + + deployment_id = f"deploy-one-sided-{'-'.join(sorted(declared))}" + litellm.model_cost[deployment_id] = {"id": deployment_id, **declared} + obj = LiteLLMLoggingObj( + model=model, + messages=[], + stream=False, + call_type="aretrieve_batch", + start_time=time.time(), + litellm_call_id="one-sided", + function_id="f", + ) + obj.litellm_params = {"litellm_metadata": {"model_info": {"id": deployment_id}}, "model": model} + obj.model_call_details["model"] = model + try: + info = obj.get_router_deployment_model_info() + assert info is not None + assert info["input_cost_per_token"] == expected_input + assert info["output_cost_per_token"] == expected_output + finally: + litellm.model_cost.pop(deployment_id, None) + + def test_falls_back_to_declared_rates_when_the_model_has_no_published_entry(self, logging_obj) -> None: + """With no published entry to layer under, the declared rates still apply.""" + deployment_id = "deploy-unpublished-model-1" + litellm.model_cost[deployment_id] = {"id": deployment_id, "input_cost_per_token": 7e-06} + logging_obj.litellm_params = { + "litellm_metadata": {"model_info": {"id": deployment_id}}, + "model": "not-a-real-provider/not-a-real-model-xyz", + } + logging_obj.model_call_details["model"] = "not-a-real-provider/not-a-real-model-xyz" + try: + info = logging_obj.get_router_deployment_model_info() + assert info is not None + assert info["input_cost_per_token"] == 7e-06 + finally: + litellm.model_cost.pop(deployment_id, None) + class TestRetrieveBatchCostPassesModelIdentity: """Regression: retrieving a batch priced it with no model identity at all. @@ -400,7 +464,7 @@ class TestRetrieveBatchCostPassesModelIdentity: """ @pytest.mark.asyncio - async def test_forwards_deployment_model_and_pricing(self, monkeypatch): + async def test_forwards_deployment_model_and_pricing(self, monkeypatch) -> None: from litellm.litellm_core_utils import litellm_logging as logging_module from litellm.types.utils import LiteLLMBatch, Usage @@ -412,9 +476,9 @@ class TestRetrieveBatchCostPassesModelIdentity: "mode": "chat", } - captured: dict = {} + captured: dict[str, object] = {} - async def fake_handle_completed_batch(**kwargs): + async def fake_handle_completed_batch(**kwargs: object) -> tuple[float, Usage, list[str]]: captured.update(kwargs) return 1.25, Usage(prompt_tokens=1800, completion_tokens=1000, total_tokens=2800), ["m"] From 800e1d4f3598cd9ed0dab30e10fc5f98d88e4861 Mon Sep 17 00:00:00 2001 From: Marty Sullivan Date: Sun, 16 Aug 2026 22:42:36 -0400 Subject: [PATCH 06/11] fix(cost): treat a batch rate configured as zero as free, not unset batch_cost_calculator gated the batch rate fields on truthiness, so a deployment that configures input_cost_per_token_batches or its output twin as 0.0 was read as having configured nothing and that token direction fell through to half the standard rate. Layering declared rates over published ones made this reachable: a deployment declaring only a zero batch rate previously kept a fabricated zero on the standard field, which happened to bill nothing. The two batch fields are now gated on presence. Verified no cost-map entry changes behavior: the only three carrying a zero batch rate are embeddings, whose standard output rate is also 0.0, so both paths yield the same zero. Adds a parametrized regression over an explicit zero, an explicit non-zero, and unset, plus coverage for the deployment id get_model_info cannot resolve, which were the lines Codecov flagged. --- litellm/cost_calculator.py | 4 +- .../test_litellm_logging.py | 13 +++++++ tests/test_litellm/test_cost_calculator.py | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index b37ff865c65..8369bc3a6a2 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -2160,7 +2160,7 @@ def batch_cost_calculator( output_cost_per_token: Final = model_info.get("output_cost_per_token") total_prompt_cost = 0.0 total_completion_cost = 0.0 - if input_cost_per_token_batches: + if input_cost_per_token_batches is not None: total_prompt_cost = usage.prompt_tokens * input_cost_per_token_batches elif input_cost_per_token: details: Final = parse_prompt_tokens_details(usage) @@ -2180,7 +2180,7 @@ def batch_cost_calculator( cache_creation_cost: Final = model_info.get("cache_creation_input_token_cost") or input_cost_per_token total_prompt_cost += cache_creation_tokens * cache_creation_cost / 2 - if output_cost_per_token_batches: + if output_cost_per_token_batches is not None: total_completion_cost = usage.completion_tokens * output_cost_per_token_batches elif output_cost_per_token: total_completion_cost = ( diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 4acbe276cf5..8e1d4cb877f 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -437,6 +437,19 @@ class TestGetRouterDeploymentModelInfo: finally: litellm.model_cost.pop(deployment_id, None) + def test_returns_none_when_the_deployment_id_resolves_no_provider(self, logging_obj) -> None: + """A registration whose id get_model_info cannot resolve yields no pricing.""" + deployment_id = "deploy-unresolvable-provider-1" + litellm.model_cost[deployment_id] = {"id": deployment_id, "input_cost_per_token": 4e-06} + logging_obj.litellm_params = {"litellm_metadata": {"model_info": {"id": deployment_id}}} + logging_obj.model_call_details["model"] = None + logging_obj.model = None + try: + with patch.object(litellm, "get_model_info", side_effect=Exception("unresolvable")): + assert logging_obj.get_router_deployment_model_info() is None + finally: + litellm.model_cost.pop(deployment_id, None) + def test_falls_back_to_declared_rates_when_the_model_has_no_published_entry(self, logging_obj) -> None: """With no published entry to layer under, the declared rates still apply.""" deployment_id = "deploy-unpublished-model-1" diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index a51f4e733b6..850d860b9d2 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -3595,6 +3595,43 @@ def test_batch_cost_calculator_cache_creation_falls_back_to_input_rate(): assert prompt_cost == pytest.approx((1000 * 3e-6 + 8000 * 3e-7 + 2000 * 3e-6) / 2) +@pytest.mark.parametrize( + "batch_rate,expected_prompt,expected_completion", + [ + (0.0, 0.0, 0.0), + (1e-6, 1000 * 1e-6, 500 * 1e-6), + (None, 1000 * 3e-6 / 2, 500 * 15e-6 / 2), + ], + ids=["explicit-zero", "explicit-nonzero", "unset"], +) +def test_batch_cost_calculator_honors_an_explicitly_zero_batch_rate( + batch_rate: float | None, + expected_prompt: float, + expected_completion: float, +) -> None: + """A batch rate configured as 0.0 means free, not unset. + + Gating the batch fields on truthiness read an explicit 0.0 as absent and + charged half the standard rate for that token direction instead. + """ + from litellm.cost_calculator import batch_cost_calculator + + model_info: dict[str, float] = {"input_cost_per_token": 3e-6, "output_cost_per_token": 15e-6} + if batch_rate is not None: + model_info["input_cost_per_token_batches"] = batch_rate + model_info["output_cost_per_token_batches"] = batch_rate + + prompt_cost, completion_cost_value = batch_cost_calculator( + usage=Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500), + model="claude-sonnet-4-5-20250929", + custom_llm_provider="anthropic", + model_info=model_info, # type: ignore[arg-type] + ) + + assert prompt_cost == pytest.approx(expected_prompt) + assert completion_cost_value == pytest.approx(expected_completion) + + def test_combine_usage_objects_sums_mirrored_cache_write_fields_once(): """ cache_write_tokens and cache_creation_tokens mirror each other on From e7c2ce8624134ba00cbfc1cfa85b05ca0ec4c895 Mon Sep 17 00:00:00 2001 From: Marty Sullivan Date: Sun, 16 Aug 2026 23:21:24 -0400 Subject: [PATCH 07/11] test(batches): cover the deployment with no resolvable model at all Codecov's remaining uncovered patch line was the early return taken when no model is available to look a published entry up by, which leaves a deployment's own declared rates standing alone. Measuring the patch lines against the coverage report now leaves none uncovered. --- .../test_litellm_logging.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 8e1d4cb877f..180889fc36f 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -437,6 +437,28 @@ class TestGetRouterDeploymentModelInfo: finally: litellm.model_cost.pop(deployment_id, None) + def test_keeps_declared_rates_when_no_model_is_resolvable(self, logging_obj) -> None: + """With no model to look a published entry up by, the declared rates stand alone.""" + deployment_id = "deploy-no-model-at-all-1" + litellm.model_cost[deployment_id] = { + "id": deployment_id, + "input_cost_per_token": 9e-06, + "output_cost_per_token": 2e-05, + "litellm_provider": "bedrock", + "mode": "chat", + } + logging_obj.litellm_params = {"litellm_metadata": {"model_info": {"id": deployment_id}}} + logging_obj.model_call_details["model"] = None + logging_obj.model = None + try: + assert logging_obj.get_deployment_model_for_cost() is None + info = logging_obj.get_router_deployment_model_info() + assert info is not None + assert info["input_cost_per_token"] == 9e-06 + assert info["output_cost_per_token"] == 2e-05 + finally: + litellm.model_cost.pop(deployment_id, None) + def test_returns_none_when_the_deployment_id_resolves_no_provider(self, logging_obj) -> None: """A registration whose id get_model_info cannot resolve yields no pricing.""" deployment_id = "deploy-unresolvable-provider-1" From bc977b76dc3340f7a24baa40a04b6207a19b09e4 Mon Sep 17 00:00:00 2001 From: Marty Sullivan Date: Sun, 16 Aug 2026 23:41:07 -0400 Subject: [PATCH 08/11] fix(batches): own deployment pricing per token direction, not per field Filling each cost field independently let a published batch rate outrank a standard rate the deployment configured itself: a deployment declaring only input_cost_per_token had its batches billed at the model's published batch price rather than half its own rate. Measured on a model that publishes both, that billed $0.001500 where the deployment's own rate meant $0.000500. Declaring either rate for a direction now claims that whole direction, so nothing published can displace it, and a direction the deployment is silent on still inherits both published rates. --- litellm/litellm_core_utils/litellm_logging.py | 23 +++++++---- .../test_litellm_logging.py | 41 +++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index c22ac9eb4c9..96d9aa744fd 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -611,8 +611,11 @@ class Logging(LiteLLMLoggingBaseClass): decides that: the router registers an entry for every deployment, and get_model_info fills absent costs with 0, so asking it directly cannot tell "configured as free" apart from "no pricing configured". A deployment - may declare only one side of its pricing, so a rate it leaves unset keeps - the model's published value instead of billing as zero. + may declare only one side of its pricing, so the side it leaves out keeps + the model's published rates instead of billing as zero. Ownership is per + token direction: declaring either rate for a direction takes that whole + direction, so a published batch rate can never displace a standard rate + the deployment configured itself. """ model_id: Final = self.get_router_model_id() if model_id is None: @@ -629,13 +632,19 @@ class Logging(LiteLLMLoggingBaseClass): published: Final = self._published_model_info() if published is None: return merged - if registered.get("input_cost_per_token") is None: + declares_input: Final = ( + registered.get("input_cost_per_token") is not None + or registered.get("input_cost_per_token_batches") is not None + ) + declares_output: Final = ( + registered.get("output_cost_per_token") is not None + or registered.get("output_cost_per_token_batches") is not None + ) + if not declares_input: merged["input_cost_per_token"] = published.get("input_cost_per_token") - if registered.get("output_cost_per_token") is None: - merged["output_cost_per_token"] = published.get("output_cost_per_token") - if registered.get("input_cost_per_token_batches") is None: merged["input_cost_per_token_batches"] = published.get("input_cost_per_token_batches") - if registered.get("output_cost_per_token_batches") is None: + if not declares_output: + merged["output_cost_per_token"] = published.get("output_cost_per_token") merged["output_cost_per_token_batches"] = published.get("output_cost_per_token_batches") return merged diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 180889fc36f..007617c309d 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -437,6 +437,47 @@ class TestGetRouterDeploymentModelInfo: finally: litellm.model_cost.pop(deployment_id, None) + def test_a_published_batch_rate_never_displaces_a_declared_standard_rate(self) -> None: + """Ownership is per token direction, not per field. + + Filling the batch field from the published entry let that rate win, so a + deployment configuring only its standard rate had batches billed at the + published batch price instead of half the rate it configured. + """ + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + + model = "ft:gpt-3.5-turbo" + published = litellm.get_model_info(model=model) + assert published["input_cost_per_token_batches"] is not None + + deployment_id = "deploy-standard-input-only-1" + litellm.model_cost[deployment_id] = { + "id": deployment_id, + "input_cost_per_token": 1e-06, + "litellm_provider": "openai", + "mode": "chat", + } + obj = LiteLLMLoggingObj( + model=model, + messages=[], + stream=False, + call_type="aretrieve_batch", + start_time=time.time(), + litellm_call_id="direction-ownership", + function_id="f", + ) + obj.litellm_params = {"litellm_metadata": {"model_info": {"id": deployment_id}}, "model": model} + obj.model_call_details["model"] = model + try: + info = obj.get_router_deployment_model_info() + assert info is not None + assert info["input_cost_per_token"] == 1e-06 + assert info["input_cost_per_token_batches"] is None + assert info["output_cost_per_token"] == published["output_cost_per_token"] + assert info["output_cost_per_token_batches"] == published["output_cost_per_token_batches"] + finally: + litellm.model_cost.pop(deployment_id, None) + def test_keeps_declared_rates_when_no_model_is_resolvable(self, logging_obj) -> None: """With no model to look a published entry up by, the declared rates stand alone.""" deployment_id = "deploy-no-model-at-all-1" From 8a43a8c7e30f3a54b977640124a511b6b9f6096c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:48:50 -0700 Subject: [PATCH 09/11] fix(logging): merge deployment pricing onto a copy of the cached model info --- litellm/litellm_core_utils/litellm_logging.py | 2 +- .../test_litellm_logging.py | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 96d9aa744fd..f59cd966261 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -626,7 +626,7 @@ class Logging(LiteLLMLoggingBaseClass): ): return None try: - merged: Final = litellm.get_model_info(model=model_id) + merged: Final = litellm.get_model_info(model=model_id).copy() except Exception: # noqa: BLE001 # get_model_info raises for ids it cannot resolve a provider for return None published: Final = self._published_model_info() diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 007617c309d..946f19b7658 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -478,6 +478,38 @@ class TestGetRouterDeploymentModelInfo: finally: litellm.model_cost.pop(deployment_id, None) + def test_merging_does_not_mutate_the_cached_model_info(self) -> None: + """The published-rate merge must not write into get_model_info's lru-cached dict. + + get_model_info returns the same cached object on every call, so writing + the published rates into it poisoned every later lookup of the + deployment id for the life of the process. + """ + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + + model = "bedrock/global.anthropic.claude-sonnet-4-6" + deployment_id = "deploy-cache-not-poisoned-1" + litellm.model_cost[deployment_id] = {"id": deployment_id, "input_cost_per_token": 1e-06} + obj = LiteLLMLoggingObj( + model=model, + messages=[], + stream=False, + call_type="aretrieve_batch", + start_time=time.time(), + litellm_call_id="cache-not-poisoned", + function_id="f", + ) + obj.litellm_params = {"litellm_metadata": {"model_info": {"id": deployment_id}}, "model": model} + obj.model_call_details["model"] = model + try: + cached_before = dict(litellm.get_model_info(model=deployment_id)) + info = obj.get_router_deployment_model_info() + assert info is not None + assert info["output_cost_per_token"] == 1.5e-05 + assert dict(litellm.get_model_info(model=deployment_id)) == cached_before + finally: + litellm.model_cost.pop(deployment_id, None) + def test_keeps_declared_rates_when_no_model_is_resolvable(self, logging_obj) -> None: """With no model to look a published entry up by, the declared rates stand alone.""" deployment_id = "deploy-no-model-at-all-1" From 5a11fe141e3907204d07d25ccdfadafa2520b779 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:36:39 -0700 Subject: [PATCH 10/11] fix(batches): price poller-tracked batches from the deployment's registered rates --- .../proxy/common_utils/check_batch_cost.py | 14 ++- litellm/litellm_core_utils/litellm_logging.py | 108 +++++++++--------- .../proxy_unit_tests/test_check_batch_cost.py | 102 +++++++++++++++++ 3 files changed, 168 insertions(+), 56 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index a05cbefd52e..a8e46349917 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -583,6 +583,7 @@ class CheckBatchCost: from litellm.files.main import afile_content from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging + from litellm.litellm_core_utils.litellm_logging import deployment_pricing_model_info from litellm.proxy.openai_files_endpoints.common_utils import ( _is_base64_encoded_unified_file_id, ) @@ -703,15 +704,20 @@ class CheckBatchCost: f"{_file_attr}={_raw_file_id!r}: {_e}" ) - # Pass deployment model_info so custom batch pricing - # (input_cost_per_token_batches etc.) is used for cost calc - deployment_model_info = deployment_info.model_info.model_dump() if deployment_info.model_info else {} + # Pass the deployment's router-registered pricing (litellm_params custom + # rates merged with the model's published rates) so custom batch pricing + # (input_cost_per_token_batches etc.) is used for cost calc, exactly as + # the inline retrieve path does. + deployment_model_info = deployment_pricing_model_info( + model_id=model_id, + deployment_model=litellm_model_name, + ) batch_cost, batch_usage, batch_models = ( await calculate_batch_cost_and_usage( file_content_dictionary=file_content_as_dict, custom_llm_provider=llm_provider, # type: ignore model_name=model_name, - model_info=deployment_model_info, # type: ignore[arg-type] + model_info=deployment_model_info, ) ) logging_obj = LiteLLMLogging( diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index f59cd966261..edb4d56a5b7 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -316,6 +316,58 @@ _DEPLOYMENT_PRICING_KEYS: Final = ( ) +def deployment_pricing_model_info(model_id: str | None, deployment_model: str | None) -> ModelInfo | None: + """Pricing the router registered under this deployment's model_info.id. + + Returns None when the deployment declares no pricing of its own, so the + caller falls back to the global cost map. The raw registration is what + decides that: the router registers an entry for every deployment, and + get_model_info fills absent costs with 0, so asking it directly cannot + tell "configured as free" apart from "no pricing configured". A deployment + may declare only one side of its pricing, so the side it leaves out keeps + the model's published rates instead of billing as zero. Ownership is per + token direction: declaring either rate for a direction takes that whole + direction, so a published batch rate can never displace a standard rate + the deployment configured itself. + """ + if model_id is None: + return None + registered: Final = litellm.model_cost.get(model_id) + if not isinstance(registered, dict) or not any(registered.get(key) is not None for key in _DEPLOYMENT_PRICING_KEYS): + return None + try: + merged: Final = litellm.get_model_info(model=model_id).copy() + except Exception: # noqa: BLE001 # get_model_info raises for ids it cannot resolve a provider for + return None + published: Final = _published_pricing(deployment_model) + if published is None: + return merged + declares_input: Final = ( + registered.get("input_cost_per_token") is not None or registered.get("input_cost_per_token_batches") is not None + ) + declares_output: Final = ( + registered.get("output_cost_per_token") is not None + or registered.get("output_cost_per_token_batches") is not None + ) + if not declares_input: + merged["input_cost_per_token"] = published.get("input_cost_per_token") + merged["input_cost_per_token_batches"] = published.get("input_cost_per_token_batches") + if not declares_output: + merged["output_cost_per_token"] = published.get("output_cost_per_token") + merged["output_cost_per_token_batches"] = published.get("output_cost_per_token_batches") + return merged + + +def _published_pricing(deployment_model: str | None) -> ModelInfo | None: + """The cost map's own entry for the deployment's model, when it resolves.""" + if deployment_model is None: + return None + try: + return litellm.get_model_info(model=deployment_model) + except Exception: # noqa: BLE001 # no published entry to layer the declared rates over + return None + + class Logging(LiteLLMLoggingBaseClass): global \ supabaseClient, \ @@ -604,59 +656,11 @@ class Logging(LiteLLMLoggingBaseClass): return next((candidate for candidate in candidates if isinstance(candidate, str) and candidate), None) def get_router_deployment_model_info(self) -> ModelInfo | None: - """Pricing the router registered under this deployment's model_info.id. - - Returns None when the deployment declares no pricing of its own, so the - caller falls back to the global cost map. The raw registration is what - decides that: the router registers an entry for every deployment, and - get_model_info fills absent costs with 0, so asking it directly cannot - tell "configured as free" apart from "no pricing configured". A deployment - may declare only one side of its pricing, so the side it leaves out keeps - the model's published rates instead of billing as zero. Ownership is per - token direction: declaring either rate for a direction takes that whole - direction, so a published batch rate can never displace a standard rate - the deployment configured itself. - """ - model_id: Final = self.get_router_model_id() - if model_id is None: - return None - registered: Final = litellm.model_cost.get(model_id) - if not isinstance(registered, dict) or not any( - registered.get(key) is not None for key in _DEPLOYMENT_PRICING_KEYS - ): - return None - try: - merged: Final = litellm.get_model_info(model=model_id).copy() - except Exception: # noqa: BLE001 # get_model_info raises for ids it cannot resolve a provider for - return None - published: Final = self._published_model_info() - if published is None: - return merged - declares_input: Final = ( - registered.get("input_cost_per_token") is not None - or registered.get("input_cost_per_token_batches") is not None + """See deployment_pricing_model_info; None means fall back to the global cost map.""" + return deployment_pricing_model_info( + model_id=self.get_router_model_id(), + deployment_model=self.get_deployment_model_for_cost(), ) - declares_output: Final = ( - registered.get("output_cost_per_token") is not None - or registered.get("output_cost_per_token_batches") is not None - ) - if not declares_input: - merged["input_cost_per_token"] = published.get("input_cost_per_token") - merged["input_cost_per_token_batches"] = published.get("input_cost_per_token_batches") - if not declares_output: - merged["output_cost_per_token"] = published.get("output_cost_per_token") - merged["output_cost_per_token_batches"] = published.get("output_cost_per_token_batches") - return merged - - def _published_model_info(self) -> ModelInfo | None: - """The cost map's own entry for this deployment's model, when it resolves.""" - deployment_model: Final = self.get_deployment_model_for_cost() - if deployment_model is None: - return None - try: - return litellm.get_model_info(model=deployment_model) - except Exception: # noqa: BLE001 # no published entry to layer the declared rates over - return None def update_environment_variables( self, diff --git a/tests/proxy_unit_tests/test_check_batch_cost.py b/tests/proxy_unit_tests/test_check_batch_cost.py index 2ac15502840..1dbbbfc43a0 100644 --- a/tests/proxy_unit_tests/test_check_batch_cost.py +++ b/tests/proxy_unit_tests/test_check_batch_cost.py @@ -449,6 +449,108 @@ class TestCheckBatchCost: ), "snapshot must be a MappingProxyType; a plain dict is rejected by get_configured_s3_bucket_name" assert snapshot["s3_bucket_name"] == "configured-batch-bucket" + @pytest.mark.asyncio + async def test_poller_prices_with_deployment_registered_batch_rates( + self, check_batch_cost_instance, mock_prisma_client, mock_llm_router + ): + """The cost poller must price with the rates the router registered for the deployment. + + The deployment's raw model_info dict carries no litellm_params pricing, so passing + its model_dump() made the poller bill custom-rate batches at the public cost-map + price while the inline retrieve path billed the declared rate. + """ + from unittest.mock import patch + + import litellm + + deployment_id = "deploy-poller-registered-rates-1" + litellm.model_cost[deployment_id] = { + "id": deployment_id, + "input_cost_per_token_batches": 2e-06, + "output_cost_per_token_batches": 4e-06, + "litellm_provider": "bedrock", + "mode": "chat", + } + + mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(return_value=1) + mock_prisma_client.db.litellm_managedobjecttable.update = AsyncMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + + mock_job = MagicMock() + mock_job.id = "job-poller-rates-1" + mock_job.unified_object_id = "dW5pZmllZF9iYXRjaF9pZA==" + mock_job.created_by = "user-1" + mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=[mock_job]) + + mock_response = MagicMock() + mock_response.status = "completed" + mock_response.output_file_id = "file-output-123" + mock_response.model_dump_json.return_value = '{"id":"batch-1","status":"completed"}' + + mock_llm_router.aretrieve_batch = AsyncMock(return_value=mock_response) + mock_llm_router.get_deployment_credentials_with_provider = MagicMock( + return_value={"custom_llm_provider": "bedrock", "aws_region_name": "us-east-1"} + ) + + mock_deployment = MagicMock() + mock_deployment.litellm_params.custom_llm_provider = "bedrock" + mock_deployment.litellm_params.model = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" + mock_deployment.model_info.model_dump.return_value = {} + mock_llm_router.get_deployment = MagicMock(return_value=mock_deployment) + + mock_file_content = MagicMock() + mock_file_content.content = b'{"recordId":"req-1"}' + + decoded_id = f"llm_model_id,{deployment_id};llm_batch_id,batch-456;" + + try: + with ( + patch( + "litellm.proxy.openai_files_endpoints.common_utils._is_base64_encoded_unified_file_id", + side_effect=[decoded_id, None], + ), + patch( + "litellm.proxy.openai_files_endpoints.common_utils.get_model_id_from_unified_batch_id", + return_value=deployment_id, + ), + patch( + "litellm.proxy.openai_files_endpoints.common_utils.get_batch_id_from_unified_batch_id", + return_value="batch-456", + ), + patch( + "litellm.files.main.afile_content", + new_callable=AsyncMock, + return_value=mock_file_content, + ), + patch( + "litellm.batches.batch_utils._get_file_content_as_dictionary", + return_value=[{"recordId": "req-1"}], + ), + patch( + "litellm.batches.batch_utils.calculate_batch_cost_and_usage", + new_callable=AsyncMock, + return_value=(0.0052, {"prompt_tokens": 1400, "completion_tokens": 600}, ["claude-haiku-4-5"]), + ) as mock_calculate, + patch( + "litellm.litellm_core_utils.get_llm_provider_logic.get_llm_provider", + return_value=("us.anthropic.claude-haiku-4-5-20251001-v1:0", "bedrock", None, None), + ), + patch("litellm.litellm_core_utils.litellm_logging.Logging") as mock_logging_cls, + ): + mock_logging_obj = MagicMock() + mock_logging_obj.async_success_handler = AsyncMock() + mock_logging_cls.return_value = mock_logging_obj + + await check_batch_cost_instance.check_batch_cost() + finally: + litellm.model_cost.pop(deployment_id, None) + + mock_calculate.assert_awaited_once() + passed_model_info = mock_calculate.await_args.kwargs["model_info"] + assert passed_model_info is not None, "poller must pass the deployment's registered pricing" + assert passed_model_info["input_cost_per_token_batches"] == 2e-06 + assert passed_model_info["output_cost_per_token_batches"] == 4e-06 + @pytest.mark.asyncio async def test_primary_path_completion_update_includes_batch_processed( self, check_batch_cost_instance, mock_prisma_client, mock_llm_router From e736b5980285f7ac8d0343d04db763c56e519bd7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:40:29 -0700 Subject: [PATCH 11/11] test(cost): type the batch_cost_calculator model_info literals instead of suppressing --- tests/test_litellm/test_cost_calculator.py | 48 ++++++++++++++-------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 850d860b9d2..75c90d793fe 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -20,7 +20,7 @@ from litellm.cost_calculator import ( response_cost_calculator, ) from litellm.types.llms.openai import OpenAIRealtimeStreamList -from litellm.types.utils import ModelResponse, PromptTokensDetailsWrapper, Usage +from litellm.types.utils import ModelInfo, ModelResponse, PromptTokensDetailsWrapper, Usage from litellm.utils import TranscriptionResponse @@ -3562,16 +3562,18 @@ def test_batch_cost_calculator_prices_cache_creation_tokens_at_cache_write_rate( """ from litellm.cost_calculator import batch_cost_calculator + model_info: ModelInfo = { + "supported_openai_params": [], + "input_cost_per_token": 3e-6, + "output_cost_per_token": 15e-6, + "cache_read_input_token_cost": 3e-7, + "cache_creation_input_token_cost": 3.75e-6, + } prompt_cost, completion_cost_value = batch_cost_calculator( usage=_batch_cache_usage(), model="claude-sonnet-4-5-20250929", custom_llm_provider="anthropic", - model_info={ # type: ignore[arg-type] - "input_cost_per_token": 3e-6, - "output_cost_per_token": 15e-6, - "cache_read_input_token_cost": 3e-7, - "cache_creation_input_token_cost": 3.75e-6, - }, + model_info=model_info, ) assert prompt_cost == pytest.approx((1000 * 3e-6 + 8000 * 3e-7 + 2000 * 3.75e-6) / 2) @@ -3581,15 +3583,17 @@ def test_batch_cost_calculator_prices_cache_creation_tokens_at_cache_write_rate( def test_batch_cost_calculator_cache_creation_falls_back_to_input_rate(): from litellm.cost_calculator import batch_cost_calculator + model_info: ModelInfo = { + "supported_openai_params": [], + "input_cost_per_token": 3e-6, + "output_cost_per_token": 15e-6, + "cache_read_input_token_cost": 3e-7, + } prompt_cost, _ = batch_cost_calculator( usage=_batch_cache_usage(), model="claude-sonnet-4-5-20250929", custom_llm_provider="anthropic", - model_info={ # type: ignore[arg-type] - "input_cost_per_token": 3e-6, - "output_cost_per_token": 15e-6, - "cache_read_input_token_cost": 3e-7, - }, + model_info=model_info, ) assert prompt_cost == pytest.approx((1000 * 3e-6 + 8000 * 3e-7 + 2000 * 3e-6) / 2) @@ -3616,16 +3620,26 @@ def test_batch_cost_calculator_honors_an_explicitly_zero_batch_rate( """ from litellm.cost_calculator import batch_cost_calculator - model_info: dict[str, float] = {"input_cost_per_token": 3e-6, "output_cost_per_token": 15e-6} - if batch_rate is not None: - model_info["input_cost_per_token_batches"] = batch_rate - model_info["output_cost_per_token_batches"] = batch_rate + base_model_info: ModelInfo = { + "supported_openai_params": [], + "input_cost_per_token": 3e-6, + "output_cost_per_token": 15e-6, + } + model_info: ModelInfo = ( + base_model_info + if batch_rate is None + else { + **base_model_info, + "input_cost_per_token_batches": batch_rate, + "output_cost_per_token_batches": batch_rate, + } + ) prompt_cost, completion_cost_value = batch_cost_calculator( usage=Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500), model="claude-sonnet-4-5-20250929", custom_llm_provider="anthropic", - model_info=model_info, # type: ignore[arg-type] + model_info=model_info, ) assert prompt_cost == pytest.approx(expected_prompt)