Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,7 @@
LITELLM_METADATA_FIELD = "litellm_metadata"
OLD_LITELLM_METADATA_FIELD = "metadata"
RETURN_RAW_MODEL_NAME_METADATA_KEY = "_complexity_router_return_raw_model_name"
INTERNAL_CALL_ORIGIN_METADATA_KEY = "internal_call_origin"
LITELLM_TRUNCATED_PAYLOAD_FIELD = "litellm_truncated"
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE = (
"Truncation is a DB storage safeguard. "
Expand Down
2 changes: 2 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
EmbeddingResponse,
GenericBudgetConfigType,
ImageResponse,
InternalCallOrigin,
LiteLLMPydanticObjectBase,
ModelResponse,
ProviderField,
Expand Down Expand Up @@ -3304,6 +3305,7 @@ class SpendLogsMetadata(TypedDict):
mcp_tool_call_metadata: Optional[StandardLoggingMCPToolCall]
vector_store_request_metadata: Optional[List[StandardLoggingVectorStoreRequest]]
routing_decision: StandardLoggingRoutingDecision | None
internal_call_origin: InternalCallOrigin | None
guardrail_information: Optional[List[StandardLoggingGuardrailInformation]]
eval_information: Optional[Any]
status: StandardLoggingPayloadStatus
Expand Down
7 changes: 6 additions & 1 deletion litellm/proxy/litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
import litellm
from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm._service_logger import ServiceLogging
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS, PRE_CALL_EXECUTED_GUARDRAILS_KEY
from litellm.constants import (
INTERNAL_CALL_ORIGIN_METADATA_KEY,
LITELLM_PROXY_MASTER_KEY_ALIAS,
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
)
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
iter_client_callback_metadata_dicts,
Expand Down Expand Up @@ -199,6 +203,7 @@ def parse_cache_control(cache_control):
"applied_policies",
"policy_sources",
"routing_decision",
INTERNAL_CALL_ORIGIN_METADATA_KEY,
"standard_logging_object",
"proxy_server_request",
"secret_fields",
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/spend_tracking/spend_tracking_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def _get_spend_logs_metadata(
model_map_information=None,
usage_object=None,
guardrail_information=None,
internal_call_origin=None,
eval_information=None,
cold_storage_object_key=cold_storage_object_key,
litellm_overhead_time_ms=None,
Expand Down
12 changes: 10 additions & 2 deletions litellm/router_strategy/complexity_router/complexity_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@
from pydantic import BaseModel

from litellm._logging import verbose_router_logger
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY, RETURN_RAW_MODEL_NAME_METADATA_KEY
from litellm.integrations.custom_logger import CustomLogger
from litellm.llms.base_llm.base_utils import type_to_response_format_param
from litellm.types.utils import (
AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
ModelResponse,
RoutingDecisionCause,
StandardLoggingRoutingDecision,
Expand Down Expand Up @@ -116,7 +117,12 @@ def _classifier_call_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]
k: _sanitize_user_api_key_auth(v) if k == "user_api_key_auth" else v
for k, v in metadata.items()
if k not in _BUDGET_RESERVATION_METADATA_KEYS
}
} | {INTERNAL_CALL_ORIGIN_METADATA_KEY: AUTOROUTER_CLASSIFIER_CALL_ORIGIN}


def _parent_session_kwargs(request_kwargs: Mapping[str, Any] | None) -> Mapping[str, Any]:
kwargs = request_kwargs or {}
return {k: kwargs[k] for k in ("litellm_session_id", "litellm_trace_id") if kwargs.get(k) is not None}


def _effective_turn_off_message_logging(request_kwargs: Mapping[str, Any] | None) -> bool | None:
Expand Down Expand Up @@ -569,6 +575,7 @@ async def _classify_with_llm(
metadata=metadata,
proxy_server_request=proxy_server_request,
turn_off_message_logging=turn_off_message_logging,
**_parent_session_kwargs(request_kwargs),
)
content = response.choices[0].message.content
if not content:
Expand Down Expand Up @@ -967,6 +974,7 @@ async def _semantic_tier_override(self, user_message: str, request_kwargs: dict)
litellm_metadata=litellm_metadata,
proxy_server_request=proxy_server_request,
turn_off_message_logging=turn_off_message_logging,
**_parent_session_kwargs(request_kwargs),
)
)[0]
route_choice = await routelayer.acall(vector=query_vector)
Expand Down
7 changes: 7 additions & 0 deletions litellm/types/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2703,6 +2703,13 @@ class StandardLoggingRoutingDecisionTierBoundaries(TypedDict):
]


InternalCallOrigin = Literal["autorouter_classifier"]
"""Which internal litellm feature originated a billed sub-call, so a spend log row
records that it is not traffic the caller sent."""

