fix(proxy)!: honor caller litellm_session_id in spend log session_id (#44199)

* fix(responses): scope session history lookup to the referenced key

* Implement SQLite-backed DB for session tests

Added a SQLite-backed database class for testing and a new test to ensure session isolation between different API keys.

* fix(proxy): honor caller litellm_session_id in spend log session_id

Updated docstring to clarify session ID resolution logic for spend log rows.

* test(proxy): caller litellm_session_id wins over the trace id

* style(proxy): fix blank lines around new tests

* fix(proxy): read the caller session from the logging payload for spend logs

* fix(responses): match user and team too when replaying session history

* fix(proxy): keep bridged Responses chains whole and scope session history by owner

* fix(proxy): keep a metadata session next to a trace header and let session headers win

* fix(proxy): send the trace-header session only to spend logs and let session headers win beside a trace header

* fix(proxy): drop a client-sent caller session and keep it out of requester metadata

* fix(proxy): keep the trace id as the spend log session when the caller session contains a NUL byte

* fix(responses): keep a continued bridged chain's session under request_correlation_in_logs

With the flag on, the caller session outranked the chain session the bridge sets for a continued Responses chain, so a chain whose session changed or first appeared on a later turn split. The continuation now logs under the chain's session and drops the turn's caller session key, as on main. Adds the /audit integration cells for the spend log caller session.

* fix(responses): log a continued bridged chain under its session through the spend-log key only

The previous commit set the chain session as the turn's litellm_session_id, which also moved the OTel conversation id of a continued turn away from its trace header. The bridge now writes the chain session into the internal spend-log session key of the metadata the spend log reads, so only the spend log session changes, as on main. Adds an OTel cell for a continued chain.

* test(spend): cover bridged chains that share one caller session

* test(spend): cover the caller session on configured pass-through routes

* test(spend): type the SQLite fake and split stacked comprehensions in the caller session tests

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Deepanshu Pal 2026-10-16 18:45:05 +05:30 • committed by GitHub
parent 4e2dd65549
commit d5ce7dbb8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1208 additions and 27 deletions

View file

@ -1629,6 +1629,7 @@ INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: Final = "_managed_background_response"
SESSION_ID_GENERATED_METADATA_KEY: Final = "litellm_session_id_generated"
SESSION_ID_OMITTED_METADATA_KEY: Final = "litellm_session_id_omitted"
CALLER_SESSION_ID_METADATA_KEY: Final = "litellm_caller_session_id"
LITELLM_TRUNCATED_PAYLOAD_FIELD: Final = "litellm_truncated"
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE: Final = (
"Truncation is a DB storage safeguard. "

View file

@ -27,6 +27,7 @@ from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm._service_logger import ServiceLogging
from litellm._uuid import uuid
from litellm.constants import (
CALLER_SESSION_ID_METADATA_KEY,
CLIENT_OUTPUT_CEILING_METADATA_KEY,
CONSUMED_REQUEST_TAGS_METADATA_KEY,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
@ -187,6 +188,30 @@ def _caller_trace_field(data: Mapping[str, object], metadata_variable_name: str,
return requester_value if isinstance(requester_value, str) and requester_value else None
def _caller_body_session_id(data: Mapping[str, object], metadata_variable_name: str) -> str | None:
top_level: Final = data.get("litellm_session_id")
if isinstance(top_level, str) and top_level:
return top_level
return _caller_trace_field(data, metadata_variable_name, "session_id")
def _caller_session_beside_trace_header(
normalized_headers: Mapping[str, str], data: Mapping[str, object], metadata_variable_name: str
) -> str | None:
session_headers: Final = {key: value for key, value in normalized_headers.items() if key != "x-litellm-trace-id"}
return get_chain_id_from_headers(session_headers) or _caller_body_session_id(data, metadata_variable_name)
def _drop_client_caller_session(request_body: Mapping[str, object]) -> None:
for metadata_key in ("metadata", "litellm_metadata"):
if isinstance(metadata := request_body.get(metadata_key), dict):
metadata.pop(CALLER_SESSION_ID_METADATA_KEY, None)
def _requester_metadata_snapshot(metadata: Mapping[str, object]) -> Mapping[str, object]:
return copy.deepcopy({key: value for key, value in metadata.items() if key != CALLER_SESSION_ID_METADATA_KEY})
def _stampable_key_hash(user_api_key_dict: UserAPIKeyAuth) -> str | None:
"""Only proxy-validated keys are stamped, proven by the unforgeable
via_virtual_key marker AND a known non-secret shape: the sha256 hex digest
@ -1623,6 +1648,8 @@ class LiteLLMProxyRequestSetup:
from litellm.proxy._types import LitellmMetadataFromRequestHeaders
metadata_from_headers: Final = LitellmMetadataFromRequestHeaders()
request_body: Final = cast(Mapping[str, object], data) # cast-ok: request body is a str-keyed JSON object
_drop_client_caller_session(request_body)
spend_logs_metadata: Final = LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers(headers)
if spend_logs_metadata is not None:
metadata_from_headers["spend_logs_metadata"] = spend_logs_metadata
@ -1631,6 +1658,9 @@ class LiteLLMProxyRequestSetup:
# Finally update the requests metadata with the `metadata_from_headers`
#########################################################################################
normalized_headers: Final = MappingProxyType(
{k.lower(): v for k, v in headers.items() if isinstance(k, str) and isinstance(v, str)}
)
agent_id_from_header: Final = headers.get("x-litellm-agent-id")
# Explicit litellm headers take precedence; fall back to any x-*-session-id header.
chain_id: Final = get_chain_id_from_headers(dict(headers))
@ -1639,6 +1669,13 @@ class LiteLLMProxyRequestSetup:
metadata_from_headers["agent_id"] = agent_id_from_header
verbose_proxy_logger.debug("Extracted agent_id from header: %s", agent_id_from_header)
spend_log_session_id: Final = (
_caller_session_beside_trace_header(normalized_headers, request_body, _metadata_variable_name)
if litellm.request_correlation_in_logs
and chain_id is not None
and chain_id == normalized_headers.get("x-litellm-trace-id")
else None
)
if chain_id:
metadata_from_headers["trace_id"] = chain_id
metadata_from_headers["session_id"] = chain_id
@ -1663,7 +1700,6 @@ class LiteLLMProxyRequestSetup:
# - but lets a caller's existing traceparent/baggage headers (from
# real OTel instrumentation) correlate with litellm's own logs instead
# of generating an unrelated trace_id.
normalized_headers: Final = MappingProxyType({k.lower(): v for k, v in headers.items() if isinstance(k, str)})
if "litellm_trace_id" not in data:
traceparent: Final = normalized_headers.get("traceparent")
if isinstance(traceparent, str):
@ -1689,11 +1725,7 @@ class LiteLLMProxyRequestSetup:
baggage: Final = normalized_headers.get("baggage")
if isinstance(baggage, str):
session_id_from_baggage: Final = _session_id_from_baggage(baggage)
caller_session_id: Final = _caller_trace_field(
cast(Mapping[str, object], data), # cast-ok: request body is a str-keyed JSON object
_metadata_variable_name,
"session_id",
)
caller_session_id: Final = _caller_trace_field(request_body, _metadata_variable_name, "session_id")
if session_id_from_baggage and (
caller_session_id is None or caller_session_id == session_id_from_baggage
):
@ -1702,7 +1734,11 @@ class LiteLLMProxyRequestSetup:
verbose_proxy_logger.debug("Extracted session_id from W3C baggage header")
if isinstance(data[_metadata_variable_name], dict):
data[_metadata_variable_name].update(metadata_from_headers)
data[_metadata_variable_name].update(
{**metadata_from_headers, CALLER_SESSION_ID_METADATA_KEY: spend_log_session_id}
if spend_log_session_id
else metadata_from_headers
)
return data
@staticmethod
@ -2417,7 +2453,7 @@ async def add_litellm_data_to_request(
# them — from leaking into requester_metadata where guardrails and audit
# paths may read from it.
if "metadata" in data and isinstance(data["metadata"], dict):
data[_metadata_variable_name]["requester_metadata"] = copy.deepcopy(data["metadata"])
data[_metadata_variable_name]["requester_metadata"] = _requester_metadata_snapshot(data["metadata"])
if _metadata_variable_name == "litellm_metadata":
data[_metadata_variable_name].update(
_promoted_trace_control_fields(

View file

@ -15,6 +15,7 @@ from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
CALLER_SESSION_ID_METADATA_KEY,
CLI_SESSION_KEY_PREFIX,
EMPTY_MAPPING,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
@ -33,6 +34,8 @@ from litellm.constants import (
from litellm.constants import (
MAX_STRING_LENGTH_PROMPT_IN_DB as DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB,
)
from litellm.integrations.otel.model.metadata import caller_session_id
from litellm.integrations.otel.model.trace_controls import TraceControls
from litellm.litellm_core_utils.classifier_logging import (
CLASSIFIER_AUDIT_FIELDS,
classifier_audit_fields,
@ -930,6 +933,16 @@ def _get_batch_trace_session_id(call_type: str | None, request_id: str | None) -
return request_id.removesuffix(BATCH_COST_REQUEST_ID_SUFFIX)
def _caller_session_for_spend_log(kwargs: Mapping[str, object], metadata: Mapping[str, object] | None) -> str | None:
beside_trace_header: Final = metadata.get(CALLER_SESSION_ID_METADATA_KEY) if metadata else None
caller_session: Final = (
beside_trace_header
if isinstance(beside_trace_header, str) and beside_trace_header
else caller_session_id(kwargs, TraceControls())
)
return caller_session if caller_session and "\x00" not in caller_session else None
def _get_session_id_for_spend_log(
kwargs: Mapping[str, object],
metadata: Mapping[str, object] | None,
@ -937,9 +950,12 @@ def _get_session_id_for_spend_log(
omit_when_missing: bool,
batch_trace_session_id: str | None = None,
) -> str | None:
"""Under `omit` only `metadata.session_id`, the key Langfuse reads, counts as a session; `litellm_session_id` may
be a copied trace id. Batch call types carry a deterministic session derived from the batch id, which outranks
the per-request trace ids because those differ between the create call and the cost poller's row."""
"""Resolve the session id for the spend log row: `omit` honors only metadata.session_id,
batch sessions outrank everything, then the conversation id the caller sent (also one an x-litellm-trace-id header
displaced from the request's session), then trace ids.
A session minted by `missing_session_id: generate` or copied from the request's trace id is not a conversation, so
it yields to the trace id, which the Responses bridge points at the previous turn's session to keep a chain whole."""
if omit_when_missing:
session_id: Final = metadata.get("session_id") if metadata else None
return str(session_id) if session_id else None
@ -948,11 +964,14 @@ def _get_session_id_for_spend_log(
if batch_trace_session_id is not None:
return batch_trace_session_id
if standard_logging_payload is not None and standard_logging_payload.get("trace_id") is not None:
return str(standard_logging_payload.get("trace_id"))
if kwargs.get("litellm_trace_id") is not None:
return str(kwargs.get("litellm_trace_id"))
return str(uuid.uuid4())
if litellm.request_correlation_in_logs and (caller_session := _caller_session_for_spend_log(kwargs, metadata)):
return caller_session
match standard_logging_payload:
case {"trace_id": trace_id} if trace_id is not None:
return str(trace_id)
case _:
litellm_trace_id: Final = kwargs.get("litellm_trace_id")
return str(litellm_trace_id) if litellm_trace_id is not None else str(uuid.uuid4())
def _get_request_duration_ms(start_time: datetime, end_time: datetime) -> int | None:

View file

@ -281,7 +281,13 @@ class ResponsesSessionHandler:
SQL query
SELECT session_id FROM spend_logs WHERE response_id = previous_response_id, SELECT * FROM spend_logs WHERE session_id = session_id
SELECT session_id, api_key, "user", team_id FROM spend_logs WHERE request_id = previous_response_id, then
SELECT * FROM spend_logs with that session_id and the same owner
Only rows with the referenced response's owner are returned: the same user and team, and for a key with
neither, the same key. The session id can be caller supplied, so matching on it alone would mix owners. A
user's or team's regenerated or second key keeps the history; a key with neither does not, and turns written
by another owner (an admin continuing the chain) are not replayed.
A just-finished turn gets a short second chance: the worker that served it may
still be writing its spend log when the follow-up arrives, and an empty result
@ -303,13 +309,24 @@ class ResponsesSessionHandler:
query: Final = """
WITH matching_session AS (
SELECT session_id
SELECT session_id, api_key, "user", team_id
FROM "LiteLLM_SpendLogs"
WHERE request_id = $1
)
SELECT *
FROM "LiteLLM_SpendLogs"
WHERE session_id IN (SELECT session_id FROM matching_session)
FROM "LiteLLM_SpendLogs" AS logs
WHERE EXISTS (
SELECT 1
FROM matching_session
WHERE matching_session.session_id = logs.session_id
AND matching_session."user" IS NOT DISTINCT FROM logs."user"
AND matching_session.team_id IS NOT DISTINCT FROM logs.team_id
AND (
COALESCE(matching_session."user", '') <> ''
OR COALESCE(matching_session.team_id, '') <> ''
OR matching_session.api_key = logs.api_key
)
)
ORDER BY "endTime" ASC;
"""

View file

@ -39,7 +39,11 @@ from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.caching import InMemoryCache
from litellm.constants import REDACTED_BY_LITELLM, REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER
from litellm.constants import (
CALLER_SESSION_ID_METADATA_KEY,
REDACTED_BY_LITELLM,
REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER,
)
from litellm.integrations.anthropic_cache_control_hook import MAX_CACHE_CONTROL_BLOCKS, AnthropicCacheControlHook
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
from litellm.litellm_core_utils.get_supported_openai_params import (
@ -563,6 +567,14 @@ class ChatCompletionSession(TypedDict, total=False):
########### End of Initialize Classes used for Responses API ###########
def _chain_session_metadata(chain_session: str, request_kwargs: Mapping[str, object]) -> Mapping[str, object]:
key: Final = "litellm_metadata" if request_kwargs.get("litellm_metadata") else "metadata"
metadata: Final = request_kwargs.get(key)
if not isinstance(metadata, dict):
return MappingProxyType({})
return {key: {**_STR_KEY_DICT_ADAPTER.validate_python(metadata), CALLER_SESSION_ID_METADATA_KEY: chain_session}}
class LiteLLMCompletionResponsesConfig:
@staticmethod
def get_supported_openai_params(model: str) -> list:
@ -1078,8 +1090,6 @@ class LiteLLMCompletionResponsesConfig:
else:
# Both are empty - this likely means function_call_output had empty/invalid call_id
# Provide a helpful error message
import litellm
raise litellm.BadRequestError(
message=(
f"Unable to create messages for completion request. "
@ -1094,8 +1104,14 @@ class LiteLLMCompletionResponsesConfig:
)
litellm_completion_request["messages"] = combined_messages
litellm_completion_request["litellm_trace_id"] = chat_completion_session.get("litellm_session_id")
return litellm_completion_request
chain_session: Final = chat_completion_session.get("litellm_session_id")
litellm_completion_request["litellm_trace_id"] = chain_session
chain_session_metadata: Final[Mapping[str, object]] = (
_chain_session_metadata(chain_session, request_kwargs)
if chain_session and litellm.request_correlation_in_logs
else MappingProxyType({})
)
return {**litellm_completion_request, **chain_session_metadata}
@staticmethod
def _transform_response_input_param_to_chat_completion_message(

View file

@ -0,0 +1,783 @@
import itertools
import json
import re
import threading
import uuid
from urllib.parse import urlsplit
from collections import deque
from collections.abc import Iterator, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from pathlib import Path
from queue import SimpleQueue
from types import MappingProxyType
from typing import Final
import anthropic
import httpx
import openai
import psutil
import pytest
import yaml
from integration._support.client import (
JSON_OBJECT,
Gateway,
Scenario,
eventually,
gateway_from_environment,
list_value,
object_value,
string_value,
)
from integration._support.budget import readiness_seconds
from integration._support.database import read_rows
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue
pytestmark: Final = pytest.mark.timeout(2 * readiness_seconds(2) + 2 * graceful_stop_seconds() + 240)
MARKER: Final = re.compile(rb"callersess-[0-9a-f]{32}")
CALLER_SESSION_KEY: Final = "litellm_caller_session_id"
PATHS: Final = MappingProxyType(
{"chat": "/v1/chat/completions", "messages": "/v1/messages", "responses": "/v1/responses"}
)
ENDPOINT_STREAMS: Final = tuple(itertools.product(PATHS, (False, True)))
Headers = Mapping[str, str] | Sequence[tuple[str, str]]
def _marker() -> str:
return "callersess-" + uuid.uuid4().hex
def _token(label: str) -> str:
return f"{label}-{uuid.uuid4().hex}"
def _frame(payload: Mapping[str, JsonValue]) -> bytes:
return b"data: " + json.dumps(payload).encode() + b"\n\n"
def _chat_reply(text: str, stream: bool) -> Reply:
identity: Final = "chatcmpl-" + uuid.uuid4().hex
usage: Final[dict[str, JsonValue]] = {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}
if not stream:
return Reply(
body=json.dumps(
{
"id": identity,
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}
],
"usage": usage,
}
).encode()
)
chunk: Final[dict[str, JsonValue]] = {
"id": identity,
"object": "chat.completion.chunk",
"created": 1,
"model": "gpt-4o-mini",
}
return Reply(
content_type="text/event-stream",
chunks=(
_frame({**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": text}}]}),
_frame({**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": usage}),
b"data: [DONE]\n\n",
),
)
def _upstream(request: Request) -> Reply:
markers: Final = tuple(found.group(0).decode() for found in MARKER.finditer(request.body))
if request.method != "POST" or not request.target.endswith("/chat/completions") or not markers:
return Reply(status=404, body=b'{"error":"unexpected request"}')
return _chat_reply(f"answer {markers[-1]}", JSON_OBJECT.validate_json(request.body).get("stream") is True)
def _user_markers(body: Mapping[str, JsonValue]) -> tuple[str, ...]:
user_contents: Final = tuple(
json.dumps(object_value(message)["content"])
for message in list_value(body.get("messages", []))
if object_value(message)["role"] == "user"
)
return tuple(found.group(0).decode() for content in user_contents if (found := MARKER.search(content.encode())))
def _spans(batch: Request) -> Iterator[dict[str, JsonValue]]:
for resource in list_value(JSON_OBJECT.validate_json(batch.body)["resourceSpans"]):
for scope in list_value(object_value(resource)["scopeSpans"]):
yield from (object_value(span) for span in list_value(object_value(scope)["spans"]))
def _attributes(span: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
return {
string_value(object_value(attribute)["key"]): object_value(attribute)["value"]
for attribute in list_value(span.get("attributes", []))
}
def _batch_attributes(batches: Sequence[Request]) -> Iterator[dict[str, JsonValue]]:
return (_attributes(span) for span in itertools.chain.from_iterable(_spans(batch) for batch in batches))
@dataclass(frozen=True, slots=True)
class Collector:
wire: Wire
accepted: deque[Request]
guard: threading.Lock
def span_attributes(self, response_id: str) -> tuple[dict[str, JsonValue], ...]:
with self.guard:
batches: Final = tuple(self.accepted)
attributes: Final = tuple(_batch_attributes(batches))
return tuple(
found
for found in attributes
if object_value(found.get("gen_ai.response.id", {})).get("stringValue") == response_id
)
def conversation_ids(self, conversations: frozenset[str]) -> tuple[str, ...]:
with self.guard:
batches: Final = tuple(self.accepted)
responded: Final = (
object_value(found.get("gen_ai.conversation.id", {})).get("stringValue")
for found in _batch_attributes(batches)
if "gen_ai.response.id" in found
)
return tuple(
conversation
for conversation in responded
if isinstance(conversation, str) and conversation in conversations
)
@dataclass(frozen=True, slots=True)
class Rig:
proxy: Gateway
scenario: Scenario
model: str
upstream: Wire
process: OwnedProxy
def body(self, endpoint: str, marker: str, extra: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
match endpoint:
case "chat":
return {"model": self.model, "messages": [{"role": "user", "content": marker}], **extra}
case "messages":
return {
"model": self.model,
"max_tokens": 16,
"messages": [{"role": "user", "content": marker}],
**extra,
}
case _:
return {"model": self.model, "input": marker, **extra}
def post(
self,
endpoint: str,
marker: str,
fields: Mapping[str, JsonValue] = MappingProxyType({}),
*,
stream: bool = False,
headers: Headers = (),
key: str | None = None,
) -> httpx.Response:
payload: Final = self.body(endpoint, marker, fields) | ({"stream": True} if stream else {})
request_headers: Final = (
("Authorization", f"Bearer {self.proxy.key if key is None else key}"),
*(headers.items() if isinstance(headers, Mapping) else headers),
)
with self.proxy.client.stream("POST", PATHS[endpoint], json=payload, headers=request_headers) as response:
response.read()
return response
def send(
self,
endpoint: str,
marker: str,
fields: Mapping[str, JsonValue] = MappingProxyType({}),
*,
stream: bool = False,
headers: Headers = (),
key: str | None = None,
) -> httpx.Response:
response: Final = self.post(endpoint, marker, fields, stream=stream, headers=headers, key=key)
assert response.status_code == 200, response.text
return response
def upstream_bodies(self, marker: str) -> tuple[dict[str, JsonValue], ...]:
bodies: Final = tuple(JSON_OBJECT.validate_json(request.body) for request in self.upstream.drain())
return tuple(body for body in bodies if _user_markers(body)[-1:] == (marker,))
@dataclass(frozen=True, slots=True)
class Rigs:
flag_off: Rig
flag_on: Rig
sink: Collector
def _spend_rows(call_id: str) -> list[dict[str, JsonValue]]:
return read_rows(
'SELECT session_id, request_id, metadata::text AS metadata_text FROM "LiteLLM_SpendLogs" '
"WHERE litellm_call_id=%s",
(call_id,),
)
def _spend_row(response: httpx.Response) -> dict[str, JsonValue]:
call_id: Final = response.headers["x-litellm-call-id"]
rows: Final = eventually(lambda: _spend_rows(call_id), lambda values: len(values) >= 1, seconds=70)
assert len(rows) == 1, rows
return rows[0]
def _session(response: httpx.Response) -> JsonValue:
return _spend_row(response)["session_id"]
def _model(scenario: Scenario, upstream: Wire) -> str:
return scenario.model(
model="hosted_vllm/gpt-4o-mini",
api_base=f"{upstream.url}/v1",
use_chat_completions_api=True,
input_cost_per_token=0.001,
output_cost_per_token=0.002,
)
def _rig_config(
directory: Path, name: str, litellm_settings: Mapping[str, JsonValue], extra: Mapping[str, JsonValue]
) -> Path:
shared: Final = object_value(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
config: Final = {
**shared,
"general_settings": {**object_value(shared["general_settings"]), "store_prompts_in_spend_logs": True},
"litellm_settings": {**object_value(shared["litellm_settings"]), **litellm_settings},
**extra,
}
path: Final = directory / f"{name}.yaml"
path.write_text(yaml.safe_dump(config))
return path
@pytest.fixture(scope="module")
def rigs(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rigs]:
accepted: Final[deque[Request]] = deque() # mutable-ok: sink thread appends each accepted OTLP batch
guard: Final = threading.Lock()
def sink(request: Request) -> Reply:
with guard:
accepted.append(request)
return Reply()
directory: Final = tmp_path_factory.mktemp("caller-session")
with ExitStack() as stack:
upstream: Final = stack.enter_context(wire_server(_upstream))
collector: Final = Collector(stack.enter_context(wire_server(sink)), accepted, guard)
shared: Final = stack.enter_context(gateway_from_environment())
flag_off: Final = stack.enter_context(
owned_proxy_process(shared, directory, {}, config=_rig_config(directory, "flag-off", {}, {}), workers=2)
)
flag_on: Final = stack.enter_context(
owned_proxy_process(
shared,
directory,
{"LITELLM_OTEL_V2": "1", "OTEL_BSP_SCHEDULE_DELAY": "300"},
config=_rig_config(
directory,
"flag-on",
{"request_correlation_in_logs": True, "callbacks": ["otel"]},
{
"callback_settings": {
"otel": {"exporter": "http/json", "endpoint": collector.wire.url, "mapper_names": ["genai"]}
}
},
),
workers=2,
)
)
off_scenario: Final = stack.enter_context(flag_off.gateway.scenario())
on_scenario: Final = stack.enter_context(flag_on.gateway.scenario())
yield Rigs(
Rig(flag_off.gateway, off_scenario, _model(off_scenario, upstream), upstream, flag_off),
Rig(flag_on.gateway, on_scenario, _model(on_scenario, upstream), upstream, flag_on),
collector,
)
@pytest.mark.parametrize(("endpoint", "stream"), ENDPOINT_STREAMS)
def test_spend_log_session_is_the_body_session_beside_a_trace_header(rigs: Rigs, endpoint: str, stream: bool) -> None:
marker: Final = _marker()
session: Final = _token("convo")
response: Final = rigs.flag_on.send(
endpoint,
marker,
stream=stream,
headers={"x-litellm-trace-id": _token("trace")},
fields={"litellm_session_id": session},
)
row: Final = _spend_row(response)
upstream: Final = rigs.flag_on.upstream_bodies(marker)
assert row["session_id"] == session, row
assert CALLER_SESSION_KEY not in string_value(row["metadata_text"]), row
assert len(upstream) == 1, upstream
assert CALLER_SESSION_KEY not in json.dumps(upstream[0]), upstream
def test_sdk_clients_record_the_body_session_beside_a_trace_header(rigs: Rigs) -> None:
rig: Final = rigs.flag_on
base_url: Final = str(rig.proxy.client.base_url)
traces: Final = tuple(_token("trace") for _ in range(3))
sessions: Final = tuple(_token("convo") for _ in range(3))
markers: Final = tuple(_marker() for _ in range(3))
chat_client: Final = openai.OpenAI(base_url=base_url + "/v1", api_key=rig.proxy.key, max_retries=0)
messages_client: Final = anthropic.Anthropic(base_url=base_url, api_key=rig.proxy.key, max_retries=0)
with chat_client.chat.completions.with_streaming_response.create(
model=rig.model,
messages=[{"role": "user", "content": markers[0]}],
stream=True,
extra_headers={"x-litellm-trace-id": traces[0]},
extra_body={"litellm_session_id": sessions[0]},
) as chat_stream:
chat_call: Final = chat_stream.headers["x-litellm-call-id"]
chat_text: Final = "".join(
chunk.choices[0].delta.content or "" for chunk in chat_stream.parse() if chunk.choices
)
message: Final = messages_client.messages.with_raw_response.create(
model=rig.model,
max_tokens=16,
messages=[{"role": "user", "content": markers[1]}],
extra_headers={"x-litellm-trace-id": traces[1]},
extra_body={"litellm_session_id": sessions[1]},
)
with chat_client.responses.with_streaming_response.create(
model=rig.model,
input=markers[2],
stream=True,
extra_headers={"x-litellm-trace-id": traces[2]},
extra_body={"litellm_session_id": sessions[2]},
) as responses_stream:
responses_call: Final = responses_stream.headers["x-litellm-call-id"]
completed: Final = tuple(event for event in responses_stream.parse() if event.type == "response.completed")
first_block: Final = message.parse().content[0]
assert chat_text == f"answer {markers[0]}", chat_text
assert isinstance(first_block, anthropic.types.TextBlock) and first_block.text == f"answer {markers[1]}", message
assert len(completed) == 1, completed
calls: Final = (chat_call, message.headers["x-litellm-call-id"], responses_call)
landed: Final = tuple(
eventually(lambda call=call: _spend_rows(call), lambda values: len(values) == 1, seconds=70)[0]["session_id"]
for call in calls
)
assert landed == sessions, (landed, calls)
def test_conversation_span_keeps_the_trace_header_and_no_internal_key(rigs: Rigs) -> None:
trace: Final = _token("trace")
response: Final = rigs.flag_on.send(
"chat", _marker(), headers={"x-litellm-trace-id": trace}, fields={"litellm_session_id": _token("convo")}
)
response_id: Final = string_value(JSON_OBJECT.validate_json(response.content)["id"])
spans: Final = eventually(lambda: rigs.sink.span_attributes(response_id), lambda found: len(found) == 1, seconds=30)
assert spans[0]["gen_ai.conversation.id"] == {"stringValue": trace}, spans
assert CALLER_SESSION_KEY not in json.dumps(spans[0]), spans
def test_a_continued_chains_conversation_spans_keep_each_turns_trace_header(rigs: Rigs) -> None:
traces: Final = (_token("trace"), _token("trace"))
first: Final = _turn(rigs.flag_on, _marker(), headers={"x-litellm-trace-id": traces[0]})
_turn(
rigs.flag_on,
_marker(),
first,
headers={"x-litellm-trace-id": traces[1]},
fields={"litellm_session_id": _token("convo")},
)
conversations: Final = eventually(
lambda: rigs.sink.conversation_ids(frozenset(traces)), lambda found: len(found) == 2, seconds=30
)
assert sorted(conversations) == sorted(traces), conversations
@pytest.mark.parametrize("endpoint", list(PATHS))
def test_flag_off_keeps_the_trace_id_beside_a_body_session(rigs: Rigs, endpoint: str) -> None:
trace: Final = _token("trace")
response: Final = rigs.flag_off.send(
endpoint, _marker(), headers={"x-litellm-trace-id": trace}, fields={"litellm_session_id": _token("convo")}
)
assert _session(response) == trace
@pytest.mark.parametrize("endpoint", list(PATHS))
def test_response_cache_hit_records_the_body_session_beside_a_trace_header(rigs: Rigs, endpoint: str) -> None:
marker: Final = _marker()
sessions: Final = (_token("convo"), _token("convo"))
responses: Final = tuple(
rigs.flag_on.send(
endpoint, marker, headers={"x-litellm-trace-id": _token("trace")}, fields={"litellm_session_id": session}
)
for session in sessions
)
assert tuple(_session(response) for response in responses) == sessions
assert len(rigs.flag_on.upstream_bodies(marker)) == 1
def test_metadata_session_beside_a_new_trace_per_turn_groups_both_turns(rigs: Rigs) -> None:
session: Final = _token("convo")
responses: Final = tuple(
rigs.flag_on.send(
"chat",
_marker(),
headers={"x-litellm-trace-id": _token("trace")},
fields={"metadata": {"session_id": session}},
)
for _ in range(2)
)
assert tuple(_session(response) for response in responses) == (session, session)
now: Final = datetime.now(timezone.utc)
page: Final = rigs.flag_on.proxy.get(
"/spend/logs/ui",
params={
"session_id": session,
"start_date": (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
"end_date": (now + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
},
)
listed: Final = sorted(string_value(object_value(row)["request_id"]) for row in list_value(page["data"]))
assert listed == sorted(
string_value(JSON_OBJECT.validate_json(response.content)["id"]) for response in responses
), page
def _shape(shape: str, trace: str, session: str, header: str) -> tuple[Headers, dict[str, JsonValue], str]:
traced: Final = {"x-litellm-trace-id": trace}
match shape:
case "session-header-wins":
return {"x-litellm-session-id": header}, {"litellm_session_id": session}, header
case "trace-header-only":
return traced, {}, trace
case "body-session-only":
return {}, {"litellm_session_id": session}, session
case "int-session":
return traced, {"litellm_session_id": 7}, trace
case "list-session":
return traced, {"litellm_session_id": [session]}, trace
case "empty-session":
return traced, {"litellm_session_id": ""}, trace
case "null-session":
return traced, {"litellm_session_id": None}, trace
case "nul-session":
return traced, {"litellm_session_id": session + "\x00x"}, trace
case "two-trace-headers":
return (
(("x-litellm-trace-id", trace), ("x-litellm-trace-id", header)),
{"litellm_session_id": session},
session,
)
case _:
return traced, {"litellm_session_id": session + "s" * 5120}, session + "s" * 5120
@pytest.mark.parametrize(
"shape",
[
"session-header-wins",
"trace-header-only",
"body-session-only",
"int-session",
"list-session",
"empty-session",
"null-session",
"nul-session",
"two-trace-headers",
"5kb-session",
],
)
def test_spend_log_session_for_each_session_shape(rigs: Rigs, shape: str) -> None:
headers, fields, expected = _shape(shape, _token("trace"), _token("convo"), _token("header"))
neighbor_trace: Final = _token("neighbor")
response: Final = rigs.flag_on.send("chat", _marker(), fields, headers=headers)
neighbor: Final = rigs.flag_on.send("chat", _marker(), headers={"x-litellm-trace-id": neighbor_trace})
assert _session(response) == expected
assert _session(neighbor) == neighbor_trace
@pytest.mark.parametrize("endpoint", ["chat", "messages"])
def test_client_sent_internal_session_key_is_ignored(rigs: Rigs, endpoint: str) -> None:
forged: Final = _token("forged")
trace: Final = _token("trace")
forged_metadata: Final[dict[str, JsonValue]] = {
"metadata" if endpoint == "chat" else "litellm_metadata": {CALLER_SESSION_KEY: forged}
}
untraced: Final = rigs.flag_on.send(endpoint, _marker(), forged_metadata)
traced: Final = rigs.flag_on.send(endpoint, _marker(), forged_metadata, headers={"x-litellm-trace-id": trace})
assert _session(untraced) not in (forged, None)
assert _session(traced) == trace
def test_identical_requests_each_log_once_with_the_body_session(rigs: Rigs) -> None:
session: Final = _token("convo")
trace: Final = _token("trace")
marker: Final = _marker()
responses: Final = tuple(
rigs.flag_on.send("chat", marker, headers={"x-litellm-trace-id": trace}, fields={"litellm_session_id": session})
for _ in range(3)
)
assert len({response.headers["x-litellm-call-id"] for response in responses}) == 3
assert tuple(_session(response) for response in responses) == (session, session, session)
def _pass_through_route(rig: Rig, target: str) -> str:
path: Final = f"/caller-session-{uuid.uuid4().hex}"
created: Final = rig.proxy.post("/config/pass_through_endpoint", {"path": path, "target": target, "auth": True})
endpoint_id: Final = string_value(object_value(list_value(created["endpoints"])[0])["id"])
rig.scenario.cleanups.callback(
lambda: rig.proxy.request("DELETE", "/config/pass_through_endpoint", params={"endpoint_id": endpoint_id})
)
return path
@pytest.mark.parametrize("field", ["litellm_session_id", "metadata"])
def test_pass_through_spend_log_session_is_the_body_session_beside_a_trace_header(rigs: Rigs, field: str) -> None:
marker: Final = _marker()
session: Final = _token("convo")
rig: Final = rigs.flag_on
path: Final = _pass_through_route(rig, f"{rig.upstream.url}/v1/chat/completions")
key: Final = rig.scenario.key(allowed_passthrough_routes=[path])
fields: Final[dict[str, JsonValue]] = (
{"litellm_session_id": session} if field == "litellm_session_id" else {"metadata": {"session_id": session}}
)
response: Final = rig.proxy.request(
"POST",
path,
rig.body("chat", marker, fields),
key=key,
headers={"x-litellm-trace-id": _token("trace")},
)
rows: Final = eventually(
lambda: read_rows(
'SELECT session_id FROM "LiteLLM_SpendLogs" WHERE api_key=%s', (sha256(key.encode()).hexdigest(),)
),
lambda found: len(found) >= 1,
seconds=70,
)
assert response.status_code == 200, response.text
assert f"answer {marker}" in response.text
assert len(rig.upstream_bodies(marker)) == 1
assert rows == [{"session_id": session}]
def _turn(
rig: Rig,
marker: str,
previous: str | None = None,
*,
key: str | None = None,
headers: Headers = (),
fields: Mapping[str, JsonValue] = MappingProxyType({}),
) -> str:
chained: Final[dict[str, JsonValue]] = {"previous_response_id": previous} if previous else {}
response: Final = rig.send("responses", marker, {**chained, **fields}, headers=headers, key=key)
_spend_row(response)
return string_value(JSON_OBJECT.validate_json(response.content)["id"])
def _turn_fields(shape: str, session: str) -> tuple[dict[str, JsonValue], dict[str, JsonValue]]:
match shape:
case "session-every-turn":
return {"litellm_session_id": session}, {"litellm_session_id": session}
case "session-from-turn-2":
return {}, {"litellm_session_id": session}
case _:
return {"litellm_session_id": _token("convo")}, {"litellm_session_id": _token("convo")}
@pytest.mark.parametrize(
("correlated", "shape", "replays_turn_1"),
[
(True, "session-every-turn", True),
(True, "session-from-turn-2", True),
(True, "new-session-per-turn", True),
(False, "session-from-turn-2", False),
],
)
def test_bridged_chain_history(rigs: Rigs, correlated: bool, shape: str, replays_turn_1: bool) -> None:
rig: Final = rigs.flag_on if correlated else rigs.flag_off
first_fields, second_fields = _turn_fields(shape, _token("convo"))
markers: Final = (_marker(), _marker(), _marker())
first: Final = _turn(rig, markers[0], headers={"x-litellm-trace-id": _token("trace")}, fields=first_fields)
second: Final = _turn(rig, markers[1], first, headers={"x-litellm-trace-id": _token("trace")}, fields=second_fields)
_turn(rig, markers[2], second)
replayed: Final = rig.upstream_bodies(markers[2])
assert len(replayed) == 1, replayed
assert _user_markers(replayed[0]) == (markers if replays_turn_1 else markers[1:])
def test_bridged_chains_sharing_a_metadata_session_replay_each_others_turns(rigs: Rigs) -> None:
shared: Final[dict[str, JsonValue]] = {"metadata": {"session_id": _token("convo")}}
markers: Final = (_marker(), _marker(), _marker())
first: Final = _turn(rigs.flag_on, markers[0], fields=shared)
_turn(rigs.flag_on, markers[1], fields=shared)
_turn(rigs.flag_on, markers[2], first)
replayed: Final = rigs.flag_on.upstream_bodies(markers[2])
assert len(replayed) == 1, replayed
assert _user_markers(replayed[0]) == markers
@dataclass(frozen=True, slots=True)
class Owners:
first: str
second: str
def _owners(scenario: Scenario, model: str, shape: str) -> Owners:
user: Final = _token("user")
match shape:
case "other-user":
return Owners(
scenario.key(user_id=user, models=[model]), scenario.key(user_id=_token("user"), models=[model])
)
case "same-user":
return Owners(scenario.key(user_id=user, models=[model]), scenario.key(user_id=user, models=[model]))
case "same-team":
team: Final = scenario.team()
return Owners(scenario.key(team_id=team, models=[model]), scenario.key(team_id=team, models=[model]))
case _:
return Owners(scenario.key(models=[model]), scenario.key(models=[model]))
@pytest.mark.parametrize(
("shape", "replays_first_owner"),
[("other-user", False), ("same-user", True), ("same-team", True), ("ownerless-keys", False)],
)
def test_bridged_history_follows_the_previous_responses_owner(
rigs: Rigs, shape: str, replays_first_owner: bool
) -> None:
rig: Final = rigs.flag_off
owners: Final = _owners(rig.scenario, rig.model, shape)
session: Final = _token("convo")
markers: Final = (_marker(), _marker(), _marker())
_turn(rig, markers[0], key=owners.first, fields={"litellm_session_id": session})
second: Final = _turn(rig, markers[1], key=owners.second, fields={"litellm_session_id": session})
_turn(rig, markers[2], second, key=owners.second)
replayed: Final = rig.upstream_bodies(markers[2])
assert len(replayed) == 1, replayed
assert _user_markers(replayed[0]) == (markers if replays_first_owner else markers[1:])
BURST: Final = ENDPOINT_STREAMS * 4
def test_concurrent_burst_across_endpoints_logs_each_session_once(rigs: Rigs) -> None:
sessions: Final = tuple(_token("convo") for _ in BURST)
def one(index: int) -> httpx.Response:
endpoint, stream = BURST[index]
return rigs.flag_on.send(
endpoint,
_marker(),
stream=stream,
headers={"x-litellm-trace-id": _token("trace")},
fields={"litellm_session_id": sessions[index]},
)
with ThreadPoolExecutor(max_workers=12) as pool:
responses: Final = tuple(pool.map(one, range(len(BURST))))
assert tuple(_session(response) for response in responses) == sessions
def _open_upstream_connections(pid: int, upstream: Wire) -> int:
port: Final = urlsplit(upstream.url).port
return sum(
1
for connection in psutil.Process(pid).net_connections(kind="tcp")
if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port
)
def _post_or_none(rig: Rig, session: str) -> httpx.Response | None:
try:
return rig.post(
"chat", _marker(), headers={"x-litellm-trace-id": _token("trace")}, fields={"litellm_session_id": session}
)
except httpx.HTTPError:
return None
def test_killing_one_worker_mid_burst_keeps_logging_the_body_session(rigs: Rigs) -> None:
release: Final = threading.Event()
arrived: Final[SimpleQueue[bytes]] = SimpleQueue()
def held(request: Request) -> Reply:
arrived.put(request.body)
assert release.wait(timeout=60), "the burst was never released"
return _upstream(request)
process: Final = rigs.flag_on.process
sessions: Final = tuple(_token("convo") for _ in range(20))
after_session: Final = _token("convo")
with wire_server(held) as wire:
rig: Final = replace(rigs.flag_on, model=_model(rigs.flag_on.scenario, wire), upstream=wire)
workers: Final = eventually(process.worker_pids, lambda found: len(found) == 2, seconds=30)
with ThreadPoolExecutor(max_workers=len(sessions)) as pool:
pending: Final = tuple(pool.submit(_post_or_none, rig, session) for session in sessions)
eventually(arrived.qsize, lambda size: size == len(sessions), seconds=60)
held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, wire) for pid in workers})
victim, survivor = sorted(workers, key=held_by.__getitem__)
with process.replacing_worker(victim) as kill:
kill.freeze()
kill()
release.set()
results: Final = tuple(future.result() for future in pending)
after: Final = rig.send(
"chat",
_marker(),
headers={"x-litellm-trace-id": _token("trace")},
fields={"litellm_session_id": after_session},
)
served: Final = tuple(
(response, session)
for response, session in zip(results, sessions, strict=True)
if response is not None and response.status_code == 200
)
assert sum(held_by.values()) == len(sessions), held_by
assert len(served) == held_by[survivor] >= len(sessions) // 2, (held_by, len(served))
assert _session(after) == after_session
assert tuple(_session(response) for response, _ in served) == tuple(session for _, session in served)

View file

@ -14,8 +14,8 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
import litellm
import litellm.constants as litellm_constants
import litellm.proxy.spend_tracking.spend_tracking_utils as spend_tracking_utils
from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, LITTELM_CLI_SERVICE_ACCOUNT_NAME, LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, MAX_SPEND_LOG_MODEL_NAME_LENGTH, REDACTED_BY_LITELM_STRING, SESSION_ID_OMITTED_METADATA_KEY, UNKNOWN_MODEL_SPEND_LOG_MODEL
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, LITTELM_CLI_SERVICE_ACCOUNT_NAME, LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, MAX_SPEND_LOG_MODEL_NAME_LENGTH, REDACTED_BY_LITELM_STRING, SESSION_ID_GENERATED_METADATA_KEY, SESSION_ID_OMITTED_METADATA_KEY, UNKNOWN_MODEL_SPEND_LOG_MODEL
from litellm.constants import CALLER_SESSION_ID_METADATA_KEY, INTERNAL_CALL_ORIGIN_METADATA_KEY, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.llms.base_llm.ocr.transformation import OCRResponse, OCRUsageInfo
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@ -243,6 +243,82 @@ def test_batch_session_outranks_the_per_request_trace_id():
assert session_id == "batch-uid-1"
def _chat_session_id(
monkeypatch: pytest.MonkeyPatch,
litellm_params: Mapping[str, object],
correlation_in_logs: bool = True,
batch_trace_session_id: str | None = None,
metadata: Mapping[str, object] = MappingProxyType({}),
) -> str | None:
monkeypatch.setattr(litellm, "request_correlation_in_logs", correlation_in_logs)
return _get_session_id_for_spend_log(
kwargs={"litellm_trace_id": "trace-abc", "litellm_params": litellm_params},
metadata=metadata,
standard_logging_payload=_TRACE_ONLY_STANDARD_LOGGING,
omit_when_missing=False,
batch_trace_session_id=batch_trace_session_id,
)
_CALLER_SESSION_PARAMS: Final = MappingProxyType({"litellm_session_id": "sess-1", "metadata": {"trace_id": "otel-1"}})
def test_caller_session_wins_over_the_per_request_trace_id(monkeypatch: pytest.MonkeyPatch):
assert _chat_session_id(monkeypatch, _CALLER_SESSION_PARAMS) == "sess-1"
def test_batch_session_outranks_a_caller_session(monkeypatch: pytest.MonkeyPatch):
assert _chat_session_id(monkeypatch, _CALLER_SESSION_PARAMS, batch_trace_session_id="batch-uid-1") == "batch-uid-1"
def test_caller_session_keeps_the_legacy_trace_id_with_correlation_logging_off(monkeypatch: pytest.MonkeyPatch):
assert _chat_session_id(monkeypatch, _CALLER_SESSION_PARAMS, correlation_in_logs=False) == "trace-abc"
@pytest.mark.parametrize(("correlation_in_logs", "expected_session_id"), [(True, "convo-1"), (False, "trace-abc")])
def test_session_displaced_by_a_trace_header_wins_only_with_correlation_logging(
monkeypatch: pytest.MonkeyPatch, correlation_in_logs: bool, expected_session_id: str
):
header_chain: Final = {"litellm_session_id": "trace-abc", "metadata": {"trace_id": "trace-abc", "session_id": "trace-abc"}}
session_id: Final = _chat_session_id(
monkeypatch,
header_chain,
correlation_in_logs=correlation_in_logs,
metadata={CALLER_SESSION_ID_METADATA_KEY: "convo-1"},
)
assert session_id == expected_session_id
@pytest.mark.parametrize(
("litellm_params", "metadata"),
[
pytest.param({"litellm_session_id": "convo\x00x"}, {}, id="body-session"),
pytest.param({"litellm_session_id": "trace-abc"}, {CALLER_SESSION_ID_METADATA_KEY: "convo\x00x"}, id="beside-trace-header"),
],
)
def test_caller_session_postgres_cannot_store_keeps_the_trace_id(
monkeypatch: pytest.MonkeyPatch, litellm_params: Mapping[str, object], metadata: Mapping[str, object]
):
assert _chat_session_id(monkeypatch, litellm_params, metadata=metadata) == "trace-abc"
@pytest.mark.parametrize(
"litellm_params",
[
pytest.param({"metadata": {}}, id="no-session"),
pytest.param(
{"litellm_session_id": "minted-1", "metadata": {"session_id": "minted-1", SESSION_ID_GENERATED_METADATA_KEY: True}},
id="minted-by-generate-policy",
),
pytest.param({"litellm_session_id": "job-7", "metadata": {"trace_id": "job-7"}}, id="copied-from-trace-id"),
],
)
def test_session_that_is_not_a_caller_conversation_keeps_the_trace_id(
monkeypatch: pytest.MonkeyPatch, litellm_params: Mapping[str, object]
):
assert _chat_session_id(monkeypatch, litellm_params) == "trace-abc"
def test_omit_policy_still_suppresses_batch_sessions():
session_id: Final = _get_session_id_for_spend_log(
kwargs={},

View file

@ -17,6 +17,7 @@ from starlette.datastructures import Headers
import litellm
from litellm.constants import (
CALLER_SESSION_ID_METADATA_KEY,
ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY,
SERVER_STREAMING_CLASSIFICATION_KEY,
SERVER_STREAMING_CLASSIFICATION_MARKER,
@ -3308,6 +3309,93 @@ def test_add_litellm_metadata_from_request_headers_x_litellm_trace_id_sets_chain
assert data["litellm_trace_id"] == "foo"
@pytest.mark.parametrize(("correlation_in_logs", "expected_spend_log_session"), [(True, "convo-1"), (False, None)])
@pytest.mark.parametrize("session_in_metadata", [False, True], ids=["top-level", "metadata"])
def test_x_litellm_trace_id_keeps_the_body_session_for_spend_logs_only(
monkeypatch: pytest.MonkeyPatch,
correlation_in_logs: bool,
expected_spend_log_session: str | None,
session_in_metadata: bool,
):
monkeypatch.setattr(litellm, "request_correlation_in_logs", correlation_in_logs)
metadata: Final[dict[str, str]] = {"session_id": "convo-1"} if session_in_metadata else {}
data: Final[dict[str, object]] = (
{"metadata": metadata} if session_in_metadata else {"metadata": metadata, "litellm_session_id": "convo-1"}
)
LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers(
headers={"x-litellm-trace-id": "req-trace-1"}, data=data, _metadata_variable_name="metadata"
)
assert metadata.get(CALLER_SESSION_ID_METADATA_KEY) == expected_spend_log_session
assert data["litellm_session_id"] == "req-trace-1"
assert metadata["session_id"] == "req-trace-1"
assert data["litellm_trace_id"] == "req-trace-1"
@pytest.mark.parametrize(
"session_header", ["x-litellm-session-id", "x-claude-code-session-id", "x-session-id"]
)
@pytest.mark.parametrize("with_trace_header", [False, True], ids=["session-header-only", "beside-trace-header"])
def test_session_headers_still_beat_the_body_session_with_correlation_logging(
monkeypatch: pytest.MonkeyPatch, session_header: str, with_trace_header: bool
):
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
metadata: Final[dict[str, str]] = {}
data: Final[dict[str, object]] = {"metadata": metadata, "litellm_session_id": "convo-1"}
trace_header: Final = {"x-litellm-trace-id": "req-trace-1"} if with_trace_header else {}
LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers(
headers={**trace_header, session_header: "hdr-session-1"}, data=data, _metadata_variable_name="metadata"
)
spend_log_session: Final = metadata.get(CALLER_SESSION_ID_METADATA_KEY) or data["litellm_session_id"]
assert spend_log_session == "hdr-session-1"
@pytest.mark.asyncio
@pytest.mark.parametrize("forged_in", ["metadata", "litellm_metadata"])
@pytest.mark.parametrize(
"headers",
[{"x-litellm-session-id": "hdr-session-1"}, {}],
ids=["session-header", "no-headers"],
)
async def test_client_metadata_cannot_set_the_spend_log_caller_session(
monkeypatch: pytest.MonkeyPatch, forged_in: str, headers: dict[str, str]
):
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
updated: Final = await add_litellm_data_to_request(
data={
"model": "gpt-5.4-mini",
"messages": [{"role": "user", "content": "hi"}],
forged_in: {CALLER_SESSION_ID_METADATA_KEY: "forged-session"},
},
request=_make_request_mock("/v1/chat/completions", headers),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert CALLER_SESSION_ID_METADATA_KEY not in updated["metadata"]
@pytest.mark.asyncio
async def test_spend_log_caller_session_stays_out_of_requester_metadata(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
updated: Final = await add_litellm_data_to_request(
data={
"model": "gpt-5.4-mini",
"messages": [{"role": "user", "content": "hi"}],
"litellm_session_id": "convo-1",
"metadata": {"team_label": "kept"},
},
request=_make_request_mock("/v1/chat/completions", {"x-litellm-trace-id": "req-trace-1"}),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated["metadata"][CALLER_SESSION_ID_METADATA_KEY] == "convo-1"
assert updated["metadata"]["requester_metadata"]["team_label"] == "kept"
assert CALLER_SESSION_ID_METADATA_KEY not in updated["metadata"]["requester_metadata"]
def test_add_litellm_metadata_from_request_headers_x_litellm_session_id_sets_chain_id():
"""x-litellm-session-id sets both metadata and top-level litellm_session_id/litellm_trace_id for call chaining."""
headers = {"x-litellm-session-id": "bar"}

View file

@ -17,6 +17,7 @@ from openai.types.responses.response_function_web_search import (
from pydantic import TypeAdapter
import litellm
from litellm.constants import CALLER_SESSION_ID_METADATA_KEY
from litellm.integrations.anthropic_cache_control_hook import MAX_CACHE_CONTROL_BLOCKS, AnthropicCacheControlHook
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR
from litellm.litellm_core_utils.prompt_templates.factory import (
@ -6499,3 +6500,69 @@ def test_transform_request_reads_service_tier_from_responses_api_request() -> No
)
assert result["service_tier"] == "priority"
def _continued_chain_request(request_kwargs: Mapping[str, object]) -> Mapping[str, object]:
async def session_lookup(previous_response_id: str) -> ChatCompletionSession:
assert previous_response_id == "resp_turn_1"
return ChatCompletionSession(
messages=[{"role": "user", "content": "turn 1"}], litellm_session_id="chain-session"
)
return asyncio.run(
LiteLLMCompletionResponsesConfig.async_responses_api_session_handler(
previous_response_id="resp_turn_1",
litellm_completion_request={
"model": _CLAUDE_ON_BEDROCK,
"custom_llm_provider": "bedrock",
"messages": [{"role": "user", "content": "turn 2"}],
"metadata": request_kwargs.get("metadata"),
},
instructions=None,
session_lookup=session_lookup,
request_kwargs=request_kwargs,
)
)
@pytest.mark.parametrize(
("request_kwargs", "logged_metadata_key"),
[
(
{
"metadata": {"tag": "client"},
"litellm_metadata": {CALLER_SESSION_ID_METADATA_KEY: "turn-session", "user_api_key": "hashed"},
},
"litellm_metadata",
),
({"metadata": {"user_api_key": "hashed"}}, "metadata"),
],
)
def test_a_continued_chain_logs_its_spend_under_the_chain_session_with_request_correlation(
monkeypatch: pytest.MonkeyPatch, request_kwargs: Mapping[str, object], logged_metadata_key: str
):
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
sent_kwargs: Final = deepcopy(request_kwargs)
request: Final = _continued_chain_request(request_kwargs)
assert request["litellm_trace_id"] == "chain-session"
assert "litellm_session_id" not in request
assert request[logged_metadata_key] == {
**TypeAdapter(dict[str, object]).validate_python(request_kwargs[logged_metadata_key]),
CALLER_SESSION_ID_METADATA_KEY: "chain-session",
}
assert request_kwargs == sent_kwargs
def test_a_continued_chain_leaves_the_turns_metadata_alone_without_request_correlation(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(litellm, "request_correlation_in_logs", False)
caller_metadata: Final = {CALLER_SESSION_ID_METADATA_KEY: "turn-session", "user_api_key": "hashed"}
request: Final = _continued_chain_request({"metadata": caller_metadata, "litellm_metadata": caller_metadata})
assert request["litellm_trace_id"] == "chain-session"
assert "litellm_metadata" not in request
assert request["metadata"] == {CALLER_SESSION_ID_METADATA_KEY: "turn-session", "user_api_key": "hashed"}

View file

@ -1,4 +1,5 @@
import json
import sqlite3
from typing import Final
from unittest.mock import AsyncMock, patch
@ -787,3 +788,80 @@ async def test_message_history_replays_real_key_named_tool_payloads() -> None:
tool_message: Final = result["messages"][2]
assert json.loads(assistant_message["tool_calls"][0]["function"]["arguments"]) == function_arguments
assert json.loads(tool_message["content"]) == function_output
class _SqliteBackedPrismaDB:
def __init__(self, rows: list[tuple[str, str, str, str, str | None, str | None]]) -> None:
self._connection = sqlite3.connect(":memory:")
self._connection.row_factory = sqlite3.Row
self._connection.execute(
'CREATE TABLE "LiteLLM_SpendLogs" '
'(request_id TEXT, api_key TEXT, session_id TEXT, "endTime" TEXT, "user" TEXT, team_id TEXT)'
)
self._connection.executemany('INSERT INTO "LiteLLM_SpendLogs" VALUES (?, ?, ?, ?, ?, ?)', rows)
async def query_raw(self, query: str, *args: object) -> list[dict[str, object]]:
return [dict(row) for row in self._connection.execute(query.replace("$1", "?"), args)]
@pytest.mark.asyncio
async def test_session_lookup_does_not_return_another_keys_rows_for_a_shared_session_id():
"""A caller-supplied litellm_session_id can collide with another key's session; history must stay per key."""
sqlite_db: Final = _SqliteBackedPrismaDB(
[
("victim-1", "victim-key", "shared-session", "2026-01-01T00:00:01", "", ""),
("victim-2", "victim-key", "shared-session", "2026-01-01T00:00:02", "", ""),
("attacker-1", "attacker-key", "shared-session", "2026-01-01T00:00:03", "", ""),
("attacker-2", "attacker-key", "other-session", "2026-01-01T00:00:04", "", ""),
]
)
fake_prisma_client = _FakePrismaClient(results=[])
fake_prisma_client.db = sqlite_db
with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma_client):
spend_logs = await ResponsesSessionHandler.get_all_spend_logs_for_previous_response_id("attacker-1")
victim_logs = await ResponsesSessionHandler.get_all_spend_logs_for_previous_response_id("victim-1")
assert [row["request_id"] for row in spend_logs] == ["attacker-1"]
assert [row["request_id"] for row in victim_logs] == ["victim-1", "victim-2"]
async def _history_request_ids(sqlite_db: _SqliteBackedPrismaDB, response_id: str) -> list[str]:
fake_prisma_client: Final = _FakePrismaClient(results=[])
fake_prisma_client.db = sqlite_db
with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma_client):
spend_logs: Final = await ResponsesSessionHandler.get_all_spend_logs_for_previous_response_id(response_id)
return [row["request_id"] for row in spend_logs]
@pytest.mark.asyncio
async def test_session_lookup_keeps_jwt_users_and_teams_apart_when_they_share_an_empty_api_key():
sqlite_db: Final = _SqliteBackedPrismaDB(
[
("alice-1", "", "shared-session", "2026-01-01T00:00:01", "alice", "team-a"),
("bob-1", "", "shared-session", "2026-01-01T00:00:02", "bob", "team-a"),
("service-a-1", "", "shared-session", "2026-01-01T00:00:03", "", "team-a"),
("service-b-1", "", "shared-session", "2026-01-01T00:00:04", "", "team-b"),
("alice-2", "", "shared-session", "2026-01-01T00:00:05", "alice", "team-a"),
]
)
assert await _history_request_ids(sqlite_db, "alice-1") == ["alice-1", "alice-2"]
assert await _history_request_ids(sqlite_db, "bob-1") == ["bob-1"]
assert await _history_request_ids(sqlite_db, "service-a-1") == ["service-a-1"]
assert await _history_request_ids(sqlite_db, "service-b-1") == ["service-b-1"]
@pytest.mark.asyncio
async def test_session_lookup_follows_the_owner_across_keys_and_keeps_ownerless_keys_apart():
sqlite_db: Final = _SqliteBackedPrismaDB(
[
("alice-old-key", "hashed-old", "shared-session", "2026-01-01T00:00:01", "alice", "team-a"),
("ownerless-1", "hashed-other", "shared-session", "2026-01-01T00:00:02", "", ""),
("alice-new-key", "hashed-new", "shared-session", "2026-01-01T00:00:03", "alice", "team-a"),
("ownerless-2", "hashed-another", "shared-session", "2026-01-01T00:00:04", "", ""),
]
)
assert await _history_request_ids(sqlite_db, "alice-new-key") == ["alice-old-key", "alice-new-key"]
assert await _history_request_ids(sqlite_db, "ownerless-2") == ["ownerless-2"]