mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(proxy): keep SpendLogs and callback session ids in sync when the request has none (#39450)
* fix(proxy): keep SpendLogs and callback session ids in sync when the request has none Add general_settings.missing_session_id (generate | reject). In generate mode one id is stamped into litellm_session_id, litellm_trace_id and metadata.session_id before callbacks run, so LiteLLM_SpendLogs.session_id and the Langfuse session id match. In reject mode such requests get a 400. Unset keeps the legacy behavior. MCP routes are not affected Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(proxy): regenerate schema.d.ts and shorten mutable-ok comment for ruff format Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): mark generated session ids so affinity consumers do not pin on them Fireworks x-session-affinity, the router session_affinity pre-call check and the complexity router session pin all read metadata.session_id as a caller-chosen stable key. A missing_session_id: generate id is fresh per request, so it now carries metadata.litellm_session_id_generated and those consumers skip it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
92edcb90db
commit
8441dd6e8c
11 changed files with 335 additions and 10 deletions
|
|
@ -1449,6 +1449,7 @@ RETURN_RAW_MODEL_NAME_METADATA_KEY: Final = "_complexity_router_return_raw_model
|
|||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY: Final = "_session_deployment_affinity_ttl"
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY: Final = "_consumed_request_tags"
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
|
||||
SESSION_ID_GENERATED_METADATA_KEY: Final = "litellm_session_id_generated"
|
||||
LITELLM_TRUNCATED_PAYLOAD_FIELD: Final = "litellm_truncated"
|
||||
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE: Final = (
|
||||
"Truncation is a DB storage safeguard. "
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from typing import Final
|
|||
|
||||
from httpx import Headers
|
||||
|
||||
from litellm.constants import SESSION_ID_GENERATED_METADATA_KEY
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
||||
|
|
@ -16,16 +17,18 @@ def get_fireworks_session_id(litellm_params: dict) -> str | None:
|
|||
"""
|
||||
Session id to send as `x-session-affinity`, or None when the caller gave none.
|
||||
|
||||
Deliberately does not fall back to `litellm_trace_id`: that is generated per
|
||||
request (`str(uuid.uuid4())` when absent), so using it pins every request to a
|
||||
different Fireworks node and prompt caching never hits.
|
||||
Deliberately does not fall back to `litellm_trace_id`, and ignores session ids the
|
||||
proxy generated for a request that had none: both are per request, so using them
|
||||
pins every request to a different Fireworks node and prompt caching never hits.
|
||||
"""
|
||||
params: Final = litellm_params
|
||||
metadata: Final = params.get("metadata")
|
||||
if isinstance(metadata, dict) and metadata.get(SESSION_ID_GENERATED_METADATA_KEY):
|
||||
return None
|
||||
for key in ("litellm_session_id", "session_id"):
|
||||
value = params.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
metadata: Final = params.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
value = metadata.get("session_id")
|
||||
if value:
|
||||
|
|
|
|||
|
|
@ -2594,6 +2594,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
None,
|
||||
description="When set to True, rejects requests that contain client-side 'metadata.tags' to prevent users from influencing budgets by sending different tags. Tags can only be inherited from the API key metadata.",
|
||||
)
|
||||
missing_session_id: Literal["generate", "reject"] | None = Field(
|
||||
None,
|
||||
description="What to do with LLM API requests that carry no session id (x-litellm-session-id header, metadata.session_id, etc.). 'generate' stamps one id into litellm_session_id, litellm_trace_id and metadata.session_id so SpendLogs and logging callbacks agree; 'reject' returns 400. Unset keeps the legacy behavior where SpendLogs falls back to the trace id while callbacks get no session id.",
|
||||
)
|
||||
enable_public_model_hub: bool = Field(
|
||||
default=False,
|
||||
description="Public model hub for users to see what models they have access to, supported openai params, etc.",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from starlette.datastructures import Headers
|
|||
import litellm
|
||||
from litellm._logging import verbose_logger, verbose_proxy_logger
|
||||
from litellm._service_logger import ServiceLogging
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import (
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
|
|
@ -23,6 +24,7 @@ from litellm.constants import (
|
|||
OTEL_SERVICE_NAME_METADATA_KEYS,
|
||||
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
|
||||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
|
||||
SESSION_ID_GENERATED_METADATA_KEY,
|
||||
)
|
||||
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
|
|
@ -40,6 +42,7 @@ from litellm.proxy._types import (
|
|||
AddTeamCallback,
|
||||
CommonProxyErrors,
|
||||
LitellmDataForBackendLLMCall,
|
||||
LiteLLMRoutes,
|
||||
LitellmUserRoles,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
|
|
@ -47,6 +50,8 @@ from litellm.proxy._types import (
|
|||
TeamCallbackMetadata,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_utils import get_request_route
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.common_utils.callback_utils import (
|
||||
decrypt_callback_vars,
|
||||
get_metadata_variable_name_from_kwargs,
|
||||
|
|
@ -715,6 +720,50 @@ def _get_anthropic_session_id_from_metadata(metadata: object) -> str | None:
|
|||
return session_id
|
||||
|
||||
|
||||
def _is_llm_inference_route(request: Request) -> bool:
|
||||
route: Final = get_request_route(request)
|
||||
return RouteChecks.is_llm_api_route(route=route) and not RouteChecks.check_route_access(
|
||||
route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value
|
||||
)
|
||||
|
||||
|
||||
def apply_missing_session_id_policy(
|
||||
data: dict[str, object], # mutable-ok: stamps session ids in place on the request body the pipeline threads through
|
||||
_metadata_variable_name: str,
|
||||
general_settings: Mapping[str, object] | None,
|
||||
request: Request,
|
||||
) -> None:
|
||||
policy: Final = general_settings.get("missing_session_id") if general_settings else None
|
||||
if policy is None or not _is_llm_inference_route(request):
|
||||
return
|
||||
metadata: Final = data.get(_metadata_variable_name)
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
if data.get("litellm_session_id") or metadata.get("session_id"):
|
||||
return
|
||||
match policy:
|
||||
case "generate":
|
||||
session_id: Final = str(data.get("litellm_trace_id") or metadata.get("trace_id") or uuid.uuid4())
|
||||
data["litellm_session_id"] = session_id # rebind-ok: data is an out-param
|
||||
data.setdefault("litellm_trace_id", session_id)
|
||||
metadata["session_id"] = session_id
|
||||
metadata[SESSION_ID_GENERATED_METADATA_KEY] = True
|
||||
case "reject":
|
||||
raise ProxyException(
|
||||
message=(
|
||||
"Request has no session id. Send an `x-litellm-session-id` header or `metadata.session_id`. "
|
||||
"Required by `general_settings.missing_session_id: reject`."
|
||||
),
|
||||
type=ProxyErrorTypes.bad_request_error,
|
||||
param="session_id",
|
||||
code=400,
|
||||
)
|
||||
case _:
|
||||
verbose_proxy_logger.warning(
|
||||
"Ignoring unknown general_settings.missing_session_id=%r; expected 'generate' or 'reject'", policy
|
||||
)
|
||||
|
||||
|
||||
def is_claude_code_user_agent(user_agent: str) -> bool:
|
||||
"""Claude Code identifies itself as ``claude-cli/<version> ...``; the IDE
|
||||
extensions and the Agent SDK run through the same CLI and share that prefix."""
|
||||
|
|
@ -1818,6 +1867,12 @@ async def add_litellm_data_to_request(
|
|||
data=data,
|
||||
_metadata_variable_name=_metadata_variable_name,
|
||||
)
|
||||
apply_missing_session_id_policy(
|
||||
data=data,
|
||||
_metadata_variable_name=_metadata_variable_name,
|
||||
general_settings=general_settings,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# Expose request headers under the metadata field for guardrails (fixes #17477)
|
||||
if _metadata_variable_name in data and isinstance(data[_metadata_variable_name], dict):
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, cast
|
|||
from pydantic import BaseModel, create_model
|
||||
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.constants import EMPTY_MAPPING, RETURN_RAW_MODEL_NAME_METADATA_KEY
|
||||
from litellm.constants import (
|
||||
EMPTY_MAPPING,
|
||||
RETURN_RAW_MODEL_NAME_METADATA_KEY,
|
||||
SESSION_ID_GENERATED_METADATA_KEY,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs
|
||||
from litellm.litellm_core_utils.internal_call_metadata import forwarded_internal_call_metadata
|
||||
|
|
@ -2712,7 +2716,7 @@ class ComplexityRouter(CustomLogger):
|
|||
"""Resolve a client-supplied session_id."""
|
||||
for metadata in ComplexityRouter._iter_metadata_dicts(request_kwargs):
|
||||
session_id = metadata.get("session_id")
|
||||
if session_id is not None:
|
||||
if session_id is not None and not metadata.get(SESSION_ID_GENERATED_METADATA_KEY):
|
||||
return str(session_id)
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from typing_extensions import TypedDict
|
|||
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.constants import SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY
|
||||
from litellm.constants import SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY, SESSION_ID_GENERATED_METADATA_KEY
|
||||
from litellm.integrations.custom_logger import CustomLogger, Span
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
|
@ -265,7 +265,7 @@ class DeploymentAffinityCheck(CustomLogger):
|
|||
@staticmethod
|
||||
def _get_session_id_from_metadata_dict(metadata: dict) -> str | None:
|
||||
session_id: Final = metadata.get("session_id")
|
||||
if session_id is None:
|
||||
if session_id is None or metadata.get(SESSION_ID_GENERATED_METADATA_KEY):
|
||||
return None
|
||||
return str(session_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import litellm
|
|||
|
||||
from litellm import get_model_info, supports_reasoning, supports_vision
|
||||
from litellm.llms.fireworks_ai.chat.transformation import FireworksAIConfig
|
||||
from litellm.constants import SESSION_ID_GENERATED_METADATA_KEY
|
||||
from litellm.llms.fireworks_ai.common_utils import get_fireworks_session_id
|
||||
from litellm.types.utils import (
|
||||
ChatCompletionMessageToolCall,
|
||||
|
|
@ -235,6 +236,21 @@ def test_get_fireworks_session_id_prefers_litellm_session_id_over_trace_id():
|
|||
)
|
||||
|
||||
|
||||
def test_get_fireworks_session_id_ignores_proxy_generated_session_id():
|
||||
"""general_settings.missing_session_id: generate stamps a fresh id per request; sending it
|
||||
as x-session-affinity would pin every request to a different node."""
|
||||
assert (
|
||||
get_fireworks_session_id(
|
||||
{
|
||||
"litellm_session_id": "generated-1",
|
||||
"litellm_trace_id": "generated-1",
|
||||
"metadata": {"session_id": "generated-1", SESSION_ID_GENERATED_METADATA_KEY: True},
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_handle_message_content_with_tool_calls():
|
||||
config = FireworksAIConfig()
|
||||
message = Message(
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ from litellm.litellm_core_utils.get_provider_specific_headers import (
|
|||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
TRUSTED_CALLBACK_VARS_FIELD,
|
||||
)
|
||||
from litellm.constants import SESSION_ID_GENERATED_METADATA_KEY
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.fireworks_ai.common_utils import get_fireworks_session_id
|
||||
from litellm.types.utils import CredentialItem
|
||||
|
||||
|
||||
|
|
@ -7719,3 +7721,177 @@ def test_stamped_model_access_groups_survive_the_litellm_metadata_merge():
|
|||
}
|
||||
|
||||
assert get_litellm_metadata_from_kwargs(kwargs)[MODEL_ACCESS_GROUP_METADATA_KEY] == ["tier-a"]
|
||||
|
||||
|
||||
def _request_for(path: str) -> MagicMock:
|
||||
request = MagicMock(spec=Request)
|
||||
request.scope = {"path": path}
|
||||
request.url = MagicMock()
|
||||
request.url.path = path
|
||||
request.url.__str__.return_value = f"http://localhost{path}"
|
||||
request.method = "POST"
|
||||
request.query_params = {}
|
||||
request.headers = {"Content-Type": "application/json"}
|
||||
request.client = MagicMock()
|
||||
request.client.host = "127.0.0.1"
|
||||
return request
|
||||
|
||||
|
||||
def _spend_log_session_id(data: dict[str, object]) -> str:
|
||||
"""Resolve session_id the way LiteLLM_SpendLogs does: standard_logging_payload.trace_id."""
|
||||
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import _get_session_id_for_spend_log
|
||||
|
||||
metadata = data["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
litellm_params = get_litellm_params(
|
||||
litellm_session_id=str(data["litellm_session_id"]) if "litellm_session_id" in data else None,
|
||||
litellm_trace_id=str(data["litellm_trace_id"]) if "litellm_trace_id" in data else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
trace_id = StandardLoggingPayloadSetup.get_standard_logging_payload_trace_id(
|
||||
logging_obj=SimpleNamespace(litellm_trace_id="per-call-random-trace-id"),
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
return _get_session_id_for_spend_log(kwargs={}, standard_logging_payload={"trace_id": trace_id})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("request_correlation_in_logs", [False, True])
|
||||
async def test_missing_session_id_generate_makes_spend_log_and_callback_session_ids_agree(
|
||||
monkeypatch: pytest.MonkeyPatch, request_correlation_in_logs: bool
|
||||
):
|
||||
"""Without a session header, SpendLogs.session_id and the metadata.session_id that Langfuse logs
|
||||
must be the same generated id, so cross-referencing the two by session_id works. The id is marked
|
||||
as generated so affinity consumers (Fireworks x-session-affinity, router session pins) skip it."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", request_correlation_in_logs)
|
||||
data = {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=_request_for("/v1/chat/completions"),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"missing_session_id": "generate"},
|
||||
)
|
||||
|
||||
callback_session_id = updated["metadata"]["session_id"]
|
||||
assert isinstance(callback_session_id, str) and len(callback_session_id) == 36
|
||||
assert _spend_log_session_id(updated) == callback_session_id
|
||||
assert updated["metadata"][SESSION_ID_GENERATED_METADATA_KEY] is True
|
||||
assert get_fireworks_session_id(
|
||||
{"litellm_session_id": updated["litellm_session_id"], "metadata": updated["metadata"]}
|
||||
) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_session_id_unset_keeps_legacy_divergence():
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "messages": []},
|
||||
request=_request_for("/v1/chat/completions"),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
)
|
||||
|
||||
assert "session_id" not in updated["metadata"]
|
||||
assert "litellm_session_id" not in updated
|
||||
assert _spend_log_session_id(updated) == "per-call-random-trace-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_session_id_generate_reuses_traceparent_trace_id():
|
||||
"""A W3C traceparent already decides SpendLogs.session_id, so the callback session id must reuse it."""
|
||||
request = _request_for("/v1/chat/completions")
|
||||
request.headers = {"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "messages": []},
|
||||
request=request,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"missing_session_id": "generate"},
|
||||
)
|
||||
|
||||
assert updated["metadata"]["session_id"] == "4bf92f3577b34da6a3ce929d0e0e4736"
|
||||
assert _spend_log_session_id(updated) == "4bf92f3577b34da6a3ce929d0e0e4736"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("policy", ["generate", "reject"])
|
||||
async def test_missing_session_id_policy_keeps_client_supplied_session_id(policy: str):
|
||||
request = _request_for("/v1/chat/completions")
|
||||
request.headers = {"x-litellm-session-id": "client-session-1"}
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "messages": []},
|
||||
request=request,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"missing_session_id": policy},
|
||||
)
|
||||
|
||||
assert updated["litellm_session_id"] == "client-session-1"
|
||||
assert updated["metadata"]["session_id"] == "client-session-1"
|
||||
assert _spend_log_session_id(updated) == "client-session-1"
|
||||
assert SESSION_ID_GENERATED_METADATA_KEY not in updated["metadata"]
|
||||
assert (
|
||||
get_fireworks_session_id({"litellm_session_id": "client-session-1", "metadata": updated["metadata"]})
|
||||
== "client-session-1"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_session_id_reject_accepts_body_metadata_session_id():
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "messages": [], "metadata": {"session_id": "body-session-1"}},
|
||||
request=_request_for("/v1/chat/completions"),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"missing_session_id": "reject"},
|
||||
)
|
||||
|
||||
assert updated["metadata"]["session_id"] == "body-session-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_session_id_reject_returns_400_without_session_id():
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "messages": []},
|
||||
request=_request_for("/v1/chat/completions"),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"missing_session_id": "reject"},
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "400"
|
||||
assert exc_info.value.param == "session_id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", ["/mcp/", "/mcp/tools", "/key/health"])
|
||||
async def test_missing_session_id_policy_skips_non_inference_routes(path: str):
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o"},
|
||||
request=_request_for(path),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"missing_session_id": "reject"},
|
||||
)
|
||||
|
||||
assert "session_id" not in updated["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_session_id_unknown_value_is_ignored():
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "messages": []},
|
||||
request=_request_for("/v1/chat/completions"),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"missing_session_id": "typo"},
|
||||
)
|
||||
|
||||
assert "session_id" not in updated["metadata"]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import litellm
|
|||
from litellm import Router
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
|
||||
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY, SESSION_ID_GENERATED_METADATA_KEY
|
||||
from litellm.router_strategy.complexity_router.complexity_router import (
|
||||
_CLASSIFICATION_CURRENT_MESSAGE_ONLY,
|
||||
_CLASSIFICATION_WITH_CONVERSATION,
|
||||
|
|
@ -4274,6 +4274,26 @@ class TestSessionAffinity:
|
|||
assert first.model == "o1-preview"
|
||||
assert second.model == "gpt-4o-mini"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_generated_session_id_never_pins(self, mock_router_instance, session_affinity_config):
|
||||
"""A session id the proxy generated for a request that had none is per request, so
|
||||
it must not create a pin even with session_affinity enabled."""
|
||||
mock_router_instance.cache = DualCache()
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=session_affinity_config,
|
||||
)
|
||||
request_kwargs = {"metadata": {"session_id": "generated-1", SESSION_ID_GENERATED_METADATA_KEY: True}}
|
||||
first = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=request_kwargs, messages=self.REASONING_MESSAGE
|
||||
)
|
||||
second = await router.async_pre_routing_hook(
|
||||
model="test-model", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE
|
||||
)
|
||||
assert first.model == "o1-preview"
|
||||
assert second.model == "gpt-4o-mini"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_be_enabled_to_pin_every_later_turn(self, mock_router_instance, session_affinity_config):
|
||||
"""Regression: session_affinity=True is the opt-in, so a shared session_id reuses the
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import json
|
|||
|
||||
import litellm
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.constants import SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY
|
||||
from litellm.constants import SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY, SESSION_ID_GENERATED_METADATA_KEY
|
||||
from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
|
||||
DeploymentAffinityCheck,
|
||||
)
|
||||
|
|
@ -180,6 +180,47 @@ async def test_async_session_id_affinity_priority_over_user_key():
|
|||
assert filtered[0]["model_info"]["id"] == "deployment-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_generated_session_id_does_not_pin_a_deployment():
|
||||
"""A session id the proxy generated for a request that had none is per request, so a
|
||||
pin stored under it must be ignored and none must be written."""
|
||||
cache = DualCache()
|
||||
callback = DeploymentAffinityCheck(
|
||||
cache=cache,
|
||||
ttl_seconds=123,
|
||||
enable_user_key_affinity=False,
|
||||
enable_responses_api_affinity=False,
|
||||
enable_session_id_affinity=True,
|
||||
)
|
||||
healthy_deployments = [
|
||||
{"model_name": "model_group", "litellm_params": {"model": "model_1"}, "model_info": {"id": "deployment-1"}},
|
||||
{"model_name": "model_group", "litellm_params": {"model": "model_2"}, "model_info": {"id": "deployment-2"}},
|
||||
]
|
||||
await cache.async_set_cache(
|
||||
DeploymentAffinityCheck.get_session_affinity_cache_key("model_group", "generated-1", user_key="user1"),
|
||||
{"model_id": "deployment-2"},
|
||||
)
|
||||
request_kwargs = {
|
||||
"metadata": {"user_api_key_hash": "user1", "session_id": "generated-1", SESSION_ID_GENERATED_METADATA_KEY: True}
|
||||
}
|
||||
|
||||
filtered = await callback.async_filter_deployments(
|
||||
model="model_group", healthy_deployments=healthy_deployments, messages=[], request_kwargs=request_kwargs
|
||||
)
|
||||
await callback.async_pre_call_deployment_hook(
|
||||
kwargs={
|
||||
"metadata": {**request_kwargs["metadata"], "deployment_model_name": "model_group"},
|
||||
"model_info": {"id": "deployment-1"},
|
||||
},
|
||||
call_type=None,
|
||||
)
|
||||
|
||||
assert len(filtered) == 2
|
||||
assert await cache.async_get_cache(
|
||||
DeploymentAffinityCheck.get_session_affinity_cache_key("model_group", "generated-1", user_key="user1")
|
||||
) == {"model_id": "deployment-2"}
|
||||
|
||||
|
||||
MOCK_RESPONSES_API_RESPONSE = {
|
||||
"id": "resp_mock-resp-456",
|
||||
"object": "response",
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25772,6 +25772,11 @@ export interface components {
|
|||
* @description Number of trusted reverse proxies/load balancers in front of the gateway that append to X-Forwarded-For. When set (and mcp_trusted_proxy_ranges validates the direct peer), the client IP for MCP access control is read this many entries from the right of the chain instead of the spoofable leftmost value, defeating append-style X-Forwarded-For forgery.
|
||||
*/
|
||||
mcp_xff_num_trusted_hops?: number | null;
|
||||
/**
|
||||
* Missing Session Id
|
||||
* @description What to do with LLM API requests that carry no session id (x-litellm-session-id header, metadata.session_id, etc.). 'generate' stamps one id into litellm_session_id, litellm_trace_id and metadata.session_id so SpendLogs and logging callbacks agree; 'reject' returns 400. Unset keeps the legacy behavior where SpendLogs falls back to the trace id while callbacks get no session id.
|
||||
*/
|
||||
missing_session_id?: ("generate" | "reject") | null;
|
||||
/**
|
||||
* Model List Healthy Only
|
||||
* @description When true, `/models`, `/v1/models/{id}` and `/model/info` hide models whose backing deployments are all unhealthy, for every caller, without needing `healthy_only=true` per request. Requires `background_health_checks: true`, and keeps deployment health state cached without turning on `enable_health_check_routing`, so routing is unaffected. With no health state nothing is hidden. Hiding is presentation-only, a hidden model can still be called.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue