feat(spend-logs): record Azure spillover source deployment in spend log metadata

SpendLogsMetadata gains a typed azure_spillover key so a request Azure
served off pay-as-you-go capacity is visible in spend tracking, stamped
from the provider response headers or the processed llm_provider- headers
on the standard logging payload. The header parsing moves into a shared
azure_spillover() helper that is_spilled_over_ptu_request() now wraps.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-17 05:48:51 +00:00
parent a57483d1c8
commit 7b855bd53f
6 changed files with 129 additions and 8 deletions

View file

@ -14,10 +14,11 @@ from typing import Final
from litellm.secret_managers.main import get_secret_bool
from litellm.types.router import ModelInfo
from litellm.types.utils import CustomPricingLiteLLMParams, MirroredPricingParams
from litellm.types.utils import AzureSpillover, CustomPricingLiteLLMParams, MirroredPricingParams
PTU_COST_ATTRIBUTION_ENV_VAR: Final = "LITELLM_ENABLE_PTU_COST_ATTRIBUTION"
AZURE_SPILLOVER_HEADER: Final = "x-ms-is-spilled-over"
AZURE_SPILLOVER_FROM_HEADER: Final = "x-ms-spillover-from-deployment"
def is_ptu_cost_attribution_enabled() -> bool:
@ -248,10 +249,21 @@ def is_spilled_over_ptu_request(
return False
if not is_ptu_cost_attribution_enabled():
return False
for headers, key in (
(response_headers, AZURE_SPILLOVER_HEADER),
(additional_headers, f"llm_provider-{AZURE_SPILLOVER_HEADER}"),
return azure_spillover(response_headers, additional_headers) is not None
def azure_spillover(
response_headers: Mapping[str, object] | None,
additional_headers: Mapping[str, object] | None,
) -> AzureSpillover | None:
"""The spillover Azure reports in the response headers, else None."""
for headers, prefix in (
(response_headers, ""),
(additional_headers, "llm_provider-"),
):
if headers is not None and str(headers.get(key)).lower() == "true":
return True
return False
if headers is None or str(headers.get(f"{prefix}{AZURE_SPILLOVER_HEADER}")).lower() != "true":
continue
return AzureSpillover(
from_deployment=str(v) if (v := headers.get(f"{prefix}{AZURE_SPILLOVER_FROM_HEADER}")) is not None else None
)
return None

View file

@ -51,6 +51,7 @@ from litellm.types.router import RouterErrors, UpdateRouterConfig
from litellm.types.router_weights import validate_router_settings_dict
from litellm.types.secret_managers.main import KeyManagementSystem
from litellm.types.utils import (
AzureSpillover,
CallTypes,
CostBreakdown,
EmbeddingResponse,
@ -3895,6 +3896,7 @@ class SpendLogsMetadata(TypedDict):
autorouter_savings: ReadOnly[float | None] # stamped by the logging payload; None = not auto-routed
litellm_gateway_injected_cache: ReadOnly[str | None]
router_metadata: ReadOnly[SpendLogsRouterMetadata | None] # None = deployment not flagged internal_router_model
azure_spillover: ReadOnly[AzureSpillover | None] # None = Azure did not report spillover
class SpendLogsPayload(TypedDict):

View file

@ -39,6 +39,7 @@ from litellm.litellm_core_utils.litellm_logging import (
is_valid_sha256_hash,
request_model_access_groups_from_litellm_params,
)
from litellm.litellm_core_utils.ptu_pricing import azure_spillover
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsRouterMetadata
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
@ -47,6 +48,7 @@ from litellm.proxy.utils import PrismaClient, hash_token
from litellm.types.router import DeploymentTypedDict, LiteLLM_Params
from litellm.types.utils import (
PROMPT_CARRYING_GUARDRAIL_FIELDS,
AzureSpillover,
CallTypes,
CostBreakdown,
LlmProviders,
@ -133,6 +135,9 @@ def _get_router_metadata_for_spend_log(
)
_STAMPED_METADATA_KEYS: Final = frozenset(("router_metadata", "azure_spillover"))
def _get_spend_logs_metadata(
metadata: dict | None,
applied_guardrails: list[str] | None = None,
@ -150,6 +155,7 @@ def _get_spend_logs_metadata(
litellm_call_id: str | None = None,
autorouter_savings: float | None = None,
router_metadata: SpendLogsRouterMetadata | None = None,
azure_spillover: AzureSpillover | None = None,
) -> SpendLogsMetadata:
if metadata is None:
return SpendLogsMetadata(
@ -191,6 +197,7 @@ def _get_spend_logs_metadata(
litellm_gateway_injected_cache=None,
litellm_call_id=litellm_call_id,
router_metadata=router_metadata,
azure_spillover=azure_spillover,
)
verbose_proxy_logger.debug(
"getting payload for SpendLogs, available keys in metadata: " + str(list(metadata.keys()))
@ -198,8 +205,9 @@ def _get_spend_logs_metadata(
# Filter the metadata dictionary to include only the specified keys
clean_metadata: Final = SpendLogsMetadata(
**{key: metadata.get(key) for key in SpendLogsMetadata.__annotations__ if key != "router_metadata"},
**{key: metadata.get(key) for key in SpendLogsMetadata.__annotations__ if key not in _STAMPED_METADATA_KEYS},
router_metadata=router_metadata,
azure_spillover=azure_spillover,
)
_raw_key: Final = clean_metadata.get("user_api_key")
_trusted_hash: Final = metadata.get("user_api_key_hash")
@ -570,6 +578,15 @@ def get_logging_payload(
selected_provider=custom_llm_provider,
router_correlation_id=litellm_call_id,
),
azure_spillover=azure_spillover(
response_headers=kwargs.get("response_headers")
if isinstance(kwargs.get("response_headers"), Mapping)
else None,
additional_headers=standard_logging_payload["hidden_params"].get("additional_headers")
if standard_logging_payload is not None
and isinstance(standard_logging_payload.get("hidden_params"), Mapping)
else None,
),
)
special_usage_fields: Final = ["completion_tokens", "prompt_tokens", "total_tokens"]

View file

@ -3075,6 +3075,12 @@ class StandardLoggingMetadata(StandardLoggingUserAPIKeyMetadata):
team_id: str | None
class AzureSpillover(TypedDict):
"""Spillover Azure reports in its response headers for a request it served from pay-as-you-go capacity."""
from_deployment: ReadOnly[str | None]
class StandardLoggingAdditionalHeaders(TypedDict, total=False):
x_ratelimit_limit_requests: int
x_ratelimit_limit_tokens: int

View file

@ -12,6 +12,7 @@ from litellm.litellm_core_utils.ptu_pricing import (
PTU_ZEROED_PRICING_FIELDS,
PTU_ZEROED_TABLE_FIELDS,
SEARCH_CONTEXT_SIZES,
azure_spillover,
is_spilled_over_ptu_request,
ptu_config_error,
ptu_identity_error,
@ -327,3 +328,31 @@ def test_no_spillover_marker_keeps_the_zeroed_ptu_rates():
)
is False
)
def test_azure_spillover_carries_the_source_deployment_from_raw_headers():
assert azure_spillover(
response_headers={
"x-ms-is-spilled-over": "true",
"x-ms-spillover-from-deployment": "my-ptu",
},
additional_headers=None,
) == {"from_deployment": "my-ptu"}
def test_azure_spillover_from_processed_headers_has_no_source_when_absent():
assert azure_spillover(
response_headers=None,
additional_headers={"llm_provider-x-ms-is-spilled-over": "true"},
) == {"from_deployment": None}
def test_no_spillover_marker_returns_none():
assert (
azure_spillover(
response_headers={"x-ms-is-spilled-over": "false"},
additional_headers=None,
)
is None
)
assert azure_spillover(response_headers=None, additional_headers=None) is None

View file

@ -4829,3 +4829,58 @@ def test_spend_log_request_id_is_the_response_id_a_bridged_messages_caller_recei
)
== "resp_01Lit6806Bridged"
)
def test_azure_spillover_stamped_from_response_headers():
"""Raw provider response headers on the logging kwargs mark the request as spilled."""
kwargs: Final = {
**_routed_call_kwargs({"id": "mi-1"}),
"response_headers": {
"x-ms-is-spilled-over": "true",
"x-ms-spillover-from-deployment": "my-ptu",
},
}
payload = get_logging_payload(
kwargs=kwargs,
response_obj=litellm.ModelResponse(id="chatcmpl-spill-raw", choices=[], usage=litellm.Usage()),
start_time=datetime.datetime.now(timezone.utc),
end_time=datetime.datetime.now(timezone.utc),
)
metadata = json.loads(payload["metadata"])
assert metadata["azure_spillover"] == {"from_deployment": "my-ptu"}
def test_azure_spillover_stamped_from_standard_logging_additional_headers():
"""Streaming requests carry the processed llm_provider- headers on the standard payload."""
kwargs: Final = {
**_routed_call_kwargs({"id": "mi-1"}),
"standard_logging_object": {
"hidden_params": {
"additional_headers": {
"llm_provider-x-ms-is-spilled-over": "true",
"llm_provider-x-ms-spillover-from-deployment": "my-ptu",
}
},
"metadata": {},
"model_map_information": None,
},
}
payload = get_logging_payload(
kwargs=kwargs,
response_obj=litellm.ModelResponse(id="chatcmpl-spill-sl", choices=[], usage=litellm.Usage()),
start_time=datetime.datetime.now(timezone.utc),
end_time=datetime.datetime.now(timezone.utc),
)
metadata = json.loads(payload["metadata"])
assert metadata["azure_spillover"] == {"from_deployment": "my-ptu"}
def test_azure_spillover_absent_without_spillover_headers():
payload = get_logging_payload(
kwargs=_routed_call_kwargs({"id": "mi-1"}),
response_obj=litellm.ModelResponse(id="chatcmpl-no-spill", choices=[], usage=litellm.Usage()),
start_time=datetime.datetime.now(timezone.utc),
end_time=datetime.datetime.now(timezone.utc),
)
metadata = json.loads(payload["metadata"])
assert metadata["azure_spillover"] is None