diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index cecc35ee1c1..4fd89493099 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -164,17 +164,33 @@ def remove_items_at_indices(items: Optional[List[Any]], indices: Iterable[int]) items.pop(index) +SPEND_TRACKING_DEPLOYMENT_IDENTITY_KEYS = ( + "model_info", + "model_group", + "deployment", + "deployment_model_name", +) + + def add_missing_spend_metadata_to_litellm_metadata(litellm_metadata: dict, metadata: dict) -> dict: """ Helper to get litellm metadata for spend tracking PATCH for issue where both `litellm_metadata` and `metadata` are present in the kwargs - and user_api_key values are in 'metadata'. + and spend-tracking values are in 'metadata'. + + The router always writes the selected deployment's identity (model_info.id, + model_group, deployment name) into `metadata`, but spend logging reads from + `litellm_metadata` whenever a request carries one (e.g. caller-supplied tags). + Without copying these over, the same deployment logs a blank `model_id` and a + provider-prefix-stripped `model` for requests that include `litellm_metadata`. """ - potential_spend_tracking_metadata_substring = "user_api_key" for key, value in metadata.items(): - if potential_spend_tracking_metadata_substring in key: + if "user_api_key" in key: litellm_metadata[key] = value + for key in SPEND_TRACKING_DEPLOYMENT_IDENTITY_KEYS: + if key not in litellm_metadata and key in metadata: + litellm_metadata[key] = metadata[key] return litellm_metadata diff --git a/tests/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index b4f539da286..5530be15a43 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -4,6 +4,7 @@ import pytest from litellm.litellm_core_utils.core_helpers import ( _FINISH_REASON_MAP, + get_litellm_metadata_from_kwargs, get_or_create_metadata_bucket, map_finish_reason, reconstruct_model_name, @@ -11,6 +12,73 @@ from litellm.litellm_core_utils.core_helpers import ( ) +def _spend_log_identity(kwargs: dict) -> dict: + """Mirror how spend logging derives model / model_id from request kwargs.""" + metadata = get_litellm_metadata_from_kwargs(kwargs) + return { + "model": reconstruct_model_name( + kwargs.get("model") or "", kwargs.get("custom_llm_provider"), metadata or {} + ), + "model_id": metadata.get("model_info", {}).get("id", ""), + "model_group": metadata.get("model_group", ""), + } + + +def test_spend_log_deployment_identity_consistent_with_litellm_metadata(): + """Regression for #35472: an openai/ passthrough deployment must log the same + model / model_id whether or not the request also carries litellm_metadata. + + The router writes the deployment identity into `metadata`, but spend logging + reads `litellm_metadata` when present, so those keys must be carried over.""" + router_metadata = { + "deployment": "openai/anthropic/claude-sonnet-5", + "model_info": {"id": "9da5dfc9-2223-4f77-b3c9-f9100d9cb2a0"}, + "model_group": "my-model", + "user_api_key_hash": "abc", + } + + without_litellm_metadata = _spend_log_identity( + { + "model": "anthropic/claude-sonnet-5", + "custom_llm_provider": "openai", + "litellm_params": {"metadata": dict(router_metadata)}, + } + ) + with_litellm_metadata = _spend_log_identity( + { + "model": "anthropic/claude-sonnet-5", + "custom_llm_provider": "openai", + "litellm_params": { + "metadata": dict(router_metadata), + "litellm_metadata": {"tags": ["t1"]}, + }, + } + ) + + expected = { + "model": "openai/anthropic/claude-sonnet-5", + "model_id": "9da5dfc9-2223-4f77-b3c9-f9100d9cb2a0", + "model_group": "my-model", + } + assert without_litellm_metadata == expected + assert with_litellm_metadata == expected + + +def test_get_litellm_metadata_from_kwargs_does_not_overwrite_existing_identity(): + """Caller-supplied identity keys in litellm_metadata must win over metadata.""" + kwargs = { + "litellm_params": { + "metadata": {"model_group": "router-group", "deployment": "router-dep"}, + "litellm_metadata": {"model_group": "caller-group"}, + } + } + + metadata = get_litellm_metadata_from_kwargs(kwargs) + + assert metadata["model_group"] == "caller-group" + assert metadata["deployment"] == "router-dep" + + class TestGetOrCreateMetadataBucket: """The single owner every guardrail writer and reader shares, so the response header and the spend log can never disagree about which dict a record lives in."""