AUTOROUTER_CLASSIFIER_CALL_ORIGIN: InternalCallOrigin = "autorouter_classifier"


class StandardLoggingRoutingDecision(TypedDict, total=False):
"""Per-request provenance for a pre-routing strategy (auto-router) decision."""

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2916,3 +2916,46 @@ def test_no_routing_decision_key_defaults_to_none_in_spend_log_metadata():
)
metadata = json.loads(payload["metadata"])
assert metadata["routing_decision"] is None


@pytest.mark.parametrize("bucket", ["metadata", "litellm_metadata"])
def test_internal_call_origin_survives_into_spend_log_metadata(bucket):
"""The origin is only useful if it reaches the row the Logs UI reads.

_get_spend_logs_metadata projects onto SpendLogsMetadata.__annotations__, so an
undeclared key is dropped silently. Both buckets are covered because the resolver
returns litellm_metadata when present and metadata otherwise, and the classifier
sub-call populates whichever the parent route used.
"""
payload = get_logging_payload(
kwargs={
"model": "gpt-4o-mini",
"litellm_params": {
bucket: {
"user_api_key": "test-key",
"internal_call_origin": "autorouter_classifier",
}
},
},
response_obj=litellm.ModelResponse(id="chatcmpl-classifier", 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["internal_call_origin"] == "autorouter_classifier"


def test_user_traffic_carries_no_internal_call_origin():
"""The negative class the badge depends on: an ordinary request must be
distinguishable from a classifier call, not merely unlabelled by accident."""
payload = get_logging_payload(
kwargs={
"model": "gpt-4o-mini",
"litellm_params": {"metadata": {"user_api_key": "test-key"}},
},
response_obj=litellm.ModelResponse(id="chatcmpl-user-traffic", 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["internal_call_origin"] is None
2 changes: 2 additions & 0 deletions tests/test_litellm/proxy/test_litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
"applied_policies": ["spoofed-policy"],
"policy_sources": {"spoofed-policy": "request"},
"routing_decision": {"cause": "forged", "routed_model": "spoofed"},
"internal_call_origin": "autorouter_classifier",
"_guardrail_pipelines": [{"name": "spoofed"}],
"_pipeline_managed_guardrails": ["evaded"],
"safe_user_metadata": "kept",
Expand Down Expand Up @@ -714,6 +715,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
"applied_policies",
"policy_sources",
"routing_decision",
"internal_call_origin",
"_guardrail_pipelines",
"_pipeline_managed_guardrails",
}
Expand Down
77 changes: 68 additions & 9 deletions tests/test_litellm/router_strategy/test_complexity_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -1422,7 +1422,7 @@ async def test_aclassify_forwards_request_metadata_for_spend_tracking(
request_metadata = {"user_api_key": "sk-abc", "user_api_key_team_id": "team-1"}
await llm_complexity_router.aclassify("hi", request_kwargs={"litellm_metadata": request_metadata})
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
assert call_kwargs["metadata"] == request_metadata
assert call_kwargs["metadata"] == {**request_metadata, "internal_call_origin": "autorouter_classifier"}

@pytest.mark.asyncio
async def test_aclassify_forwards_metadata_key_used_by_chat_completions(
Expand All @@ -1440,7 +1440,7 @@ async def test_aclassify_forwards_metadata_key_used_by_chat_completions(
request_metadata = {"user_api_key": "sk-abc", "user_api_key_team_id": "team-1"}
await llm_complexity_router.aclassify("hi", request_kwargs={"metadata": request_metadata})
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
assert call_kwargs["metadata"] == request_metadata
assert call_kwargs["metadata"] == {**request_metadata, "internal_call_origin": "autorouter_classifier"}

@pytest.mark.asyncio
async def test_aclassify_captures_request_body_in_proxy_server_request(
Expand Down Expand Up @@ -1551,12 +1551,38 @@ async def test_aclassify_strips_budget_reservation_from_classifier_metadata(
"user_api_key": "sk-abc",
"user_api_key_team_id": "team-1",
"user_api_key_auth": {"models": ["gpt-4o"]},
"internal_call_origin": "autorouter_classifier",
}
assert request_metadata["user_api_key_auth"] == {
"models": ["gpt-4o"],
"budget_reservation": {"reserved_cost": 1.0},
}

@pytest.mark.asyncio
@pytest.mark.parametrize(
"parent_kwargs, expected",
[
({"litellm_trace_id": "trace-1"}, {"litellm_trace_id": "trace-1"}),
({"litellm_session_id": "sess-1"}, {"litellm_session_id": "sess-1"}),
(
{"litellm_session_id": "sess-1", "litellm_trace_id": "trace-1"},
{"litellm_session_id": "sess-1", "litellm_trace_id": "trace-1"},
),
({}, {}),
],
)
async def test_aclassify_chains_classifier_call_into_parent_session(
self, llm_complexity_router, mock_router_instance, parent_kwargs, expected
):
"""Without the parent's session identity the router mints a fresh trace id for the
sub-call, so the classifier's spend row lands in a session of its own and never
appears in the trace of the request that triggered it."""
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}'))
await llm_complexity_router.aclassify("hi", request_kwargs={"metadata": {}, **parent_kwargs})
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
for key in ("litellm_session_id", "litellm_trace_id"):
assert call_kwargs.get(key) == expected.get(key)

@pytest.mark.asyncio
async def test_aclassify_falls_back_to_heuristic_on_llm_exception(
self, llm_complexity_router, mock_router_instance
Expand Down Expand Up @@ -1604,7 +1630,7 @@ async def test_pre_routing_hook_uses_llm_classifier_end_to_end(self, llm_complex
assert result is not None
assert result.model == "o1-preview" # REASONING tier model
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
assert call_kwargs["metadata"] == request_metadata
assert call_kwargs["metadata"] == {**request_metadata, "internal_call_origin": "autorouter_classifier"}


class TestRouterPreRoutingAliasOverrides:
Expand Down Expand Up @@ -2281,8 +2307,9 @@ async def test_semantic_embedding_call_carries_caller_metadata(self, basic_confi
)
assert result is not None
assert fake_router.async_embedding_kwargs, "expected an embedding call for the prompt"
assert fake_router.async_embedding_kwargs[0]["metadata"] == caller_metadata
assert fake_router.async_embedding_kwargs[0]["litellm_metadata"] == caller_litellm_metadata
origin = {"internal_call_origin": "autorouter_classifier"}
assert fake_router.async_embedding_kwargs[0]["metadata"] == {**caller_metadata, **origin}
assert fake_router.async_embedding_kwargs[0]["litellm_metadata"] == {**caller_litellm_metadata, **origin}

@pytest.mark.asyncio
async def test_semantic_embedding_call_captures_request_body_in_proxy_server_request(self, basic_config):
Expand Down Expand Up @@ -2391,6 +2418,7 @@ async def test_semantic_embedding_call_strips_budget_reservation(self, basic_con
"user_api_key_hash": "hash-abc",
"user_api_key_team_id": "team-1",
"user_api_key_auth": {"models": ["voyage-3-5"]},
"internal_call_origin": "autorouter_classifier",
}
assert fake_router.async_embedding_kwargs[0]["metadata"] == expected
assert fake_router.async_embedding_kwargs[0]["litellm_metadata"] == expected
Expand Down Expand Up @@ -2726,15 +2754,46 @@ def test_cost_callback_cannot_recover_reservation_from_sanitized_metadata(self):
assert sanitized["user_api_key_auth"] is not None
assert _get_budget_reservation_from_metadata(sanitized) is None

def test_returns_empty_dict_for_missing_metadata(self):
def test_absent_parent_bucket_stays_empty(self):
"""An absent bucket must not be materialized just to carry the origin.

The embedding path passes both buckets, and get_litellm_metadata_from_kwargs
prefers litellm_metadata whenever it is truthy, backfilling only user_api_key*
keys from metadata. Returning an origin-only dict here would make a chat
completions parent's empty litellm_metadata win and silently drop
requester_ip_address, tags and spend_logs_metadata from the classifier's row."""
from litellm.router_strategy.complexity_router.complexity_router import (
_classifier_call_metadata,
)

for absent in (None, {}):
result = _classifier_call_metadata(absent)
assert result == {}
assert isinstance(result, dict)
assert _classifier_call_metadata(absent) == {}

def test_classifier_buckets_keep_non_spend_fields_on_a_chat_completions_parent(self):
"""Drives the real resolver over the buckets the embedding classifier builds."""
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs
from litellm.router_strategy.complexity_router.complexity_router import (
_classifier_call_metadata,
)

parent = {
"user_api_key": "sk-abc",
"requester_ip_address": "10.0.0.1",
"spend_logs_metadata": {"team_note": "keep me"},
"tags": ["prod"],
}
resolved = get_litellm_metadata_from_kwargs(
{
"litellm_params": {
"metadata": _classifier_call_metadata(parent),
"litellm_metadata": _classifier_call_metadata(None),
}
}
)
assert resolved["internal_call_origin"] == "autorouter_classifier"
assert resolved["requester_ip_address"] == "10.0.0.1"
assert resolved["spend_logs_metadata"] == {"team_note": "keep me"}
assert resolved["tags"] == ["prod"]

def test_sanitized_auth_keeps_access_group_fields_and_leaves_original_untouched(self):
from litellm.proxy._types import UserAPIKeyAuth
Expand Down
Loading