Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_fix_agent_mcp_grants

This commit is contained in:
mateo-berri 2026-09-01 18:37:31 -07:00
commit e4c6badca2
104 changed files with 6456 additions and 527 deletions

View file

@ -80,7 +80,7 @@ jobs:
LITELLM_IMAGE: litellm-image-scan:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py -v
# Scans the whole shipped artifact: OS/apk plus every language package
# baked into the image, including ones no lockfile declares (e.g. prisma's
@ -124,7 +124,7 @@ jobs:
LITELLM_IMAGE: litellm-runtime-scan:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py -v
migrations-image:
name: migrations-image
@ -185,7 +185,7 @@ jobs:
LITELLM_COMPONENT_PORT: "4000"
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py -v
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py -v
ui-image:
name: ui-image

View file

@ -66,6 +66,7 @@ RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-gr
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13
# Copy full source tree
@ -87,6 +88,7 @@ RUN uv sync --frozen --no-default-groups --no-editable \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
@ -101,6 +103,12 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime
USER root
# The base image only configures Chainguard's authenticated apk repo, which
# requires an enterprise subscription. Add the public Wolfi repo so `apk add`
# also works for anyone installing extra packages into a running container.
# https://github.com/BerriAI/litellm/issues/33518
RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories
# node (without npm) is required by the prisma CLI at runtime
RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile

View file

@ -64,6 +64,7 @@ RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-gr
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13
# Copy full source tree
@ -85,6 +86,7 @@ RUN uv sync --frozen --no-default-groups --no-editable \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \

View file

@ -70,6 +70,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13
# Copy full source tree
@ -97,6 +98,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13 \
--no-sources-package litellm-proxy-extras; \
else \
@ -106,6 +108,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13; \
fi

View file

@ -26,7 +26,7 @@ If `db.useStackgresOperator` is used (not yet implemented):
| `replicaCount` | The number of LiteLLM Proxy pods to be deployed | `1` |
| `masterkeySecretName` | The name of the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use the generated secret name. | N/A |
| `masterkeySecretKey` | The key within the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use `masterkey` as the key. | N/A |
| `masterkey` | The Master API Key for LiteLLM. If not specified, a random key in the `sk-...` format is generated. | N/A |
| `masterkey` | The Master API Key for LiteLLM. If not specified, a random key in the `sk-...` format is generated on first install and reused on upgrades. | N/A |
| `environmentSecrets` | An optional array of Secret object names. The keys and values in these secrets will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` |
| `environmentConfigMaps` | An optional array of ConfigMap object names. The keys and values in these configmaps will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` |
| `image.repository` | LiteLLM Proxy image repository | `ghcr.io/berriai/litellm` |
@ -212,6 +212,8 @@ service, the **Proxy Endpoint** should be set to `http://<RELEASE>-litellm:4000`
The **Proxy Key** is the value specified for `masterkey` or, if a `masterkey`
was not provided to the helm command line, the `masterkey` is a randomly
generated string in the `sk-...` format stored in the `<RELEASE>-litellm-masterkey` Kubernetes Secret.
The key is generated once on the first install; later `helm upgrade` runs reuse the
value already in that Secret, so upgrading never rotates the master key.
```bash
kubectl -n litellm get secret <RELEASE>-litellm-masterkey -o jsonpath="{.data.masterkey}"

View file

@ -1,9 +1,11 @@
{{- if not .Values.masterkeySecretName }}
{{ $masterkey := (.Values.masterkey | default (printf "sk-%s" (randAlphaNum 18))) }}
{{- $secretName := printf "%s-masterkey" (include "litellm.fullname" .) }}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }}
{{- $masterkey := .Values.masterkey | default (dig "data" "masterkey" "" $existing | b64dec) | default (printf "sk-%s" (randAlphaNum 18)) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "litellm.fullname" . }}-masterkey
name: {{ $secretName }}
data:
masterkey: {{ $masterkey | b64enc }}
type: Opaque

View file

@ -15,6 +15,53 @@ tests:
# Note: The masterkey is generated as "sk-<18-random-chars>" in plain text,
# but stored as base64 encoded in Kubernetes secret (requirement).
# "sk-" base64 encodes to "c2st", so we check for "^c2st" pattern.
- it: should reuse the master key already stored in the cluster instead of generating a new one on upgrade
template: secret-masterkey.yaml
set:
masterkeySecretName: ""
kubernetesProvider:
scheme:
"v1/Secret":
gvr:
version: "v1"
resource: "secrets"
namespaced: true
objects:
- kind: Secret
apiVersion: v1
metadata:
name: RELEASE-NAME-litellm-masterkey
namespace: NAMESPACE
data:
masterkey: c2stZXhpc3Rpbmcta2V5
asserts:
- equal:
path: data.masterkey
value: c2stZXhpc3Rpbmcta2V5
- it: should let an explicit masterkey value override the one already stored in the cluster
template: secret-masterkey.yaml
set:
masterkeySecretName: ""
masterkey: sk-explicit
kubernetesProvider:
scheme:
"v1/Secret":
gvr:
version: "v1"
resource: "secrets"
namespaced: true
objects:
- kind: Secret
apiVersion: v1
metadata:
name: RELEASE-NAME-litellm-masterkey
namespace: NAMESPACE
data:
masterkey: c2stZXhpc3Rpbmcta2V5
asserts:
- equal:
path: data.masterkey
value: c2stZXhwbGljaXQ=
- it: should not create a secret if masterkeySecretName is set
template: secret-masterkey.yaml
set:

View file

@ -1590,6 +1590,7 @@ KEY_ROTATION_JOB_NAME: Final = "litellm_key_rotation_job"
EXPIRED_UI_SESSION_KEY_CLEANUP_JOB_NAME: Final = "litellm_expired_ui_session_key_cleanup_job"
WEEKLY_SPEND_REPORT_JOB_ID: Final = "weekly_spend_report_job"
MONTHLY_SPEND_REPORT_JOB_ID: Final = "monthly_spend_report_job"
USER_SPEND_ALERTS_JOB_ID: Final = "user_spend_alerts_job"
PROMETHEUS_FALLBACK_STATS_JOB_ID: Final = "prometheus_fallback_stats_job"
SLACK_DAILY_REPORT_LOCK_ID: Final = "slack_daily_report"
SLACK_MODEL_DEPRECATION_LOCK_ID: Final = "slack_model_deprecation_warning"

View file

@ -68,6 +68,7 @@ from .utils import process_slack_alerting_variables
if TYPE_CHECKING:
from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager
from litellm.proxy.utils import PrismaClient
from litellm.router import Router as _Router
Router = _Router
@ -1944,6 +1945,69 @@ Model Info:
except Exception as e:
verbose_proxy_logger.exception("Error sending weekly spend report %s", e)
async def send_user_spend_alerts(self, prisma_client: "PrismaClient | None" = None) -> None:
"""Check per-user daily/monthly spend thresholds and spend anomalies, alerting once per user per period."""
if self.alerting is None or "slack" not in self.alerting:
return
thresholds_enabled: Final = AlertType.user_spend_thresholds in self.alert_types
anomalies_enabled: Final = AlertType.user_spend_anomalies in self.alert_types
if not thresholds_enabled and not anomalies_enabled:
return
if prisma_client is None:
from litellm.proxy.proxy_server import prisma_client as global_prisma_client
prisma_client = global_prisma_client # rebind-ok: fall back to the proxy's global client
if prisma_client is None:
return
from litellm.integrations.SlackAlerting.user_spend_alerts import (
evaluate_user_spend,
fetch_user_spend_rows,
)
try:
today: Final = datetime.datetime.now(datetime.timezone.utc).date()
rows: Final = await fetch_user_spend_rows(
prisma_client=prisma_client,
today=today,
baseline_days=self.alerting_args.spend_anomaly_baseline_days,
)
all_events: Final = tuple(
event
for row in rows
for event in evaluate_user_spend(
row=row,
args=self.alerting_args,
today=today,
thresholds_enabled=thresholds_enabled,
anomalies_enabled=anomalies_enabled,
)
)
cached_flags: Final = await asyncio.gather(
*(self.internal_usage_cache.async_get_cache(key=event.cache_key) for event in all_events)
)
new_events: Final = tuple(event for event, cached in zip(all_events, cached_flags) if not cached)
for alert_type in (AlertType.user_spend_thresholds, AlertType.user_spend_anomalies):
typed_events = tuple(event for event in new_events if event.alert_type == alert_type)
if not typed_events:
continue
await self.send_alert(
message="\n\n".join(event.message for event in typed_events),
level="High",
alert_type=alert_type,
alerting_metadata={}, # mutable-ok: send_alert takes a dict payload
)
for event in typed_events:
await self.internal_usage_cache.async_set_cache(
key=event.cache_key,
value="SENT",
ttl=event.cache_ttl,
)
except Exception as e: # noqa: BLE001 # background job must not crash the scheduler
verbose_proxy_logger.exception("Error sending user spend alerts: %s", e)
async def send_fallback_stats_from_prometheus(self):
"""
Helper to send fallback statistics from prometheus server -> to slack

View file

@ -0,0 +1,139 @@
"""Per-user daily/monthly spend threshold alerts and spend anomaly detection."""
import datetime
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, Literal
from pydantic import TypeAdapter
from litellm.constants import HOURS_IN_A_DAY
from litellm.types.integrations.slack_alerting import AlertType, SlackAlertingArgs
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
DAY_SECONDS: Final = HOURS_IN_A_DAY * 60 * 60
MONTHLY_ALERT_TTL_SECONDS: Final = 32 * DAY_SECONDS
USER_SPEND_QUERY: Final = """
SELECT
user_id,
COALESCE(SUM(spend) FILTER (WHERE date = $1), 0)::float AS daily_spend,
COALESCE(SUM(spend) FILTER (WHERE date >= $2), 0)::float AS monthly_spend,
COALESCE(SUM(spend) FILTER (WHERE date >= $3 AND date < $1), 0)::float AS baseline_spend
FROM "LiteLLM_DailyUserSpend"
WHERE date >= LEAST($2, $3) AND user_id IS NOT NULL
GROUP BY user_id
HAVING COALESCE(SUM(spend) FILTER (WHERE date >= $2), 0) > 0
"""
@dataclass(frozen=True, slots=True)
class UserSpendRow:
user_id: str
daily_spend: float
monthly_spend: float
baseline_spend: float
@dataclass(frozen=True, slots=True)
class UserSpendAlertEvent:
kind: Literal["daily_threshold", "monthly_threshold", "anomaly"]
alert_type: AlertType
message: str
cache_key: str
cache_ttl: int
USER_SPEND_ROWS_ADAPTER: Final = TypeAdapter(tuple[UserSpendRow, ...])
async def fetch_user_spend_rows(
prisma_client: "PrismaClient",
today: datetime.date,
baseline_days: int,
) -> tuple[UserSpendRow, ...]:
today_str: Final = today.strftime("%Y-%m-%d")
month_start_str: Final = today.replace(day=1).strftime("%Y-%m-%d")
baseline_start_str: Final = (today - datetime.timedelta(days=max(baseline_days, 1))).strftime("%Y-%m-%d")
raw: Final = await prisma_client.db.query_raw(USER_SPEND_QUERY, today_str, month_start_str, baseline_start_str)
return USER_SPEND_ROWS_ADAPTER.validate_python(raw)
def _daily_threshold_event(row: UserSpendRow, args: SlackAlertingArgs, today_str: str) -> UserSpendAlertEvent | None:
threshold: Final = args.daily_spend_per_user_threshold
if threshold is None or row.daily_spend < threshold:
return None
return UserSpendAlertEvent(
kind="daily_threshold",
alert_type=AlertType.user_spend_thresholds,
message=(
f"User Daily Spend Threshold Crossed:\n"
f"User: `{row.user_id}`\n"
f"Spend Today: `${row.daily_spend:.2f}`\n"
f"Daily Threshold: `${threshold:.2f}`"
),
cache_key=f"user_spend_alert_daily_{row.user_id}_{today_str}",
cache_ttl=DAY_SECONDS,
)
def _monthly_threshold_event(row: UserSpendRow, args: SlackAlertingArgs, month_str: str) -> UserSpendAlertEvent | None:
threshold: Final = args.monthly_spend_per_user_threshold
if threshold is None or row.monthly_spend < threshold:
return None
return UserSpendAlertEvent(
kind="monthly_threshold",
alert_type=AlertType.user_spend_thresholds,
message=(
f"User Monthly Spend Threshold Crossed:\n"
f"User: `{row.user_id}`\n"
f"Spend This Month: `${row.monthly_spend:.2f}`\n"
f"Monthly Threshold: `${threshold:.2f}`"
),
cache_key=f"user_spend_alert_monthly_{row.user_id}_{month_str}",
cache_ttl=MONTHLY_ALERT_TTL_SECONDS,
)
def _anomaly_event(row: UserSpendRow, args: SlackAlertingArgs, today_str: str) -> UserSpendAlertEvent | None:
if row.daily_spend < args.spend_anomaly_min_spend:
return None
baseline_daily_avg: Final = row.baseline_spend / args.spend_anomaly_baseline_days
if row.baseline_spend > 0 and row.daily_spend <= args.spend_anomaly_multiplier * baseline_daily_avg:
return None
return UserSpendAlertEvent(
kind="anomaly",
alert_type=AlertType.user_spend_anomalies,
message=(
f"User Spend Anomaly Detected:\n"
f"User: `{row.user_id}`\n"
f"Spend Today: `${row.daily_spend:.2f}`\n"
f"Daily Average (last {args.spend_anomaly_baseline_days} days): `${baseline_daily_avg:.2f}`\n"
f"Trigger: spend above `{args.spend_anomaly_multiplier}x` the daily average "
f"(minimum `${args.spend_anomaly_min_spend:.2f}`)"
),
cache_key=f"user_spend_alert_anomaly_{row.user_id}_{today_str}",
cache_ttl=DAY_SECONDS,
)
def evaluate_user_spend(
row: UserSpendRow,
args: SlackAlertingArgs,
today: datetime.date,
thresholds_enabled: bool,
anomalies_enabled: bool,
) -> tuple[UserSpendAlertEvent, ...]:
today_str: Final = today.strftime("%Y-%m-%d")
month_str: Final = today.strftime("%Y-%m")
threshold_events: Final = (
(
_daily_threshold_event(row=row, args=args, today_str=today_str),
_monthly_threshold_event(row=row, args=args, month_str=month_str),
)
if thresholds_enabled
else ()
)
anomaly_events: Final = (_anomaly_event(row=row, args=args, today_str=today_str),) if anomalies_enabled else ()
return tuple(event for event in (*threshold_events, *anomaly_events) if event is not None)

View file

@ -11,6 +11,7 @@ import json
import os
from collections.abc import Mapping, Sequence
from datetime import datetime
from types import MappingProxyType
from typing import Any, Final, Literal
import httpx
@ -30,12 +31,16 @@ from litellm.integrations.datadog.datadog_mock_client import (
)
from litellm.litellm_core_utils.dd_tracing import tracer
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_content_list_to_str,
handle_any_messages_to_chat_completion_str_messages_conversion,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.proxy.spend_tracking.savings import extract_cache_creation_tokens, extract_cache_read_tokens
from litellm.types.integrations.datadog_llm_obs import *
from litellm.types.utils import (
CallTypes,
@ -44,6 +49,189 @@ from litellm.types.utils import (
StandardLoggingPayloadErrorInformation,
)
_EMPTY_MAPPING: Final[Mapping[str, Any]] = MappingProxyType({})
_EMPTY_MESSAGE: Final[Message] = {"role": "", "content": ""}
_MAX_PARSED_TOOL_ARGUMENT_CHARS: Final = 256 * 1024
def _mapping_field(source: Mapping[str, Any], key: str) -> Mapping[str, Any]:
"""The value at `key` when it is a mapping, else an empty one."""
value: Final = source.get(key)
return value if isinstance(value, dict) else _EMPTY_MAPPING
def _content_blocks(message: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]:
content: Final = message.get("content")
if not isinstance(content, list):
return ()
return tuple(block for block in content if isinstance(block, dict))
def _to_dd_arguments(raw_arguments: object) -> dict[str, Any] | str:
"""
Arguments as the object LLM Obs types them as, or the raw string when they are not one.
Strings past the size bound ship unparsed: decoding multiplies memory on hostile compact
JSON, and the raw string is what the intake receives either way.
"""
if not isinstance(raw_arguments, str):
return raw_arguments if isinstance(raw_arguments, dict) else str(raw_arguments)
if len(raw_arguments) > _MAX_PARSED_TOOL_ARGUMENT_CHARS:
return raw_arguments
parsed: Final = safe_json_loads(raw_arguments)
return parsed if isinstance(parsed, dict) else raw_arguments
def _to_dd_tool_calls(message: Mapping[str, Any]) -> tuple[ToolCall, ...]:
"""
The tool calls a message carries, in LLM Obs' ToolCall schema, from either dialect.
OpenAI puts them in `tool_calls` with the callee nested under `function` and `arguments`
serialized; Anthropic puts them in `content` as `tool_use` blocks with `input` already an
object. LLM Obs reads `name` / `arguments` / `tool_id` either way.
"""
raw_tool_calls: Final = message.get("tool_calls")
openai_calls: Final = tuple(
ToolCall(
name=function.get("name", ""),
arguments=_to_dd_arguments(function.get("arguments", "")),
tool_id=tool_call.get("id", ""),
type=tool_call.get("type", "function"),
)
for tool_call in (raw_tool_calls if isinstance(raw_tool_calls, list) else ())
if isinstance(tool_call, dict)
for function in [_mapping_field(tool_call, "function")]
)
anthropic_calls: Final = tuple(
ToolCall(
name=block.get("name", ""),
arguments=_to_dd_arguments(block.get("input") or {}),
tool_id=block.get("id", ""),
type="tool_use",
)
for block in _content_blocks(message)
if block.get("type") == "tool_use"
)
return openai_calls + anthropic_calls
def _to_dd_tool_results(message: Mapping[str, Any], tool_call_names: Mapping[str, str]) -> tuple[ToolResult, ...]:
"""
The tool results a message carries, linked back to the call each answers.
OpenAI models a result as a whole `role: "tool"` message keyed by `tool_call_id`;
Anthropic nests `tool_result` blocks inside a user message, keyed by `tool_use_id`.
"""
def to_result(tool_id: str, result: object) -> ToolResult:
return ToolResult(
name=tool_call_names.get(tool_id, ""),
result=result if isinstance(result, str) else safe_dumps(result),
tool_id=tool_id,
type="function",
)
if message.get("role") == "tool":
return (to_result(str(message.get("tool_call_id", "")), message.get("content") or ""),)
return tuple(
to_result(str(block.get("tool_use_id", "")), block.get("content") or "")
for block in _content_blocks(message)
if block.get("type") == "tool_result"
)
def _tool_call_names_by_id(messages: Sequence[object]) -> Mapping[str, str]:
"""Ids to tool names for result linking; reads names structurally and parses nothing."""
openai_pairs: Final = tuple(
(tool_call.get("id"), function.get("name", ""))
for message in messages
if isinstance(message, dict) and isinstance(message.get("tool_calls"), list)
for tool_call in message["tool_calls"]
if isinstance(tool_call, dict)
for function in [_mapping_field(tool_call, "function")]
)
anthropic_pairs: Final = tuple(
(block.get("id"), block.get("name", ""))
for message in messages
if isinstance(message, dict)
for block in _content_blocks(message)
if block.get("type") == "tool_use"
)
return MappingProxyType({str(tool_id): str(name) for tool_id, name in openai_pairs + anthropic_pairs if tool_id})
def _to_dd_message(message: object, tool_call_names: Mapping[str, str]) -> Message:
"""
Map one chat message onto LLM Obs' Message schema, adding fields and never destroying content.
Content collapses to its text only when it has text; a content list with none (tool blocks,
images) rides along unchanged so nothing the caller logged is lost. Tool calls and results
move into the fields the LLM Obs Tools panel reads, from both the OpenAI and Anthropic shapes.
"""
if not isinstance(message, dict):
converted: Final = handle_any_messages_to_chat_completion_str_messages_conversion(message)
return converted[0] if converted else _EMPTY_MESSAGE
text: Final = convert_content_list_to_str(message) # pyright: ignore[reportArgumentType] # caller-supplied dict
original_content: Final = message.get("content")
content: Final = (
text if text or not isinstance(original_content, list) or not original_content else original_content
)
reasoning: Final = message.get("reasoning_content")
tool_calls: Final = _to_dd_tool_calls(message)
tool_results: Final = _to_dd_tool_results(message, tool_call_names)
dd_message: Final[Message] = {
"role": message.get("role", ""),
"content": content,
**({"reasoning_content": reasoning} if reasoning is not None else {}),
**({"tool_calls": tool_calls} if tool_calls else {}),
**({"tool_results": tool_results} if tool_results else {}),
}
return dd_message
def _to_dd_messages(messages: object) -> tuple[Message, ...]:
"""Map a whole conversation, resolving each tool result against the calls that precede it."""
if messages is None:
return ()
if not isinstance(messages, list):
return tuple(handle_any_messages_to_chat_completion_str_messages_conversion(messages))
tool_call_names: Final = _tool_call_names_by_id(messages)
return tuple(_to_dd_message(message, tool_call_names) for message in messages)
def _to_dd_tool_definition(entry: Mapping[str, Any]) -> ToolDefinition | None:
function: Final = entry.get("function")
declared: Final[Mapping[str, Any]] = function if isinstance(function, dict) else entry
name: Final = declared.get("name")
if not name:
return None
schema: Final = declared.get("parameters") or declared.get("input_schema")
description: Final = declared.get("description", "")
if not isinstance(schema, dict):
return ToolDefinition(name=name, description=description)
return ToolDefinition(name=name, description=description, schema=schema)
def _to_dd_tool_definitions(model_parameters: object) -> tuple[ToolDefinition, ...]:
"""
Map the request's declared tools onto LLM Obs' ToolDefinition schema.
Handles the wrapped chat-completions shape and the bare shape the Anthropic and
Responses surfaces use, since both reach this logger through `model_parameters`.
"""
if not isinstance(model_parameters, dict):
return ()
raw_tools: Final = model_parameters.get("tools") or model_parameters.get("functions")
if not isinstance(raw_tools, list):
return ()
return tuple(
definition
for entry in raw_tools
if isinstance(entry, dict)
if (definition := _to_dd_tool_definition(entry)) is not None
)
class DataDogLLMObsLogger(CustomBatchLogger):
def __init__(self, **kwargs):
@ -222,12 +410,9 @@ class DataDogLLMObsLogger(CustomBatchLogger):
if standard_logging_payload is None:
raise Exception("DataDogLLMObs: standard_logging_object is not set")
messages = standard_logging_payload["messages"]
messages = self._ensure_string_content(messages=messages)
metadata: Final = kwargs.get("litellm_params", {}).get("metadata", {})
input_meta: Final = InputMeta(messages=handle_any_messages_to_chat_completion_str_messages_conversion(messages))
input_meta: Final = InputMeta(messages=_to_dd_messages(standard_logging_payload["messages"]))
output_meta: Final = OutputMeta(
messages=self._get_response_messages(
standard_logging_payload=standard_logging_payload,
@ -241,22 +426,20 @@ class DataDogLLMObsLogger(CustomBatchLogger):
if isinstance(metadata, dict):
metadata_parent_id = metadata.get("parent_id")
meta: Final = Meta(
kind=self._get_datadog_span_kind(standard_logging_payload.get("call_type"), metadata_parent_id),
input=input_meta,
output=output_meta,
metadata=self._get_dd_llm_obs_payload_metadata(standard_logging_payload),
error=error_info,
)
tool_definitions: Final = _to_dd_tool_definitions(standard_logging_payload.get("model_parameters"))
span_kind: Final = self._get_datadog_span_kind(standard_logging_payload.get("call_type"), metadata_parent_id)
payload_metadata: Final = self._get_dd_llm_obs_payload_metadata(standard_logging_payload)
# Calculate metrics (you may need to adjust these based on available data)
metrics: Final = LLMMetrics(
input_tokens=float(standard_logging_payload.get("prompt_tokens", 0)),
output_tokens=float(standard_logging_payload.get("completion_tokens", 0)),
total_tokens=float(standard_logging_payload.get("total_tokens", 0)),
total_cost=float(standard_logging_payload.get("response_cost", 0)),
time_to_first_token=self._get_time_to_first_token_seconds(standard_logging_payload),
)
meta: Final[Meta] = {
"kind": span_kind,
"input": input_meta,
"output": output_meta,
"metadata": payload_metadata,
"error": error_info,
**({"tool_definitions": tool_definitions} if tool_definitions else {}),
}
metrics: Final = self._assemble_metrics(standard_logging_payload)
payload: Final[LLMObsPayload] = LLMObsPayload(
parent_id=metadata_parent_id if metadata_parent_id else "undefined",
@ -314,6 +497,45 @@ class DataDogLLMObsLogger(CustomBatchLogger):
)
return error_info
def _assemble_metrics(self, standard_logging_payload: StandardLoggingPayload) -> LLMMetrics:
"""
Build the span metrics, including the prompt-cache counts LLM Obs charts cache savings from.
Cache counts resolve through the same owners the savings dashboard uses, so every provider
spelling is covered, and `non_cached_input_tokens` subtracts BOTH cache categories because
litellm's normalized prompt count includes both (the invariant the cost calculator's custom
pricing helper documents). A zero residual on a fully cached request is real data and is
emitted; a zero read or write count is absence and is not.
"""
prompt_tokens: Final = float(standard_logging_payload.get("prompt_tokens", 0))
completion_tokens: Final = float(standard_logging_payload.get("completion_tokens", 0))
total_tokens: Final = float(standard_logging_payload.get("total_tokens", 0))
total_cost: Final = float(standard_logging_payload.get("response_cost", 0))
time_to_first_token: Final = self._get_time_to_first_token_seconds(standard_logging_payload)
raw_usage: Final = (standard_logging_payload.get("metadata") or {}).get("usage_object")
usage_object: Final = raw_usage if isinstance(raw_usage, dict) else None
cache_read: Final = float(extract_cache_read_tokens(usage_object))
cache_write: Final = float(extract_cache_creation_tokens(usage_object))
metrics: Final[LLMMetrics] = {
"input_tokens": prompt_tokens,
"output_tokens": completion_tokens,
"total_tokens": total_tokens,
"total_cost": total_cost,
"time_to_first_token": time_to_first_token,
**(
{
**({"cache_read_input_tokens": cache_read} if cache_read else {}),
**({"cache_write_input_tokens": cache_write} if cache_write else {}),
"non_cached_input_tokens": max(prompt_tokens - cache_read - cache_write, 0.0),
}
if cache_read or cache_write
else {}
),
}
return metrics
def _get_time_to_first_token_seconds(self, standard_logging_payload: StandardLoggingPayload) -> float:
"""
Get the time to first token in seconds
@ -335,7 +557,7 @@ class DataDogLLMObsLogger(CustomBatchLogger):
def _get_response_messages(
self, standard_logging_payload: StandardLoggingPayload, call_type: str | None
) -> list[object]:
) -> tuple[Message, ...]:
"""
Get the messages from the response object
@ -344,7 +566,7 @@ class DataDogLLMObsLogger(CustomBatchLogger):
response_obj = standard_logging_payload.get("response")
if response_obj is None:
return []
return ()
# edge case: handle response_obj is a string representation of a dict
if isinstance(response_obj, str):
@ -357,7 +579,7 @@ class DataDogLLMObsLogger(CustomBatchLogger):
# fallback to json parsing
response_obj = json.loads(str(response_obj))
except json.JSONDecodeError:
return []
return ()
if call_type in [
CallTypes.completion.value,
@ -375,12 +597,12 @@ class DataDogLLMObsLogger(CustomBatchLogger):
if isinstance(response_obj, dict) and "choices" in response_obj:
choices: Final = response_obj["choices"]
if choices and len(choices) > 0 and "message" in choices[0]:
return [choices[0]["message"]]
return []
return _to_dd_messages([choices[0]["message"]])
return ()
except (KeyError, IndexError, TypeError):
# In case of any error accessing the response structure, return empty list
return []
return []
return ()
return ()
def _get_datadog_span_kind(
self, call_type: str | None, parent_id: str | None = None
@ -485,17 +707,6 @@ class DataDogLLMObsLogger(CustomBatchLogger):
# Default fallback for unknown or passthrough operations
return "llm"
def _ensure_string_content(self, messages: str | Sequence[object] | Mapping[object, object] | None) -> list[object]:
if messages is None:
return []
if isinstance(messages, str):
return [messages]
elif isinstance(messages, list):
return [message for message in messages]
elif isinstance(messages, dict):
return [str(messages.get("content", ""))]
return []
def _get_dd_llm_obs_payload_metadata(self, standard_logging_payload: StandardLoggingPayload) -> dict[str, object]:
"""
Fields to track in DD LLM Observability metadata from litellm standard logging payload
@ -524,10 +735,6 @@ class DataDogLLMObsLogger(CustomBatchLogger):
spend_metrics: Final = self._get_spend_metrics(standard_logging_payload)
_metadata.update({"spend_metrics": dict(spend_metrics)})
## extract tool calls and add to metadata
tool_call_metadata: Final = self._extract_tool_call_metadata(standard_logging_payload)
_metadata.update(tool_call_metadata)
_standard_logging_metadata: Final[dict] = dict(standard_logging_payload.get("metadata", {})) or {}
_metadata.update(_standard_logging_metadata)
return _metadata
@ -647,107 +854,3 @@ class DataDogLLMObsLogger(CustomBatchLogger):
verbose_logger.debug("Original value: %s", user_api_key_budget_reset_at)
return spend_metrics
def _process_input_messages_preserving_tool_calls(self, messages: Sequence[object]) -> list[dict[str, object]]:
"""
Process input messages while preserving tool_calls and tool message types.
This bypasses the lossy string conversion when tool calls are present,
allowing complex nested tool_calls objects to be preserved for Datadog.
"""
processed: Final = []
for msg in messages:
if isinstance(msg, dict):
# Preserve messages with tool_calls or tool role as-is
if "tool_calls" in msg or msg.get("role") == "tool":
processed.append(msg)
else:
# For regular messages, still apply string conversion
converted = handle_any_messages_to_chat_completion_str_messages_conversion([msg])
processed.extend(converted)
else:
# For non-dict messages, apply string conversion
converted = handle_any_messages_to_chat_completion_str_messages_conversion([msg])
processed.extend(converted)
return processed
@staticmethod
def _tool_calls_kv_pair(tool_calls: list[dict[str, Any]]) -> dict[str, object]:
"""
Extract tool call information into key-value pairs for Datadog metadata.
Similar to OpenTelemetry's implementation but adapted for Datadog's format.
"""
kv_pairs: Final[dict[str, object]] = {}
for idx, tool_call in enumerate(tool_calls):
try:
# Extract tool call ID
tool_id = tool_call.get("id")
if tool_id:
kv_pairs[f"tool_calls.{idx}.id"] = tool_id
# Extract tool call type
tool_type = tool_call.get("type")
if tool_type:
kv_pairs[f"tool_calls.{idx}.type"] = tool_type
# Extract function information
function = tool_call.get("function")
if function:
function_name = function.get("name")
if function_name:
kv_pairs[f"tool_calls.{idx}.function.name"] = function_name
function_arguments = function.get("arguments")
if function_arguments:
# Store arguments as JSON string for Datadog
if isinstance(function_arguments, str):
kv_pairs[f"tool_calls.{idx}.function.arguments"] = function_arguments
else:
import json
kv_pairs[f"tool_calls.{idx}.function.arguments"] = json.dumps(function_arguments)
except (KeyError, TypeError, ValueError) as e:
verbose_logger.debug("DataDogLLMObs: Error processing tool call %s: %s", idx, e)
continue
return kv_pairs
def _extract_tool_call_metadata(self, standard_logging_payload: StandardLoggingPayload) -> dict[str, object]:
"""
Extract tool call information from both input messages and response for Datadog metadata.
"""
tool_call_metadata: Final[dict[str, object]] = {}
try:
# Extract tool calls from input messages
messages: Final = standard_logging_payload.get("messages", [])
if messages and isinstance(messages, list):
for message in messages:
if isinstance(message, dict) and "tool_calls" in message:
tool_calls = message.get("tool_calls")
if tool_calls:
input_tool_calls_kv = self._tool_calls_kv_pair(tool_calls)
# Prefix with "input_" to distinguish from response tool calls
for key, value in input_tool_calls_kv.items():
tool_call_metadata[f"input_{key}"] = value
# Extract tool calls from response
response_obj: Final = standard_logging_payload.get("response")
if response_obj and isinstance(response_obj, dict):
choices: Final = response_obj.get("choices", [])
for choice in choices:
if isinstance(choice, dict):
message = choice.get("message")
if message and isinstance(message, dict):
tool_calls = message.get("tool_calls")
if tool_calls:
response_tool_calls_kv = self._tool_calls_kv_pair(tool_calls)
# Prefix with "output_" to distinguish from input tool calls
for key, value in response_tool_calls_kv.items():
tool_call_metadata[f"output_{key}"] = value
except Exception as e:
verbose_logger.debug("DataDogLLMObs: Error extracting tool call metadata: %s", e)
return tool_call_metadata

View file

@ -6,6 +6,7 @@ import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import Enum
from types import MappingProxyType
from typing import TYPE_CHECKING, ClassVar, Final, cast
from urllib.parse import urlsplit
@ -62,6 +63,31 @@ if TYPE_CHECKING:
# --- typed sub-structures ---------------------------------------------------- #
def _cache_token_value(*values: object) -> int | None:
explicit_zero = False
invalid_before_zero = False
for raw_value in values:
if raw_value is None:
continue
if isinstance(raw_value, bool):
parsed = None
else:
try:
parsed = as_int(raw_value)
except (OverflowError, ValueError):
parsed = None
if parsed is None:
if not explicit_zero:
invalid_before_zero = True
elif parsed > 0:
return parsed
elif parsed == 0:
explicit_zero = True
elif not explicit_zero:
invalid_before_zero = True
return 0 if explicit_zero and not invalid_before_zero else None
@dataclass(frozen=True)
class LLMRequestParams:
temperature: float | None = None
@ -104,12 +130,25 @@ class LLMUsage:
metadata: Final[Mapping[str, object]] = payload.get("metadata") or {}
raw_usage: Final = metadata.get("usage_object")
usage_object: Final[Mapping[str, object]] = raw_usage if isinstance(raw_usage, Mapping) else {}
raw_details: Final = usage_object.get("prompt_tokens_details")
prompt_details: Final[Mapping[str, object]] = (
raw_details if isinstance(raw_details, Mapping) else MappingProxyType({})
)
return cls(
input_tokens=as_int(payload.get("prompt_tokens")),
output_tokens=as_int(payload.get("completion_tokens")),
total_tokens=as_int(payload.get("total_tokens")),
cache_creation_input_tokens=as_int(usage_object.get("cache_creation_input_tokens")),
cache_read_input_tokens=as_int(usage_object.get("cache_read_input_tokens")),
cache_creation_input_tokens=_cache_token_value(
usage_object.get("cache_creation_input_tokens"),
prompt_details.get("cache_write_tokens"),
prompt_details.get("cache_creation_tokens"),
prompt_details.get("cache_creation_input_tokens"),
),
cache_read_input_tokens=_cache_token_value(
usage_object.get("cache_read_input_tokens"),
prompt_details.get("cached_tokens"),
usage_object.get("prompt_cache_hit_tokens"),
),
)

View file

@ -8,6 +8,7 @@ import math
import os
import sys
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import replace
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, cast
@ -58,6 +59,7 @@ from litellm.types.utils import (
if TYPE_CHECKING:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from prometheus_client import Gauge
from prometheus_client.metrics import MetricWrapperBase
from litellm.router import Router
@ -476,6 +478,30 @@ class PrometheusLogger(CustomLogger):
labelnames=self.get_labels_for_metric("litellm_remaining_api_key_tokens_for_model"),
)
self.litellm_api_key_rate_limit_allowed_metric = self._gauge_factory(
"litellm_api_key_rate_limit_allowed_metric",
"Configured rate limit for the API Key in the current window (rpm_limit / tpm_limit), by rate_limit_type",
labelnames=self.get_labels_for_metric("litellm_api_key_rate_limit_allowed_metric"),
)
self.litellm_api_key_rate_limit_used_metric = self._gauge_factory(
"litellm_api_key_rate_limit_used_metric",
"Requests or tokens the API Key has consumed in the current rate limit window, by rate_limit_type",
labelnames=self.get_labels_for_metric("litellm_api_key_rate_limit_used_metric"),
)
self.litellm_team_rate_limit_allowed_metric = self._gauge_factory(
"litellm_team_rate_limit_allowed_metric",
"Configured rate limit for the Team in the current window (team rpm_limit / tpm_limit), by rate_limit_type",
labelnames=self.get_labels_for_metric("litellm_team_rate_limit_allowed_metric"),
)
self.litellm_team_rate_limit_used_metric = self._gauge_factory(
"litellm_team_rate_limit_used_metric",
"Requests or tokens the Team has consumed in the current rate limit window, by rate_limit_type",
labelnames=self.get_labels_for_metric("litellm_team_rate_limit_used_metric"),
)
########################################
# LLM API Deployment Metrics / analytics
########################################
@ -1475,6 +1501,11 @@ class PrometheusLogger(CustomLogger):
model_id=enum_values.model_id,
)
self._set_key_and_team_rate_limit_metrics(
standard_logging_payload=standard_logging_payload, # pyright: ignore[reportArgumentType] # isinstance(dict) above narrows the TypedDict to dict[Unknown, Unknown]
enum_values=enum_values,
)
# set latency metrics
self._set_latency_metrics(
kwargs=kwargs,
@ -2002,17 +2033,102 @@ class PrometheusLogger(CustomLogger):
"""
if standard_logging_payload is None:
return None
return PrometheusLogger._get_int_from_v3_rate_limit_headers(
standard_logging_payload=standard_logging_payload,
header_name=f"x-ratelimit-model_per_key-remaining-{rate_limit_type}",
)
@staticmethod
def _get_int_from_v3_rate_limit_headers(
standard_logging_payload: StandardLoggingPayload,
header_name: str,
) -> int | None:
hidden_params: Final = standard_logging_payload.get("hidden_params")
if hidden_params is None:
return None
additional_headers: Final = hidden_params.get("additional_headers")
additional_headers: Final[Mapping[str, object] | None] = hidden_params.get("additional_headers")
if additional_headers is None:
return None
value: Final = dict(additional_headers).get(f"x-ratelimit-model_per_key-remaining-{rate_limit_type}")
value: Final = additional_headers.get(header_name)
if isinstance(value, bool) or not isinstance(value, int):
return None
return value
def _set_key_and_team_rate_limit_metrics(
self,
standard_logging_payload: StandardLoggingPayload,
enum_values: UserAPIKeyLabelValues,
) -> None:
"""
Export the key-level and team-level RPM / TPM limit and current window
usage from the ``x-ratelimit-{api_key,team}-{limit,remaining}-*``
headers the v3 rate limiter mirrors into the logging payload. The
limiter already read these counters (from Redis when configured) on
the request path, so no extra store lookup happens here. Descriptors
without a configured limit emit no header, so their series is removed
rather than left at the value from before the limit was dropped.
"""
descriptor_gauges: Final[
tuple[tuple[Literal["api_key", "team"], DEFINED_PROMETHEUS_METRICS, Gauge, Gauge], ...]
] = (
(
"api_key",
"litellm_api_key_rate_limit_allowed_metric",
self.litellm_api_key_rate_limit_allowed_metric,
self.litellm_api_key_rate_limit_used_metric,
),
(
"team",
"litellm_team_rate_limit_allowed_metric",
self.litellm_team_rate_limit_allowed_metric,
self.litellm_team_rate_limit_used_metric,
),
)
for descriptor_key, metric_name, allowed_gauge, used_gauge in descriptor_gauges:
for rate_limit_type in ("requests", "tokens"):
self._set_rate_limit_allowed_and_used_gauges(
standard_logging_payload=standard_logging_payload,
enum_values=enum_values,
descriptor_key=descriptor_key,
metric_name=metric_name,
allowed_gauge=allowed_gauge,
used_gauge=used_gauge,
rate_limit_type=rate_limit_type,
)
def _set_rate_limit_allowed_and_used_gauges(
self,
standard_logging_payload: StandardLoggingPayload,
enum_values: UserAPIKeyLabelValues,
descriptor_key: Literal["api_key", "team"],
metric_name: DEFINED_PROMETHEUS_METRICS,
allowed_gauge: Gauge,
used_gauge: Gauge,
rate_limit_type: Literal["requests", "tokens"],
) -> None:
limit: Final = self._get_int_from_v3_rate_limit_headers(
standard_logging_payload=standard_logging_payload,
header_name=f"x-ratelimit-{descriptor_key}-limit-{rate_limit_type}",
)
remaining: Final = self._get_int_from_v3_rate_limit_headers(
standard_logging_payload=standard_logging_payload,
header_name=f"x-ratelimit-{descriptor_key}-remaining-{rate_limit_type}",
)
labelled_values: Final = replace(enum_values, rate_limit_type=rate_limit_type)
labelnames: Final = self.get_labels_for_metric(metric_name)
labels: Final = prometheus_label_factory(
supported_enum_labels=labelnames,
enum_values=labelled_values,
label_context=PrometheusLabelFactoryContext(labelled_values),
)
if limit is None or remaining is None:
label_values: Final = tuple(labels.get(label) for label in labelnames)
self._bounded_prometheus_series_tracker.remove_series(allowed_gauge, label_values)
self._bounded_prometheus_series_tracker.remove_series(used_gauge, label_values)
return
allowed_gauge.labels(**labels).set(limit)
used_gauge.labels(**labels).set(limit - remaining)
def _set_virtual_key_rate_limit_metrics(
self,
user_api_key: str | None,

View file

@ -60,6 +60,10 @@ class BoundedPrometheusSeriesTracker:
break
del series[tracked_label_values]
def remove_series(self, metric: object, label_values: tuple[str | None, ...]) -> bool:
"""Drop one child series, True when it is gone (removed or never existed)."""
return self._remove_metric_child(metric, label_values)
def _should_run_ttl_cleanup(
self,
metric_name: str,

View file

@ -12,6 +12,7 @@ import asyncio
import json
import os
import random
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timezone
@ -154,18 +155,6 @@ class GetModelCostMap:
return True
@staticmethod
def fetch_remote_model_cost_map(url: str, timeout: int = 5) -> dict:
"""
Fetch the model cost map from a remote URL.
Returns the parsed JSON dict. Raises on network/parse errors
(caller is expected to handle).
"""
response: Final = httpx.get(url, timeout=timeout)
response.raise_for_status()
return response.json()
RETRYABLE_FETCH_STATUS_CODES: Final = frozenset({429, 500, 502, 503, 504})
MODEL_COST_MAP_FETCH_MAX_ATTEMPTS: Final = 3
@ -212,6 +201,13 @@ class _AsyncGetClient(Protocol):
def get(self, url: str, *, timeout: float | None = None) -> Awaitable[httpx.Response]: ...
class _SyncGetClient(Protocol):
def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: ...
_FetchAttemptOutcome = ModelCostMapReloaded | ModelCostMapReloadUnavailable | _FetchAttemptRetryable
def _default_reload_client() -> _AsyncGetClient:
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
@ -219,13 +215,30 @@ def _default_reload_client() -> _AsyncGetClient:
return get_async_httpx_client(llm_provider=httpxSpecialProvider.ModelCostMap)
async def _attempt_fetch(
client: _AsyncGetClient, url: str, timeout: int
) -> ModelCostMapReloaded | ModelCostMapReloadUnavailable | _FetchAttemptRetryable:
def _classify_fetch_error(error: httpx.HTTPError | httpx.InvalidURL, url: str) -> _FetchAttemptOutcome:
reason: Final = f"{type(error).__name__} fetching {url}: {error}"
if isinstance(error, (httpx.InvalidURL, httpx.UnsupportedProtocol)):
return ModelCostMapReloadUnavailable(reason=reason)
return _FetchAttemptRetryable(reason=reason, retry_after_seconds=None)
async def _attempt_fetch(client: _AsyncGetClient, url: str, timeout: int) -> _FetchAttemptOutcome:
try:
response: Final = await client.get(url, timeout=timeout)
except httpx.HTTPError as e:
return _FetchAttemptRetryable(reason=f"{type(e).__name__} fetching {url}: {e}", retry_after_seconds=None)
except (httpx.HTTPError, httpx.InvalidURL) as e:
return _classify_fetch_error(e, url)
return _classify_fetch_response(response, url)
def _attempt_fetch_sync(client: _SyncGetClient, url: str, timeout: int) -> _FetchAttemptOutcome:
try:
response: Final = client.get(url, timeout=timeout)
except (httpx.HTTPError, httpx.InvalidURL) as e:
return _classify_fetch_error(e, url)
return _classify_fetch_response(response, url)
def _classify_fetch_response(response: httpx.Response, url: str) -> _FetchAttemptOutcome:
if response.status_code in RETRYABLE_FETCH_STATUS_CODES:
return _FetchAttemptRetryable(
reason=f"HTTP {response.status_code} from {url}",
@ -242,6 +255,22 @@ async def _attempt_fetch(
return ModelCostMapReloaded(model_cost_map=parsed)
def _next_retry_wait(
outcome: _FetchAttemptRetryable, attempt: int, max_attempts: int, rng: random.Random
) -> float | ModelCostMapReloadUnavailable:
if attempt == max_attempts:
return ModelCostMapReloadUnavailable(reason=f"{outcome.reason} (after {max_attempts} attempts)")
wait_seconds: Final = _retry_wait_seconds(outcome=outcome, attempt=attempt, rng=rng)
verbose_logger.warning(
"LiteLLM: model cost map fetch attempt %d/%d failed (%s); retrying in %.1fs",
attempt,
max_attempts,
outcome.reason,
wait_seconds,
)
return wait_seconds
async def _fetch_remote_model_cost_map_with_retry(
url: str,
timeout: int,
@ -254,20 +283,32 @@ async def _fetch_remote_model_cost_map_with_retry(
outcome = await _attempt_fetch(client=client, url=url, timeout=timeout)
if not isinstance(outcome, _FetchAttemptRetryable):
return outcome
if attempt == max_attempts:
return ModelCostMapReloadUnavailable(reason=f"{outcome.reason} (after {max_attempts} attempts)")
wait_seconds = _retry_wait_seconds(outcome=outcome, attempt=attempt, rng=rng)
verbose_logger.warning(
"LiteLLM: model cost map fetch attempt %d/%d failed (%s); retrying in %.1fs",
attempt,
max_attempts,
outcome.reason,
wait_seconds,
)
wait_seconds = _next_retry_wait(outcome=outcome, attempt=attempt, max_attempts=max_attempts, rng=rng)
if isinstance(wait_seconds, ModelCostMapReloadUnavailable):
return wait_seconds
await sleep(wait_seconds)
return ModelCostMapReloadUnavailable(reason="model cost map fetch failed")
def _fetch_remote_model_cost_map_with_retry_sync(
url: str,
timeout: int,
max_attempts: int,
sleep: Callable[[float], None],
rng: random.Random,
client: _SyncGetClient,
) -> ModelCostMapReloadResult:
for attempt in range(1, max_attempts + 1):
outcome = _attempt_fetch_sync(client=client, url=url, timeout=timeout)
if not isinstance(outcome, _FetchAttemptRetryable):
return outcome
wait_seconds = _next_retry_wait(outcome=outcome, attempt=attempt, max_attempts=max_attempts, rng=rng)
if isinstance(wait_seconds, ModelCostMapReloadUnavailable):
return wait_seconds
sleep(wait_seconds)
return ModelCostMapReloadUnavailable(reason="model cost map fetch failed")
async def refetch_model_cost_map(
url: str,
timeout: int = 5,
@ -423,13 +464,21 @@ def _finalize_model_cost_map(model_cost: dict) -> dict:
return _expand_model_aliases(model_cost)
def get_model_cost_map(url: str) -> dict:
def get_model_cost_map(
url: str,
timeout: int = 5,
max_attempts: int = MODEL_COST_MAP_FETCH_MAX_ATTEMPTS,
sleep: Callable[[float], None] = time.sleep,
rng: random.Random | None = None,
client: "_SyncGetClient | None" = None,
) -> dict:
"""
Public entry point returns the model cost map dict.
1. If ``LITELLM_LOCAL_MODEL_COST_MAP`` is set, uses the local backup only.
2. Otherwise fetches from ``url``, validates integrity, and falls back
to the local backup on any failure.
2. Otherwise fetches from ``url``, retrying transient HTTP errors
(429/5xx/transport) with Retry-After-aware backoff, validates
integrity, and falls back to the local backup on any failure.
Only the backup model count is cached (a single int) for validation.
The full backup dict is only parsed when it must be *returned* as a
@ -448,17 +497,24 @@ def get_model_cost_map(url: str) -> dict:
_cost_map_source_info.url = url
_cost_map_source_info.is_env_forced = False
try:
content: Final = GetModelCostMap.fetch_remote_model_cost_map(url)
except Exception as e:
result: Final = _fetch_remote_model_cost_map_with_retry_sync(
url=url,
timeout=timeout,
max_attempts=max_attempts,
sleep=sleep,
rng=rng if rng is not None else random.Random(),
client=client if client is not None else httpx,
)
if isinstance(result, ModelCostMapReloadUnavailable):
verbose_logger.warning(
"LiteLLM: Failed to fetch remote model cost map from %s: %s. Falling back to local backup.",
url,
str(e),
result.reason,
)
_cost_map_source_info.source = "local"
_cost_map_source_info.fallback_reason = f"Remote fetch failed: {e}"
_cost_map_source_info.fallback_reason = f"Remote fetch failed: {result.reason}"
return _finalize_model_cost_map(GetModelCostMap.load_local_model_cost_map())
content: Final = result.model_cost_map
# Validate using cached count (cheap int comparison, no file I/O)
if not GetModelCostMap.validate_model_cost_map(

View file

@ -2959,13 +2959,25 @@ class Logging(LiteLLMLoggingBaseClass):
"Model=%s not found in completion cost map. Setting 'response_cost' to None", self.model
)
self.model_call_details["response_cost"] = None
except Exception: # noqa: BLE001 # cost calculation must never block later callbacks (slot release)
verbose_logger.exception(
"Error calculating streaming response cost for model=%s. Setting 'response_cost' to None",
self.model,
)
self.model_call_details["response_cost"] = None
self._merge_hidden_params_from_response_into_metadata(complete_streaming_response)
## STANDARDIZED LOGGING PAYLOAD
self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload(
complete_streaming_response, start_time, end_time
)
try:
self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload(
complete_streaming_response, start_time, end_time
)
except Exception: # noqa: BLE001 # payload build must never block later callbacks (slot release)
verbose_logger.exception(
"LiteLLM.LoggingError: [Non-Blocking] Exception building the standard logging payload "
"for a streaming response; callbacks still run without it"
)
# print standard logging payload
if (standard_logging_payload := self.model_call_details.get("standard_logging_object")) is not None:
@ -3005,32 +3017,39 @@ class Logging(LiteLLMLoggingBaseClass):
## LOGGING HOOK ##
for callback in callbacks:
if isinstance(callback, CustomGuardrail):
from litellm.types.guardrails import GuardrailEventHooks
try:
if isinstance(callback, CustomGuardrail):
from litellm.types.guardrails import GuardrailEventHooks
if (
callback.should_run_guardrail(
data=self.model_call_details,
event_type=GuardrailEventHooks.logging_only,
if (
callback.should_run_guardrail(
data=self.model_call_details,
event_type=GuardrailEventHooks.logging_only,
)
is not True
):
continue
self.model_call_details, result = await callback.async_logging_hook(
kwargs=self.model_call_details,
result=result,
call_type=self.call_type,
)
is not True
):
continue
self.model_call_details, result = await callback.async_logging_hook(
kwargs=self.model_call_details,
result=result,
call_type=self.call_type,
)
elif isinstance(callback, CustomLogger):
result = redact_message_input_output_from_custom_logger(
result=result, litellm_logging_obj=self, custom_logger=callback
)
self.model_call_details, result = await callback.async_logging_hook(
kwargs=self.model_call_details,
result=result,
call_type=self.call_type,
elif isinstance(callback, CustomLogger):
result = redact_message_input_output_from_custom_logger(
result=result, litellm_logging_obj=self, custom_logger=callback
)
self.model_call_details, result = await callback.async_logging_hook(
kwargs=self.model_call_details,
result=result,
call_type=self.call_type,
)
except Exception: # noqa: BLE001 # one failing hook must not skip later callbacks (slot release)
verbose_logger.error(
"LiteLLM.LoggingError: [Non-Blocking] Exception occurred in async_logging_hook %s",
traceback.format_exc(),
)
self._handle_callback_failure(callback=callback)
self.has_run_logging(event_type="async_success")

View file

@ -4957,10 +4957,13 @@ def make_valid_bedrock_tool_name(input_tool_name: str) -> str:
def add_cache_point_tool_block(tool: dict, model: str | None = None) -> BedrockToolBlock | None:
from litellm.llms.bedrock.common_utils import is_claude_4_5_on_bedrock
from litellm.llms.bedrock.common_utils import (
bedrock_model_accepts_cache_points,
is_claude_4_5_on_bedrock,
)
cache_control: Final = tool.get("cache_control", None)
if cache_control is not None:
if cache_control is not None and bedrock_model_accepts_cache_points(model):
cache_point: Final = cache_control.get("type", "ephemeral")
if cache_point == "ephemeral":
cache_point_block: Final[CachePointBlock] = {"type": "default"}

View file

@ -86,22 +86,41 @@ def _decoded_sse_data_line(line: bytes) -> object | None:
return None
def _anthropic_error_event_payload(chunk: object) -> Mapping[str, object] | None:
def _anthropic_event_payload(chunk: object, event_type: str) -> Mapping[str, object] | None:
if isinstance(chunk, dict):
return chunk if chunk.get("type") == "error" else None
return chunk if chunk.get("type") == event_type else None
if isinstance(chunk, (bytes, bytearray)):
decoded_lines: Final = (_decoded_sse_data_line(line) for line in chunk.splitlines())
return next(
(
candidate
for candidate in decoded_lines
if isinstance(candidate, dict) and candidate.get("type") == "error"
if isinstance(candidate, dict) and candidate.get("type") == event_type
),
None,
)
return None
def _anthropic_error_event_payload(chunk: object) -> Mapping[str, object] | None:
return _anthropic_event_payload(chunk, "error")
def parse_anthropic_refusal_stop_details(chunk: object) -> Mapping[str, object] | None:
"""
Return the ``stop_details`` object of an Anthropic SSE ``message_delta``
chunk whose delta carries ``stop_reason: "refusal"`` (a safeguard refusal:
https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback),
or None for any other chunk, a plain refusal without ``stop_details`` included.
"""
payload: Final = _anthropic_event_payload(chunk, "message_delta")
delta: Final = payload.get("delta") if payload is not None else None
if not isinstance(delta, dict) or delta.get("stop_reason") != "refusal":
return None
stop_details: Final = delta.get("stop_details")
return stop_details if isinstance(stop_details, dict) else None
def _anthropic_error_body(chunk: object) -> Mapping[str, object] | None:
"""Return the ``error`` object of an Anthropic SSE ``event: error`` chunk, or None."""
payload: Final = _anthropic_error_event_payload(chunk)

View file

@ -1,11 +1,40 @@
from collections.abc import Mapping
from functools import lru_cache
from typing import Any, Final, cast, get_type_hints
from typing import TYPE_CHECKING, Any, Final, cast, get_type_hints
from litellm.types.llms.anthropic import AnthropicMessagesRequestOptionalParams
from litellm.types.llms.anthropic_messages.anthropic_response import (
AnthropicMessagesResponse,
)
if TYPE_CHECKING:
from litellm.exceptions import ContentPolicyViolationError
def get_safeguard_refusal_stop_details(response: object) -> Mapping[str, Any] | None:
"""
Return the ``stop_details`` of an Anthropic Messages response refused by a
safeguard (``stop_reason: "refusal"`` carrying ``stop_details``:
https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback),
or None for any other response, a plain refusal without ``stop_details`` included.
"""
if not isinstance(response, dict) or response.get("stop_reason") != "refusal":
return None
stop_details: Final = response.get("stop_details")
return stop_details if isinstance(stop_details, dict) else None
def safeguard_refusal_error(model: str, stop_details: Mapping[str, object]) -> "ContentPolicyViolationError":
"""The exception a safeguard-refused Anthropic response converts into so the
content-policy fallback chain can re-dispatch it."""
from litellm.exceptions import ContentPolicyViolationError
return ContentPolicyViolationError(
message=f"Anthropic safeguard refusal (category: {stop_details.get('category')}).",
model=model,
llm_provider="anthropic",
)
@lru_cache(maxsize=1)
def _anthropic_messages_optional_param_keys() -> frozenset[str]:
@ -100,14 +129,12 @@ def mock_response(
model=model,
)
return AnthropicMessagesResponse(
**{
"content": [{"text": mock_response, "type": "text"}],
"id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
"model": "claude-sonnet-4-20250514",
"role": "assistant",
"stop_reason": "end_turn",
"stop_sequence": None,
"type": "message",
"usage": {"input_tokens": 2095, "output_tokens": 503},
}
content=[{"text": mock_response, "type": "text"}],
id="msg_013Zva2CMHLNnXjNJJKqJ2EF",
model="claude-sonnet-4-20250514",
role="assistant",
stop_reason="end_turn",
stop_sequence=None,
type="message",
usage={"input_tokens": 2095, "output_tokens": 503},
)

View file

@ -87,6 +87,7 @@ from ..common_utils import (
BedrockError,
BedrockModelInfo,
bedrock_converse_supports_parallel_tool_use_config,
bedrock_model_accepts_cache_points,
get_anthropic_beta_from_headers,
get_bedrock_tool_name,
is_bedrock_application_inference_profile_arn,
@ -1149,7 +1150,7 @@ class AmazonConverseConfig(BaseConfig):
model: str | None = None,
) -> SystemContentBlock | ContentBlock | None:
cache_control: Final = message_block.get("cache_control", None)
if cache_control is None:
if cache_control is None or not bedrock_model_accepts_cache_points(model):
return None
cache_point: Final = self._build_cache_point_block(cache_control, model)
@ -1613,7 +1614,7 @@ class AmazonConverseConfig(BaseConfig):
# Append cachePoint to tools if cache_control_injection_points has tool_config
cache_injection_points: Final = additional_request_params.pop("cache_control_injection_points", None)
if cache_injection_points and len(bedrock_tools) > 0:
if cache_injection_points and len(bedrock_tools) > 0 and bedrock_model_accepts_cache_points(model):
for point in cache_injection_points:
if point.get("location") == "tool_config":
cache_point = self._build_cache_point_block(point.get("control"), model)

View file

@ -816,6 +816,30 @@ def bedrock_converse_supports_parallel_tool_use_config(model: str) -> bool:
)
def bedrock_model_accepts_cache_points(model: str | None) -> bool:
"""
Whether Converse ``cachePoint`` blocks may be sent to this model.
Bedrock rejects requests carrying cachePoint blocks for models without prompt
caching support ("You invoked an unsupported model or your request did not allow
prompt caching"), so a model whose cost-map entry does not declare
``supports_prompt_caching`` must not receive them. A model absent from the map
(an application inference profile ARN, a model newer than the map) keeps emitting
so existing caching setups never silently degrade. ``litellm.utils.supports_prompt_caching``
is not reusable here: it returns False for unmapped models, the opposite polarity.
"""
if model is None:
return True
entries: Final = tuple(
entry
for candidate in (model, get_bedrock_base_model(model))
if (entry := litellm.model_cost.get(candidate)) is not None
)
if not entries:
return True
return any(entry.get("supports_prompt_caching") is True for entry in entries)
def is_claude_4_5_on_bedrock(model: str) -> bool:
"""
Check if the model supports Bedrock prompt caching with an extended '1h' TTL

View file

@ -3,6 +3,7 @@ import concurrent.futures
import contextlib
import os
import ssl
import sys
import typing
import urllib.request
from collections.abc import Callable, Generator
@ -75,10 +76,22 @@ except ImportError:
pass
def _current_task_is_cancelling() -> bool:
task: Final = asyncio.current_task()
if task is None or sys.version_info < (3, 11):
return True
return task.cancelling() > 0
@contextlib.contextmanager
def map_aiohttp_exceptions() -> Generator[None, None, None]:
try:
yield
except asyncio.CancelledError as exc:
# a closing connector cancels its shielded DNS task; that surfaces here without the request task being cancelled
if _current_task_is_cancelling():
raise
raise httpx.ConnectError("aiohttp transport cancelled the request internally") from exc
except Exception as exc:
mapped_exc: type[Exception] | None = None

View file

@ -7,7 +7,7 @@ Canonical definition for ``litellm_usertable``. Re-exported from
from datetime import datetime
from pydantic import ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
from litellm.models.object_permission import LiteLLM_ObjectPermissionTable
from litellm.models.organization_membership import (
@ -67,3 +67,11 @@ class LiteLLM_UserTable(LiteLLMPydanticObjectBase):
if not self.models:
return True
return model_name in self.models
class SCIMPlaceholder(BaseModel):
"""A user row keyed by a value that names another account by SSO identity or email."""
placeholder_user_id: str
resolved_user_ids: tuple[str, ...]
team_ids: tuple[str, ...]

View file

@ -1,7 +1,8 @@
import asyncio
import importlib
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable, Mapping, Sequence
from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal
import anyio
@ -20,8 +21,11 @@ from litellm.proxy._experimental.mcp_server.exceptions import (
MCPUpstreamAuthError,
)
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
ServerListOk,
ServerOutcome,
classify_list_exception,
list_fault_http_status,
outcome_wire_value,
)
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
acting_user_auth,
@ -99,6 +103,7 @@ if MCP_AVAILABLE:
ListMCPToolsRestAPIResponseObject,
MCPInfo,
MCPServer,
_aggregate_server_key, # pyright: ignore[reportPrivateUsage] # same per-server key as the tools/list _meta outcomes
_apply_toolset_scope,
_fire_mcp_tool_call_logging,
execute_mcp_tool,
@ -803,9 +808,6 @@ if MCP_AVAILABLE:
list(allowed_server_ids_set), _rest_client_ip
)
list_tools_result: Final = []
error_message = None
# If server_id is specified, only query that specific server
if server_id:
return await _list_tools_for_single_server(
@ -849,22 +851,19 @@ if MCP_AVAILABLE:
else {}
)
# Query all servers the user has access to
errors: Final = []
for allowed_server_id in allowed_server_ids:
server = global_mcp_server_manager.get_mcp_server_by_id(allowed_server_id)
if server is None:
continue
server_auth_header = _get_server_auth_header(server, mcp_server_auth_headers, mcp_auth_header)
user_oauth_extra_headers = await _get_user_oauth_extra_headers(
async def list_server(
server: MCPServer,
) -> tuple[Sequence[ListMCPToolsRestAPIResponseObject], ServerOutcome]:
server_auth_header: Final = _get_server_auth_header(
server, mcp_server_auth_headers, mcp_auth_header
)
user_oauth_extra_headers: Final = await _get_user_oauth_extra_headers(
server,
user_api_key_dict,
prefetched_creds=prefetched_oauth_creds,
)
try:
tools_result = await _get_tools_for_single_server(
tools_result: Final = await _get_tools_for_single_server(
server,
server_auth_header,
raw_headers_from_request,
@ -872,24 +871,36 @@ if MCP_AVAILABLE:
extra_headers=user_oauth_extra_headers,
apply_tool_filters=apply_tool_filters,
)
list_tools_result.extend(tools_result)
except Exception as e:
verbose_logger.exception("Error getting tools from %s: %s", server.name, e)
errors.append(
f"{get_server_prefix(server)}: {classify_list_exception(e).tag}"
if isinstance(e, (MCPServerListError, MCPUpstreamAuthError))
else f"{get_server_prefix(server)}: {e}"
)
continue
return (), classify_list_exception(e)
return tools_result, ServerListOk(tool_count=len(tools_result))
if errors and not list_tools_result:
error_message = "Failed to get tools from servers: " + "; ".join(errors)
return {
"tools": list_tools_result,
"error": "partial_failure" if error_message else None,
"message": (error_message if error_message else "Successfully retrieved tools"),
}
# Query all servers the user has access to
queried_servers: Final = tuple(
server
for server in map(global_mcp_server_manager.get_mcp_server_by_id, allowed_server_ids)
if server is not None
)
listings: Final = tuple([await list_server(server) for server in queried_servers])
list_tools_result: Final = [tool for tools, _ in listings for tool in tools]
server_outcomes: Final = MappingProxyType(
{_aggregate_server_key(server): outcome for server, (_, outcome) in zip(queried_servers, listings)}
)
errors: Final = tuple(
f"{key}: {outcome.tag}" for key, outcome in server_outcomes.items() if outcome.tag != "ok"
)
error_message: Final = (
"Failed to get tools from servers: " + "; ".join(errors)
if errors and not list_tools_result
else None
)
return {
"tools": list_tools_result,
"error": "partial_failure" if error_message else None,
"message": (error_message if error_message else "Successfully retrieved tools"),
"server_outcomes": {key: outcome_wire_value(outcome) for key, outcome in server_outcomes.items()},
}
except MCPUpstreamAuthError as e:
# Surface upstream pass-through 401/403 challenges to the client so

View file

@ -32016,6 +32016,62 @@
"title": "SCIMPatchOperation",
"type": "object"
},
"SCIMPlaceholder": {
"description": "A user row keyed by a value that names another account by SSO identity or email.",
"properties": {
"placeholder_user_id": {
"title": "Placeholder User Id",
"type": "string"
},
"resolved_user_ids": {
"items": {
"type": "string"
},
"title": "Resolved User Ids",
"type": "array"
},
"team_ids": {
"items": {
"type": "string"
},
"title": "Team Ids",
"type": "array"
}
},
"required": [
"placeholder_user_id",
"resolved_user_ids",
"team_ids"
],
"title": "SCIMPlaceholder",
"type": "object"
},
"SCIMPlaceholderMergeResult": {
"properties": {
"merged_into_user_id": {
"title": "Merged Into User Id",
"type": "string"
},
"placeholder_user_id": {
"title": "Placeholder User Id",
"type": "string"
},
"team_ids": {
"items": {
"type": "string"
},
"title": "Team Ids",
"type": "array"
}
},
"required": [
"placeholder_user_id",
"merged_into_user_id",
"team_ids"
],
"title": "SCIMPlaceholderMergeResult",
"type": "object"
},
"SCIMServiceProviderConfig": {
"properties": {
"authenticationSchemes": {
@ -33655,6 +33711,129 @@
"scim"
]
}
},
"/scim/v2/placeholders": {
"get": {
"description": "List user rows whose id is another account's SSO identity or email.\n\nAn earlier release provisioned a group member it could not match as a user keyed\nby the raw member value, and that row now shadows the account the value really\nnames, so every push of that member is refused. This lists those rows so an\noperator can fold each one into the account it shadows with\n``POST /scim/v2/placeholders/{user_id}/merge``. A row that has an SSO identity of\nits own or owns virtual keys is left out: someone uses that account.",
"operationId": "list_placeholders_scim_v2_placeholders_get",
"parameters": [
{
"in": "query",
"name": "feature",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Feature"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/SCIMPlaceholder"
},
"title": "Response List Placeholders Scim V2 Placeholders Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "List Placeholders",
"tags": [
"scim"
]
}
},
"/scim/v2/placeholders/{user_id}/merge": {
"post": {
"description": "Fold a placeholder user into the one account its id names by SSO identity or email.\n\nThe account is added to every team the placeholder is on, then the placeholder is\ndeleted the way ``DELETE /scim/v2/Users/{id}`` deletes a user, so the next group\npush resolves the member value to the real account. Refused with 409 when the row\nhas an SSO identity of its own, owns virtual keys, or names no account or several.",
"operationId": "merge_placeholder_scim_v2_placeholders__user_id__merge_post",
"parameters": [
{
"in": "path",
"name": "user_id",
"required": true,
"schema": {
"title": "User ID",
"type": "string"
}
},
{
"in": "query",
"name": "feature",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Feature"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SCIMPlaceholderMergeResult"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Merge Placeholder",
"tags": [
"scim"
]
}
}
}
},

View file

@ -1216,6 +1216,13 @@ class GenerateKeyRequest(KeyRequestBase):
organization_id: str | None = None
project_id: str | None = None
@field_validator("team_id", mode="before")
@classmethod
def treat_cleared_team_id_as_unset(cls, v: object) -> object:
if v == "":
return None
return v
class GenerateKeyResponse(KeyRequestBase):
key: str

View file

@ -0,0 +1,174 @@
"""
Restamp the public ``model`` on the Anthropic Messages ``message_start`` event, the only
stream event carrying a model, so streamed responses report the requested model like
non-streaming ones do.
Chunks reach the serializer either as already-encoded SSE frames (``bytes``/``str``, the
provider passthrough path) or as event dicts (fake-stream and agentic paths).
"""
import json
import re
from collections.abc import Mapping
from typing import Final
from pydantic import TypeAdapter, ValidationError
_MESSAGE_START_EVENT: Final = "message_start"
_MESSAGE_START_MARKER: Final = b"message_start"
_SSE_DATA_FIELD: Final = "data:"
_SSE_FRAME_END_PATTERN: Final = re.compile(rb"\r\n\r\n|\r\r|\n\n")
_MAX_HELD_BYTES: Final = 65536
_PING_MARKERS: Final = (b"event: ping", b'"type": "ping"', b'"type":"ping"')
_EVENT_ADAPTER: Final = TypeAdapter(Mapping[str, object])
def _restamped_event(event: Mapping[str, object], requested_model: str) -> Mapping[str, object] | None:
message: Final = event.get("message")
if event.get("type") != _MESSAGE_START_EVENT or not isinstance(message, dict):
return None
if message.get("model") == requested_model:
return None
return {**event, "message": {**message, "model": requested_model}} # mutable-ok: SSE payload, re-serialized as is
def _restamped_data_line(line: str, requested_model: str) -> str | None:
stripped: Final = line.strip()
if not stripped.startswith(_SSE_DATA_FIELD):
return None
payload: Final = stripped[len(_SSE_DATA_FIELD) :].strip()
if not payload or payload == "[DONE]":
return None
try:
event: Final = _EVENT_ADAPTER.validate_json(payload)
except ValidationError:
return None
restamped: Final = _restamped_event(event, requested_model)
if restamped is None:
return None
terminator: Final = line[len(line.rstrip("\r\n")) :]
return f"data: {json.dumps(restamped, separators=(',', ':'))}{terminator}"
def _restamped_frame(frame: str, requested_model: str) -> str | None:
lines: Final = frame.splitlines(keepends=True)
restamped: Final = tuple(_restamped_data_line(line, requested_model) for line in lines)
if all(line is None for line in restamped):
return None
return "".join(new if new is not None else old for new, old in zip(restamped, lines))
def restamp_anthropic_stream_chunk_model(chunk: object, requested_model: str) -> object:
"""
Return ``chunk`` with the ``message_start`` model replaced by ``requested_model``.
Chunks that carry no model are returned unchanged.
"""
if isinstance(chunk, dict):
try:
event: Final = _EVENT_ADAPTER.validate_python(chunk)
except ValidationError:
return chunk
return _restamped_event(event, requested_model) or chunk
if isinstance(chunk, (bytes, bytearray)):
if _MESSAGE_START_EVENT.encode() not in chunk:
return chunk
restamped_bytes: Final = _restamped_frame(chunk.decode("utf-8", errors="ignore"), requested_model)
return chunk if restamped_bytes is None else restamped_bytes.encode("utf-8")
if isinstance(chunk, str):
if _MESSAGE_START_EVENT not in chunk:
return chunk
restamped_text: Final = _restamped_frame(chunk, requested_model)
return chunk if restamped_text is None else restamped_text
return chunk
def _is_ping_frame(frame: bytes) -> bool:
return any(marker in frame for marker in _PING_MARKERS)
class AnthropicStreamModelRestamper:
"""
Per-stream restamper for the encoded passthrough path, where chunks are raw
transport reads: the ``message_start`` SSE frame can arrive split across
chunks or coalesced with later frames. Complete frames (``\\n\\n``,
``\\r\\n\\r\\n``, or ``\\r\\r`` terminated) are emitted as their terminator
closes them and an incomplete tail is held until it completes, so the
restamp never misses a torn frame; ``flush`` returns whatever is still held
when the stream ends so no bytes are swallowed. Once ``message_start`` has
been handled, or the first real event proves the stream carries none, every
later chunk passes through untouched.
"""
def __init__(self, requested_model: str) -> None:
self._requested_model: Final = requested_model
self._held = b""
self._armed = True
def process(self, chunk: object) -> object:
if not self._armed:
return chunk
if isinstance(chunk, (bytes, bytearray)):
return self._process_encoded(bytes(chunk))
if isinstance(chunk, str):
return self._process_encoded(chunk.encode("utf-8"))
restamped: Final = restamp_anthropic_stream_chunk_model(chunk, self._requested_model)
if isinstance(chunk, dict) and chunk.get("type") not in (None, "ping"):
self._armed = False
return restamped
def flush(self) -> bytes:
held: Final = self._held
self._held = b""
self._armed = False
if not held:
return b""
restamped: Final = restamp_anthropic_stream_chunk_model(held, self._requested_model)
return restamped if isinstance(restamped, bytes) else held
def _process_encoded(self, data: bytes) -> bytes:
combined: Final = self._held + data
boundaries: Final = tuple(match.end() for match in _SSE_FRAME_END_PATTERN.finditer(combined))
if not boundaries:
if len(combined) > _MAX_HELD_BYTES:
self._held = b""
self._armed = False
return combined
self._held = combined
return b""
emitted: Final = self._restamped_closed_block(combined[: boundaries[-1]])
tail: Final = combined[boundaries[-1] :]
if not self._armed:
self._held = b""
return emitted + tail
self._held = tail
return emitted
def _restamped_closed_block(self, closed: bytes) -> bytes:
boundaries: Final = tuple(match.end() for match in _SSE_FRAME_END_PATTERN.finditer(closed))
frames: Final = tuple(closed[start:end] for start, end in zip((0, *boundaries[:-1]), boundaries))
decider: Final = next(
(
index
for index, frame in enumerate(frames)
if _MESSAGE_START_MARKER in frame or (b"data:" in frame and not _is_ping_frame(frame))
),
None,
)
if decider is None:
return closed
self._armed = False
if _MESSAGE_START_MARKER not in frames[decider]:
return closed
restamped_text: Final = _restamped_frame(
frames[decider].decode("utf-8", errors="ignore"), self._requested_model
)
if restamped_text is None:
return closed
return b"".join(
restamped_text.encode("utf-8") if index == decider else frame for index, frame in enumerate(frames)
)

View file

@ -9,6 +9,7 @@ import click
import requests
from .auth import context_secret_vault, get_stored_api_key, login
from .cmd_quoting import quote_for_cmd
ANTHROPIC_BASE_URL_ENV: Final = "ANTHROPIC_BASE_URL"
ANTHROPIC_AUTH_TOKEN_ENV: Final = "ANTHROPIC_AUTH_TOKEN"
@ -151,31 +152,9 @@ def verify_proxy_key(
_WINDOWS_SHIM_SUFFIXES: Final[frozenset[str]] = frozenset({".cmd", ".bat"})
_CMD_PERCENT_GUARD: Final = "%%cd:~,%"
_CMD_LINE_BREAKS: Final = ("\r", "\n")
def _double_trailing_backslashes(segment: str) -> str:
bare: Final = segment.rstrip("\\")
return bare + "\\" * 2 * (len(segment) - len(bare))
def _quote_for_cmd(token: str) -> str:
"""Quote one token so both parsers that read it see the original text.
Follows the algorithm the Rust standard library settled on for batch files
after CVE-2024-24576. Two parsers see this token: cmd.exe, which ends a
quoted string on a lone `"` and so wants an embedded one doubled, and the
shim's own interpreter, which re-splits `%*` under C runtime rules where a
backslash escapes the quote that follows it, so every backslash run standing
before a quote is doubled. Quoting cannot stop cmd expanding `%VAR%`, so each
`%` is prefixed with `%%cd:~,`: the zero-length substring of the always
defined `cd` expands to nothing and leaves no `%` pair for cmd to match.
"""
escaped: Final = '""'.join(_double_trailing_backslashes(part) for part in token.split('"'))
return '"' + escaped.replace("%", _CMD_PERCENT_GUARD) + '"'
def _windows_command(path: str, args: Sequence[str]) -> str | tuple[str, ...]:
"""Build what CreateProcess runs, routing batch shims through cmd.exe.
@ -202,7 +181,7 @@ def _windows_command(path: str, args: Sequence[str]) -> str | tuple[str, ...]:
f"Cannot pass an argument containing a line break to `{os.path.basename(path)}` on "
"Windows: cmd.exe ends the command line there, so the agent would silently lose it."
)
inner: Final = " ".join(_quote_for_cmd(token) for token in (path, *rest))
inner: Final = " ".join(quote_for_cmd(token) for token in (path, *rest))
return f'cmd.exe /d /e:on /v:off /s /c "{inner}"'

View file

@ -8,6 +8,7 @@ live here rather than in either command module.
import shlex
import shutil
import sys
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
@ -17,6 +18,8 @@ from pydantic import JsonValue, TypeAdapter, ValidationError
from litellm.litellm_core_utils.private_json import write_private_json
from .cmd_quoting import quote_for_cmd
ENV_KEY: Final = "env"
API_KEY_HELPER_KEY: Final = "apiKeyHelper"
ANTHROPIC_BASE_URL_KEY: Final = "ANTHROPIC_BASE_URL"
@ -87,9 +90,12 @@ def merge_claude_settings(
return {**settings, ENV_KEY: env, API_KEY_HELPER_KEY: api_key_helper}
def resolve_api_key_helper(base_url: str) -> str:
def resolve_api_key_helper(base_url: str, platform: str = sys.platform) -> str:
"""Build the shell command Claude Code should run for its apiKeyHelper.
Claude Code hands the string to the system shell, `sh` on POSIX and cmd.exe
on Windows, so every token is quoted for the shell that will read it.
Resolves `lite` to an absolute path so the helper works regardless of the
PATH visible to whatever subprocess Claude Code spawns it from. Passing
--base-url explicitly (rather than relying on the bare invocation Claude
@ -106,7 +112,8 @@ def resolve_api_key_helper(base_url: str) -> str:
raise ClaudeSettingsError(
"Could not find `lite` on your PATH. Claude Code's apiKeyHelper needs an absolute path to it."
)
return f"{shlex.quote(lite_path)} --base-url {shlex.quote(base_url)} auth print-token"
quote: Final = quote_for_cmd if platform.startswith("win") else shlex.quote
return " ".join(quote(token) for token in (lite_path, "--base-url", base_url, "auth", "print-token"))
def write_claude_settings(base_url: str, settings_path: Path, owners: Sequence[SettingsFileOwner]) -> None:

View file

@ -0,0 +1,26 @@
"""Quoting for command lines that cmd.exe reads before handing them to a program."""
from typing import Final
_CMD_PERCENT_GUARD: Final = "%%cd:~,%"
def _double_trailing_backslashes(segment: str) -> str:
bare: Final = segment.rstrip("\\")
return bare + "\\" * 2 * (len(segment) - len(bare))
def quote_for_cmd(token: str) -> str:
"""Quote one token so both parsers that read it see the original text.
Follows the algorithm the Rust standard library settled on for batch files
after CVE-2024-24576. Two parsers see this token: cmd.exe, which ends a
quoted string on a lone `"` and so wants an embedded one doubled, and the
program's own C runtime argv split, where a backslash escapes the quote that
follows it, so every backslash run standing before a quote is doubled.
Quoting cannot stop cmd expanding `%VAR%`, so each `%` is prefixed with
`%%cd:~,`: the zero-length substring of the always defined `cd` expands to
nothing and leaves no `%` pair for cmd to match.
"""
escaped: Final = '""'.join(_double_trailing_backslashes(part) for part in token.split('"'))
return '"' + escaped.replace("%", _CMD_PERCENT_GUARD) + '"'

View file

@ -176,6 +176,9 @@ if TYPE_CHECKING:
ProxyConfig = _ProxyConfig
else:
ProxyConfig = Any
from litellm.proxy.anthropic_endpoints.streaming_model_restamp import (
AnthropicStreamModelRestamper,
)
from litellm.proxy.litellm_pre_call_utils import (
add_litellm_data_to_request,
refresh_proxy_server_request_body_snapshot,
@ -2490,6 +2493,9 @@ class ProxyBaseLLMRequestProcessing:
request_data=self.data,
proxy_logging_obj=proxy_logging_obj,
request=request,
restamp_model=(
None if _should_return_raw_model_name(self.data) else requested_model_from_client
),
)
return await create_response(
generator=wrap_sse_stream_with_keepalive_pings(
@ -3442,6 +3448,16 @@ class ProxyBaseLLMRequestProcessing:
else:
return chunk
@staticmethod
def _sse_chunk_serializer(restamper: AnthropicStreamModelRestamper | None) -> StreamChunkSerializer:
if restamper is None:
return ProxyBaseLLMRequestProcessing.return_sse_chunk
def serialize(chunk: object) -> str:
return ProxyBaseLLMRequestProcessing.return_sse_chunk(restamper.process(chunk))
return serialize
@staticmethod
async def _finalize_streaming_generator_cleanup(
request: Request | None,
@ -3502,11 +3518,16 @@ class ProxyBaseLLMRequestProcessing:
serialize_chunk: StreamChunkSerializer,
serialize_error: StreamErrorSerializer,
request: Request | None = None,
flush_tail: Callable[[], bytes] | None = None,
) -> AsyncGenerator[str, None]:
"""
Shared streaming data generator: runs proxy iterator hook, per-chunk hook,
cost injection, then yields chunks via serialize_chunk; on exception runs
failure hook and yields via serialize_error. Use for SSE or NDJSON.
``flush_tail`` runs once after the upstream iterator completes cleanly and
its non-empty result is yielded, so a serializer that buffers bytes across
chunks can emit anything still held at end of stream.
"""
verbose_proxy_logger.debug("inside generator")
# Resolve per-stream (not per-chunk) whether the heavy per-chunk path
@ -3569,6 +3590,9 @@ class ProxyBaseLLMRequestProcessing:
# so it must not suppress that refund.
delivered_chunk = delivered_chunk or chunk != STREAM_SSE_KEEPALIVE_PING_BYTES
yield serialize_chunk(chunk)
held_tail: Final = flush_tail() if flush_tail is not None else b""
if held_tail:
yield serialize_chunk(held_tail)
stream_completed = True
except (asyncio.CancelledError, GeneratorExit):
# Client disconnected mid-stream. CancelledError / GeneratorExit
@ -3579,8 +3603,7 @@ class ProxyBaseLLMRequestProcessing:
# billing and release exactly once. This is the outermost generator
# Starlette closes on disconnect, so the nested iterator hook (which
# only sees GeneratorExit on GC) cannot own the refund.
if not stream_completed:
client_disconnected = True
client_disconnected = not stream_completed
if not delivered_chunk and not _withheld_provider_output(response):
from litellm.proxy.spend_tracking.budget_reservation import (
release_budget_reservation_on_cancel,
@ -3634,6 +3657,7 @@ class ProxyBaseLLMRequestProcessing:
request_data: dict,
proxy_logging_obj: ProxyLogging,
request: Request | None = None,
restamp_model: str | None = None,
) -> AsyncGenerator[str, None]:
"""
Anthropic /messages and Google /generateContent streaming data generator require SSE events.
@ -3642,17 +3666,23 @@ class ProxyBaseLLMRequestProcessing:
SSE serializers directly (rather than re-wrapping it in another
``async for: yield`` trampoline), so a streamed chunk traverses one
fewer async-generator layer / coroutine resume on the hot path.
``restamp_model`` publishes that name on the Anthropic ``message_start``
event in place of the provider's model, matching what the non-streaming
response reports.
"""
restamper: Final = AnthropicStreamModelRestamper(restamp_model) if restamp_model else None
return ProxyBaseLLMRequestProcessing.async_streaming_data_generator(
response=response,
user_api_key_dict=user_api_key_dict,
request_data=request_data,
proxy_logging_obj=proxy_logging_obj,
serialize_chunk=ProxyBaseLLMRequestProcessing.return_sse_chunk,
serialize_chunk=ProxyBaseLLMRequestProcessing._sse_chunk_serializer(restamper),
serialize_error=lambda proxy_exc: (
f"{STREAM_SSE_DATA_PREFIX}{json.dumps({'error': proxy_exc.to_dict()})}\n\n"
),
request=request,
flush_tail=None if restamper is None else restamper.flush,
)
@overload

View file

@ -29,6 +29,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.models.user import SCIMPlaceholder
from litellm.proxy._types import (
LiteLLM_TeamTable,
LiteLLM_UserTable,
@ -585,6 +586,37 @@ async def _users_named_by_member_value(
return tuple(dict.fromkeys(row.user_id for row in rows))
async def _accounts_named_by_member_value(value: str, prisma_client: PrismaClient) -> tuple[str, ...]:
"""Every user id this member value names, by user id, SSO identity or email.
Classification needs to know whether the value is one account's ``user_id`` and
whether it names any other account, so all three fields are read in one pass. The
id is compared exactly and unstripped, as a primary key lookup would; the
identities compare as ``_users_named_by_member_value`` describes. Two rows are
enough to tell one account from several, so the read stops there. Only a full
read that lacks the row keyed by the value leaves that row's existence open, and
only then is the id read on its own.
"""
subject: Final = value.strip()
email: Final[_CaseInsensitiveMatch] = {"equals": subject, "mode": "insensitive"}
users: Final = _table(UserRepository(prisma_client))
rows: Final = await users.find_many(
where={ # mutable-ok: Prisma filter
"OR": [ # mutable-ok: Prisma filter
{"user_id": value}, # mutable-ok: Prisma filter
{"sso_user_id": subject}, # mutable-ok: Prisma filter
{"user_email": email}, # mutable-ok: Prisma filter
],
},
take=2,
)
named: Final = tuple(dict.fromkeys(row.user_id for row in rows))
if len(named) < 2 or value in named:
return named
keyed: Final = await users.find_unique(where={"user_id": value})
return named if keyed is None else (value, *named)
async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient) -> _ClassifiedGroupMember:
"""
Decide what a single SCIM group member refers to.
@ -627,11 +659,9 @@ async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient
if member_type == "group":
return _SkippedGroupMember(value=value, reason="nested_group")
user: Final = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": value})
if user is not None:
shared_with: Final = tuple(
other for other in await _users_named_by_member_value(value, prisma_client) if other != value
)
named: Final = await _accounts_named_by_member_value(value, prisma_client)
if value in named:
shared_with: Final = tuple(other for other in named if other != value)
if shared_with:
verbose_proxy_logger.warning(
"SCIM: group member '%s' is one account's user id and is also account '%s' by SSO identity or email, "
@ -651,7 +681,6 @@ async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient
if team is not None and _team_metadata_has_scim_provenance(team.metadata):
return _SkippedGroupMember(value=value, reason="existing_team")
named: Final = await _users_named_by_member_value(value, prisma_client)
if len(named) == 1:
verbose_proxy_logger.info(
"SCIM: group member '%s' matched user_id '%s' by SSO identity or email",
@ -1834,6 +1863,89 @@ async def delete_user(
raise handle_exception_on_proxy(e)
@scim_router.get(
"/placeholders",
response_model=tuple[SCIMPlaceholder, ...],
dependencies=(Depends(user_api_key_auth),),
)
async def list_placeholders() -> tuple[SCIMPlaceholder, ...]:
"""
List user rows whose id is another account's SSO identity or email.
An earlier release provisioned a group member it could not match as a user keyed
by the raw member value, and that row now shadows the account the value really
names, so every push of that member is refused. This lists those rows so an
operator can fold each one into the account it shadows with
``POST /scim/v2/placeholders/{user_id}/merge``. A row that has an SSO identity of
its own or owns virtual keys is left out: someone uses that account.
"""
try:
prisma_client: Final = await _get_prisma_client_or_raise_exception()
async with prisma_client.tx() as tx:
return await UserRepository(prisma_client).find_shadowing_placeholders(tx)
except Exception as e:
raise handle_exception_on_proxy(e)
def _placeholder_rejection(placeholder: LiteLLM_UserTable, resolved: tuple[str, ...], key_count: int) -> str | None:
if placeholder.sso_user_id is not None:
return f"User '{placeholder.user_id}' has an SSO identity of its own, so it is an account someone signs in to"
if key_count:
return f"User '{placeholder.user_id}' owns {key_count} virtual keys. Move or delete them before merging it"
if not resolved:
return f"User '{placeholder.user_id}' shadows no account: no other user has that id as SSO identity or email"
if len(resolved) > 1:
return (
f"User '{placeholder.user_id}' names {len(resolved)} accounts ({', '.join(resolved)}). Resolve that first"
)
return None
@scim_router.post(
"/placeholders/{user_id}/merge",
response_model=SCIMPlaceholderMergeResult,
dependencies=(Depends(user_api_key_auth),),
)
async def merge_placeholder(
user_id: str = Path(..., title="User ID"),
) -> SCIMPlaceholderMergeResult:
"""
Fold a placeholder user into the one account its id names by SSO identity or email.
The account is added to every team the placeholder is on, then the placeholder is
deleted the way ``DELETE /scim/v2/Users/{id}`` deletes a user, so the next group
push resolves the member value to the real account. Refused with 409 when the row
has an SSO identity of its own, owns virtual keys, or names no account or several.
"""
try:
prisma_client: Final = await _get_prisma_client_or_raise_exception()
placeholder: Final = await _check_user_exists(user_id)
resolved: Final = tuple(
other for other in await _users_named_by_member_value(user_id, prisma_client, take=None) if other != user_id
)
owned_keys: Final[_UserIdWhere] = {"user_id": user_id}
keys: Final = await _table(VerificationTokenRepository(prisma_client)).find_many(where=owned_keys)
rejection: Final = _placeholder_rejection(placeholder, resolved, len(keys))
if rejection is not None:
detail: Final[_ScimErrorDetail] = {"error": rejection}
raise HTTPException(status_code=409, detail=detail)
target_user_id: Final = resolved[0]
team_ids: Final = tuple(placeholder.teams)
for team_id in team_ids:
await _add_user_to_team(user_id=target_user_id, team_id=team_id)
await delete_user(user_id=user_id)
await _recompute_scim_member_roles(prisma_client, (target_user_id,))
verbose_proxy_logger.info(
"SCIM: merged placeholder user '%s' into '%s', moving teams %s", user_id, target_user_id, team_ids
)
return SCIMPlaceholderMergeResult(
placeholder_user_id=user_id, merged_into_user_id=target_user_id, team_ids=team_ids
)
except Exception as e:
raise handle_exception_on_proxy(e)
def _parse_member_entry(entry: object) -> SCIMMember | None:
"""Parse one entry of a SCIM patch value, or None when it carries no id."""
if isinstance(entry, str):

View file

@ -812,6 +812,8 @@ def _resolve_team_callback_wiring(
else { # mutable-ok: Logging arg
**callback_vars,
TRUSTED_CALLBACK_VARS_FIELD: callback_vars,
"metadata": {}, # mutable-ok: Logging arg
"model_info": {}, # mutable-ok: Logging arg
}
)
return _TeamCallbackWiring(

View file

@ -39,7 +39,7 @@ from typing import (
import anyio
import websockets
import websockets.exceptions
from pydantic import BaseModel, Json, JsonValue
from pydantic import BaseModel, Json, JsonValue, ValidationError
from typing_extensions import NotRequired, ReadOnly, assert_never
from litellm._uuid import uuid
@ -253,6 +253,7 @@ from litellm.constants import (
PROXY_BUDGET_RESCHEDULER_MAX_TIME,
PROXY_BUDGET_RESCHEDULER_MIN_TIME,
PROXY_CONFIG_RELOAD_INTERVAL_SECONDS,
USER_SPEND_ALERTS_JOB_ID,
WEEKLY_SPEND_REPORT_JOB_ID,
)
from litellm.exceptions import RejectedRequestError
@ -9866,6 +9867,35 @@ class ProxyStartupEvent:
replace_existing=True,
)
slack_alerting_args: Final = proxy_logging_obj.slack_alerting_instance.alerting_args
user_spend_check_interval: Final = (
slack_alerting_args.user_spend_check_interval
if isinstance(slack_alerting_args, SlackAlertingArgs) # pyright: ignore[reportUnnecessaryIsInstance] # tests inject a mock slack_alerting_instance
else SlackAlertingArgs().user_spend_check_interval
)
async def _scheduled_user_spend_alerts() -> None:
if (
await pod_lock_manager.acquire_lock(
cronjob_id=USER_SPEND_ALERTS_JOB_ID,
ttl=max(user_spend_check_interval - 60, 60),
allow_reentrant=False,
)
is False
):
return
await proxy_logging_obj.slack_alerting_instance.send_user_spend_alerts()
scheduler.add_job(
_scheduled_user_spend_alerts,
"interval",
seconds=user_spend_check_interval,
next_run_time=datetime.now(timezone.utc) + timedelta(seconds=10 + random.randint(0, 60)),
id=USER_SPEND_ALERTS_JOB_ID,
replace_existing=True,
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
)
if os.getenv("PROMETHEUS_URL"):
from zoneinfo import ZoneInfo
@ -14972,17 +15002,25 @@ async def alerting_settings(
alerting_args_dict = {}
alerting_values = None
allowed_args: Final = {
"slack_alerting": {"type": "Boolean"},
"daily_report_frequency": {"type": "Integer"},
"report_check_interval": {"type": "Integer"},
"budget_alert_ttl": {"type": "Integer"},
"outage_alert_ttl": {"type": "Integer"},
"region_outage_alert_ttl": {"type": "Integer"},
"minor_outage_alert_threshold": {"type": "Integer"},
"major_outage_alert_threshold": {"type": "Integer"},
"max_outage_alert_list_size": {"type": "Integer"},
}
allowed_args: Final = MappingProxyType(
{
"slack_alerting": "Boolean",
"daily_report_frequency": "Integer",
"report_check_interval": "Integer",
"budget_alert_ttl": "Integer",
"outage_alert_ttl": "Integer",
"region_outage_alert_ttl": "Integer",
"minor_outage_alert_threshold": "Integer",
"major_outage_alert_threshold": "Integer",
"max_outage_alert_list_size": "Integer",
"daily_spend_per_user_threshold": "Float",
"monthly_spend_per_user_threshold": "Float",
"spend_anomaly_multiplier": "Float",
"spend_anomaly_baseline_days": "Integer",
"spend_anomaly_min_spend": "Float",
"user_spend_check_interval": "Integer",
}
)
_slack_alerting: Final[SlackAlerting] = proxy_logging_obj.slack_alerting_instance
_slack_alerting_args_dict: Final = _slack_alerting.alerting_args.model_dump()
@ -14997,7 +15035,7 @@ async def alerting_settings(
_response_obj = ConfigList(
field_name="slack_alerting",
field_type=allowed_args["slack_alerting"]["type"],
field_type=allowed_args["slack_alerting"],
field_description="Enable slack alerting for monitoring proxy in production: llm outages, budgets, spend tracking failures.",
field_value=is_slack_enabled,
stored_in_db=True if alerting_values is not None else False,
@ -15016,7 +15054,7 @@ async def alerting_settings(
_response_obj = ConfigList(
field_name=field_name,
field_type=allowed_args[field_name]["type"],
field_type=allowed_args[field_name],
field_description=field_info.description or "",
field_value=_slack_alerting_args_dict.get(field_name, None),
stored_in_db=_stored_in_db,
@ -16444,6 +16482,16 @@ async def update_config_general_settings(
detail={"error": f"Invalid type of field value={type(data.field_value)} passed in."},
)
if data.field_name == "alerting_args":
try:
SlackAlertingArgs.model_validate(data.field_value)
except ValidationError as e:
errors: Final = "; ".join(f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}" for err in e.errors())
raise HTTPException(
status_code=400,
detail={"error": f"Invalid alerting_args: {errors}"},
)
## get general settings from db
db_general_settings: Final = await _config_param_table(prisma_client).find_first(
where={"param_name": "general_settings"}

View file

@ -120,13 +120,15 @@ async def _apply_over_budget_reservation_policy(
applied_entries: list[dict[str, float | str]],
reservation_cost: float,
current_spend: float,
fail_closed_budget_enforcement: bool = False,
) -> float:
"""
Decide what to do when a counter is over budget, and return the reservation
cost to carry into the next counter. Three outcomes: an over-budget key that
opted into throttling releases its own reservation (the rate limiter slows
it) and keeps the cost; a partially-remaining budget resizes the reservation
down to what is left; anything else hard-blocks by raising.
down to what is left, unless strict enforcement is on, because the known
estimate already does not fit; anything else hard-blocks by raising.
"""
if _key_reservation_should_release_for_throttle(counter.counter_key, valid_token):
await _release_applied_entries_best_effort(entries=[entry], default_reserved_cost=reservation_cost)
@ -134,21 +136,36 @@ async def _apply_over_budget_reservation_policy(
return reservation_cost
remaining_before_reservation: Final = counter.max_budget - (current_spend - reservation_cost)
if remaining_before_reservation > 1e-12:
await _resize_applied_reservation(
entries=applied_entries,
current_reserved_cost=reservation_cost,
new_reserved_cost=remaining_before_reservation,
if remaining_before_reservation <= 1e-12:
_raise_counter_budget_exceeded(counter=counter, current_cost=current_spend)
if fail_closed_budget_enforcement and current_spend - counter.max_budget > 1e-12:
_raise_counter_budget_exceeded(
counter=counter,
current_cost=current_spend - reservation_cost,
estimated_cost=reservation_cost,
)
return remaining_before_reservation
await _resize_applied_reservation(
entries=applied_entries,
current_reserved_cost=reservation_cost,
new_reserved_cost=remaining_before_reservation,
)
return remaining_before_reservation
def _raise_counter_budget_exceeded(
counter: _BudgetCounter,
current_cost: float,
estimated_cost: float | None = None,
) -> NoReturn:
estimate_detail: Final = "" if estimated_cost is None else f"Estimated request cost: {estimated_cost}, "
raise litellm.BudgetExceededError(
current_cost=current_spend,
current_cost=current_cost,
max_budget=counter.max_budget,
message=(
"Budget has been exceeded! "
f"{counter.entity_type}={counter.entity_id} "
f"Current cost: {current_spend}, "
f"Current cost: {current_cost}, "
f"{estimate_detail}"
f"Max budget: {counter.max_budget}"
),
entity_type=_COUNTER_ENTITY_TYPES.get(counter.entity_type),
@ -258,6 +275,7 @@ async def reserve_budget_for_request(
applied_entries=applied_entries,
reservation_cost=reservation_cost,
current_spend=current_spend,
fail_closed_budget_enforcement=fail_closed_budget_enforcement,
)
continue
except Exception:

View file

@ -6,15 +6,34 @@ import json
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final
from litellm.models.user import LiteLLM_UserTable
from pydantic import TypeAdapter
from litellm.models.user import LiteLLM_UserTable, SCIMPlaceholder
from litellm.repositories.base_repository import BaseRepository, DbRecord, record_to_dict
from litellm.repositories.prisma_protocols import TableActions
if TYPE_CHECKING:
from prisma import Prisma
from prisma import models as prisma_models
_JSON_ENCODED_COLUMNS: Final = frozenset({"metadata", "model_spend", "model_max_budget"})
_SHADOWING_PLACEHOLDERS_SQL: Final = """
SELECT p.user_id AS placeholder_user_id,
array_agg(r.user_id ORDER BY r.user_id) AS resolved_user_ids,
p.teams AS team_ids
FROM "LiteLLM_UserTable" p
JOIN "LiteLLM_UserTable" r
ON r.user_id <> p.user_id
AND (r.sso_user_id = p.user_id OR LOWER(r.user_email) = LOWER(p.user_id))
WHERE p.sso_user_id IS NULL
AND NOT EXISTS (SELECT 1 FROM "LiteLLM_VerificationToken" k WHERE k.user_id = p.user_id)
GROUP BY p.user_id, p.teams
ORDER BY p.user_id
"""
_PLACEHOLDER_ROWS_ADAPTER: Final = TypeAdapter(tuple[SCIMPlaceholder, ...])
class UserRepository(BaseRepository[LiteLLM_UserTable]):
"""Repository for user database operations."""
@ -59,6 +78,11 @@ class UserRepository(BaseRepository[LiteLLM_UserTable]):
"""Find all users in a team."""
return await self.find_many(where={"teams": {"has": team_id}})
async def find_shadowing_placeholders(self, tx: "Prisma") -> tuple[SCIMPlaceholder, ...]:
"""Users with no SSO id and no virtual keys whose id is another user's SSO id or email."""
rows: Final = await tx.query_raw(_SHADOWING_PLACEHOLDERS_SQL)
return _PLACEHOLDER_ROWS_ADAPTER.validate_python(rows)
async def count_billable_users(self) -> int:
"""Number of users that count toward the license seat limit.

View file

@ -22,7 +22,7 @@ import traceback
import weakref
from collections import defaultdict
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Mapping, Sequence
from functools import lru_cache
from functools import lru_cache, partial
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeAlias, TypeVar, Union, cast
@ -143,7 +143,11 @@ from litellm.router_utils.cooldown_handlers import (
from litellm.router_utils.fallback_event_handlers import (
AttemptedFallbackTargets,
_check_non_standard_fallback_format,
get_fallback_model_group,
clear_pre_routing_selection,
fallback_lookup_groups,
get_fallback_model_group_for_lookup_groups,
get_pre_routing_selection,
record_pre_routing_selection,
run_async_fallback,
)
from litellm.router_utils.get_retry_from_policy import (
@ -821,6 +825,7 @@ class Router:
self._zero_cost_cache: dict[str, bool] = {}
self._routing_group_rows: tuple[DeploymentTypedDict, ...] | None = None
self._init_routing_groups(None)
self._provider_unresolved_deployments: tuple[Callable[[], Deployment | None], ...] = ()
self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds
self.model_group_affinity_config = model_group_affinity_config
@ -4918,6 +4923,19 @@ class Router:
)
response = await response
if self._should_raise_anthropic_refusal_error(
model=model,
original_generic_function=original_generic_function,
response=response,
kwargs=kwargs,
):
from litellm.llms.anthropic.experimental_pass_through.messages.utils import (
safeguard_refusal_error,
)
refusal_details: Final = cast(dict, response["stop_details"]) # cast-ok: gate verified the shape
raise safeguard_refusal_error(model=model, stop_details=refusal_details)
self.success_calls[model_name] += 1
verbose_router_logger.info("ageneric_api_call_with_fallbacks(model=%s)\x1b[32m 200 OK\x1b[0m", model_name)
@ -4964,6 +4982,11 @@ class Router:
# fallback to the original reference for any non-picklable value.
# The original_generic_function is preserved so the per-attempt
# helper knows which underlying API to call on fallback.
# The pre-routing hook stamps its tier selection into this bucket during the primary
# attempt; seeding it before the snapshot gives both the live kwargs and the copy a
# bucket, so the post-call carry-over below always has somewhere to read and write.
kwargs.setdefault("litellm_metadata", {}) # mutable-ok: shared bucket # rebind-ok: stamp must be readable here
fallback_kwargs: Final[dict[str, object]] = kwargs.copy()
if isinstance(fallback_kwargs.get("litellm_metadata"), dict):
fallback_kwargs["litellm_metadata"] = safe_deep_copy(fallback_kwargs["litellm_metadata"])
@ -4973,6 +4996,14 @@ class Router:
response: Final = await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs)
# The snapshot predates the pre-routing hook, so the tier it stamped into the live kwargs
# is carried over write-or-clear: a stale or caller-supplied selection left in the copy
# would key the mid-stream fallback lookup off a tier this attempt never routed to.
clear_pre_routing_selection(fallback_kwargs)
live_pre_routing_selection: Final = get_pre_routing_selection(kwargs)
if live_pre_routing_selection is not None:
record_pre_routing_selection(fallback_kwargs, live_pre_routing_selection)
if kwargs.get("stream") and isinstance(response, BaseResponsesAPIStreamingIterator):
return await self._aresponses_streaming_iterator(
response=response,
@ -5030,6 +5061,10 @@ class Router:
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
aclose_if_supported,
parse_anthropic_error_event,
parse_anthropic_refusal_stop_details,
)
from litellm.llms.anthropic.experimental_pass_through.messages.utils import (
safeguard_refusal_error,
)
source_iterator: Final = response
@ -5068,13 +5103,35 @@ class Router:
continue
if _anthropic_stream_commits_now(chunk, has_generated_content, len(buffered_lifecycle_chunks)):
has_generated_content = True # rebind-ok: real content seen, or the buffer cap was hit
error_event = parse_anthropic_error_event(chunk)
# A transport can split one SSE data line across byte chunks, so pre-content
# detection parses the accumulated buffer plus the current chunk, never the
# chunk alone; the buffer is already capped, which bounds this window too.
parse_window = ( # rebind-ok: freshly computed each iteration, never carried over
b"".join(c for c in (*buffered_lifecycle_chunks, chunk) if isinstance(c, (bytes, bytearray))) # pyright: ignore[reportUnnecessaryIsInstance] # bridge-path chunks are not always bytes at runtime
if not has_generated_content and isinstance(chunk, (bytes, bytearray)) # pyright: ignore[reportUnnecessaryIsInstance] # bridge-path chunks are not always bytes at runtime
else chunk
)
error_event = parse_anthropic_error_event(parse_window)
retriable_pending_error = ( # rebind-ok: freshly computed each iteration, never carried over
not has_generated_content
and error_event is not None
and _is_retriable_anthropic_status(error_event[2])
and not _anthropic_stream_error_is_gateway_verdict(chunk)
)
refusal_stop_details = ( # rebind-ok: freshly computed each iteration, never carried over
parse_anthropic_refusal_stop_details(parse_window)
if not has_generated_content and error_event is None
else None
)
if refusal_stop_details is not None and self._has_content_policy_fallback(model, initial_kwargs):
refusal_error = safeguard_refusal_error(model=model, stop_details=refusal_stop_details)
raise MidStreamFallbackError(
message=refusal_error.message,
model=model,
llm_provider="anthropic",
original_exception=refusal_error,
is_pre_first_chunk=True,
)
if not has_generated_content and not retriable_pending_error and error_event is None:
buffered_lifecycle_chunks = (*buffered_lifecycle_chunks, chunk)
continue
@ -5186,8 +5243,13 @@ class Router:
kwargs=initial_kwargs,
metadata_variable_name="litellm_metadata",
)
# The content-policy dispatch branch matches on the trigger's own type, so a refusal's
# MidStreamFallbackError envelope is unwrapped here or the wrong fallback list is consulted.
fallback_trigger: Final[Exception] = (
e.original_exception if isinstance(e.original_exception, litellm.ContentPolicyViolationError) else e
)
fallback_response = await self.async_function_with_fallbacks_common_utils( # rebind-ok: set on success
e=e,
e=fallback_trigger,
disable_fallbacks=False,
fallbacks=fallbacks,
context_window_fallbacks=context_window_fallbacks,
@ -5243,6 +5305,11 @@ class Router:
# share, leaking primary-deployment metadata into the mid-stream
# fallback request. safe_deep_copy avoids deep-copying the full
# kwargs (which can hold non-deepcopyable logging handles/clients).
# The pre-routing hook stamps its tier selection into this bucket during the primary
# attempt; seeding it before the snapshot gives both the live kwargs and the copy a
# bucket, so the post-call carry-over below always has somewhere to read and write.
kwargs.setdefault("litellm_metadata", {}) # mutable-ok: shared bucket # rebind-ok: stamp must be readable here
fallback_kwargs: Final[dict[str, object]] = kwargs.copy() # mutable-ok: mutated below before re-entry
if isinstance(fallback_kwargs.get("litellm_metadata"), dict):
fallback_kwargs["litellm_metadata"] = safe_deep_copy(fallback_kwargs["litellm_metadata"])
@ -5252,6 +5319,14 @@ class Router:
response: Final = await self._ageneric_api_call_with_fallbacks(original_function=original_function, **kwargs)
# The snapshot predates the pre-routing hook, so the tier it stamped into the live kwargs
# is carried over write-or-clear: a stale or caller-supplied selection left in the copy
# would key the mid-stream fallback lookup off a tier this attempt never routed to.
clear_pre_routing_selection(fallback_kwargs)
live_pre_routing_selection: Final = get_pre_routing_selection(kwargs)
if live_pre_routing_selection is not None:
record_pre_routing_selection(fallback_kwargs, live_pre_routing_selection)
if kwargs.get("stream") and hasattr(response, "__aiter__"):
return await self._aanthropic_messages_streaming_iterator(
response=cast("AsyncIterator[bytes]", response), # cast-ok: stream=True always returns a byte iterator
@ -6807,6 +6882,9 @@ class Router:
original_exception: Final = e
fallback_model_group = None
original_model_group: Final[str | None] = kwargs.get("model")
# A pre-routing hook (complexity / auto / adaptive / quality routers) picks a tier
# behind the router name, and fallbacks are configured per tier, not per router.
lookup_groups: Final[tuple[str, ...]] = fallback_lookup_groups(kwargs, model_group)
fallback_failure_exception_str = ""
if disable_fallbacks is True or original_model_group is None:
@ -6851,15 +6929,15 @@ class Router:
]
# Get external fallbacks — handle both standard and non-standard formats
external_fallback_group: list | None = None
if fallbacks is not None and model_group is not None:
if fallbacks is not None and lookup_groups:
if _check_non_standard_fallback_format(fallbacks=fallbacks):
# Non-standard formats (e.g. ["claude-3-haiku"] or
# [{"model": "...", "messages": [...]}]) are passed through directly
external_fallback_group = fallbacks
else:
external_fallback_group, generic_idx = get_fallback_model_group(
external_fallback_group, generic_idx = get_fallback_model_group_for_lookup_groups(
fallbacks=fallbacks,
model_group=cast(str, model_group),
lookup_groups=lookup_groups,
)
if external_fallback_group is None and generic_idx is not None:
external_fallback_group = fallbacks[generic_idx]["*"]
@ -6917,9 +6995,9 @@ class Router:
if isinstance(e, litellm.ContextWindowExceededError):
if context_window_fallbacks is not None:
context_window_fallback_model_group: Final[list[str] | None] = (
self._get_fallback_model_group_from_fallbacks(
self._get_fallback_model_group_for_lookup_groups(
fallbacks=context_window_fallbacks,
model_group=model_group,
lookup_groups=lookup_groups,
)
)
if context_window_fallback_model_group is None:
@ -6950,9 +7028,9 @@ class Router:
elif isinstance(e, litellm.ContentPolicyViolationError):
if content_policy_fallbacks is not None:
content_policy_fallback_model_group: Final[list[str] | None] = (
self._get_fallback_model_group_from_fallbacks(
self._get_fallback_model_group_for_lookup_groups(
fallbacks=content_policy_fallbacks,
model_group=model_group,
lookup_groups=lookup_groups,
)
)
if content_policy_fallback_model_group is None:
@ -6979,14 +7057,14 @@ class Router:
if litellm.expose_router_debug_in_errors:
e.message += f"\n{error_message}"
if fallbacks is not None and model_group is not None:
if fallbacks is not None and lookup_groups:
verbose_router_logger.debug("inside model fallbacks: %s", mask_sensitive_structure(fallbacks))
(
fallback_model_group,
generic_fallback_idx,
) = get_fallback_model_group(
) = get_fallback_model_group_for_lookup_groups(
fallbacks=fallbacks, # if fallbacks = [{"gpt-3.5-turbo": ["claude-3-haiku"]}]
model_group=cast(str, model_group),
lookup_groups=lookup_groups,
)
## if none, check for generic fallback
if fallback_model_group is None and generic_fallback_idx is not None:
@ -6995,12 +7073,12 @@ class Router:
if fallback_model_group is None:
masked_fallbacks: Final = mask_sensitive_structure(fallbacks)
verbose_router_logger.info(
"No fallback model group found for original model_group=%s. Fallbacks=%s",
model_group,
"No fallback model group found for lookup_groups=%s. Fallbacks=%s",
" -> ".join(lookup_groups),
masked_fallbacks,
)
if hasattr(original_exception, "message") and litellm.expose_router_debug_in_errors:
original_exception.message += f"No fallback model group found for original model_group={model_group}. Fallbacks={masked_fallbacks}"
original_exception.message += f"No fallback model group found for lookup_groups={' -> '.join(lookup_groups)}. Fallbacks={masked_fallbacks}"
raise original_exception
input_kwargs.update(
@ -7046,6 +7124,7 @@ class Router:
If it fails after num_retries, fall back to another model group
"""
model_group: Final[str | None] = kwargs.get("model")
clear_pre_routing_selection(kwargs) # pyright: ignore[reportUnknownArgumentType] # **kwargs is untyped at this boundary
if not isinstance(kwargs.get("attempted_targets"), AttemptedFallbackTargets):
_fallback_metadata_key: Final = _get_router_metadata_variable_name(
function_name=getattr(kwargs.get("original_function"), "__name__", None)
@ -7471,6 +7550,24 @@ class Router:
break
return fallback_model_group
def _get_fallback_model_group_for_lookup_groups(
self,
fallbacks: list[dict[str, list[str]]], # mutable-ok: mirrors the sibling resolver's contract
lookup_groups: tuple[str, ...],
) -> list[str] | None: # mutable-ok: mirrors the sibling resolver's contract
"""First lookup group whose exact-key chain resolves (tier first, then requested group)."""
return next(
(
resolved
for resolved in (
self._get_fallback_model_group_from_fallbacks(fallbacks=fallbacks, model_group=group)
for group in lookup_groups
)
if resolved is not None
),
None,
)
def _get_first_default_fallback(self) -> str | None:
"""
Returns the first model from the default_fallbacks list, if it exists.
@ -7886,6 +7983,31 @@ class Router:
return True
return False
def _has_content_policy_fallback(self, model_group: str, kwargs: Mapping[str, Any]) -> bool:
"""
Whether a content-policy fallback would resolve for this request, keyed the same way
async_function_with_fallbacks_common_utils resolves it: the tier a pre-routing hook
selected wins over the requested group. Raising without this returning True would turn
a deliverable response into an error the fallback chain cannot recover from.
"""
content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self.content_policy_fallbacks)
if content_policy_fallbacks is not None:
return (
self._get_fallback_model_group_for_lookup_groups(
fallbacks=content_policy_fallbacks,
lookup_groups=fallback_lookup_groups(kwargs, model_group),
)
is not None
)
if self._has_default_fallbacks():
return True
verbose_router_logger.debug(
"No content-policy fallback available. Returning original response. model=%s, content_policy_fallbacks=%s",
model_group,
content_policy_fallbacks,
)
return False
def _should_raise_content_policy_error(self, model: str, response: ModelResponse, kwargs: dict) -> bool:
"""
Determines if a content policy error should be raised.
@ -7898,27 +8020,26 @@ class Router:
if response.choices[0].finish_reason != "content_filter":
return False
content_policy_fallbacks: Final = kwargs.get("content_policy_fallbacks", self.content_policy_fallbacks)
return self._has_content_policy_fallback(model, kwargs)
### ONLY RAISE ERROR IF CP FALLBACK AVAILABLE ###
if content_policy_fallbacks is not None:
fallback_model_group = None
for item in content_policy_fallbacks: # [{"gpt-3.5-turbo": ["gpt-4"]}]
if list(item.keys())[0] == model:
fallback_model_group = item[model]
break
if fallback_model_group is not None:
return True
elif self._has_default_fallbacks(): # default fallbacks set
return True
verbose_router_logger.debug(
"Content Policy Error occurred. No available fallbacks. Returning original response. model=%s, content_policy_fallbacks=%s",
model,
content_policy_fallbacks,
def _should_raise_anthropic_refusal_error(
self, model: str, original_generic_function: Callable, response: object, kwargs: Mapping[str, Any]
) -> bool:
"""
The /v1/messages twin of _should_raise_content_policy_error: an Anthropic safeguard
refusal (stop_reason "refusal" carrying stop_details) re-enters the fallback chain only
when a content-policy fallback is configured; a plain refusal without stop_details, or
any response with nothing configured, is returned to the client unchanged.
"""
from litellm.llms.anthropic.experimental_pass_through.messages.utils import (
get_safeguard_refusal_stop_details,
)
return False
if getattr(original_generic_function, "__name__", "") != "anthropic_messages":
return False
if get_safeguard_refusal_stop_details(response) is None:
return False
return self._has_content_policy_fallback(model, kwargs)
def _get_healthy_deployments(self, model: str, parent_otel_span: Span | None):
_all_deployments: list = []
@ -8352,6 +8473,19 @@ class Router:
return deployment
except Exception as e:
if self.ignore_invalid_deployments:
if isinstance(e, litellm.BadRequestError):
self._provider_unresolved_deployments = (
*self._provider_unresolved_deployments,
partial(
self._create_deployment,
deployment_info=deployment_info,
_model_name=_model_name,
_litellm_params=_litellm_params,
_model_info=_model_info,
declared_id=declared_id,
duplicate_ids=duplicate_ids,
),
)
verbose_router_logger.exception(
"Error creating deployment: %s, ignoring and continuing with other deployments.", e
)
@ -8781,6 +8915,7 @@ class Router:
self.quality_routers = {}
self.complexity_routers = {}
self.auto_routers = {}
self._provider_unresolved_deployments = ()
self._invalidate_model_group_info_cache()
self._invalidate_access_groups_cache()
# we add api_base/api_key each model so load balancing between azure/gpt on api_base1 and api_base2 works
@ -9403,8 +9538,12 @@ class Router:
"""Re-assert this router's deployments onto a freshly fetched catalog.
Reads ``model_list`` at call time, so only deployments the router still
serves are restored.
serves are restored, plus any config deployment the fresh catalog now resolves.
"""
provider_unresolved: Final = self._provider_unresolved_deployments
self._provider_unresolved_deployments = ()
for create_deployment in provider_unresolved:
create_deployment()
for entry in tuple(self.model_list):
try:
deployment = entry if isinstance(entry, Deployment) else Deployment(**entry)
@ -12087,6 +12226,7 @@ class Router:
if pre_routing_hook_response is not None:
model = pre_routing_hook_response.model
messages = pre_routing_hook_response.messages
record_pre_routing_selection(request_kwargs, model)
if pre_routing_hook_response.litellm_params:
accepted_tier_params: Final = self._tier_params_the_target_accepts(
model, pre_routing_hook_response.litellm_params, request_kwargs
@ -12202,6 +12342,7 @@ class Router:
if pre_routing_hook_response is not None:
model = pre_routing_hook_response.model
messages = pre_routing_hook_response.messages
record_pre_routing_selection(request_kwargs, model)
if pre_routing_hook_response.litellm_params:
accepted_tier_params: Final = self._tier_params_the_target_accepts(
model, pre_routing_hook_response.litellm_params, request_kwargs

View file

@ -214,6 +214,91 @@ def _check_stripped_model_group(model_group: str, fallback_key: str) -> bool:
return False
PRE_ROUTING_SELECTED_MODEL_KEY: Final = "pre_routing_selected_model"
_ROUTER_METADATA_BUCKETS: Final = ("metadata", "litellm_metadata")
def record_pre_routing_selection(request_kwargs: Mapping[str, Any] | None, selected_model: str) -> None:
"""
Remember which model a pre-routing hook picked, so fallback lookup can key off it.
Fallback resolution runs on an outer kwargs dict that ``**kwargs`` already copied, so
writing the model there is invisible by the time routing picks a tier. The metadata
buckets are nested dicts shared by reference across those copies, which is how the
router already carries values back up.
The write goes through the proxy-internal bucket resolver, never into both buckets:
on /v1/messages the top-level ``metadata`` dict is the provider's own request field,
so a blanket write would forward the tier stamp upstream.
"""
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs
if request_kwargs is None:
return
bucket: Final = request_kwargs.get(get_metadata_variable_name_from_kwargs(request_kwargs))
if isinstance(bucket, dict):
bucket[PRE_ROUTING_SELECTED_MODEL_KEY] = selected_model
def clear_pre_routing_selection(request_kwargs: Mapping[str, object] | None) -> None:
"""
Drop any selection the router did not make itself on this hop.
The buckets carry whatever the caller sent, so an inbound value is the caller
choosing a fallback chain rather than the router choosing a tier. A fallback hop
also inherits the previous hop's selection, which would key its own failure off
the tier that already failed. Clearing at the start of every hop leaves only a
value the pre-routing hook wrote while routing that hop.
"""
if request_kwargs is None:
return
for bucket in (request_kwargs.get(name) for name in _ROUTER_METADATA_BUCKETS):
if isinstance(bucket, dict) and PRE_ROUTING_SELECTED_MODEL_KEY in bucket:
del bucket[PRE_ROUTING_SELECTED_MODEL_KEY]
def get_pre_routing_selection(kwargs: Mapping[str, Any]) -> str | None:
"""The model a pre-routing hook selected for this request, if one did."""
buckets: Final = (kwargs.get(name) for name in _ROUTER_METADATA_BUCKETS)
selections: Final = (bucket.get(PRE_ROUTING_SELECTED_MODEL_KEY) for bucket in buckets if isinstance(bucket, dict))
return next((selected for selected in selections if isinstance(selected, str) and selected), None)
def fallback_lookup_groups(kwargs: Mapping[str, Any], model_group: str | None) -> tuple[str, ...]:
"""
Ordered keys for resolving a fallback chain: the tier a pre-routing hook selected wins,
and the requested group still resolves when no tier-keyed chain exists, so configs keyed
on the router name (the documented contract) keep working behind auto-routers.
"""
ordered: Final = (get_pre_routing_selection(kwargs), model_group)
return tuple(dict.fromkeys(group for group in ordered if group))
def _resolved_a_specific_chain(
fallbacks: list[Any], # mutable-ok: mirrors get_fallback_model_group's contract
result: tuple[list[str] | None, int | None], # mutable-ok: mirrors get_fallback_model_group's contract
) -> bool:
resolved, generic_idx = result
if resolved is None:
return False
return generic_idx is None or resolved is not fallbacks[generic_idx]["*"]
def get_fallback_model_group_for_lookup_groups(
fallbacks: list[Any], # mutable-ok: mirrors get_fallback_model_group's contract
lookup_groups: tuple[str, ...],
) -> tuple[list[str] | None, int | None]: # mutable-ok: mirrors get_fallback_model_group's contract
"""
First lookup group with a specifically-keyed chain wins; the generic "*" chain applies
only after every group missed, so a catch-all cannot shadow a later group's own chain.
"""
results: Final = tuple(get_fallback_model_group(fallbacks=fallbacks, model_group=group) for group in lookup_groups)
specific: Final = next((result for result in results if _resolved_a_specific_chain(fallbacks, result)), None)
if specific is not None:
return specific
return next((result for result in results if result[0] is not None), (None, None))
def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[list[str] | None, int | None]:
"""
Returns:
@ -412,6 +497,7 @@ async def run_async_fallback(
# LOGGING
kwargs = litellm_router.log_retry(kwargs=kwargs, e=original_exception)
verbose_router_logger.info("Falling back to model_group = %s", mask_sensitive_structure(mg))
kwargs.pop("_target_order", None) # rebind-ok: next hop must not inherit the previous order target
if isinstance(mg, str):
kwargs["model"] = mg
elif isinstance(mg, dict):

View file

@ -427,6 +427,8 @@ class DeploymentAffinityCheck(CustomLogger):
"""
request_kwargs = request_kwargs or {}
typed_healthy_deployments: Final = cast(list[dict], healthy_deployments)
if request_kwargs.get("_target_order") is not None:
return typed_healthy_deployments
(
enable_user_key,

View file

@ -58,6 +58,9 @@ class PromptCachingDeploymentCheck(CustomLogger):
request_kwargs: dict | None = None,
parent_otel_span: Span | None = None,
) -> list[dict]:
if request_kwargs is not None and request_kwargs.get("_target_order") is not None:
return healthy_deployments
if messages is not None and is_prompt_caching_valid_prompt(
messages=messages,
model=model,

View file

@ -4,21 +4,58 @@ Payloads for Datadog LLM Observability Service (LLMObs)
API Reference: https://docs.datadoghq.com/llm_observability/setup/api/?tab=example#api-standards
"""
from collections.abc import Sequence
from typing import Any, Literal
from typing_extensions import TypedDict
from typing_extensions import ReadOnly, TypedDict
from litellm.types.integrations.custom_logger import StandardCustomLoggerInitParams
class ToolCall(TypedDict, total=False):
"""A tool call on a message, as LLM Obs names its fields."""
name: ReadOnly[str]
arguments: ReadOnly[dict[str, Any] | str] # parsed object, or the raw string when it will not parse to one
tool_id: ReadOnly[str]
type: ReadOnly[str]
class ToolResult(TypedDict, total=False):
"""The result of a tool call, as LLM Obs names its fields."""
name: ReadOnly[str]
result: ReadOnly[str]
tool_id: ReadOnly[str]
type: ReadOnly[str]
class ToolDefinition(TypedDict, total=False):
"""A tool the model was offered on the request."""
name: ReadOnly[str]
description: ReadOnly[str]
schema: ReadOnly[dict[str, Any]]
class Message(TypedDict, total=False):
"""A message on a span, as LLM Obs names its fields."""
content: ReadOnly[str]
role: ReadOnly[str]
reasoning_content: ReadOnly[str]
tool_calls: ReadOnly[Sequence[ToolCall]]
tool_results: ReadOnly[Sequence[ToolResult]]
class InputMeta(TypedDict):
messages: list[
dict[str, Any] # changed to fit with tool calls
messages: Sequence[
Message | dict[str, Any] # changed to fit with tool calls
] # Relevant Issue: https://github.com/BerriAI/litellm/issues/9494
class OutputMeta(TypedDict):
messages: list[Any]
messages: Sequence[Any]
class DDLLMObsError(TypedDict, total=False):
@ -36,6 +73,7 @@ class Meta(TypedDict, total=False):
output: OutputMeta # The span's output information.
metadata: dict[str, Any]
error: DDLLMObsError | None # Error information on the span
tool_definitions: ReadOnly[Sequence[ToolDefinition]] # The tools offered to the model on this request
class LLMMetrics(TypedDict, total=False):
@ -45,6 +83,9 @@ class LLMMetrics(TypedDict, total=False):
time_to_first_token: float
time_per_output_token: float
total_cost: float
cache_read_input_tokens: ReadOnly[float]
cache_write_input_tokens: ReadOnly[float]
non_cached_input_tokens: ReadOnly[float]
class LLMObsPayload(TypedDict, total=False):

View file

@ -270,6 +270,10 @@ DEFINED_PROMETHEUS_METRICS = Literal[
"litellm_deployment_rpm_limit",
"litellm_remaining_api_key_requests_for_model",
"litellm_remaining_api_key_tokens_for_model",
"litellm_api_key_rate_limit_allowed_metric",
"litellm_api_key_rate_limit_used_metric",
"litellm_team_rate_limit_allowed_metric",
"litellm_team_rate_limit_used_metric",
"litellm_llm_api_failed_requests_metric",
"litellm_callback_logging_failures_metric",
"litellm_in_flight_requests",
@ -775,6 +779,22 @@ class PrometheusMetricLabels:
UserAPIKeyLabelNames.MODEL_ID.value,
]
litellm_api_key_rate_limit_allowed_metric: ClassVar[tuple[str, ...]] = (
UserAPIKeyLabelNames.API_KEY_HASH.value,
UserAPIKeyLabelNames.API_KEY_ALIAS.value,
UserAPIKeyLabelNames.RATE_LIMIT_TYPE.value,
)
litellm_api_key_rate_limit_used_metric = litellm_api_key_rate_limit_allowed_metric
litellm_team_rate_limit_allowed_metric: ClassVar[tuple[str, ...]] = (
UserAPIKeyLabelNames.TEAM.value,
UserAPIKeyLabelNames.TEAM_ALIAS.value,
UserAPIKeyLabelNames.RATE_LIMIT_TYPE.value,
)
litellm_team_rate_limit_used_metric = litellm_team_rate_limit_allowed_metric
litellm_llm_api_failed_requests_metric = [
UserAPIKeyLabelNames.END_USER.value,
UserAPIKeyLabelNames.API_KEY_HASH.value,

View file

@ -91,6 +91,40 @@ class SlackAlertingArgs(LiteLLMPydanticObjectBase):
default=False,
description="If true, the alerting payload will be printed to the console.",
)
daily_spend_per_user_threshold: float | None = Field(
default=None,
gt=0,
allow_inf_nan=False,
description="Alert when a user's spend for the current day (UTC) crosses this USD amount. Off by default.",
)
monthly_spend_per_user_threshold: float | None = Field(
default=None,
gt=0,
allow_inf_nan=False,
description="Alert when a user's spend for the current calendar month (UTC) crosses this USD amount. Off by default.",
)
spend_anomaly_multiplier: float = Field(
default=3.0,
gt=0,
allow_inf_nan=False,
description="Flag a user's spend as anomalous when today's spend exceeds this multiple of their trailing daily average.",
)
spend_anomaly_baseline_days: int = Field(
default=7,
ge=1,
description="Number of trailing days used to compute a user's daily average spend for anomaly detection.",
)
spend_anomaly_min_spend: float = Field(
default=10.0,
gt=0,
allow_inf_nan=False,
description="Minimum spend (USD) a user must reach today before an anomaly alert can fire. Reduces false positives.",
)
user_spend_check_interval: int = Field(
default=3600,
ge=60,
description="How often (in seconds) to check per-user spend thresholds and anomalies. Default is hourly.",
)
class DeploymentMetrics(LiteLLMPydanticObjectBase):
@ -138,6 +172,8 @@ class AlertType(str, Enum):
budget_alerts = "budget_alerts"
spend_reports = "spend_reports"
failed_tracking_spend = "failed_tracking_spend"
user_spend_thresholds = "user_spend_thresholds"
user_spend_anomalies = "user_spend_anomalies"
# Database alerts
db_exceptions = "db_exceptions"
@ -182,6 +218,7 @@ DEFAULT_ALERT_TYPES: Final[list[AlertType]] = [
AlertType.budget_alerts,
AlertType.spend_reports,
AlertType.failed_tracking_spend,
AlertType.user_spend_thresholds,
# Database alerts
AlertType.db_exceptions,
# Report alerts

View file

@ -78,6 +78,16 @@ class AnthropicUsage(TypedDict, total=False):
server_tool_use: NotRequired[ReadOnly[ServerToolUsage]]
class AnthropicStopDetails(TypedDict, total=False):
"""
Safeguard verdict accompanying a `stop_reason: "refusal"` response:
https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback
"""
category: ReadOnly[str | None]
explanation: ReadOnly[str | None]
class AnthropicMessagesResponse(TypedDict, total=False):
"""
Anthropic Messages API Response: https://docs.anthropic.com/en/api/messages
@ -90,7 +100,8 @@ class AnthropicMessagesResponse(TypedDict, total=False):
id: str
model: str | None # This represents the Model type from Anthropic
role: Literal["assistant"] | None
stop_reason: Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] | None
stop_reason: Literal["end_turn", "max_tokens", "stop_sequence", "tool_use", "refusal"] | None
stop_details: NotRequired[ReadOnly[AnthropicStopDetails | None]]
stop_sequence: str | None
type: Literal["message"] | None
usage: AnthropicUsage | None

View file

@ -150,6 +150,12 @@ class SCIMGroup(SCIMResource):
members: list[SCIMMember] | None = None
class SCIMPlaceholderMergeResult(BaseModel):
placeholder_user_id: str
merged_into_user_id: str
team_ids: tuple[str, ...]
# SCIM List Response Models
class SCIMListResponse(BaseModel):
schemas: list[str] = ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]

View file

@ -3636,6 +3636,8 @@ all_litellm_params = (
"client",
"rpm",
"tpm",
"default_api_key_rpm_limit",
"default_api_key_tpm_limit",
"itpm",
"otpm",
"max_parallel_requests",

View file

@ -4876,11 +4876,7 @@ def _get_deployment_order(deployment: dict | Any) -> int | None:
def _get_order_filtered_deployments(healthy_deployments: list[dict], target_order: int | None = None) -> list:
if target_order is not None:
filtered: Final = [d for d in healthy_deployments if _get_deployment_order(d) == target_order]
if filtered:
return filtered
# target_order doesn't match any deployment (e.g., external fallback model) — return all
return healthy_deployments
return [d for d in healthy_deployments if _get_deployment_order(d) == target_order]
# Default: pick min order group
_valid_orders: Final[list[int]] = [

View file

@ -2,10 +2,16 @@
The e2e suite ships results to Loki/Grafana from a standard pytest JUnit report
(`--junitxml=e2e-report.xml`), not a bespoke log line. JUnit already records
outcome, duration, and node id for every `<testcase>`; the only signals it cannot
derive on its own are the normalized suite package and the coverage-registry cell
ids a test covers. Those ride along as JUnit `<property>` entries via each item's
`user_properties`, attached in `conftest.py::pytest_collection_modifyitems`.
outcome, duration, and node id for every `<testcase>`; the signals it cannot
derive on its own are the normalized suite package, the coverage-registry cell
ids a test covers, and where the test's source lives. Those ride along as JUnit
`<property>` entries via each item's `user_properties`, attached in
`conftest.py::pytest_collection_modifyitems`.
`source` is a property rather than the `file=` / `line=` attributes pytest used
to write, because the `xunit2` family this suite runs on drops those, and
switching families would change the XML for every consumer of it -- the
Buildkite Test Engine upload and the Loki pipeline included.
"""
from __future__ import annotations
@ -14,22 +20,61 @@ from collections.abc import Iterable
import pytest
# Hardcoded because the runner image copies tests/e2e/ to /app/e2e, so nothing
# at runtime names this suite's place in the repo. test_junit_properties.py
# fails from a checkout if it moves.
SUITE_ROOT = "tests/e2e"
def suite_parts(path_part: str) -> tuple[str, ...]:
"""Path components of a suite file relative to tests/e2e, however it ran.
Pytest paths are rootdir-relative, and rootdir moves with the invocation: a
repo-root run gives `tests/e2e/logging/test_x.py`, a suite-cwd run (the
runner image) gives `logging/test_x.py`. Both collapse to the same tuple.
"""
raw = tuple(p for p in path_part.replace("\\", "/").split("/") if p and p != ".")
return raw[2:] if len(raw) >= 3 and raw[0] == "tests" and raw[1] == "e2e" else raw
def package_from_nodeid(nodeid: str) -> str:
"""Top-level suite package under tests/e2e/, or 'root' for top-level files.
Pytest nodeids are relative to the invocation cwd. Repo-root runs look like
`tests/e2e/logging/...`; suite-cwd runs look like `logging/...`. Strip the
`tests/e2e` prefix so package is the suite dir either way.
"""
path_part = nodeid.split("::", 1)[0].replace("\\", "/")
raw = tuple(p for p in path_part.split("/") if p and p != ".")
parts = raw[2:] if len(raw) >= 3 and raw[0] == "tests" and raw[1] == "e2e" else raw
"""Top-level suite package under tests/e2e/, or 'root' for top-level files."""
parts = suite_parts(nodeid.split("::", 1)[0])
if len(parts) <= 1:
return "root"
return parts[0]
def source_from_location(path: str, lineno: int | None) -> str:
"""Repo-relative `path:line` for a test, or '' when nothing is linkable.
`pytest.Item.location` gives a rootdir-relative path and a ZERO-based line.
The path is re-rooted at SUITE_ROOT so consumers need not know how pytest was
started, and the line is emitted ONE-based to match editors, tracebacks and
code hosts. A decorated test anchors at its first decorator, which is where
pytest reports it.
Empty rather than a guess for anything unlinkable: no line, a path reaching
upward, or a path carrying a colon, which is both how an absolute Windows
path arrives and a character `path:line` has no way to represent.
"""
if lineno is None:
return ""
normalized = path.replace("\\", "/")
if normalized.startswith("/") or ":" in normalized or ".." in normalized.split("/"):
return ""
parts = suite_parts(normalized)
if not parts:
return ""
return f"{'/'.join((SUITE_ROOT, *parts))}:{lineno + 1}"
def source_from_item(item: pytest.Item) -> str:
"""Read the repo-relative `path:line` off a pytest Item's reported location."""
path, lineno, _ = item.location
return source_from_location(path, lineno)
def dedupe_covers(marker_args: Iterable[tuple[object, ...]]) -> tuple[str, ...]:
"""Flatten @pytest.mark.covers arg lists into unique, order-preserving cell
ids, dropping anything that is not a non-empty string."""
@ -43,10 +88,12 @@ def covers_from_item(item: pytest.Item) -> tuple[str, ...]:
def result_properties(item: pytest.Item) -> tuple[tuple[str, str], ...]:
"""The custom signals a standard reporter cannot derive: the normalized suite
package and the comma-joined coverage-registry cell ids this test covers."""
package, the comma-joined coverage-registry cell ids this test covers, and the
repo-relative `path:line` its source sits at."""
return (
("package", package_from_nodeid(item.nodeid)),
("covers", ",".join(covers_from_item(item))),
("source", source_from_item(item)),
)

View file

@ -806,6 +806,7 @@ class LiteLLMParamsBody(BaseModel):
mock_response: str | None = None
timeout: float | None = None
tpm: int | None = None
weight: int | None = None
ModelMode = Literal["batch", "realtime", "image_generation"]
@ -820,6 +821,7 @@ class ModelInfoBody(BaseModel):
mode: ModelMode | None = None
access_groups: list[str] | None = None
team_id: str | None = None
allowed_fails_policy: dict[str, int] | None = None
class ModelNewBody(BaseModel):

View file

@ -19,6 +19,8 @@ from models import (
ChatMessage,
ChatResponse,
LiteLLMParamsBody,
ModelInfoBody,
ModelNewBody,
ReliabilityChatBody,
RouterSettingsOverride,
)
@ -26,6 +28,18 @@ from models import (
REAL_MODEL = "openai/gpt-5.5"
REAL_KEY = "os.environ/OPENAI_API_KEY"
# The smallest-context chat model OpenAI still serves (16385 tokens). A prompt
# past that limit comes back as a real `context_length_exceeded` 400, which is
# what litellm maps to ContextWindowExceededError.
SMALL_CONTEXT_MODEL = "openai/gpt-3.5-turbo"
SMALL_CONTEXT_LIMIT_TOKENS = 16385
def oversized_prompt(marker: str) -> str:
"""A prompt comfortably past SMALL_CONTEXT_MODEL's context limit, so the
provider refuses it on length rather than answering a truncated version."""
return f"{marker} " + ("token " * (SMALL_CONTEXT_LIMIT_TOKENS + 4000))
def create_bad_base_deployment(proxy: ProxyClient, name: str) -> str:
"""Register a deployment pointing at an unreachable base, so every call to it
@ -40,6 +54,38 @@ def create_timeout_deployment(proxy: ProxyClient, name: str) -> str:
return proxy.create_model(name, LiteLLMParamsBody(model=REAL_MODEL, api_key=REAL_KEY, timeout=0.001))
def create_small_context_deployment(proxy: ProxyClient, name: str) -> str:
"""Register a deployment on the smallest-context model OpenAI still serves, so an
oversized prompt earns a real context-window refusal from the provider."""
return proxy.create_model(name, LiteLLMParamsBody(model=SMALL_CONTEXT_MODEL, api_key=REAL_KEY))
def create_always_timing_out_deployment(proxy: ProxyClient, name: str) -> str:
"""The always-picked half of a retry pair: a 1ms deadline the backend always
exceeds, all of the model group's shuffle weight, and a cooldown policy that
benches it on its first Timeout so the retry cannot land on it again."""
return proxy.register_model(
ModelNewBody(
model_name=name,
litellm_params=LiteLLMParamsBody(model=REAL_MODEL, api_key=REAL_KEY, timeout=0.001, weight=1),
model_info=ModelInfoBody(allowed_fails_policy={"TimeoutErrorAllowedFails": 0}),
)
)
def create_zero_weight_backup_deployment(proxy: ProxyClient, name: str) -> str:
"""The other half of a retry pair: healthy, but weight 0, so the weighted shuffle
never opens on it. It is reachable only once its sibling is benched and the
weighted pick falls through to a uniform one over what is left."""
return proxy.register_model(
ModelNewBody(
model_name=name,
litellm_params=LiteLLMParamsBody(model=REAL_MODEL, api_key=REAL_KEY, weight=0),
model_info=ModelInfoBody(),
)
)
def chat_override(
proxy: ProxyClient,
key: str,

View file

@ -9,6 +9,10 @@ in the x-litellm-attempted-fallbacks header. Empty content is accepted only when
`finish_reason == "length"` and the response billed completion tokens, since
gpt-5.5 counts reasoning against max_tokens and can consume the whole budget
before emitting any text; a fallback that produced nothing at all still fails.
The context-window case is a different reroute from a plain failure: the provider
refuses the prompt on length, and `context_window_fallbacks` is the setting that
reroutes it, not `fallbacks`.
"""
from __future__ import annotations
@ -25,8 +29,10 @@ from reliability_support import (
completion_tokens_of,
content_of,
create_bad_base_deployment,
create_small_context_deployment,
create_timeout_deployment,
finish_reason_of,
oversized_prompt,
reasoning_tokens_of,
)
@ -82,3 +88,17 @@ class TestReliabilityFallbacks:
override=RouterSettingsOverride(fallbacks=[{primary: ["gpt-5.5"]}]),
)
_assert_served_by_fallback(resp)
@pytest.mark.covers("reliability.fallback.context_window.routes_to_fallback")
def test_context_window_routes_to_fallback(
self, client: ComplexityRouterClient, resources: ResourceManager, scoped_key: str
) -> None:
primary = f"reliability-ctxfail-{unique_marker()}"
model_id = create_small_context_deployment(client.proxy, primary)
resources.defer(lambda: client.proxy.delete_model(model_id))
resp = chat_override(
client.proxy, scoped_key, primary, oversized_prompt(unique_marker()),
override=RouterSettingsOverride(context_window_fallbacks=[{primary: ["gpt-5.5"]}]),
)
_assert_served_by_fallback(resp)

View file

@ -0,0 +1,73 @@
"""Live e2e: a request that fails on its first deployment is retried inside its own
model group and still comes back a completion.
The model group is a pair: an always-timing-out deployment that holds all of the
group's shuffle weight, and a healthy backup at weight 0. The weighted pick always
opens on the timing-out one, its first Timeout benches it (an
`allowed_fails_policy` of `TimeoutErrorAllowedFails: 0`), and the retry falls
through to the only deployment left. So the customer sees a completion and the
proxy reports that it took a retry to get there, with no random first pick in the
middle of it.
"""
from __future__ import annotations
import pytest
from complexity_router_client import ComplexityRouterClient
from e2e_config import unique_marker
from lifecycle import ResourceManager
from models import RouterSettingsOverride
from reliability_support import (
chat_override,
completion_tokens_of,
content_of,
create_always_timing_out_deployment,
create_zero_weight_backup_deployment,
finish_reason_of,
)
pytestmark = pytest.mark.e2e
class TestReliabilityRetries:
@pytest.mark.covers("reliability.retry.timeout.succeeds_within_retries")
def test_timeout_on_first_deployment_succeeds_on_retry(
self, client: ComplexityRouterClient, resources: ResourceManager, scoped_key: str
) -> None:
group = f"reliability-retry-{unique_marker()}"
timing_out = create_always_timing_out_deployment(client.proxy, group)
resources.defer(lambda: client.proxy.delete_model(timing_out))
backup = create_zero_weight_backup_deployment(client.proxy, group)
resources.defer(lambda: client.proxy.delete_model(backup))
resp = chat_override(
client.proxy,
scoped_key,
group,
f"say hi {unique_marker()}",
override=RouterSettingsOverride(num_retries=2),
)
assert resp.status_code == 200, (
f"the retry should have landed on the healthy backup, got {resp.status_code}: {resp.body[:300]}"
)
attempted = resp.headers.get("x-litellm-attempted-retries")
assert attempted is not None, "response is missing the x-litellm-attempted-retries header"
assert int(attempted) >= 1, (
f"x-litellm-attempted-retries is {attempted!r}; a 200 with no retry means the request never "
"opened on the timing-out deployment, so this proves nothing about retries"
)
content = content_of(resp)
finish_reason = finish_reason_of(resp)
completion_tokens = completion_tokens_of(resp) or 0
assert isinstance(content, str), (
f"the retry should have returned a completion body, got content {content!r} (body={resp.body[:300]})"
)
assert content or (finish_reason == "length" and completion_tokens > 0), (
f"the retry returned empty content with finish_reason={finish_reason!r}, "
f"completion_tokens={completion_tokens}; empty content is only acceptable when the budget "
f"was spent on non-visible reasoning (body={resp.body[:300]})"
)

View file

@ -0,0 +1,146 @@
"""Harness coverage for the custom JUnit properties.
No proxy and no ``e2e`` marker. Pins the two normalizations that have to agree
about where a suite file lives -- ``package_from_nodeid`` (strip the suite root)
and ``source_from_location`` (re-root at it) -- across both ways the suite is
launched, plus the one-based line offset and the refusal to emit a path that
escapes the suite. The consumers of these properties are the Loki/Grafana
rollups and, for ``source``, the status page's per-test links to GitHub.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from junit_properties import (
SUITE_ROOT,
attach_result_properties,
dedupe_covers,
package_from_nodeid,
result_properties,
source_from_location,
suite_parts,
)
class FakeMarker:
def __init__(self, name: str, *args: object) -> None:
self.name = name
self.args = args
class FakeItem:
"""The three attributes junit_properties reads off a pytest Item."""
def __init__(
self, nodeid: str, location: tuple[str, int | None, str], markers: tuple[FakeMarker, ...] = ()
) -> None:
self.nodeid = nodeid
self.location = location
self.user_properties: list[tuple[str, str]] = []
self._markers = markers
def iter_markers(self, name: str):
return (marker for marker in self._markers if marker.name == name)
def repo_root() -> Path | None:
"""The litellm checkout above this file, or None when there isn't one."""
return next((p for p in Path(__file__).resolve().parents if (p / ".git").exists()), None)
class TestSuiteParts:
@pytest.mark.parametrize(
"path",
["logging/test_x.py", "tests/e2e/logging/test_x.py", "./logging/test_x.py", "tests\\e2e\\logging\\test_x.py"],
)
def test_both_invocation_shapes_collapse_to_the_same_components(self, path: str) -> None:
"""A repo-root run and a suite-cwd run report the same file differently;
every downstream signal has to see one spelling."""
assert suite_parts(path) == ("logging", "test_x.py")
def test_top_level_suite_file_keeps_its_single_component(self) -> None:
assert suite_parts("tests/e2e/test_fixture_mode.py") == ("test_fixture_mode.py",)
class TestPackageFromNodeid:
@pytest.mark.parametrize(
("nodeid", "expected"),
[
("logging/test_x.py::TestFoo::test_bar", "logging"),
("tests/e2e/logging/test_x.py::TestFoo::test_bar", "logging"),
("quota_management/spend_tracking/test_x.py::test_bar", "quota_management"),
("test_fixture_mode.py::TestParseFixtureMode::test_known_values_normalize", "root"),
("tests/e2e/test_fixture_mode.py::test_bar", "root"),
],
)
def test_package_is_the_first_dir_under_the_suite_root(self, nodeid: str, expected: str) -> None:
assert package_from_nodeid(nodeid) == expected
class TestSourceFromLocation:
@pytest.mark.parametrize("path", ["a2a/test_a2a_agent_e2e.py", "tests/e2e/a2a/test_a2a_agent_e2e.py"])
def test_path_is_repo_relative_however_pytest_was_started(self, path: str) -> None:
assert source_from_location(path, 40) == "tests/e2e/a2a/test_a2a_agent_e2e.py:41"
def test_line_is_emitted_one_based(self) -> None:
"""pytest.Item.location counts from 0; editors, tracebacks and GitHub's
#L anchor all count from 1, and an off-by-one lands on the decorator."""
assert source_from_location("a2a/test_x.py", 0) == "tests/e2e/a2a/test_x.py:1"
def test_top_level_suite_file_sits_directly_under_the_suite_root(self) -> None:
assert source_from_location("test_fixture_mode.py", 39) == "tests/e2e/test_fixture_mode.py:40"
@pytest.mark.parametrize(
("path", "lineno"),
[
("a2a/test_x.py", None),
("/app/e2e/a2a/test_x.py", 40),
("C:\\app\\e2e\\a2a\\test_x.py", 40),
("../conftest.py", 40),
("", 40),
],
)
def test_nothing_linkable_yields_empty_rather_than_a_guess(self, path: str, lineno: int | None) -> None:
"""A colon is rejected on two counts: it is how a Windows absolute path
arrives, and `path:line` cannot represent one in the path half."""
assert source_from_location(path, lineno) == ""
class TestResultProperties:
def test_every_test_carries_package_covers_and_source(self) -> None:
item = FakeItem(
"logging/test_x.py::TestFoo::test_bar",
("logging/test_x.py", 40, "TestFoo.test_bar"),
(FakeMarker("covers", "LOG-1", "LOG-2"),),
)
assert result_properties(item) == (
("package", "logging"),
("covers", "LOG-1,LOG-2"),
("source", "tests/e2e/logging/test_x.py:41"),
)
def test_attach_is_idempotent(self) -> None:
"""Collection can run the hook more than once; a second pass must not
double the <property> entries in the report."""
item = FakeItem("logging/test_x.py::test_bar", ("logging/test_x.py", 40, "test_bar"))
attach_result_properties(item)
attach_result_properties(item)
assert [name for name, _ in item.user_properties] == ["package", "covers", "source"]
class TestSuiteRoot:
def test_suite_root_names_this_file_s_real_home(self) -> None:
"""SUITE_ROOT is hardcoded because the runner image has no repo to read it
from. Where there IS a checkout, prove the constant still points at us --
otherwise a moved tests/e2e/ ships links that 404."""
root = repo_root()
if root is None:
pytest.skip("no checkout above this file (the runner image copies tests/e2e/ to /app/e2e)")
assert (root / SUITE_ROOT / Path(__file__).name).resolve() == Path(__file__).resolve()
class TestDedupeCovers:
def test_ids_are_unique_order_preserving_and_non_empty_strings(self) -> None:
assert dedupe_covers([("A", "B"), ("B", ""), ("C", 7)]) == ("A", "B", "C")

View file

@ -0,0 +1,58 @@
"""Image-level check that the built proxy image can import the Bedrock realtime SDK.
Bedrock Nova Sonic (`/v1/realtime`) imports `aws_sdk_bedrock_runtime` lazily on the
first session, so an image whose `uv sync` stages skip the `bedrock-realtime` extra
boots, passes health checks, and then fails every Nova Sonic session with
"Missing aws_sdk_bedrock_runtime". Importing inside the built image is what catches
that class of regression (missing extra, lockfile drift, a stage that syncs a
different set of extras), which a static Dockerfile check cannot.
Gated on LITELLM_IMAGE like the other image checks in this directory; exercised
where an image has been built (the image-scan workflow). Requires a working docker CLI.
"""
import os
import shutil
import subprocess
from typing import Final
import pytest
IMAGE: Final = os.getenv("LITELLM_IMAGE")
NON_ROOT_UID: Final = "12345:0"
IMPORT_PROBE: Final = "import aws_sdk_bedrock_runtime, smithy_aws_core; print('bedrock-realtime ok')"
pytestmark = [
pytest.mark.skipif(IMAGE is None, reason="requires a built image (set LITELLM_IMAGE)"),
pytest.mark.skipif(shutil.which("docker") is None, reason="requires the docker CLI"),
]
def test_image_imports_bedrock_realtime_sdk():
assert IMAGE is not None
probe: Final = subprocess.run(
[
"docker",
"run",
"--rm",
"--network",
"none",
"--user",
NON_ROOT_UID,
"--entrypoint",
"python",
IMAGE,
"-c",
IMPORT_PROBE,
],
capture_output=True,
text=True,
check=False,
)
assert probe.returncode == 0 and "bedrock-realtime ok" in probe.stdout, (
f"{IMAGE} cannot import aws_sdk_bedrock_runtime as uid {NON_ROOT_UID}, so Bedrock Nova Sonic "
"/v1/realtime sessions fail with 'Missing aws_sdk_bedrock_runtime'. Is `--extra bedrock-realtime` "
f"passed to every `uv sync` in its Dockerfile?\nstdout:\n{probe.stdout}\nstderr:\n{probe.stderr}"
)

View file

@ -0,0 +1,402 @@
"""
Unit tests for safeguard-refusal fallback on the /v1/messages router surface.
An Anthropic safeguard refusal is an HTTP 200 whose body carries
stop_reason "refusal" plus a stop_details object; the router converts it
into a ContentPolicyViolationError so the content-policy fallback chain
runs, but only when a matching fallback is configured. A plain refusal
without stop_details, or any refusal with nothing configured, must reach
the client byte-identical.
The upstream is faked at the HTTP boundary by intercepting the third-party
transport (httpx.AsyncClient.send), so requests run litellm's real
transformation, allowlist, and streaming pipeline end to end.
"""
import json
from typing import Any, AsyncIterator
from unittest.mock import patch
import httpx
import pytest
from litellm import Router
from litellm.router_utils.fallback_event_handlers import (
PRE_ROUTING_SELECTED_MODEL_KEY,
record_pre_routing_selection,
)
REFUSAL_RESPONSE: dict[str, Any] = {
"id": "msg_refusal",
"type": "message",
"role": "assistant",
"model": "claude-fable-5",
"content": [],
"stop_reason": "refusal",
"stop_sequence": None,
"stop_details": {"category": "cyber", "explanation": "flagged"},
"usage": {"input_tokens": 25, "output_tokens": 1},
}
PLAIN_REFUSAL_RESPONSE: dict[str, Any] = {k: v for k, v in REFUSAL_RESPONSE.items() if k != "stop_details"}
OK_RESPONSE: dict[str, Any] = {
"id": "msg_ok",
"type": "message",
"role": "assistant",
"model": "claude-opus-5",
"content": [{"type": "text", "text": "hello"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 25, "output_tokens": 2},
}
def _sse(event: str, data: dict[str, Any]) -> bytes:
return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
REFUSAL_STREAM_FRAMES: tuple[bytes, ...] = (
_sse("message_start", {"type": "message_start", "message": {**REFUSAL_RESPONSE, "stop_reason": None}}),
_sse(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": "refusal", "stop_details": {"category": "cyber"}},
"usage": {"output_tokens": 1},
},
),
_sse("message_stop", {"type": "message_stop"}),
)
OK_STREAM_FRAMES: tuple[bytes, ...] = (
_sse("message_start", {"type": "message_start", "message": {**OK_RESPONSE, "stop_reason": None}}),
_sse(
"content_block_delta",
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello"}},
),
_sse("message_stop", {"type": "message_stop"}),
)
def _split_frames_mid_data_line(frames: tuple[bytes, ...]) -> tuple[bytes, ...]:
"""Split each frame's data line in half, modeling a transport chunk boundary."""
return tuple(part for frame in frames for part in (frame[: len(frame) // 2], frame[len(frame) // 2 :]))
class _FrameStream(httpx.AsyncByteStream):
def __init__(self, frames: tuple[bytes, ...]) -> None:
self._frames = frames
async def __aiter__(self) -> AsyncIterator[bytes]:
for frame in self._frames:
yield frame
async def aclose(self) -> None:
return None
class FakeAnthropicUpstream:
"""Intercepts the third-party transport (httpx.AsyncClient.send): refuses on fable
models, answers on others. The router deliberately does not forward caller-injected
clients, so the transport is the seam that exercises the real litellm pipeline."""
def __init__(
self,
refusal_body: dict[str, Any] = REFUSAL_RESPONSE,
refusal_frames: tuple[bytes, ...] = REFUSAL_STREAM_FRAMES,
) -> None:
self.refusal_body = refusal_body
self.refusal_frames = refusal_frames
self.calls: list[str] = []
self.bodies: list[dict[str, Any]] = []
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
body = json.loads(request.content or b"{}")
model = body.get("model", "")
self.calls.append(model)
self.bodies.append(body)
refuses = "fable" in model
if body.get("stream"):
frames = self.refusal_frames if refuses else OK_STREAM_FRAMES
return httpx.Response(
200,
stream=_FrameStream(frames),
headers={"content-type": "text/event-stream"},
request=request,
)
return httpx.Response(200, json=self.refusal_body if refuses else OK_RESPONSE, request=request)
def install(self):
async def _send(_client: httpx.AsyncClient, request: httpx.Request, **kwargs: Any) -> httpx.Response:
return await self.send(request, **kwargs)
return patch("httpx.AsyncClient.send", new=_send)
FABLE_TIER = {
"model_name": "fable-tier",
"litellm_params": {"model": "anthropic/claude-fable-5", "api_key": "sk-test"},
}
OPUS_TARGET = {
"model_name": "opus-target",
"litellm_params": {"model": "anthropic/claude-opus-5", "api_key": "sk-test"},
}
def _router(content_policy_fallbacks: list | None) -> Router:
return Router(model_list=[FABLE_TIER, OPUS_TARGET], content_policy_fallbacks=content_policy_fallbacks)
async def _collect(stream: AsyncIterator[bytes]) -> bytes:
return b"".join([chunk async for chunk in stream])
@pytest.mark.asyncio
async def test_non_streaming_refusal_with_fallback_row_returns_fallback_response():
fake = FakeAnthropicUpstream()
router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}])
with fake.install():
response = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, messages=[{"role": "user", "content": "hi"}]
)
assert response["stop_reason"] == "end_turn"
assert response["id"] == "msg_ok"
assert len(fake.calls) == 2
assert "claude-opus-5" in fake.calls[1]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"content_policy_fallbacks, upstream_body",
[
(None, REFUSAL_RESPONSE),
([{"unrelated-group": ["opus-target"]}], REFUSAL_RESPONSE),
([{"fable-tier": ["opus-target"]}], PLAIN_REFUSAL_RESPONSE),
],
ids=["nothing-configured", "row-for-other-group", "refusal-without-stop-details"],
)
async def test_non_streaming_refusal_passes_through_untouched(content_policy_fallbacks, upstream_body):
fake = FakeAnthropicUpstream(refusal_body=upstream_body)
router = _router(content_policy_fallbacks=content_policy_fallbacks)
with fake.install():
response = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, messages=[{"role": "user", "content": "hi"}]
)
assert response["stop_reason"] == "refusal"
assert response.get("stop_details") == upstream_body.get("stop_details")
assert len(fake.calls) == 1
@pytest.mark.asyncio
async def test_streaming_refusal_with_fallback_row_streams_fallback_frames():
fake = FakeAnthropicUpstream()
router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}])
with fake.install():
stream = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}]
)
body = await _collect(stream)
assert b'"refusal"' not in body
assert b"text_delta" in body
assert len(fake.calls) == 2
@pytest.mark.asyncio
async def test_streaming_refusal_split_across_chunks_still_falls_back():
fake = FakeAnthropicUpstream(refusal_frames=_split_frames_mid_data_line(REFUSAL_STREAM_FRAMES))
router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}])
with fake.install():
stream = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}]
)
body = await _collect(stream)
assert b'"refusal"' not in body
assert b"text_delta" in body
assert len(fake.calls) == 2
@pytest.mark.asyncio
async def test_streaming_refusal_without_fallback_row_passes_frames_through():
fake = FakeAnthropicUpstream()
router = _router(content_policy_fallbacks=None)
with fake.install():
stream = await router.aanthropic_messages(
model="fable-tier", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}]
)
body = await _collect(stream)
assert b'"stop_reason": "refusal"' in body
assert b"stop_details" in body
assert len(fake.calls) == 1
@pytest.mark.asyncio
async def test_streaming_refusal_on_routed_tier_matches_tier_keyed_row_without_inbound_metadata():
"""The pre-routing hook's tier stamp must reach the mid-stream fallback lookup even when the
request carries no metadata bucket at all (the snapshot is taken before the request runs)."""
fake = FakeAnthropicUpstream()
smart_router = {
"model_name": "smart-router",
"litellm_params": {
"model": "auto_router/complexity_router",
"complexity_router_config": {
"tiers": {"SIMPLE": "fable-tier", "MEDIUM": "fable-tier", "COMPLEX": "fable-tier"}
},
"complexity_router_default_model": "fable-tier",
},
"model_info": {"id": "router-1", "db_model": True},
}
router = Router(
model_list=[FABLE_TIER, OPUS_TARGET, smart_router],
content_policy_fallbacks=[{"fable-tier": ["opus-target"]}],
ignore_invalid_deployments=True,
)
with fake.install():
stream = await router.aanthropic_messages(
model="smart-router", max_tokens=16, stream=True, messages=[{"role": "user", "content": "hi"}]
)
body = await _collect(stream)
assert b'"refusal"' not in body
assert b"text_delta" in body
assert len(fake.calls) == 2
@pytest.mark.asyncio
async def test_caller_forged_tier_stamp_cannot_pick_the_streaming_fallback_chain():
fake = FakeAnthropicUpstream()
router = _router(content_policy_fallbacks=[{"forged-tier": ["opus-target"]}])
with fake.install():
stream = await router.aanthropic_messages(
model="fable-tier",
max_tokens=16,
stream=True,
messages=[{"role": "user", "content": "hi"}],
litellm_metadata={PRE_ROUTING_SELECTED_MODEL_KEY: "forged-tier"},
)
body = await _collect(stream)
assert b'"stop_reason": "refusal"' in body
assert len(fake.calls) == 1
@pytest.mark.asyncio
async def test_tier_stamp_never_reaches_provider_bound_metadata():
"""On /v1/messages the top-level metadata dict is Anthropic's own request field, so the
routed-tier stamp must never appear in any upstream body even when the client sends one."""
fake = FakeAnthropicUpstream()
smart_router = {
"model_name": "smart-router",
"litellm_params": {
"model": "auto_router/complexity_router",
"complexity_router_config": {
"tiers": {"SIMPLE": "fable-tier", "MEDIUM": "fable-tier", "COMPLEX": "fable-tier"}
},
"complexity_router_default_model": "fable-tier",
},
"model_info": {"id": "router-1", "db_model": True},
}
router = Router(
model_list=[FABLE_TIER, OPUS_TARGET, smart_router],
content_policy_fallbacks=[{"fable-tier": ["opus-target"]}],
ignore_invalid_deployments=True,
)
with fake.install():
response = await router.aanthropic_messages(
model="smart-router",
max_tokens=16,
messages=[{"role": "user", "content": "hi"}],
metadata={"user_id": "u1"},
)
assert response["stop_reason"] == "end_turn"
assert len(fake.bodies) == 2
for body in fake.bodies:
assert body.get("metadata") == {"user_id": "u1"}
def test_record_pre_routing_selection_writes_only_the_internal_bucket():
"""The Anthropic request's own metadata field must never carry the tier stamp."""
kwargs = {"metadata": {"user_id": "u1"}, "litellm_metadata": {}}
record_pre_routing_selection(kwargs, "tier-x")
assert kwargs["litellm_metadata"] == {PRE_ROUTING_SELECTED_MODEL_KEY: "tier-x"}
assert kwargs["metadata"] == {"user_id": "u1"}
def test_refusal_gate_keys_on_pre_routing_tier_stamp():
router = _router(content_policy_fallbacks=[{"tier-group": ["opus-target"]}])
def anthropic_messages(**kwargs: Any) -> None:
return None
refusal_kwargs = {"litellm_metadata": {PRE_ROUTING_SELECTED_MODEL_KEY: "tier-group"}}
assert (
router._should_raise_anthropic_refusal_error(
model="router-group",
original_generic_function=anthropic_messages,
response=dict(REFUSAL_RESPONSE),
kwargs=refusal_kwargs,
)
is True
)
assert (
router._should_raise_anthropic_refusal_error(
model="router-group",
original_generic_function=anthropic_messages,
response=dict(REFUSAL_RESPONSE),
kwargs={},
)
is False
)
def test_has_content_policy_fallback_default_fallbacks_arm():
router = Router(model_list=[OPUS_TARGET], fallbacks=[{"*": ["opus-target"]}])
assert router._has_content_policy_fallback("any-group", {}) is True
assert router._has_content_policy_fallback("any-group", {"content_policy_fallbacks": [{"other": ["x"]}]}) is False
def test_get_fallback_model_group_for_lookup_groups_orders_tier_before_requested():
router = _router(content_policy_fallbacks=None)
fallbacks = [{"tier1": ["backup-a"]}, {"smart-router": ["backup-b"]}]
assert router._get_fallback_model_group_for_lookup_groups(
fallbacks=fallbacks, lookup_groups=("tier1", "smart-router")
) == ["backup-a"]
assert router._get_fallback_model_group_for_lookup_groups(
fallbacks=fallbacks, lookup_groups=("tier9", "smart-router")
) == ["backup-b"]
assert router._get_fallback_model_group_for_lookup_groups(fallbacks=fallbacks, lookup_groups=()) is None
def test_refusal_gate_ignores_other_generic_call_types():
router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}])
def aresponses(**kwargs: Any) -> None:
return None
assert (
router._should_raise_anthropic_refusal_error(
model="fable-tier",
original_generic_function=aresponses,
response=dict(REFUSAL_RESPONSE),
kwargs={},
)
is False
)

View file

@ -0,0 +1,193 @@
import datetime
from typing import Final
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import ValidationError
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.integrations.SlackAlerting.user_spend_alerts import (
UserSpendRow,
evaluate_user_spend,
)
from litellm.types.integrations.slack_alerting import (
DEFAULT_ALERT_TYPES,
AlertType,
SlackAlertingArgs,
)
TODAY: Final = datetime.date(2026, 8, 15)
def _row(
daily_spend: float = 0.0,
monthly_spend: float = 0.0,
baseline_spend: float = 0.0,
) -> UserSpendRow:
return UserSpendRow(
user_id="user-1",
daily_spend=daily_spend,
monthly_spend=monthly_spend,
baseline_spend=baseline_spend,
)
def _evaluate(row: UserSpendRow, args: SlackAlertingArgs, thresholds: bool = True, anomalies: bool = True):
return evaluate_user_spend(
row=row,
args=args,
today=TODAY,
thresholds_enabled=thresholds,
anomalies_enabled=anomalies,
)
def test_daily_threshold_crossed():
args: Final = SlackAlertingArgs(daily_spend_per_user_threshold=50.0, spend_anomaly_min_spend=1000.0)
events: Final = _evaluate(_row(daily_spend=75.0, monthly_spend=75.0), args)
assert [e.kind for e in events] == ["daily_threshold"]
assert "`$75.00`" in events[0].message
assert "`$50.00`" in events[0].message
assert events[0].alert_type == AlertType.user_spend_thresholds
assert events[0].cache_key == "user_spend_alert_daily_user-1_2026-08-15"
def test_daily_threshold_not_crossed():
args: Final = SlackAlertingArgs(daily_spend_per_user_threshold=50.0, spend_anomaly_min_spend=1000.0)
assert _evaluate(_row(daily_spend=49.99, monthly_spend=49.99), args) == ()
def test_thresholds_unset_by_default():
args: Final = SlackAlertingArgs(spend_anomaly_min_spend=1000.0)
assert _evaluate(_row(daily_spend=999.0, monthly_spend=999.0), args) == ()
def test_monthly_threshold_crossed():
args: Final = SlackAlertingArgs(monthly_spend_per_user_threshold=200.0, spend_anomaly_min_spend=1000.0)
events: Final = _evaluate(_row(daily_spend=5.0, monthly_spend=250.0), args)
assert [e.kind for e in events] == ["monthly_threshold"]
assert events[0].cache_key == "user_spend_alert_monthly_user-1_2026-08"
def test_thresholds_disabled_suppresses_threshold_events():
args: Final = SlackAlertingArgs(
daily_spend_per_user_threshold=50.0,
monthly_spend_per_user_threshold=200.0,
spend_anomaly_min_spend=1000.0,
)
assert _evaluate(_row(daily_spend=75.0, monthly_spend=250.0), args, thresholds=False) == ()
def test_anomaly_detected_above_multiple_of_baseline():
args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0)
events: Final = _evaluate(
_row(daily_spend=70.0, monthly_spend=100.0, baseline_spend=70.0), args
)
assert [e.kind for e in events] == ["anomaly"]
assert events[0].alert_type == AlertType.user_spend_anomalies
assert "`$10.00`" in events[0].message
assert events[0].cache_key == "user_spend_alert_anomaly_user-1_2026-08-15"
def test_no_anomaly_within_baseline_multiple():
args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0)
assert (
_evaluate(_row(daily_spend=25.0, monthly_spend=100.0, baseline_spend=70.0), args) == ()
)
def test_no_anomaly_below_min_spend_floor():
args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0)
assert _evaluate(_row(daily_spend=9.0, monthly_spend=9.0, baseline_spend=0.1), args) == ()
def test_anomaly_for_new_user_without_baseline():
args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0)
events: Final = _evaluate(_row(daily_spend=15.0, monthly_spend=15.0), args)
assert [e.kind for e in events] == ["anomaly"]
def test_sparse_baseline_averages_over_full_window():
args: Final = SlackAlertingArgs(
spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0, spend_anomaly_baseline_days=7
)
events: Final = _evaluate(_row(daily_spend=13.0, monthly_spend=20.0, baseline_spend=7.0), args)
assert [e.kind for e in events] == ["anomaly"]
def test_anomalies_not_in_default_alert_types():
assert AlertType.user_spend_anomalies not in DEFAULT_ALERT_TYPES
assert AlertType.user_spend_thresholds in DEFAULT_ALERT_TYPES
def test_invalid_config_rejected():
with pytest.raises(ValidationError, match="daily_spend_per_user_threshold"):
SlackAlertingArgs(daily_spend_per_user_threshold=0)
with pytest.raises(ValidationError, match="spend_anomaly_baseline_days"):
SlackAlertingArgs(spend_anomaly_baseline_days=0)
with pytest.raises(ValidationError, match="user_spend_check_interval"):
SlackAlertingArgs(user_spend_check_interval=10)
def test_non_finite_config_rejected():
with pytest.raises(ValidationError, match="daily_spend_per_user_threshold"):
SlackAlertingArgs(daily_spend_per_user_threshold=float("inf"))
with pytest.raises(ValidationError, match="spend_anomaly_multiplier"):
SlackAlertingArgs(spend_anomaly_multiplier=float("nan"))
with pytest.raises(ValidationError, match="spend_anomaly_min_spend"):
SlackAlertingArgs(spend_anomaly_min_spend=float("inf"))
with pytest.raises(ValidationError, match="user_spend_check_interval"):
SlackAlertingArgs(user_spend_check_interval=float("inf"))
def test_anomalies_disabled_suppresses_anomaly_events():
args: Final = SlackAlertingArgs(spend_anomaly_multiplier=3.0, spend_anomaly_min_spend=10.0)
assert _evaluate(_row(daily_spend=500.0, monthly_spend=500.0), args, anomalies=False) == ()
@pytest.mark.asyncio
async def test_send_user_spend_alerts_sends_and_dedupes():
slack_alerting: Final = SlackAlerting(
alerting=["slack"],
alerting_args={"daily_spend_per_user_threshold": 50.0, "spend_anomaly_min_spend": 1000.0},
)
mock_prisma: Final = AsyncMock()
mock_prisma.db.query_raw = AsyncMock(
return_value=[
{
"user_id": "user-1",
"daily_spend": 75.0,
"monthly_spend": 75.0,
"baseline_spend": 0.0,
},
{
"user_id": "user-2",
"daily_spend": 60.0,
"monthly_spend": 60.0,
"baseline_spend": 0.0,
},
]
)
with patch.object(slack_alerting, "send_alert", new_callable=AsyncMock) as mock_send_alert:
await slack_alerting.send_user_spend_alerts(prisma_client=mock_prisma)
assert mock_send_alert.call_count == 1
sent_kwargs: Final = mock_send_alert.call_args.kwargs
assert sent_kwargs["alert_type"] == AlertType.user_spend_thresholds
assert "User Daily Spend Threshold Crossed" in sent_kwargs["message"]
assert "`user-1`" in sent_kwargs["message"]
assert "`user-2`" in sent_kwargs["message"]
await slack_alerting.send_user_spend_alerts(prisma_client=mock_prisma)
assert mock_send_alert.call_count == 1
@pytest.mark.asyncio
async def test_send_user_spend_alerts_noop_when_alert_types_disabled():
slack_alerting: Final = SlackAlerting(
alerting=["slack"],
alert_types=[AlertType.budget_alerts],
alerting_args={"daily_spend_per_user_threshold": 50.0},
)
mock_prisma: Final = AsyncMock()
await slack_alerting.send_user_spend_alerts(prisma_client=mock_prisma)
mock_prisma.db.query_raw.assert_not_called()

View file

@ -0,0 +1,469 @@
"""
Regression tests for the Datadog LLM Observability payload schema (issue #35786).
Datadog renders tool calls, tool results and prompt-cache savings only from the fields its
own schema names. These assert on the payload `create_llm_obs_payload` actually hands the
intake, so a regression that moves data back into `meta.metadata` fails here.
Fixtures mirror what a live proxy run recorded on the callback, including the provider
spelling of prompt-cache counts (`prompt_tokens_details.cached_tokens`).
"""
import json
import os
from datetime import datetime, timedelta
from typing import Any
from unittest.mock import patch
import pytest
from litellm.integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
TOOL_DEFINITION: dict[str, Any] = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
ASSISTANT_TOOL_CALL: dict[str, Any] = {
"id": "call_abc123",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city":"Paris","unit":"c"}'},
}
@pytest.fixture
def logger() -> DataDogLLMObsLogger:
with patch.dict(os.environ, {"DD_API_KEY": "k", "DD_SITE": "us5.datadoghq.com"}, clear=True):
with patch("asyncio.create_task"):
return DataDogLLMObsLogger()
NOT_GIVEN: Any = object()
def build_payload(
messages: Any = NOT_GIVEN,
response_message: dict[str, Any] | None = None,
usage_object: dict[str, Any] | None = None,
model_parameters: dict[str, Any] | None = None,
prompt_tokens: int = 4447,
) -> dict[str, Any]:
return {
"standard_logging_object": {
"call_type": "acompletion",
"messages": [{"role": "user", "content": "hi"}] if messages is NOT_GIVEN else messages,
"response": {"choices": [{"message": response_message or {"role": "assistant", "content": "hello"}}]},
"model_parameters": model_parameters or {},
"metadata": {"usage_object": usage_object} if usage_object is not None else {},
"prompt_tokens": prompt_tokens,
"completion_tokens": 507,
"total_tokens": prompt_tokens + 507,
"response_cost": 0.02,
"status": "success",
},
"litellm_params": {"metadata": {}},
}
def build(logger: DataDogLLMObsLogger, **kwargs: Any) -> dict[str, Any]:
"""Build a span and read it back as the JSON the intake receives, not as Python objects."""
start = datetime(2026, 9, 1, 12, 0, 0)
payload = logger.create_llm_obs_payload(build_payload(**kwargs), start, start + timedelta(seconds=2))
return json.loads(safe_dumps(payload))
def test_output_tool_calls_use_the_datadog_tool_call_schema(logger: DataDogLLMObsLogger) -> None:
"""Datadog reads name/arguments/tool_id off the tool call; OpenAI nests them under `function`."""
payload = build(
logger,
response_message={"role": "assistant", "content": None, "tool_calls": [ASSISTANT_TOOL_CALL]},
)
message = payload["meta"]["output"]["messages"][0]
assert message["tool_calls"] == [
{
"name": "get_weather",
"arguments": {"city": "Paris", "unit": "c"},
"tool_id": "call_abc123",
"type": "function",
}
]
assert "function" not in message["tool_calls"][0]
def test_tool_calls_are_not_duplicated_into_metadata(logger: DataDogLLMObsLogger) -> None:
"""The flat `output_tool_calls.*` keys were a second copy of a fact that now has its own field."""
payload = build(
logger,
response_message={"role": "assistant", "content": None, "tool_calls": [ASSISTANT_TOOL_CALL]},
)
assert [key for key in payload["meta"]["metadata"] if "tool_calls." in key] == []
def test_tool_result_message_links_back_to_its_tool_call(logger: DataDogLLMObsLogger) -> None:
"""Datadog pairs a result with its call through tool_id, and names the tool from the call."""
payload = build(
logger,
messages=[
{"role": "user", "content": "Weather in Paris?"},
{"role": "assistant", "content": None, "tool_calls": [ASSISTANT_TOOL_CALL]},
{"role": "tool", "tool_call_id": "call_abc123", "content": '{"temp_c": 18}'},
],
)
tool_message = payload["meta"]["input"]["messages"][2]
assert tool_message["tool_results"] == [
{"name": "get_weather", "result": '{"temp_c": 18}', "tool_id": "call_abc123", "type": "function"}
]
def test_tool_result_without_a_matching_call_still_reports_its_id(logger: DataDogLLMObsLogger) -> None:
"""A truncated conversation loses the call, so the name is unknown but the link must survive."""
payload = build(
logger,
messages=[{"role": "tool", "tool_call_id": "call_orphan", "content": "42"}],
)
assert payload["meta"]["input"]["messages"][0]["tool_results"] == [
{"name": "", "result": "42", "tool_id": "call_orphan", "type": "function"}
]
def test_cache_tokens_are_reported_as_span_metrics(logger: DataDogLLMObsLogger) -> None:
"""
Datadog charts cache savings from span metrics; nested usage_object is not read for it.
litellm's normalized prompt count includes both cache categories, so the three cache
metrics must partition input_tokens: read + write + non_cached == input.
"""
payload = build(
logger,
usage_object={"prompt_tokens_details": {"cached_tokens": 4300, "cache_write_tokens": 95}},
)
metrics = payload["metrics"]
assert metrics["cache_read_input_tokens"] == 4300.0
assert metrics["cache_write_input_tokens"] == 95.0
assert metrics["non_cached_input_tokens"] == 4447.0 - 4300.0 - 95.0
assert (
metrics["cache_read_input_tokens"] + metrics["cache_write_input_tokens"] + metrics["non_cached_input_tokens"]
== metrics["input_tokens"]
)
def test_cache_write_tokens_are_not_counted_as_non_cached(logger: DataDogLLMObsLogger) -> None:
"""A cache-priming request must not report its primed prefix as full-price uncached input."""
payload = build(logger, usage_object={"prompt_tokens_details": {"cache_write_tokens": 4000}})
assert payload["metrics"]["cache_write_input_tokens"] == 4000.0
assert payload["metrics"]["non_cached_input_tokens"] == 4447.0 - 4000.0
assert "cache_read_input_tokens" not in payload["metrics"]
def test_a_fully_cached_request_reports_a_zero_non_cached_count(logger: DataDogLLMObsLogger) -> None:
"""Zero residual is real data: everything was served from cache. Inconsistent counts clamp to it."""
payload = build(
logger,
usage_object={"prompt_tokens_details": {"cached_tokens": 4352, "cache_write_tokens": 95}},
)
assert payload["metrics"]["non_cached_input_tokens"] == 0.0
def test_anthropic_top_level_cache_keys_are_read(logger: DataDogLLMObsLogger) -> None:
"""A raw Anthropic usage dict records the counts top level, not under prompt_tokens_details."""
payload = build(
logger,
usage_object={"cache_read_input_tokens": 4300, "cache_creation_input_tokens": 95},
)
metrics = payload["metrics"]
assert metrics["cache_read_input_tokens"] == 4300.0
assert metrics["cache_write_input_tokens"] == 95.0
assert metrics["non_cached_input_tokens"] == 4447.0 - 4300.0 - 95.0
def test_cache_metrics_come_from_the_normalized_field_not_the_anthropic_one(logger: DataDogLLMObsLogger) -> None:
"""
litellm normalizes every provider's cache counters into prompt_tokens_details.
A real cached request from a non-Anthropic provider carries only `cached_tokens`, so
reading the Anthropic-specific `cache_read_input_tokens` key reports nothing for it.
"""
payload = build(
logger,
usage_object={"prompt_tokens_details": {"audio_tokens": None, "cached_tokens": 4096}},
prompt_tokens=4335,
)
assert payload["metrics"]["cache_read_input_tokens"] == 4096.0
assert payload["metrics"]["non_cached_input_tokens"] == 4335.0 - 4096.0
@pytest.mark.parametrize(
"usage_object",
[
{"prompt_tokens_details": {"cache_write_tokens": 95}},
{"prompt_tokens_details": {"cache_creation_tokens": 95}},
{"cache_creation_input_tokens": 95},
],
)
def test_every_spelling_of_cache_write_tokens_is_read(
logger: DataDogLLMObsLogger, usage_object: dict[str, Any]
) -> None:
"""A raw usage dict that bypassed litellm's normalizer can carry any provider's spelling."""
payload = build(logger, usage_object=usage_object)
assert payload["metrics"]["cache_write_input_tokens"] == 95.0
def test_a_cache_read_does_not_emit_a_zero_cache_write(logger: DataDogLLMObsLogger) -> None:
"""A zero write on every cache-read span would drag Datadog's cache-write average to nothing."""
payload = build(logger, usage_object={"prompt_tokens_details": {"cached_tokens": 4096}})
assert payload["metrics"]["cache_read_input_tokens"] == 4096.0
assert "cache_write_input_tokens" not in payload["metrics"]
def test_no_cache_keys_when_the_provider_reports_no_caching(logger: DataDogLLMObsLogger) -> None:
"""An uncached request must not gain zero-valued cache metrics that dilute cache dashboards."""
payload = build(logger, usage_object={"prompt_tokens_details": None})
assert "cache_read_input_tokens" not in payload["metrics"]
assert "cache_write_input_tokens" not in payload["metrics"]
assert "non_cached_input_tokens" not in payload["metrics"]
def test_tool_definitions_are_sent_on_meta(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, model_parameters={"tools": [TOOL_DEFINITION]})
assert payload["meta"]["tool_definitions"] == [
{
"name": "get_weather",
"description": "Get current weather for a city",
"schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
}
]
def test_tool_definitions_accept_the_bare_anthropic_shape(logger: DataDogLLMObsLogger) -> None:
"""The Anthropic surface declares tools unwrapped, with input_schema instead of parameters."""
payload = build(
logger,
model_parameters={"tools": [{"name": "get_weather", "description": "d", "input_schema": {"type": "object"}}]},
)
assert payload["meta"]["tool_definitions"] == [
{"name": "get_weather", "description": "d", "schema": {"type": "object"}}
]
def test_meta_omits_tool_definitions_when_no_tools_were_offered(logger: DataDogLLMObsLogger) -> None:
assert "tool_definitions" not in build(logger)["meta"]
def test_unparseable_tool_arguments_are_preserved_rather_than_dropped(logger: DataDogLLMObsLogger) -> None:
"""A truncated argument string is still the only record of what the model tried to call."""
payload = build(
logger,
response_message={
"role": "assistant",
"content": None,
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": '{"city":'}}],
},
)
assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == '{"city":'
def test_oversized_tool_arguments_ship_unparsed(logger: DataDogLLMObsLogger) -> None:
"""
Decoding attacker-sized compact JSON multiplies memory for a span that is only logging.
This payload is perfectly valid JSON, so the only reason it arrives as a string is the
size bound; a smaller copy of the same shape comes back as an object below.
"""
oversized = '{"a":"' + "x" * 300_000 + '"}'
payload = build(
logger,
response_message={
"role": "assistant",
"content": None,
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": oversized}}],
},
)
assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == oversized
def test_valid_arguments_below_the_bound_still_parse(logger: DataDogLLMObsLogger) -> None:
"""The size bound must not swallow ordinary arguments; this is the oversized test's control."""
payload = build(
logger,
response_message={
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "f", "arguments": '{"a":"' + "x" * 64 + '"}'}}
],
},
)
assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == {"a": "x" * 64}
def test_a_result_is_named_even_when_its_call_had_unparseable_arguments(logger: DataDogLLMObsLogger) -> None:
"""Correlating a result to its call reads ids and names, so bad arguments cannot break linking."""
payload = build(
logger,
messages=[
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_abc123", "type": "function", "function": {"name": "get_weather", "arguments": "{"}}
],
},
{"role": "tool", "tool_call_id": "call_abc123", "content": "18C"},
],
)
assert payload["meta"]["input"]["messages"][1]["tool_results"] == [
{"name": "get_weather", "result": "18C", "tool_id": "call_abc123", "type": "function"}
]
def test_deeply_nested_tool_arguments_do_not_drop_the_span(logger: DataDogLLMObsLogger) -> None:
"""json.loads raises RecursionError, not JSONDecodeError, on hostile nesting."""
payload = build(
logger,
response_message={
"role": "assistant",
"content": None,
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "[" * 50_000}}],
},
)
assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == "[" * 50_000
def test_tool_arguments_that_parse_to_a_non_object_stay_a_string(logger: DataDogLLMObsLogger) -> None:
"""Datadog types arguments as an object, so a bare JSON scalar must not land there as one."""
payload = build(
logger,
response_message={
"role": "assistant",
"content": None,
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "42"}}],
},
)
assert payload["meta"]["output"]["messages"][0]["tool_calls"][0]["arguments"] == "42"
def test_a_tool_without_a_name_is_not_offered_as_a_definition(logger: DataDogLLMObsLogger) -> None:
"""A nameless tool cannot be matched to a call, so it is dropped rather than sent blank."""
payload = build(logger, model_parameters={"tools": [{"function": {"description": "no name"}}, TOOL_DEFINITION]})
assert [tool["name"] for tool in payload["meta"]["tool_definitions"]] == ["get_weather"]
def test_a_tool_definition_without_a_schema_omits_the_field(logger: DataDogLLMObsLogger) -> None:
"""An empty schema object would read as a tool that takes no arguments, which is a different claim."""
payload = build(logger, model_parameters={"tools": [{"name": "ping", "description": "d"}]})
assert payload["meta"]["tool_definitions"] == [{"name": "ping", "description": "d"}]
def test_a_non_dict_message_still_reaches_datadog(logger: DataDogLLMObsLogger) -> None:
"""Callers can log arbitrary message payloads, and dropping the span over one loses the request."""
payload = build(logger, messages=["just a bare string"])
assert payload["meta"]["input"]["messages"] == [{"input": "just a bare string"}]
def test_messages_logged_as_a_bare_string_still_reach_datadog(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, messages="the whole prompt as one string")
assert payload["meta"]["input"]["messages"] == [{"input": "the whole prompt as one string"}]
def test_non_chat_call_types_log_an_empty_input(logger: DataDogLLMObsLogger) -> None:
"""Embedding and image calls carry no messages; fabricating an "None" turn misreads in Datadog."""
payload = build(logger, messages=None)
assert payload["meta"]["input"]["messages"] == []
def test_anthropic_tool_blocks_map_to_tool_calls_and_results(logger: DataDogLLMObsLogger) -> None:
"""/v1/messages carries tool traffic as content blocks, not OpenAI fields."""
payload = build(
logger,
messages=[
{"role": "user", "content": [{"type": "text", "text": "Weather in Tokyo?"}]},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"city": "Tokyo"}}],
},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "18C"}]},
],
)
assistant, result_turn = payload["meta"]["input"]["messages"][1:3]
assert assistant["tool_calls"] == [
{"name": "get_weather", "arguments": {"city": "Tokyo"}, "tool_id": "toolu_1", "type": "tool_use"}
]
assert result_turn["tool_results"] == [
{"name": "get_weather", "result": "18C", "tool_id": "toolu_1", "type": "function"}
]
def test_content_with_no_text_parts_is_preserved_not_blanked(logger: DataDogLLMObsLogger) -> None:
"""A content list the mapper does not understand must ride along, not be erased."""
blocks = [{"type": "image_url", "image_url": {"url": "https://example.com/x.png"}}]
payload = build(logger, messages=[{"role": "user", "content": blocks}])
assert payload["meta"]["input"]["messages"][0]["content"] == blocks
def test_multimodal_content_parts_are_flattened_to_text(logger: DataDogLLMObsLogger) -> None:
"""Datadog types Message.content as a string, so content lists collapse to their text."""
payload = build(
logger,
messages=[
{"role": "user", "content": [{"type": "text", "text": "describe "}, {"type": "text", "text": "this"}]}
],
)
assert payload["meta"]["input"]["messages"][0]["content"] == "describe this"
def test_mapping_input_messages_does_not_mutate_the_shared_payload(logger: DataDogLLMObsLogger) -> None:
"""Sibling callbacks read the same messages list, so flattening must not write through it."""
messages: list[dict[str, Any]] = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
kwargs = build_payload(messages=messages)
start = datetime(2026, 9, 1, 12, 0, 0)
logger.create_llm_obs_payload(kwargs, start, start + timedelta(seconds=1))
assert messages[0]["content"] == [{"type": "text", "text": "hi"}]
def test_reasoning_content_survives_the_mapping(logger: DataDogLLMObsLogger) -> None:
payload = build(
logger,
response_message={"role": "assistant", "content": "answer", "reasoning_content": "thinking"},
)
assert payload["meta"]["output"]["messages"][0]["reasoning_content"] == "thinking"

View file

@ -541,6 +541,74 @@ def test_llm_call_adapter_extracts_cache_tokens_from_usage_object():
assert data.usage.cache_read_input_tokens == 3
def test_llm_call_adapter_normalizes_nested_cache_tokens():
cases: Final = (
({"prompt_tokens_details": {"cached_tokens": 3}}, 3, None),
({"prompt_cache_hit_tokens": 11}, 11, None),
({"prompt_tokens_details": {"cache_write_tokens": 7}}, None, 7),
({"prompt_tokens_details": {"cache_creation_tokens": 13}}, None, 13),
({"prompt_tokens_details": {"cache_creation_input_tokens": 17}}, None, 17),
)
for usage_object, expected_read, expected_creation in cases:
case_payload = _sample_payload(metadata={"usage_object": usage_object})
data = LLMCallSpanData.from_standard_logging_payload(case_payload)
assert data.usage.cache_read_input_tokens == expected_read
assert data.usage.cache_creation_input_tokens == expected_creation
def test_llm_call_adapter_prefers_nested_count_over_zero_top_level():
payload = _sample_payload(
metadata={
"usage_object": {
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
"prompt_tokens_details": {"cached_tokens": 5, "cache_write_tokens": 7},
}
}
)
data = LLMCallSpanData.from_standard_logging_payload(payload)
assert data.usage.cache_read_input_tokens == 5
assert data.usage.cache_creation_input_tokens == 7
def test_llm_call_adapter_ignores_invalid_cache_values_before_valid_fallbacks():
payload = _sample_payload(
metadata={
"usage_object": {
"cache_read_input_tokens": -1,
"cache_creation_input_tokens": "5.0",
"prompt_tokens_details": {"cached_tokens": 5, "cache_write_tokens": 7},
}
}
)
data = LLMCallSpanData.from_standard_logging_payload(payload)
assert data.usage.cache_read_input_tokens == 5
assert data.usage.cache_creation_input_tokens == 7
def test_llm_call_adapter_ignores_non_finite_cache_values():
payload = _sample_payload(
metadata={
"usage_object": {
"prompt_tokens_details": {"cached_tokens": float("nan")},
}
}
)
data = LLMCallSpanData.from_standard_logging_payload(payload)
assert data.usage.cache_read_input_tokens is None
def test_llm_call_adapter_preserves_explicit_zero_and_omits_missing_cache_tokens():
for usage_object, expected_read, expected_creation in (
({"prompt_tokens_details": {"cached_tokens": 0}}, 0, None),
({}, None, None),
):
case_payload = _sample_payload(metadata={"usage_object": usage_object})
data = LLMCallSpanData.from_standard_logging_payload(case_payload)
assert data.usage.cache_read_input_tokens == expected_read
assert data.usage.cache_creation_input_tokens == expected_creation
def test_llm_call_adapter_cache_tokens_none_without_usage_object():
data = LLMCallSpanData.from_standard_logging_payload(_sample_payload())
assert data.usage.cache_creation_input_tokens is None

View file

@ -93,6 +93,7 @@ async def test_async_post_call_success_hook_includes_client_ip_user_agent():
logger._increment_token_metrics = MagicMock()
logger._increment_remaining_budget_metrics = AsyncMock()
logger._set_virtual_key_rate_limit_metrics = MagicMock()
logger._set_key_and_team_rate_limit_metrics = MagicMock()
logger._set_latency_metrics = MagicMock()
logger.set_llm_deployment_success_metrics = MagicMock()
logger._increment_cache_metrics = MagicMock()

View file

@ -13,6 +13,7 @@ Covers two follow-up gaps to the unified rate-limit error work:
429s don't silently break when the new class lands.
"""
from collections.abc import Mapping
from unittest.mock import MagicMock, patch
import pytest
@ -471,3 +472,254 @@ def test_should_ignore_non_int_v3_header_values(bad_value):
logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with(
sys.maxsize
)
KEY_AND_TEAM_RATE_LIMIT_METRICS = (
"litellm_api_key_rate_limit_allowed_metric",
"litellm_api_key_rate_limit_used_metric",
"litellm_team_rate_limit_allowed_metric",
"litellm_team_rate_limit_used_metric",
)
def _clear_prometheus_registry() -> None:
from prometheus_client import REGISTRY
for collector in list(REGISTRY._collector_to_names.keys()):
try:
REGISTRY.unregister(collector)
except Exception:
pass
def _collected_samples(metric_name: str) -> dict[tuple[tuple[str, str], ...], float]:
from prometheus_client import REGISTRY
return {
tuple(sorted(sample.labels.items())): sample.value
for metric in REGISTRY.collect()
for sample in metric.samples
if sample.name == metric_name
}
def _success_kwargs_with_rate_limit_headers(additional_headers: Mapping[str, object] | None) -> dict[str, object]:
return {
"model": "claude-haiku-4-5",
"litellm_params": {"metadata": {}},
"standard_logging_object": {
"id": "t",
"call_type": "completion",
"response_cost": 0.001,
"status": "success",
"total_tokens": 20,
"prompt_tokens": 15,
"completion_tokens": 5,
"startTime": 1.0,
"endTime": 2.0,
"completionStartTime": 1.5,
"model": "claude-haiku-4-5",
"model_id": "model-123",
"model_group": "anthropic-haiku-4-5",
"api_base": "https://api.anthropic.com",
"custom_llm_provider": "anthropic",
"request_tags": [],
"end_user": None,
"cache_hit": False,
"stream": False,
"response": None,
"model_parameters": None,
"metadata": {
"user_api_key_hash": "key-hash",
"user_api_key_alias": "key-alias",
"user_api_key_team_id": "team-id",
"user_api_key_team_alias": "team-alias",
"user_api_key_user_id": "u",
"user_api_key_user_email": "e@x.com",
"user_api_key_org_id": None,
"user_api_key_org_alias": None,
"requester_metadata": None,
"user_api_key_end_user_id": None,
"usage_object": None,
},
"hidden_params": {
"litellm_overhead_time_ms": None,
"additional_headers": additional_headers,
},
},
}
async def _run_success_event(
additional_headers: Mapping[str, object] | None, logger: PrometheusLogger | None = None
) -> None:
import datetime
now = datetime.datetime.now()
await (logger or PrometheusLogger()).async_log_success_event(
_success_kwargs_with_rate_limit_headers(additional_headers), None, now, now
)
@pytest.mark.asyncio
async def test_should_emit_key_and_team_rate_limit_allowed_and_used_from_v3_headers():
"""
LIT-1672: the v3 limiter mirrors ``x-ratelimit-{api_key,team}-{limit,remaining}-*``
into the logging payload. The gauges must expose the configured limit as-is
and the window consumption as ``limit - remaining`` for each key / team
dimension, split by ``rate_limit_type``.
"""
_clear_prometheus_registry()
try:
await _run_success_event(
{
"x-ratelimit-api_key-limit-requests": 10,
"x-ratelimit-api_key-remaining-requests": 7,
"x-ratelimit-api_key-limit-tokens": 20000,
"x-ratelimit-api_key-remaining-tokens": 19947,
"x-ratelimit-team-limit-requests": 50,
"x-ratelimit-team-remaining-requests": 47,
"x-ratelimit-team-limit-tokens": 40000,
"x-ratelimit-team-remaining-tokens": 39960,
"x-ratelimit-model_per_key-limit-requests": 5,
"x-ratelimit-model_per_key-remaining-requests": 1,
}
)
key_requests = (
("api_key_alias", "key-alias"),
("hashed_api_key", "key-hash"),
("rate_limit_type", "requests"),
)
key_tokens = (
("api_key_alias", "key-alias"),
("hashed_api_key", "key-hash"),
("rate_limit_type", "tokens"),
)
team_requests = (
("rate_limit_type", "requests"),
("team", "team-id"),
("team_alias", "team-alias"),
)
team_tokens = (
("rate_limit_type", "tokens"),
("team", "team-id"),
("team_alias", "team-alias"),
)
assert _collected_samples("litellm_api_key_rate_limit_allowed_metric") == {
key_requests: 10,
key_tokens: 20000,
}
assert _collected_samples("litellm_api_key_rate_limit_used_metric") == {
key_requests: 3,
key_tokens: 53,
}
assert _collected_samples("litellm_team_rate_limit_allowed_metric") == {
team_requests: 50,
team_tokens: 40000,
}
assert _collected_samples("litellm_team_rate_limit_used_metric") == {
team_requests: 3,
team_tokens: 40,
}
finally:
_clear_prometheus_registry()
@pytest.mark.asyncio
async def test_should_emit_only_the_dimensions_the_limiter_enforced():
"""
A key with only ``rpm_limit`` set and no team limits produces only the
key/requests headers, so no tokens series and no team series may appear
(a phantom 0 or sys.maxsize series would misreport an unlimited dimension).
"""
_clear_prometheus_registry()
try:
await _run_success_event(
{
"x-ratelimit-api_key-limit-requests": 10,
"x-ratelimit-api_key-remaining-requests": 10,
}
)
key_requests = (
("api_key_alias", "key-alias"),
("hashed_api_key", "key-hash"),
("rate_limit_type", "requests"),
)
assert _collected_samples("litellm_api_key_rate_limit_allowed_metric") == {key_requests: 10}
assert _collected_samples("litellm_api_key_rate_limit_used_metric") == {key_requests: 0}
assert _collected_samples("litellm_team_rate_limit_allowed_metric") == {}
assert _collected_samples("litellm_team_rate_limit_used_metric") == {}
finally:
_clear_prometheus_registry()
@pytest.mark.asyncio
async def test_should_drop_key_and_team_series_once_the_limiter_stops_reporting_a_limit():
"""
Removing a key's ``rpm_limit`` / ``tpm_limit`` (or a team's ``tpm_limit``)
makes the v3 limiter stop emitting that descriptor's headers on later
requests. The old allowed/used samples must disappear instead of keeping
a limit that no longer exists on the scrape.
"""
_clear_prometheus_registry()
try:
logger = PrometheusLogger()
await _run_success_event(
{
"x-ratelimit-api_key-limit-requests": 10,
"x-ratelimit-api_key-remaining-requests": 7,
"x-ratelimit-api_key-limit-tokens": 20000,
"x-ratelimit-api_key-remaining-tokens": 19947,
"x-ratelimit-team-limit-requests": 50,
"x-ratelimit-team-remaining-requests": 47,
"x-ratelimit-team-limit-tokens": 40000,
"x-ratelimit-team-remaining-tokens": 39960,
},
logger=logger,
)
await _run_success_event(
{
"x-ratelimit-team-limit-requests": 50,
"x-ratelimit-team-remaining-requests": 46,
},
logger=logger,
)
team_requests = (
("rate_limit_type", "requests"),
("team", "team-id"),
("team_alias", "team-alias"),
)
assert _collected_samples("litellm_api_key_rate_limit_allowed_metric") == {}
assert _collected_samples("litellm_api_key_rate_limit_used_metric") == {}
assert _collected_samples("litellm_team_rate_limit_allowed_metric") == {team_requests: 50}
assert _collected_samples("litellm_team_rate_limit_used_metric") == {team_requests: 4}
finally:
_clear_prometheus_registry()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"additional_headers",
[
None,
{"x-ratelimit-model_per_key-remaining-requests": 42},
{"x-ratelimit-api_key-limit-requests": 10},
{"x-ratelimit-api_key-limit-requests": "10", "x-ratelimit-api_key-remaining-requests": "7"},
{"x-ratelimit-team-limit-tokens": True, "x-ratelimit-team-remaining-tokens": 5},
],
)
async def test_should_emit_no_key_or_team_rate_limit_series_without_a_complete_int_pair(
additional_headers,
):
_clear_prometheus_registry()
try:
await _run_success_event(additional_headers)
for metric_name in KEY_AND_TEAM_RATE_LIMIT_METRICS:
assert _collected_samples(metric_name) == {}, metric_name
finally:
_clear_prometheus_registry()

View file

@ -2932,6 +2932,28 @@ def test_add_cache_point_tool_block_passes_ttl_for_claude_4_5(monkeypatch):
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
def test_add_cache_point_tool_block_stands_down_for_model_without_prompt_caching(monkeypatch):
"""A tool carrying cache_control must not become a cachePoint for a Bedrock model
whose cost-map entry lacks prompt caching support, since Bedrock rejects the whole
request. An unmapped id keeps emitting so ARN deployments do not lose caching."""
from litellm.litellm_core_utils.prompt_templates.factory import (
add_cache_point_tool_block,
)
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
tool = {"cache_control": {"type": "ephemeral"}}
assert add_cache_point_tool_block(tool, model="nvidia.nemotron-super-3-120b") is None
assert add_cache_point_tool_block(tool, model="us.nvidia.nemotron-super-3-120b") is None
assert add_cache_point_tool_block(
tool, model="arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123"
) == {"cachePoint": {"type": "default"}}
assert add_cache_point_tool_block(tool, model="us.anthropic.claude-sonnet-4-5-20250929-v1:0") == {
"cachePoint": {"type": "default"}
}
def test_bedrock_tools_pt_passes_ttl_for_claude_4_5(monkeypatch):
"""
End-to-end: _bedrock_tools_pt should produce cachePoint blocks with ttl

View file

@ -256,14 +256,12 @@ def test_get_model_cost_map_stamps_loaded_at(monkeypatch):
from litellm.litellm_core_utils import get_model_cost_map as module
monkeypatch.setattr(module._cost_map_source_info, "loaded_at", None)
monkeypatch.setattr(
module.GetModelCostMap,
"fetch_remote_model_cost_map",
staticmethod(lambda url, timeout=5: _load_root_cost_map()),
client, _calls = _mock_client(
[httpx.Response(200, content=_real_map_bytes())], client_cls=httpx.Client
)
before = datetime.now(timezone.utc)
module.get_model_cost_map(url="https://example.invalid/cost_map.json")
module.get_model_cost_map(url="https://example.invalid/cost_map.json", client=client)
loaded_at = module.get_model_cost_map_loaded_at()
assert loaded_at is not None
@ -308,7 +306,7 @@ def _unset_local_cost_map_env(monkeypatch):
monkeypatch.delenv("LITELLM_LOCAL_MODEL_COST_MAP", raising=False)
def _mock_client(outcomes):
def _mock_client(outcomes, client_cls=httpx.AsyncClient):
"""httpx client over a MockTransport serving one outcome per request; an exception instance is raised."""
calls = {"count": 0}
@ -320,7 +318,7 @@ def _mock_client(outcomes):
raise outcome
return outcome
return httpx.AsyncClient(transport=httpx.MockTransport(handler)), calls
return client_cls(transport=httpx.MockTransport(handler)), calls
@pytest.mark.asyncio
@ -450,3 +448,97 @@ async def test_refetch_respects_local_env_override(monkeypatch):
)
assert isinstance(result, ModelCostMapReloaded)
assert len(result.model_cost_map) > 100
# ---------------------------------------------------------------------------
# get_model_cost_map: the boot-time load retries transient failures like a reload does
# ---------------------------------------------------------------------------
from litellm.litellm_core_utils.get_model_cost_map import (
get_model_cost_map,
get_model_cost_map_source_info,
)
class _SyncSleepRecorder:
"""Injected in place of time.sleep so the boot path's waits are asserted without delay."""
def __init__(self):
self.waits = []
def __call__(self, seconds: float) -> None:
self.waits.append(seconds)
def test_boot_load_retries_transient_failures_instead_of_falling_back():
"""A refused connection then a 503 at pod boot used to pin the process to the bundled
backup for its lifetime; both are transient and must be retried before giving up."""
client, calls = _mock_client(
[
httpx.ConnectError("connection refused"),
httpx.Response(503),
httpx.Response(200, content=_real_map_bytes()),
],
client_cls=httpx.Client,
)
sleeper = _SyncSleepRecorder()
cost_map = get_model_cost_map(url=_URL, sleep=sleeper, rng=random.Random(0), client=client)
assert calls["count"] == 3
assert len(sleeper.waits) == 2
assert 2.0 <= sleeper.waits[0] < 3.0
assert 4.0 <= sleeper.waits[1] < 5.0
source = get_model_cost_map_source_info()
assert source["source"] == "remote"
assert source["fallback_reason"] is None
assert cost_map.keys() >= _load_root_cost_map().keys() - {"sample_spec", FALLBACK_GENERALIZATIONS_KEY}
def test_boot_load_honors_retry_after_then_falls_back_after_max_attempts():
"""An outage longer than the retry budget still ends on the bundled backup, and the
recorded fallback reason says how many attempts were spent so operators can tell."""
client, calls = _mock_client(
[httpx.Response(429, headers={"Retry-After": "7"})], client_cls=httpx.Client
)
sleeper = _SyncSleepRecorder()
cost_map = get_model_cost_map(url=_URL, sleep=sleeper, rng=random.Random(0), client=client)
assert calls["count"] == 3
assert sleeper.waits == [7.0, 7.0]
source = get_model_cost_map_source_info()
assert source["source"] == "local"
assert "after 3 attempts" in source["fallback_reason"]
assert len(cost_map) > 100
def test_boot_load_does_not_retry_permanent_failures():
"""A 404 or a malformed URL cannot heal by waiting: one attempt, no sleeps, backup."""
client, calls = _mock_client([httpx.Response(404)], client_cls=httpx.Client)
sleeper = _SyncSleepRecorder()
get_model_cost_map(url=_URL, sleep=sleeper, rng=random.Random(0), client=client)
assert calls["count"] == 1
assert sleeper.waits == []
assert get_model_cost_map_source_info()["source"] == "local"
get_model_cost_map(url="not a url", sleep=sleeper, rng=random.Random(0))
assert sleeper.waits == []
assert get_model_cost_map_source_info()["source"] == "local"
def test_boot_load_respects_local_env_override(monkeypatch):
"""LITELLM_LOCAL_MODEL_COST_MAP=True still short-circuits to the backup with zero HTTP."""
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
def _fail(request):
raise AssertionError("no HTTP request should be made when local map is forced")
cost_map = get_model_cost_map(
url=_URL,
sleep=_SyncSleepRecorder(),
client=httpx.Client(transport=httpx.MockTransport(_fail)),
)
assert len(cost_map) > 100
assert get_model_cost_map_source_info()["is_env_forced"] is True

View file

@ -5479,6 +5479,97 @@ def test_pre_call_redacts_and_masks_raw_request(logging_obj):
assert "key=*****" in raw_api_base
def _streaming_logging_obj_with_callbacks(callbacks: list[CustomLogger]):
import datetime
obj = LitellmLogging(
model="anthropic/claude-opus-5",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="completion",
start_time=datetime.datetime.now(),
litellm_call_id="slot-leak-test",
function_id="slot-leak-test",
)
obj.model_call_details["litellm_params"] = {"metadata": {}}
return patch.object(obj, "get_combined_callback_list", return_value=callbacks), obj
def _assembled_stream_result():
response = ModelResponse()
response.choices[0].message.content = "hello"
return response
@pytest.mark.asyncio
async def test_streaming_success_callbacks_survive_logging_hook_failure():
"""Regression for leaked max_parallel_requests slots: a raising
async_logging_hook must not abort the success-callback loop that
releases the rate-limiter slot."""
broken = CustomLogger()
broken.async_logging_hook = AsyncMock(side_effect=RuntimeError("broken stream payload"))
releasing = CustomLogger()
releasing.async_log_success_event = AsyncMock()
patcher, logging_obj = _streaming_logging_obj_with_callbacks([broken, releasing])
with patcher:
await logging_obj.async_success_handler(result=_assembled_stream_result())
releasing.async_log_success_event.assert_awaited_once()
@pytest.mark.asyncio
async def test_streaming_success_callbacks_survive_cost_calculation_failure():
releasing = CustomLogger()
releasing.async_log_success_event = AsyncMock()
patcher, logging_obj = _streaming_logging_obj_with_callbacks([releasing])
with patcher, patch.object(
logging_obj, "_response_cost_calculator", side_effect=ValueError("bad usage block")
):
await logging_obj.async_success_handler(result=_assembled_stream_result())
assert logging_obj.model_call_details["response_cost"] is None
releasing.async_log_success_event.assert_awaited_once()
@pytest.mark.asyncio
async def test_streaming_success_callbacks_survive_standard_logging_payload_failure():
releasing = CustomLogger()
releasing.async_log_success_event = AsyncMock()
patcher, logging_obj = _streaming_logging_obj_with_callbacks([releasing])
with patcher, patch.object(
logging_obj, "_build_standard_logging_payload", side_effect=ValueError("incomplete stream")
):
await logging_obj.async_success_handler(result=_assembled_stream_result())
assert logging_obj.model_call_details.get("standard_logging_object") is None
releasing.async_log_success_event.assert_awaited_once()
@pytest.mark.asyncio
async def test_streaming_success_callbacks_survive_guardrail_logging_hook_failure():
from litellm.integrations.custom_guardrail import CustomGuardrail
skipping = CustomGuardrail(guardrail_name="skipping-guardrail")
skipping.should_run_guardrail = MagicMock(return_value=False)
skipping.async_logging_hook = AsyncMock()
raising = CustomGuardrail(guardrail_name="raising-guardrail")
raising.should_run_guardrail = MagicMock(return_value=True)
raising.async_logging_hook = AsyncMock(side_effect=RuntimeError("guardrail hook failed"))
releasing = CustomLogger()
releasing.async_log_success_event = AsyncMock()
patcher, logging_obj = _streaming_logging_obj_with_callbacks([skipping, raising, releasing])
with patcher:
await logging_obj.async_success_handler(result=_assembled_stream_result())
skipping.async_logging_hook.assert_not_awaited()
raising.async_logging_hook.assert_awaited_once()
releasing.async_log_success_event.assert_awaited_once()
def _resolve(custom_llm_provider, litellm_params, optional_params, model):
from litellm.litellm_core_utils.litellm_logging import (
_resolve_vertex_location_for_cost,

View file

@ -3,11 +3,13 @@ import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import litellm
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.llms.anthropic.chat.handler import ModelResponseIterator, make_call
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.types.llms.openai import (
ChatCompletionToolCallChunk,
ChatCompletionToolCallFunctionChunk,
@ -46,6 +48,43 @@ async def test_make_call_passes_logging_obj_to_client_post():
assert call_kwargs.get("logging_obj") is logging_obj
def test_anthropic_completion_does_not_send_deployment_default_limits():
captured_requests: list[httpx.Request] = []
def respond(request: httpx.Request) -> httpx.Response:
captured_requests.append(request)
return httpx.Response(
200,
json={
"id": "msg_default_limits",
"type": "message",
"role": "assistant",
"model": "claude-3-5-haiku-20241022",
"content": [{"type": "text", "text": "Hello"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 1, "output_tokens": 1},
},
)
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond)))
try:
litellm.completion(
model="anthropic/claude-3-5-haiku-20241022",
messages=[{"role": "user", "content": "Hello"}],
api_key="test-key",
client=client,
default_api_key_rpm_limit=60,
default_api_key_tpm_limit=5000000,
)
finally:
client.close()
request_body = json.loads(captured_requests[0].content)
assert "default_api_key_rpm_limit" not in request_body
assert "default_api_key_tpm_limit" not in request_body
def test_redacted_thinking_content_block_delta():
chunk = {
"type": "content_block_start",

View file

@ -5248,6 +5248,84 @@ def test_cache_control_injection_tool_config_drops_ttl_for_unsupported_model():
assert tools[-1] == {"cachePoint": {"type": "default"}}
@pytest.mark.parametrize(
("model", "expects_cache_points"),
[
pytest.param("nvidia.nemotron-super-3-120b", False, id="mapped-model-without-prompt-caching"),
pytest.param("us.nvidia.nemotron-super-3-120b", False, id="regional-prefix-resolves-through-base-model"),
pytest.param(
"us.anthropic.claude-3-5-sonnet-20240620-v1:0", False, id="claude-named-but-not-caching-on-bedrock"
),
pytest.param("us.anthropic.claude-sonnet-4-5-20250929-v1:0", True, id="mapped-model-with-prompt-caching"),
pytest.param(
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123",
True,
id="unmapped-arn-keeps-emitting",
),
],
)
def test_cache_points_emitted_only_for_models_that_support_prompt_caching(model, expects_cache_points, monkeypatch):
"""Bedrock rejects cachePoint blocks for models without prompt caching support
("You invoked an unsupported model or your request did not allow prompt caching"),
and clients like Claude Code attach cache_control to every request, so a map-known
model without the capability must not receive them. Unmapped ids (application
inference profile ARNs, models newer than the map) keep emitting so existing
caching setups never silently degrade."""
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
body = AmazonConverseConfig().transform_request(
model=model,
messages=[
{"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]},
{"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}]},
],
optional_params={},
litellm_params={},
headers={},
)
assert ("cachePoint" in json.dumps(body)) is expects_cache_points
assert body["system"][0]["text"] == "sys"
assert body["messages"][0]["content"][0]["text"] == "hi"
def test_tool_config_cachepoint_not_placed_or_credited_for_model_without_prompt_caching(monkeypatch):
"""The tool_config injection point must stand down with the rest of the cachePoint
emission when the model cannot cache, and spend attribution must not credit the
gateway for a breakpoint that was never placed."""
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
bucket: dict = {"user_api_key": "sk-test"}
data = AmazonConverseConfig()._transform_request_helper(
model="nvidia.nemotron-super-3-120b",
system_content_blocks=[],
optional_params={
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
},
}
],
"cache_control_injection_points": [{"location": "tool_config"}],
},
messages=[{"role": "user", "content": "hi"}],
litellm_params={"metadata": bucket, "litellm_metadata": None, "model_info": {"id": "dep-bedrock"}},
)
assert "cachePoint" not in json.dumps(data.get("toolConfig", {}))
assert "litellm_gateway_injected_cache" not in bucket
def test_translate_response_format_json_schema_still_injects_tool():
"""
response_format with an explicit json_schema should still use the
@ -6211,7 +6289,7 @@ def test_message_level_cache_control_drops_ttl_for_unsupported_model(ttl_target)
result = _bedrock_converse_messages_pt(
messages=_agentic_messages_with_ttl(ttl_target),
model="anthropic.claude-3-5-sonnet-20240620-v1:0",
model="anthropic.claude-3-5-sonnet-20241022-v2:0",
llm_provider="bedrock_converse",
)

View file

@ -1,7 +1,11 @@
import asyncio
import concurrent.futures
import socket
import sys
from typing import Final
import aiohttp
import aiohttp.abc
import aiohttp.client_exceptions
import aiohttp.http_exceptions
import httpx
@ -1140,3 +1144,55 @@ async def test_stopped_loop_session_disposed_synchronously_on_recycle():
finally:
await new_session.close()
result["loop"].close()
class _CancellingResolver(aiohttp.abc.AbstractResolver):
"""Cancels the given task (or, by default, aiohttp's shielded DNS child task) mid-lookup."""
def __init__(self, task_to_cancel: "asyncio.Task[object] | None" = None):
self._task_to_cancel: Final = task_to_cancel
async def resolve(
self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET
) -> list[aiohttp.abc.ResolveResult]:
target: Final = self._task_to_cancel or asyncio.current_task()
assert target is not None
target.cancel()
await asyncio.sleep(0)
raise OSError("resolver finished after the task was cancelled")
async def close(self) -> None:
return None
@pytest.mark.asyncio
@pytest.mark.skipif(
sys.version_info < (3, 11), reason="Task.cancelling() is needed to tell the two cancellations apart"
)
async def test_internal_dns_cancellation_maps_to_connect_error():
"""A CancelledError the request task never asked for must surface as a mapped httpx transport error."""
session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(resolver=_CancellingResolver()))
transport = LiteLLMAiohttpTransport(client=session)
try:
with pytest.raises(httpx.ConnectError):
await transport.handle_async_request(httpx.Request("GET", "http://example.invalid/"))
current = asyncio.current_task()
assert current is not None and current.cancelling() == 0
finally:
await transport.aclose()
@pytest.mark.asyncio
async def test_genuine_request_cancellation_still_propagates():
"""Cancelling the request task itself (client disconnect, shutdown) must still propagate unmapped."""
current = asyncio.current_task()
assert current is not None
session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(resolver=_CancellingResolver(current)))
transport = LiteLLMAiohttpTransport(client=session)
try:
with pytest.raises(asyncio.CancelledError):
await transport.handle_async_request(httpx.Request("GET", "http://example.invalid/"))
finally:
if sys.version_info >= (3, 11):
current.uncancel()
await transport.aclose()

View file

@ -1431,7 +1431,11 @@ class TestListToolsRestAPI:
async def test_aggregate_list_absorbs_one_server_auth_failure(self, monkeypatch):
"""The multi-server aggregate listing degrades a server whose upstream
rejects auth to an empty contribution and still returns the healthy
server's tools with a 200, rather than surfacing a 401."""
server's tools with a 200, rather than surfacing a 401. The absorbed
server must still show up as a classified per-server outcome so a REST
caller can tell "needs upstream auth" apart from "has no tools"."""
from pydantic import TypeAdapter
from litellm.proxy._experimental.mcp_server.exceptions import (
MCPUpstreamAuthError,
)
@ -1497,6 +1501,11 @@ class TestListToolsRestAPI:
assert result["tools"] == ["good-tool"]
assert result["error"] is None
wire_body = json.loads(TypeAdapter(dict).dump_json(result))
assert wire_body["server_outcomes"] == {
"good": {"status": "ok", "tool_count": 1},
"bad": {"status": "auth_required", "http_status": 401},
}
async def test_name_resolution_finds_server_by_uuid(self, monkeypatch):
"""When server_id is a name string, it should be resolved to its UUID

View file

@ -0,0 +1,288 @@
"""
Tests for restamping the public model on Anthropic Messages streaming chunks.
"""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from litellm.proxy.anthropic_endpoints.streaming_model_restamp import (
AnthropicStreamModelRestamper,
restamp_anthropic_stream_chunk_model,
)
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
def _message_start_frame(model: str, line_end: str = "\n") -> bytes:
payload = {
"type": "message_start",
"message": {"id": "msg_1", "type": "message", "role": "assistant", "model": model, "content": []},
}
return f"event: message_start{line_end}data: {json.dumps(payload)}{line_end}{line_end}".encode()
def _proxy_logging_obj_streaming(frames: list[bytes]) -> MagicMock:
async def _iterator_hook(**_kwargs):
for frame in frames:
yield frame
proxy_logging_obj = MagicMock()
proxy_logging_obj.async_post_call_streaming_iterator_hook = _iterator_hook
proxy_logging_obj.async_post_call_streaming_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["response"])
return proxy_logging_obj
def _model_from_frame(frame: bytes | str) -> str:
text = frame.decode("utf-8") if isinstance(frame, bytes) else frame
data_line = next(line for line in text.split("\n") if line.startswith("data:"))
return json.loads(data_line[len("data:") :])["message"]["model"]
def test_restamps_sse_bytes_frame():
restamped = restamp_anthropic_stream_chunk_model(
_message_start_frame("claude-haiku-4-5-20251001"), "claude-auto-1"
)
assert isinstance(restamped, bytes)
assert _model_from_frame(restamped) == "claude-auto-1"
assert b"event: message_start" in restamped
def test_restamps_event_dict():
chunk = {"type": "message_start", "message": {"id": "msg_1", "model": "claude-sonnet-4-6"}}
restamped = restamp_anthropic_stream_chunk_model(chunk, "claude-auto-2")
assert restamped == {"type": "message_start", "message": {"id": "msg_1", "model": "claude-auto-2"}}
assert chunk["message"]["model"] == "claude-sonnet-4-6"
@pytest.mark.parametrize(
"chunk",
[
b'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n',
{"type": "content_block_delta", "delta": {"text": "hi"}},
{"type": "message_start", "message": "not-a-dict"},
b"event: message_start\ndata: not-json\n\n",
b"data: [DONE]\n\n",
],
)
def test_leaves_chunks_without_a_model_untouched(chunk):
assert restamp_anthropic_stream_chunk_model(chunk, "claude-auto-1") == chunk
@pytest.mark.asyncio
async def test_sse_generator_publishes_requested_model_on_message_start():
"""The message_start event reports the requested model, not the provider's."""
delta_frame = b'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n'
proxy_logging_obj = _proxy_logging_obj_streaming([_message_start_frame("claude-haiku-4-5-20251001"), delta_frame])
chunks = [
chunk
async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator(
response=MagicMock(),
user_api_key_dict=MagicMock(),
request_data={"model": "claude-auto-1"},
proxy_logging_obj=proxy_logging_obj,
restamp_model="claude-auto-1",
)
]
assert _model_from_frame(chunks[0]) == "claude-auto-1"
assert chunks[1] == delta_frame
@pytest.mark.asyncio
async def test_sse_generator_keeps_provider_model_when_restamping_is_off():
proxy_logging_obj = _proxy_logging_obj_streaming([_message_start_frame("claude-haiku-4-5-20251001")])
chunks = [
chunk
async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator(
response=MagicMock(),
user_api_key_dict=MagicMock(),
request_data={"model": "claude-auto-1"},
proxy_logging_obj=proxy_logging_obj,
)
]
assert _model_from_frame(chunks[0]) == "claude-haiku-4-5-20251001"
def test_restamps_message_start_split_across_transport_chunks():
frame = _message_start_frame("claude-haiku-4-5-20251001")
restamper = AnthropicStreamModelRestamper("claude-auto-1")
held = restamper.process(frame[:25])
emitted = restamper.process(frame[25:])
assert held == b""
assert isinstance(emitted, bytes)
assert _model_from_frame(emitted) == "claude-auto-1"
def test_emits_coalesced_frames_with_only_message_start_rewritten():
delta = b'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n'
combined = _message_start_frame("claude-haiku-4-5-20251001") + delta
emitted = restamper_output = AnthropicStreamModelRestamper("claude-auto-1").process(combined)
assert isinstance(restamper_output, bytes)
assert _model_from_frame(emitted) == "claude-auto-1"
assert emitted.endswith(delta)
def test_ping_frames_keep_the_restamper_armed():
ping = b'event: ping\ndata: {"type": "ping"}\n\n'
frame = _message_start_frame("claude-haiku-4-5-20251001")
restamper = AnthropicStreamModelRestamper("claude-auto-1")
assert restamper.process(ping) == ping
reassembled = restamper.process(frame[:10])
reassembled += restamper.process(frame[10:])
assert _model_from_frame(reassembled) == "claude-auto-1"
def test_first_non_ping_event_disarms_the_restamper():
delta = b'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n'
late_message_start = _message_start_frame("claude-haiku-4-5-20251001")
restamper = AnthropicStreamModelRestamper("claude-auto-1")
assert restamper.process(delta) == delta
assert restamper.process(late_message_start) == late_message_start
def test_oversized_unterminated_chunk_flushes_unmodified():
blob = b"data: " + b"x" * 70000
restamper = AnthropicStreamModelRestamper("claude-auto-1")
assert restamper.process(blob) == blob
frame = _message_start_frame("claude-haiku-4-5-20251001")
assert restamper.process(frame) == frame
def test_dict_message_start_disarms_after_restamp():
restamper = AnthropicStreamModelRestamper("claude-auto-1")
first = restamper.process({"type": "message_start", "message": {"id": "msg_1", "model": "claude-sonnet-4-6"}})
second = {"type": "message_start", "message": {"id": "msg_2", "model": "claude-sonnet-4-6"}}
assert first == {"type": "message_start", "message": {"id": "msg_1", "model": "claude-auto-1"}}
assert restamper.process(second) == second
@pytest.mark.asyncio
async def test_sse_generator_restamps_message_start_split_across_chunks():
frame = _message_start_frame("claude-haiku-4-5-20251001")
proxy_logging_obj = _proxy_logging_obj_streaming([frame[:30], frame[30:]])
chunks = [
chunk
async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator(
response=MagicMock(),
user_api_key_dict=MagicMock(),
request_data={"model": "claude-auto-1"},
proxy_logging_obj=proxy_logging_obj,
restamp_model="claude-auto-1",
)
]
joined = b"".join(chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") for chunk in chunks)
assert _model_from_frame(joined) == "claude-auto-1"
def test_restamps_crlf_terminated_message_start_frame():
frame = _message_start_frame("claude-haiku-4-5-20251001", line_end="\r\n")
delta = b'event: content_block_delta\r\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\r\n\r\n'
restamper = AnthropicStreamModelRestamper("claude-auto-1")
emitted = restamper.process(frame)
assert isinstance(emitted, bytes)
assert _model_from_frame(emitted) == "claude-auto-1"
assert emitted.endswith(b"\r\n\r\n")
assert restamper.process(delta) == delta
def test_restamps_cr_terminated_message_start_frame():
frame = _message_start_frame("claude-haiku-4-5-20251001", line_end="\r")
restamper = AnthropicStreamModelRestamper("claude-auto-1")
emitted = restamper.process(frame)
assert isinstance(emitted, bytes)
assert b'"model":"claude-auto-1"' in emitted
assert emitted.endswith(b"\r\r")
def test_restamps_crlf_message_start_split_across_transport_chunks():
frame = _message_start_frame("claude-haiku-4-5-20251001", line_end="\r\n")
restamper = AnthropicStreamModelRestamper("claude-auto-1")
held = restamper.process(frame[:25])
emitted = restamper.process(frame[25:])
assert held == b""
assert isinstance(emitted, bytes)
assert _model_from_frame(emitted) == "claude-auto-1"
def test_flush_returns_restamped_held_tail():
unterminated = _message_start_frame("claude-haiku-4-5-20251001")[:-2]
restamper = AnthropicStreamModelRestamper("claude-auto-1")
assert restamper.process(unterminated) == b""
flushed = restamper.flush()
assert b'"model":"claude-auto-1"' in flushed
assert restamper.flush() == b""
def test_flush_disarms_the_restamper():
restamper = AnthropicStreamModelRestamper("claude-auto-1")
frame = _message_start_frame("claude-haiku-4-5-20251001")
assert restamper.flush() == b""
assert restamper.process(frame) == frame
@pytest.mark.asyncio
async def test_sse_generator_flushes_held_tail_at_end_of_stream():
unterminated = _message_start_frame("claude-haiku-4-5-20251001")[:-2]
proxy_logging_obj = _proxy_logging_obj_streaming([unterminated])
chunks = [
chunk
async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator(
response=MagicMock(),
user_api_key_dict=MagicMock(),
request_data={"model": "claude-auto-1"},
proxy_logging_obj=proxy_logging_obj,
restamp_model="claude-auto-1",
)
]
joined = b"".join(chunk if isinstance(chunk, bytes) else chunk.encode("utf-8") for chunk in chunks)
assert b'"model":"claude-auto-1"' in joined
@pytest.mark.asyncio
async def test_sse_generator_restamps_crlf_stream():
frame = _message_start_frame("claude-haiku-4-5-20251001", line_end="\r\n")
delta = b'event: content_block_delta\r\ndata: {"type":"content_block_delta","delta":{"text":"hi"}}\r\n\r\n'
proxy_logging_obj = _proxy_logging_obj_streaming([frame, delta])
chunks = [
chunk
async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator(
response=MagicMock(),
user_api_key_dict=MagicMock(),
request_data={"model": "claude-auto-1"},
proxy_logging_obj=proxy_logging_obj,
restamp_model="claude-auto-1",
)
]
assert _model_from_frame(chunks[0]) == "claude-auto-1"
assert chunks[1] == delta

View file

@ -26,6 +26,64 @@ def _owners(*backup_paths):
CLAUDE_SETTINGS_MODULE = "litellm.proxy.client.cli.commands.claude_settings"
AUTH_MODULE = "litellm.proxy.client.cli.commands.auth"
WINDOWS_LITE_EXE = "C:\\Users\\u\\AppData\\Local\\Programs\\Python\\Python313\\Scripts\\lite.EXE"
CMD_METACHARACTERS = frozenset("&|<>^()")
CMD_PERCENT_GUARD = "%%cd:~,%"
def _through_cmd_exe(command):
"""The line cmd.exe hands to CreateProcess after reading the apiKeyHelper.
A `"` toggles cmd's quote state and the metacharacters only act outside it. cmd expands
`%VAR%` even inside quotes, so every `%` has to arrive as the `%%cd:~,%` guard: the first
`%` has no variable name and stays literal, and `%cd:~,%` is a zero length substring of `cd`.
"""
assert not any(CMD_METACHARACTERS & set(run) for run in command.split('"')[::2]), command
assert command.count("%") == 3 * command.count(CMD_PERCENT_GUARD), command
return command.replace(CMD_PERCENT_GUARD, "%")
def _through_c_runtime(command_line):
"""argv as the Microsoft C runtime builds it for the `lite` executable.
Outside quotes whitespace ends an argument. A `"` toggles quoting, and inside quotes `""`
is a literal quote. Backslashes are literal unless they run up to a `"`, where each pair
is one backslash and an odd one left over makes the quote literal.
"""
argv = []
current = None
quoted = False
i = 0
while i < len(command_line):
ch = command_line[i]
if ch in " \t" and not quoted:
if current is not None:
argv.append(current)
current = None
i += 1
continue
if current is None:
current = ""
if ch == "\\":
run = len(command_line[i:]) - len(command_line[i:].lstrip("\\"))
before_quote = command_line[i + run : i + run + 1] == '"'
current += "\\" * (run // 2 if before_quote else run)
if before_quote and run % 2:
current += '"'
i += 1
i += run
elif ch == '"':
if quoted and command_line[i + 1 : i + 2] == '"':
current += '"'
i += 1
else:
quoted = not quoted
i += 1
else:
current += ch
i += 1
return argv if current is None else [*argv, current]
@pytest.fixture
@ -199,6 +257,36 @@ class TestApiKeyHelperIsActuallyInvocable:
assert "Not authenticated for this server" in result.output
def _windows_argv(self, lite_exe, base_url):
with patch(f"{CLAUDE_SETTINGS_MODULE}.shutil.which", return_value=lite_exe):
helper = resolve_api_key_helper(base_url, platform="win32")
return _through_c_runtime(_through_cmd_exe(helper))
@pytest.mark.parametrize(
("lite_exe", "base_url"),
[
(WINDOWS_LITE_EXE, "http://localhost:4000"),
("C:\\Program Files\\LiteLLM\\lite.EXE", "https://gateway.example.com/?a=1&b=2"),
("C:\\Users\\u\\Scripts\\lite.EXE", "https://gateway.example.com/team%20a/%7Eproxy"),
('C:\\odd "dir"\\lite.EXE', "http://localhost:4000/x\\"),
],
)
def test_the_windows_command_survives_cmd_exe_and_the_c_runtime(self, lite_exe, base_url):
assert self._windows_argv(lite_exe, base_url) == [lite_exe, "--base-url", base_url, "auth", "print-token"]
def test_the_windows_command_carries_the_base_url_through_cmd_quoting(self):
stale = CliTokenRecord(
base_url="http://other-proxy.example.com",
key="sk-stale",
timestamp=time.time(),
)
argv = self._windows_argv(WINDOWS_LITE_EXE, "http://localhost:4000")
with patch(f"{AUTH_MODULE}.load_cli_token", return_value=stale):
result = CliRunner().invoke(cli, argv[1:])
assert argv[0] == WINDOWS_LITE_EXE
assert "Not authenticated for this server" in result.output
class TestConflictingOwnersOfTheSettingsFile:
"""Both `lite up` and `lite autoroute up` restore a backup when they stop.

View file

@ -225,6 +225,32 @@ class TestResolveApiKeyHelper:
with pytest.raises(ClaudeSettingsError, match="Could not find `lite`"):
resolve_api_key_helper("http://localhost:4000")
def test_windows_quotes_for_cmd_exe_instead_of_posix_sh(self, monkeypatch):
"""cmd.exe takes a single quote literally, so a POSIX-quoted backslashed path is unrunnable."""
lite_exe = "C:\\Users\\u\\AppData\\Local\\Programs\\Python\\Python313\\Scripts\\lite.EXE"
monkeypatch.setattr(shutil, "which", lambda name: lite_exe)
helper = resolve_api_key_helper("https://gateway.example.com", platform="win32")
assert helper == f'"{lite_exe}" "--base-url" "https://gateway.example.com" "auth" "print-token"'
def test_windows_keeps_a_spaced_path_and_a_metacharacter_url_as_single_tokens(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda name: "C:\\Program Files\\LiteLLM\\lite.EXE")
helper = resolve_api_key_helper("https://gateway.example.com/?a=1&b=2", platform="win32")
assert helper == (
'"C:\\Program Files\\LiteLLM\\lite.EXE" "--base-url" "https://gateway.example.com/?a=1&b=2" '
'"auth" "print-token"'
)
def test_non_windows_platforms_keep_posix_quoting(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/lite")
helper = resolve_api_key_helper("http://example.com/path; rm -rf /", platform="darwin")
assert helper == "/usr/local/bin/lite --base-url 'http://example.com/path; rm -rf /' auth print-token"
def _make_ctx(base_url):
return click.Context(click.Command("test"), obj={"base_url": base_url})

View file

@ -1,7 +1,8 @@
import logging
import time
from collections.abc import Mapping
from collections.abc import Callable, Mapping, Sequence
from itertools import chain
from types import MappingProxyType
from typing import Final
from unittest.mock import AsyncMock, MagicMock, call
@ -38,6 +39,7 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import (
get_groups,
get_users,
get_service_provider_config,
merge_placeholder,
patch_group,
patch_team_membership,
patch_user,
@ -52,6 +54,7 @@ from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIMMember,
SCIMPatchOp,
SCIMPatchOperation,
SCIMPlaceholderMergeResult,
SCIMServiceProviderConfig,
SCIMUser,
SCIMUserEmail,
@ -778,13 +781,17 @@ async def test_handle_existing_user_by_email_without_teams_preserves_memberships
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=None),
)
mock_team_member_add = mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add",
AsyncMock(),
mock_team_member_add = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add",
AsyncMock(),
)
)
mock_team_member_delete = mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
AsyncMock(),
mock_team_member_delete = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
AsyncMock(),
)
)
new_user_request = NewUserRequest(
@ -1645,6 +1652,25 @@ async def test_update_group_e2e(mocker):
ScimTransformations.transform_litellm_team_to_scim_group.assert_called_once_with(updated_team)
def _rows_by_exact_id(
user_row: Callable[[Mapping[str, str]], LiteLLM_UserTable | MagicMock | None],
) -> Callable[..., tuple[LiteLLM_UserTable | MagicMock, ...]]:
"""``find_many`` stand-in for the classifier's cross-field read on a table where a
member value only ever matches as an exact ``user_id``."""
def rows(where: Mapping[str, object], take: int | None = None) -> tuple[LiteLLM_UserTable | MagicMock, ...]:
clauses: Final = where["OR"]
assert isinstance(clauses, list)
found: Final = tuple(user_row(clause) for clause in clauses if "user_id" in clause)
return tuple(row for row in found if row is not None)
return rows
def _user_row_for(where: Mapping[str, str]) -> LiteLLM_UserTable:
return LiteLLM_UserTable(user_id=where["user_id"])
@pytest.mark.asyncio
async def test_create_group_with_nonexistent_users_rejects(mocker, monkeypatch):
"""
@ -1696,9 +1722,8 @@ async def test_create_group_with_nonexistent_users_rejects(mocker, monkeypatch):
return mock_user
return None # new-user-1 and new-user-2 don't exist
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup))
# Mock dependencies
mocker.patch(
@ -1782,9 +1807,8 @@ async def test_update_group_with_nonexistent_users_rejects(mocker, monkeypatch):
return mock_user
return None # new-user-3 and new-user-4 don't exist
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup))
# Mock dependencies
mocker.patch(
@ -1853,9 +1877,8 @@ async def test_create_group_with_nonexistent_users_creates_when_flag_true(mocker
return mock_user
return None # new-user-1 and new-user-2 don't exist
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup))
# Mock user creation
created_user_1 = NewUserResponse(user_id="new-user-1", key="test-key-1")
@ -1943,9 +1966,8 @@ async def test_extract_group_member_ids_with_flag_true_creates_users(mocker, mon
return mock_user
return None # new-user-1 doesn't exist
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup))
# Mock user creation
created_user = NewUserResponse(user_id="new-user-1", key="test-key-1")
@ -2013,9 +2035,8 @@ async def test_extract_group_member_ids_with_flag_false_rejects(mocker, monkeypa
return mock_user
return None # new-user-1 doesn't exist
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(mock_user_lookup))
# Mock dependencies
mocker.patch(
@ -3121,8 +3142,7 @@ async def test_process_group_patch_operations_add_retains_existing_members(mocke
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
# new-user already exists in the DB
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock(user_id="new-user"))
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=())
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=(mocker.MagicMock(user_id="new-user"),))
_, final_members, _ = await _process_group_patch_operations(
patch_ops=patch_ops,
@ -3415,8 +3435,7 @@ async def test_patch_group_add_applies_delta_and_keeps_concurrent_add(mocker):
)
mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=final_team)
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=())
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(_user_row_for))
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@ -3509,8 +3528,7 @@ async def test_patch_group_replace_stays_absolute_against_concurrent_roster(mock
)
mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=final_team)
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=())
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(_user_row_for))
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@ -3640,8 +3658,7 @@ async def test_process_group_patch_add_filtered_path_without_value(mocker):
prisma_client = mocker.MagicMock()
prisma_client.db = mocker.MagicMock()
prisma_client.db.litellm_usertable = mocker.MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=LiteLLM_UserTable(user_id="user-3"))
prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=())
prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=(LiteLLM_UserTable(user_id="user-3"),))
_, final_members, _ = await _process_group_patch_operations(
patch_ops=patch_ops,
@ -3733,12 +3750,14 @@ def _member_resolution_prisma(
starts folding it, fails here instead of passing.
A caller that must know which accounts match rather than merely how many
passes take=None, so an unbounded read returns every match.
passes take=None, so an unbounded read returns every match. The row keyed by
the value comes last, the order a bounded read is least prepared for, since
the database promises no order at all.
"""
clauses: Final = where["OR"]
assert isinstance(clauses, list)
fields: Final = tuple(next(iter(clause)) for clause in clauses)
assert fields == ("sso_user_id", "user_email"), fields
assert fields in (("user_id", "sso_user_id", "user_email"), ("sso_user_id", "user_email")), fields
def comparison(clause: Mapping[str, object]) -> tuple[str, bool]:
"""The needle and whether production asked for a case-insensitive compare,
@ -3749,8 +3768,9 @@ def _member_resolution_prisma(
assert isinstance(criterion, dict), criterion
return criterion["equals"], criterion.get("mode") == "insensitive"
sso_needle, sso_insensitive = comparison(clauses[0])
email_needle, email_insensitive = comparison(clauses[1])
by_field: Final = dict(zip(fields, (comparison(clause) for clause in clauses)))
sso_needle, sso_insensitive = by_field["sso_user_id"]
email_needle, email_insensitive = by_field["user_email"]
def same(stored: str, needle: str, insensitive: bool) -> bool:
return stored.casefold() == needle.casefold() if insensitive else stored == needle
@ -3768,6 +3788,11 @@ def _member_resolution_prisma(
if same(email, email_needle, email_insensitive)
for user_id in user_ids
),
(
user_id
for user_id in users
if "user_id" in by_field and same(user_id, by_field["user_id"][0], by_field["user_id"][1])
),
)
)
found: Final = tuple(dict.fromkeys(matched))
@ -4452,9 +4477,11 @@ async def test_create_group_applies_default_team_params(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=_member_resolution_prisma(mocker, users=set(), teams=set())),
)
new_team_mock = mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_group
"litellm.proxy.management_endpoints.scim.scim_v2.new_team",
AsyncMock(return_value=mocker.MagicMock()),
new_team_mock = (
mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_group
"litellm.proxy.management_endpoints.scim.scim_v2.new_team",
AsyncMock(return_value=mocker.MagicMock()),
)
)
mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_group
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group",
@ -4611,9 +4638,15 @@ async def test_resolve_group_member_ids_dedupes_repeated_member(mocker, scim_ups
def _identity_lookup(value: str) -> object:
"""The single cross-field lookup the classifier is expected to issue."""
"""The single cross-field lookup the classifier is expected to issue per member."""
return call(
where={"OR": [{"sso_user_id": value}, {"user_email": {"equals": value, "mode": "insensitive"}}]},
where={
"OR": [
{"user_id": value},
{"sso_user_id": value},
{"user_email": {"equals": value, "mode": "insensitive"}},
]
},
take=2,
)
@ -4903,9 +4936,7 @@ async def test_process_group_patch_remove_by_the_id_the_directory_added_with(
@pytest.mark.asyncio
async def test_process_group_patch_remove_still_drops_a_placeholder_by_its_literal_id(
mocker, scim_upsert_user_enabled
):
async def test_process_group_patch_remove_still_drops_a_placeholder_by_its_literal_id(mocker, scim_upsert_user_enabled):
"""An earlier release put unmatched ids on the roster verbatim, so a remove has to
keep clearing the id as written even once it also resolves."""
patch_ops = SCIMPatchOp(
@ -4916,7 +4947,10 @@ async def test_process_group_patch_remove_still_drops_a_placeholder_by_its_liter
team_id="parent-group",
team_alias="Parent Group",
members=[],
members_with_roles=[Member(user_id="legacy@example.com", role="user"), Member(user_id="keep-user", role="user")],
members_with_roles=[
Member(user_id="legacy@example.com", role="user"),
Member(user_id="keep-user", role="user"),
],
)
_, final_members, _ = await _process_group_patch_operations(
@ -5081,11 +5115,8 @@ async def test_process_group_patch_remove_refuses_when_two_members_share_the_id(
assert "more than one member of this group" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_resolve_group_member_ids_exact_user_id_wins_when_it_names_nobody_else(
mocker, scim_upsert_user_enabled
):
async def test_resolve_group_member_ids_exact_user_id_wins_when_it_names_nobody_else(mocker, scim_upsert_user_enabled):
"""The canonical user id stays authoritative, including when the same account also
holds that value as its email, which is how a SCIM-provisioned account is keyed."""
prisma_client = _member_resolution_prisma(
@ -5147,10 +5178,79 @@ async def test_resolve_group_member_ids_refuses_a_user_id_that_names_another_acc
assert exc_info.value.status_code == 400
assert "member-id" in str(exc_info.value.detail)
create_user_mock.assert_not_called()
assert any(
record.levelno >= logging.WARNING and "someone-else" in record.getMessage() for record in caplog.records
assert any(record.levelno >= logging.WARNING and "someone-else" in record.getMessage() for record in caplog.records)
@pytest.mark.asyncio
async def test_resolve_group_member_ids_reads_the_exact_id_when_two_other_accounts_fill_the_lookup(
mocker, scim_upsert_user_enabled
):
"""A value that is one account's id and two other accounts' identities fills the
bounded lookup with the other two. The account keyed by the value must still be
found, or the id would lose its precedence and a non-canonical type would skip
a member that names a real user."""
prisma_client = _member_resolution_prisma(
mocker,
users={"shared"},
teams=set(),
sso_user_id_to_user_id={"shared": "by-sso"},
email_to_user_id={"shared": "by-email"},
)
create_user_mock = mocker.patch( # test-quality-ok: user creation is module-level, not injectable into the resolver
"litellm.proxy.management_endpoints.scim.scim_v2._create_user_if_not_exists",
AsyncMock(return_value=None),
)
with pytest.raises(HTTPException) as exc_info:
await _resolve_group_member_ids(
members=[SCIMMember(value="shared", type="direct")],
created_via="scim_group_membership",
prisma_client=prisma_client,
)
assert exc_info.value.status_code == 400
assert "shared" in str(exc_info.value.detail)
create_user_mock.assert_not_called()
assert prisma_client.db.litellm_usertable.find_many.await_args_list == [_identity_lookup("shared")]
prisma_client.db.litellm_usertable.find_unique.assert_awaited_once_with(where={"user_id": "shared"})
@pytest.mark.asyncio
async def test_resolve_group_member_ids_reads_the_user_table_once_per_member(mocker, scim_upsert_user_enabled):
"""Every member costs one read of the user table, however it resolves: by its exact
id (which still outranks a non-canonical type), by identity, as a SCIM team, or not
at all. Looking the exact id up on its own before the identity read doubled the
reads of a push, and the identity read is a scan."""
prisma_client = _member_resolution_prisma(
mocker,
users={"by-id"},
teams={"by-team"},
email_to_user_id={"by-email@example.com": "email-user"},
)
mocker.patch( # test-quality-ok: user creation is module-level, not injectable into the resolver
"litellm.proxy.management_endpoints.scim.scim_v2._create_user_if_not_exists",
AsyncMock(return_value=NewUserResponse(user_id="nobody", key="key")),
)
result = await _resolve_group_member_ids(
members=[
SCIMMember(value="by-id", type="direct"),
SCIMMember(value="by-email@example.com"),
SCIMMember(value="by-team"),
SCIMMember(value="nobody"),
],
created_via="scim_group_membership",
prisma_client=prisma_client,
)
assert result.all_member_ids == ["by-id", "email-user", "nobody"]
prisma_client.db.litellm_usertable.find_unique.assert_not_awaited()
assert prisma_client.db.litellm_usertable.find_many.await_args_list == [
_identity_lookup("by-id"),
_identity_lookup("by-email@example.com"),
_identity_lookup("by-team"),
_identity_lookup("nobody"),
]
@pytest.mark.asyncio
@ -5536,10 +5636,7 @@ async def test_resolve_group_member_ids_admits_member_created_concurrently(mocke
the member is still admitted: the id resolves to a real user row, so failing
or dropping it would be wrong either way."""
prisma_client = _member_resolution_prisma(mocker, users=set(), teams=set())
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
side_effect=[None, LiteLLM_UserTable(user_id="raced-user")]
)
prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=())
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=LiteLLM_UserTable(user_id="raced-user"))
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._create_user_if_not_exists",
AsyncMock(return_value=None),
@ -5619,3 +5716,196 @@ async def test_patch_group_404s_when_team_deleted_mid_request(mocker):
assert exc_info.value.code == "404"
assert f"Group not found with ID: {group_id}" in exc_info.value.message
_SHADOW_MEMBER_VALUE: Final = "00u1shadow"
_SHADOWED_ACCOUNT: Final = "real-1"
_SHADOWED_GROUP: Final = "grp-eng"
def _shadowed_tenant_rows() -> tuple[LiteLLM_UserTable, ...]:
"""A placeholder keyed by the raw member value, and the real account that value names by SSO id."""
return (
LiteLLM_UserTable(user_id=_SHADOW_MEMBER_VALUE, user_email=_SHADOW_MEMBER_VALUE, teams=[_SHADOWED_GROUP]),
LiteLLM_UserTable(user_id=_SHADOWED_ACCOUNT, user_email="alice@example.com", sso_user_id=_SHADOW_MEMBER_VALUE),
)
def _shadow_tenant_prisma(
mocker: MockerFixture,
*,
rows: Sequence[LiteLLM_UserTable],
keys_owned_by: Mapping[str, int] = MappingProxyType({}),
) -> MagicMock:
"""Prisma fake whose user rows are live: deleting one removes it from every later lookup."""
users: Final[dict[str, LiteLLM_UserTable]] = {row.user_id: row for row in rows}
team: Final = LiteLLM_TeamTable(
team_id=_SHADOWED_GROUP,
members=[_SHADOW_MEMBER_VALUE],
members_with_roles=[Member(user_id=_SHADOW_MEMBER_VALUE, role="user")],
metadata={SCIM_MANAGED_TEAM_METADATA_KEY: True},
)
async def find_unique(where: Mapping[str, str]) -> LiteLLM_UserTable | None:
return users.get(where["user_id"])
def clause_matches(row: LiteLLM_UserTable, clause: Mapping[str, object]) -> bool:
if "user_id" in clause:
return row.user_id == clause["user_id"]
if "sso_user_id" in clause:
return row.sso_user_id == clause["sso_user_id"]
email_filter: Final = clause["user_email"]
assert isinstance(email_filter, dict)
return (row.user_email or "").casefold() == str(email_filter["equals"]).casefold()
async def identity_rows(where: Mapping[str, object], take: int | None = None) -> tuple[LiteLLM_UserTable, ...]:
clauses: Final = where["OR"]
assert isinstance(clauses, list)
matched: Final = tuple(row for row in users.values() if any(clause_matches(row, clause) for clause in clauses))
return matched[:take] if take else matched
async def delete(where: Mapping[str, str]) -> LiteLLM_UserTable | None:
return users.pop(where["user_id"], None)
async def keys_for(where: Mapping[str, object]) -> tuple[MagicMock, ...]:
return tuple(mocker.MagicMock() for _ in range(keys_owned_by.get(str(where["user_id"]), 0)))
async def team_lookup(where: Mapping[str, str]) -> LiteLLM_TeamTable | None:
return team if where["team_id"] == team.team_id else None
prisma_client = mocker.MagicMock()
prisma_client.db = mocker.MagicMock()
prisma_client.db.litellm_usertable = mocker.MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=find_unique)
prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=identity_rows)
prisma_client.db.litellm_usertable.delete = AsyncMock(side_effect=delete)
prisma_client.db.litellm_teamtable = mocker.MagicMock()
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(side_effect=team_lookup)
prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=team)
prisma_client.db.litellm_verificationtoken = mocker.MagicMock()
prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(side_effect=keys_for)
prisma_client.db.litellm_invitationlink = mocker.MagicMock(delete_many=AsyncMock(return_value=0))
prisma_client.db.litellm_organizationmembership = mocker.MagicMock(delete_many=AsyncMock(return_value=0))
prisma_client.db.litellm_teammembership = mocker.MagicMock(delete_many=AsyncMock(return_value=0))
return prisma_client
@pytest.fixture
def shadowed_tenant(mocker, monkeypatch, scim_upsert_user_enabled) -> MagicMock:
from litellm.proxy import proxy_server
prisma_client: Final = _shadow_tenant_prisma(mocker, rows=_shadowed_tenant_rows())
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
return prisma_client
async def _push_shadow_member(prisma_client: MagicMock):
return await _resolve_group_member_ids(
members=[SCIMMember(value=_SHADOW_MEMBER_VALUE)],
created_via="scim_group_membership",
prisma_client=prisma_client,
)
@pytest.mark.asyncio
async def test_merge_placeholder_hands_the_group_to_the_shadowed_account(mocker, shadowed_tenant):
"""Every group push of the shadowing value is refused until the placeholder is folded into
the real account; after the merge the same push resolves to that account."""
team_member_add_mock = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add", AsyncMock()
)
)
team_member_delete_mock = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", AsyncMock()
)
)
with pytest.raises(HTTPException) as before:
await _push_shadow_member(shadowed_tenant)
assert before.value.status_code == 400
result: Final = await merge_placeholder(user_id=_SHADOW_MEMBER_VALUE)
assert result == SCIMPlaceholderMergeResult(
placeholder_user_id=_SHADOW_MEMBER_VALUE,
merged_into_user_id=_SHADOWED_ACCOUNT,
team_ids=(_SHADOWED_GROUP,),
)
added: Final = team_member_add_mock.call_args.kwargs["data"]
assert (added.team_id, added.member.user_id) == (_SHADOWED_GROUP, _SHADOWED_ACCOUNT)
dropped: Final = team_member_delete_mock.call_args.kwargs["data"]
assert (dropped.team_id, dropped.user_id) == (_SHADOWED_GROUP, _SHADOW_MEMBER_VALUE)
shadowed_tenant.db.litellm_teammembership.delete_many.assert_awaited_once_with(
where={"user_id": _SHADOW_MEMBER_VALUE}
)
shadowed_tenant.db.litellm_usertable.delete.assert_awaited_once_with(where={"user_id": _SHADOW_MEMBER_VALUE})
after: Final = await _push_shadow_member(shadowed_tenant)
assert after.all_member_ids == [_SHADOWED_ACCOUNT]
assert after.created_users == []
@pytest.mark.asyncio
async def test_merge_placeholder_keeps_the_placeholder_when_the_roster_write_fails(mocker, shadowed_tenant):
"""If the real account cannot join the team, the placeholder stays on it, or the membership is gone
from both accounts."""
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add",
AsyncMock(side_effect=Exception("database connection lost")),
)
team_member_delete_mock = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", AsyncMock()
)
)
with pytest.raises(ProxyException):
await merge_placeholder(user_id=_SHADOW_MEMBER_VALUE)
team_member_delete_mock.assert_not_awaited()
shadowed_tenant.db.litellm_usertable.delete.assert_not_awaited()
assert await shadowed_tenant.db.litellm_usertable.find_unique(where={"user_id": _SHADOW_MEMBER_VALUE}) is not None
@pytest.mark.parametrize(
("rows", "keys_owned_by", "merged", "reason"),
[
pytest.param(_shadowed_tenant_rows(), {}, _SHADOWED_ACCOUNT, "SSO identity of its own", id="real-account"),
pytest.param(
_shadowed_tenant_rows(), {_SHADOW_MEMBER_VALUE: 2}, _SHADOW_MEMBER_VALUE, "2 virtual keys", id="owns-keys"
),
pytest.param(_shadowed_tenant_rows()[:1], {}, _SHADOW_MEMBER_VALUE, "shadows no account", id="names-nobody"),
pytest.param(
(*_shadowed_tenant_rows(), LiteLLM_UserTable(user_id="real-2", user_email=_SHADOW_MEMBER_VALUE.upper())),
{},
_SHADOW_MEMBER_VALUE,
"names 2 accounts (real-1, real-2)",
id="names-two-accounts",
),
],
)
@pytest.mark.asyncio
async def test_merge_placeholder_refuses_rows_that_are_not_a_lone_placeholder(
mocker, monkeypatch, scim_upsert_user_enabled, rows, keys_owned_by, merged, reason
):
"""Only a row with no SSO identity and no keys whose id names exactly one other account is folded;
anything else could move memberships to the wrong person, so nothing is written."""
from litellm.proxy import proxy_server
prisma_client: Final = _shadow_tenant_prisma(mocker, rows=rows, keys_owned_by=keys_owned_by)
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
team_member_add_mock = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add", AsyncMock()
)
)
with pytest.raises(ProxyException) as exc_info:
await merge_placeholder(user_id=merged)
assert int(exc_info.value.code) == 409
assert reason in str(exc_info.value.message)
team_member_add_mock.assert_not_awaited()
prisma_client.db.litellm_usertable.delete.assert_not_awaited()

View file

@ -17489,3 +17489,49 @@ async def test_check_project_key_limits_still_rejects_real_model_outside_project
assert exc_info.value.status_code == 400
assert "Model 'gpt-5.4-mini' not in project's allowed models" in exc_info.value.detail["error"]
def test_generate_key_request_blank_team_id_is_personal():
"""The UI Team-field clear submits team_id=""; it must count as no team (LIT-3925)."""
from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
_is_team_key,
)
cleared = GenerateKeyRequest(team_id="")
assert cleared.team_id is None
assert _is_team_key(data=cleared) is False
assert RegenerateKeyRequest(team_id="").team_id is None
assert GenerateKeyRequest(team_id="team-1").team_id == "team-1"
def test_key_generation_check_blank_team_id_uses_personal_permissions(monkeypatch):
"""key_generation_check with team_id="" must take the personal-key path instead
of failing the team lookup with "Unable to find team object" (LIT-3925)."""
from litellm.proxy._types import KeyManagementRoutes
from litellm.proxy.management_endpoints.key_management_endpoints import (
key_generation_check,
)
monkeypatch.setattr(
litellm,
"key_generation_settings",
{
"team_key_generation": {"allowed_team_member_roles": ["admin"]},
"personal_key_generation": {"allowed_user_roles": ["proxy_admin", "internal_user"]},
},
)
assert (
key_generation_check(
team_table=None,
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-alice",
user_id="alice",
),
data=GenerateKeyRequest(key_alias="personal", team_id=""),
route=KeyManagementRoutes.KEY_GENERATE,
)
is True
)

View file

@ -2,6 +2,7 @@ import asyncio
import json
import logging
import os
from collections.abc import Callable
from contextlib import ExitStack, contextmanager
from io import BytesIO
from types import SimpleNamespace
@ -29,6 +30,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
websocket_passthrough_request,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY,
@ -5464,7 +5466,10 @@ def test_the_marker_check_distinguishes_the_two_route_kinds():
assert request_dispatched_to_pass_through_endpoint(builtin) is False
async def _drive_passthrough_request_and_capture_logging(user_api_key_dict: UserAPIKeyAuth) -> tuple[int, object]:
async def _drive_passthrough_request_and_capture_logging(
user_api_key_dict: UserAPIKeyAuth,
on_pre_call: Callable[[LiteLLMLoggingObj | None], None] | None = None,
) -> tuple[int, LiteLLMLoggingObj | None]:
import litellm
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
@ -5487,10 +5492,12 @@ async def _drive_passthrough_request_and_capture_logging(user_api_key_dict: User
mock_request.query_params = QueryParams({})
mock_request.body = AsyncMock(return_value=b'{"model": "gemini-2.0-flash"}')
captured_data: dict = {}
captured_data: dict = {} # mutable-ok: the pre-call hook records the request data into it
async def capture_pre_call_hook(user_api_key_dict, data, call_type):
captured_data.update(data)
if on_pre_call is not None:
on_pre_call(data.get("litellm_logging_obj"))
return data
mock_proxy_logging = MagicMock()
@ -5623,3 +5630,103 @@ async def test_resolve_team_callback_wiring_fails_open_on_operational_error():
assert wiring.success_callbacks is None
assert wiring.failure_callbacks is None
assert wiring.logging_kwargs is None
@pytest.mark.asyncio
async def test_pass_through_request_leaves_guardrail_readable_metadata():
"""A pre-call guardrail reads the request headers off the passthrough logging
params without raising."""
from litellm.proxy.guardrails.guardrail_hooks.hiddenlayer.hiddenlayer import (
_logged_request_headers,
)
user_api_key_dict = UserAPIKeyAuth(
api_key="test-key",
team_id="test-team",
team_metadata={
"logging": [
{
"callback_name": "langfuse",
"callback_type": "success_and_failure",
"callback_vars": {
"langfuse_public_key": "pk_test",
"langfuse_secret_key": "sk_test",
},
}
]
},
)
observed: dict[str, dict[str, str] | BaseException] = {} # mutable-ok: the pre-call hook records into it
def read_headers_the_way_a_guardrail_does(logging_obj: LiteLLMLoggingObj | None) -> None:
assert logging_obj is not None
try:
observed["headers"] = _logged_request_headers(logging_obj)
except Exception as exc: # noqa: BLE001 - the regression is that this used to raise
observed["headers"] = exc
status_code, logging_obj = await _drive_passthrough_request_and_capture_logging(
user_api_key_dict, on_pre_call=read_headers_the_way_a_guardrail_does
)
assert "headers" in observed, "the pre-call hook never ran, so nothing was observed"
assert observed["headers"] == {}, f"guardrail header read failed: {observed['headers']!r}"
assert status_code == 200
assert logging_obj is not None
assert logging_obj.dynamic_success_callbacks, "team success callbacks must stay wired"
assert logging_obj.standard_callback_dynamic_params.get("langfuse_public_key") == "pk_test"
@pytest.mark.asyncio
async def test_pass_through_request_leaves_cost_router_logger_working():
"""The cost router's logger reads the deployment id off the passthrough logging
params without raising. least_busy shares the read but swallows the exception,
so this is the strategy where the break is observable."""
from litellm._logging import verbose_logger
from litellm.caching.caching import DualCache
from litellm.router_strategy.lowest_cost import LowestCostLoggingHandler
handler = LowestCostLoggingHandler(router_cache=DualCache())
user_api_key_dict = UserAPIKeyAuth(
api_key="test-key",
team_id="test-team",
team_metadata={
"logging": [
{
"callback_name": "langfuse",
"callback_type": "success_and_failure",
"callback_vars": {
"langfuse_public_key": "pk_test",
"langfuse_secret_key": "sk_test",
},
}
]
},
)
status_code, logging_obj = await _drive_passthrough_request_and_capture_logging(user_api_key_dict)
assert status_code == 200
assert logging_obj is not None
raised: list[logging.LogRecord] = [] # mutable-ok: logging.Handler records into it
class _RecordTracebacks(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
if record.exc_info is not None:
raised.append(record)
recorder = _RecordTracebacks()
verbose_logger.addHandler(recorder)
try:
await handler.async_log_success_event(
kwargs=logging_obj.model_call_details,
response_obj=None,
start_time=None,
end_time=None,
)
finally:
verbose_logger.removeHandler(recorder)
assert not raised, f"cost router logger raised on the passthrough logging params: {raised[0].exc_info}"

View file

@ -819,6 +819,94 @@ async def test_should_cap_known_estimate_to_remaining_budget(
) == pytest.approx(0.9)
@pytest.mark.asyncio
async def test_fail_closed_rejects_known_estimate_exceeding_remaining_budget(
spend_counter_state,
):
"""LIT-5922: with strict enforcement on, a request whose known estimate does
not fit the remaining budget must be rejected before dispatch instead of
having its reservation shrunk to the headroom and admitted, and the counter
must be restored to the pre-request spend."""
counter_cache, key_cache = spend_counter_state
proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
valid_token = UserAPIKeyAuth(
token="key-budget-known-estimate-fail-closed",
spend=0.9,
max_budget=1.0,
)
counter_cache.in_memory_cache.set_cache(
key="spend:key:key-budget-known-estimate-fail-closed",
value=0.9,
)
with patch( # test-quality-ok: reserve_budget_for_request takes no estimator, so pinning the estimate needs this attribute
"litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
return_value=0.6,
):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await reserve_budget_for_request(
request_body=_request_body(),
route="/chat/completions",
llm_router=None,
valid_token=valid_token,
team_object=None,
user_object=None,
prisma_client=None,
user_api_key_cache=key_cache,
proxy_logging_obj=proxy_logging_obj,
fail_closed_budget_enforcement=True,
)
assert exc_info.value.current_cost == pytest.approx(0.9)
assert exc_info.value.max_budget == pytest.approx(1.0)
assert "Current cost: 0.9, Estimated request cost: 0.6, Max budget: 1.0" in str(exc_info.value)
assert counter_cache.in_memory_cache.get_cache(
key="spend:key:key-budget-known-estimate-fail-closed"
) == pytest.approx(0.9)
@pytest.mark.asyncio
async def test_fail_closed_tolerates_float_noise_when_estimate_exactly_fits(
spend_counter_state,
):
"""0.1 + 0.2 lands a hair above 0.3 in floating point. Strict enforcement
must treat that as fitting the budget, not reject it."""
counter_cache, key_cache = spend_counter_state
proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
valid_token = UserAPIKeyAuth(
token="key-budget-fail-closed-float-noise",
spend=0.1,
max_budget=0.3,
)
counter_cache.in_memory_cache.set_cache(
key="spend:key:key-budget-fail-closed-float-noise",
value=0.1,
)
with patch( # test-quality-ok: reserve_budget_for_request takes no estimator, so pinning the estimate needs this attribute
"litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
return_value=0.2,
):
reservation = await reserve_budget_for_request(
request_body=_request_body(),
route="/chat/completions",
llm_router=None,
valid_token=valid_token,
team_object=None,
user_object=None,
prisma_client=None,
user_api_key_cache=key_cache,
proxy_logging_obj=proxy_logging_obj,
fail_closed_budget_enforcement=True,
)
assert reservation is not None
assert reservation["reserved_cost"] == pytest.approx(0.2)
assert counter_cache.in_memory_cache.get_cache(
key="spend:key:key-budget-fail-closed-float-noise"
) == pytest.approx(0.3)
@pytest.mark.asyncio
async def test_should_clamp_reservation_to_default_when_output_cap_missing(
spend_counter_state,

View file

@ -10445,6 +10445,75 @@ async def test_update_config_general_settings_emits_audit_log(monkeypatch):
assert before["some_api_key"] != "sk-stored-secret"
@pytest.mark.asyncio
async def test_update_config_field_rejects_out_of_range_alerting_args(monkeypatch):
"""Out-of-range alerting_args must be rejected at save time. If they land in the
DB, SlackAlertingArgs raises during the config reload and alerting breaks."""
from unittest.mock import MagicMock
from fastapi import HTTPException
import litellm.proxy.proxy_server as proxy_server_module
from litellm.proxy._types import ConfigFieldUpdate
from litellm.proxy.proxy_server import update_config_general_settings
monkeypatch.setattr(proxy_server_module, "prisma_client", MagicMock())
admin = UserAPIKeyAuth(
api_key="hashed-admin",
user_id="admin-1",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
with pytest.raises(HTTPException) as exc_info:
await update_config_general_settings(
data=ConfigFieldUpdate(
field_name="alerting_args",
field_value={
"daily_spend_per_user_threshold": -5.0,
"user_spend_check_interval": 20,
},
config_type="general_settings",
),
user_api_key_dict=admin,
)
assert exc_info.value.status_code == 400
error_msg = exc_info.value.detail["error"]
assert "daily_spend_per_user_threshold" in error_msg
assert "user_spend_check_interval" in error_msg
@pytest.mark.asyncio
async def test_update_config_field_accepts_valid_alerting_args(monkeypatch):
import litellm.proxy.proxy_server as proxy_server_module
from litellm.proxy._types import ConfigFieldUpdate
from litellm.proxy.proxy_server import update_config_general_settings
fake = _fake_prisma_with_config({})
monkeypatch.setattr(proxy_server_module, "prisma_client", fake)
monkeypatch.setattr(litellm, "store_audit_logs", False)
admin = UserAPIKeyAuth(
api_key="hashed-admin",
user_id="admin-1",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
await update_config_general_settings(
data=ConfigFieldUpdate(
field_name="alerting_args",
field_value={
"daily_spend_per_user_threshold": 5.0,
"user_spend_check_interval": 60,
},
config_type="general_settings",
),
user_api_key_dict=admin,
)
written = json.loads(fake.db.litellm_config.upsert.call_args.kwargs["data"]["update"]["param_value"])
assert written["alerting_args"]["daily_spend_per_user_threshold"] == 5.0
@pytest.mark.asyncio
async def test_update_config_general_settings_applies_ssrf_globals(monkeypatch):
import litellm.proxy.proxy_server as proxy_server_module

View file

@ -598,6 +598,45 @@ async def test_async_filter_deployments_falls_back_when_cached_deployment_is_unh
assert filtered == healthy_deployments
@pytest.mark.asyncio
async def test_async_filter_deployments_does_not_pin_when_target_order_is_set():
user_key = "user-key-order-fallback"
stable_model_map_key = "claude-sonnet-4-5@20250929"
cache = AsyncMock()
cache.async_get_cache = AsyncMock(return_value={"model_id": "deployment-1"})
callback = DeploymentAffinityCheck(
cache=cache,
ttl_seconds=123,
enable_user_key_affinity=True,
enable_responses_api_affinity=False,
)
healthy_deployments = [
{
"model_name": stable_model_map_key,
"litellm_params": {"model": f"vertex_ai/{stable_model_map_key}"},
"model_info": {"id": "deployment-1"},
},
{
"model_name": stable_model_map_key,
"litellm_params": {
"model": f"bedrock/global.anthropic.{stable_model_map_key}-v1:0"
},
"model_info": {"id": "deployment-2"},
},
]
filtered = await callback.async_filter_deployments(
model="some-router-model-group",
healthy_deployments=healthy_deployments,
messages=None,
request_kwargs={"_target_order": 2, "metadata": {"user_api_key_hash": user_key}},
parent_otel_span=None,
)
assert filtered == healthy_deployments
cache.async_get_cache.assert_not_called()
@pytest.mark.asyncio
async def test_async_user_key_affinity_ttl_expiry_allows_reroute():
"""

View file

@ -150,6 +150,25 @@ async def test_async_filter_deployments_narrows_prompt_above_model_minimum():
assert filtered == [deployments[1]]
@pytest.mark.asyncio
async def test_async_filter_deployments_does_not_pin_when_target_order_is_set():
cache = DualCache()
check = PromptCachingDeploymentCheck(cache=cache)
deployments = _deployments("anthropic/claude-opus-4-6", "anthropic/claude-opus-4-6")
messages = _messages(word_count=5000)
await PromptCachingCache(cache=cache).async_add_model_id(model_id="dep-2", messages=messages, tools=None)
filtered = await check.async_filter_deployments(
model=MODEL_GROUP_ALIAS,
healthy_deployments=deployments,
messages=messages,
request_kwargs={"_target_order": 2},
)
assert filtered == deployments
@pytest.mark.asyncio
async def test_async_filter_deployments_narrows_for_group_whose_model_minimum_is_lower():
"""

View file

@ -11,7 +11,10 @@ from litellm.router_utils.fallback_event_handlers import (
AttemptedFallbackTargets,
_trigger_cooldown_for_failed_deployment,
fallback_attempt_key,
clear_pre_routing_selection,
get_fallback_model_group,
get_pre_routing_selection,
record_pre_routing_selection,
run_async_fallback,
)
@ -1090,3 +1093,119 @@ async def test_run_async_fallback_preserves_original_model_group_on_nested_fallb
metadata = router.received_kwargs["metadata"]
assert metadata["attempted_fallbacks"] == 2
assert metadata["original_model_group"] == "primary-model"
class TestPreRoutingSelectionCarriesToFallbacks:
"""#38832: a complexity/auto router picks a tier behind the router name, but fallback
lookup kept using the router name, so the tier's configured chain never ran."""
def test_selection_is_recorded_in_the_metadata_bucket(self):
kwargs = {"model": "smart-router", "metadata": {}}
record_pre_routing_selection(kwargs, "tier1")
assert kwargs["metadata"]["pre_routing_selected_model"] == "tier1"
assert get_pre_routing_selection(kwargs) == "tier1"
def test_selection_is_recorded_in_the_litellm_metadata_bucket(self):
kwargs = {"model": "smart-router", "litellm_metadata": {}}
record_pre_routing_selection(kwargs, "tier2")
assert get_pre_routing_selection(kwargs) == "tier2"
def test_a_bucket_survives_the_kwargs_copy_that_fallbacks_run_on(self):
"""The bucket is shared by reference, which is the whole reason this works."""
outer = {"model": "smart-router", "metadata": {}}
inner = {**outer}
record_pre_routing_selection(inner, "tier1")
assert get_pre_routing_selection(outer) == "tier1"
def test_no_selection_reads_as_none(self):
assert get_pre_routing_selection({"model": "smart-router", "metadata": {}}) is None
assert get_pre_routing_selection({"model": "smart-router"}) is None
def test_missing_kwargs_is_a_no_op(self):
"""A caller with no kwargs must not raise, and must not leak the selection anywhere."""
record_pre_routing_selection(None, "tier1")
assert get_pre_routing_selection({}) is None
def test_a_non_dict_bucket_is_ignored(self):
kwargs = {"model": "smart-router", "metadata": "not-a-dict"}
record_pre_routing_selection(kwargs, "tier1")
assert get_pre_routing_selection(kwargs) is None
def test_fallbacks_resolve_against_the_selected_tier(self):
"""The lookup the router performs, keyed on the tier rather than the router name."""
fallbacks = [{"tier1": ["backup-a", "backup-b"]}, {"tier2": ["backup-c"]}]
assert get_fallback_model_group(fallbacks=fallbacks, model_group="tier1")[0] == ["backup-a", "backup-b"]
assert get_fallback_model_group(fallbacks=fallbacks, model_group="smart-router")[0] is None
class TestPreRoutingSelectionIsPerHop:
"""#38832 review: the buckets also carry whatever the caller sent, and a fallback hop
inherits the previous hop's tier, so a hop must start without a selection."""
def test_a_caller_supplied_selection_is_dropped(self):
kwargs = {"model": "plain", "metadata": {"pre_routing_selected_model": "tier1"}}
clear_pre_routing_selection(kwargs)
assert get_pre_routing_selection(kwargs) is None
assert "pre_routing_selected_model" not in kwargs["metadata"]
def test_both_buckets_are_cleared(self):
kwargs = {
"metadata": {"pre_routing_selected_model": "tier1"},
"litellm_metadata": {"pre_routing_selected_model": "tier2"},
}
clear_pre_routing_selection(kwargs)
assert get_pre_routing_selection(kwargs) is None
def test_the_rest_of_the_bucket_is_left_alone(self):
kwargs = {"metadata": {"pre_routing_selected_model": "tier1", "tags": ["a"]}}
clear_pre_routing_selection(kwargs)
assert kwargs["metadata"] == {"tags": ["a"]}
def test_clearing_is_a_no_op_without_a_usable_bucket(self):
kwargs = {"model": "plain", "metadata": "not-a-dict"}
clear_pre_routing_selection(None)
clear_pre_routing_selection(kwargs)
assert kwargs == {"model": "plain", "metadata": "not-a-dict"}
def test_a_selection_recorded_after_clearing_is_kept(self):
"""Clearing runs before routing, so the hook's own write must survive it."""
kwargs = {"model": "smart-router", "metadata": {"pre_routing_selected_model": "stale"}}
clear_pre_routing_selection(kwargs)
record_pre_routing_selection(kwargs, "tier1")
assert get_pre_routing_selection(kwargs) == "tier1"
class TestOrderedFallbackLookupGroups:
def test_tier_first_then_requested_group_deduped(self):
from litellm.router_utils.fallback_event_handlers import (
PRE_ROUTING_SELECTED_MODEL_KEY,
fallback_lookup_groups,
)
kwargs = {"litellm_metadata": {PRE_ROUTING_SELECTED_MODEL_KEY: "tier1"}}
assert fallback_lookup_groups(kwargs, "smart-router") == ("tier1", "smart-router")
assert fallback_lookup_groups(kwargs, "tier1") == ("tier1",)
assert fallback_lookup_groups({}, "smart-router") == ("smart-router",)
assert fallback_lookup_groups({}, None) == ()
def test_first_resolving_group_wins_and_generic_idx_survives_a_miss(self):
from litellm.router_utils.fallback_event_handlers import (
get_fallback_model_group_for_lookup_groups,
)
fallbacks = [{"tier1": ["backup-a"]}, {"smart-router": ["backup-b"]}, {"*": ["backup-c"]}]
assert get_fallback_model_group_for_lookup_groups(fallbacks, ("tier1", "smart-router")) == (["backup-a"], None)
assert get_fallback_model_group_for_lookup_groups(fallbacks, ("tier9", "smart-router")) == (["backup-b"], None)
assert get_fallback_model_group_for_lookup_groups(fallbacks, ("tier9", "no-such")) == (["backup-c"], 2)
assert get_fallback_model_group_for_lookup_groups([{"tier1": ["backup-a"]}], ("no", "nope")) == (None, None)

View file

@ -0,0 +1,52 @@
"""
Static checks on the root Dockerfile's apk repository configuration.
The base image (cgr.dev/chainguard/wolfi-base) only configures the
authenticated Chainguard apk repo (https://apk.cgr.dev/chainguard) in
/etc/apk/repositories, which requires a Chainguard enterprise subscription.
Anyone pulling the published litellm image and running `apk add` inside it
hits SSL/auth failures with no fallback repo configured, so nothing can be
installed. See https://github.com/BerriAI/litellm/issues/33518
"""
import os
import re
import pytest
DOCKERFILE_PATH = os.path.join(
os.path.dirname(__file__),
"..",
"..",
"Dockerfile",
)
def _runtime_stage(dockerfile_text: str) -> str:
"""Return the contents of the final `FROM ... AS runtime` build stage."""
match = re.search(r"^FROM .*\bAS runtime\b(.*)\Z", dockerfile_text, re.MULTILINE | re.DOTALL)
assert match, "Dockerfile has no `FROM ... AS runtime` stage"
return match.group(1)
@pytest.mark.skipif(
not os.path.exists(DOCKERFILE_PATH),
reason="Dockerfile not present in this checkout",
)
def test_runtime_stage_adds_public_wolfi_repo():
"""The runtime stage must add the public Wolfi apk repo so `apk add`
works for users without a Chainguard enterprise subscription."""
with open(DOCKERFILE_PATH, "r", encoding="utf-8") as f:
contents = f.read()
runtime_stage = _runtime_stage(contents)
assert re.search(
r"echo\s+[\"']?https://packages\.wolfi\.dev/os[\"']?\s*>>\s*/etc/apk/repositories",
runtime_stage,
), (
"Runtime stage must append the public Wolfi apk repo "
'(RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories) '
"so `apk add` works without Chainguard enterprise credentials. "
"See https://github.com/BerriAI/litellm/issues/33518"
)

View file

@ -0,0 +1,56 @@
"""
Static checks that every proxy Docker image installs the `bedrock-realtime` extra.
Bedrock Nova Sonic speech-to-speech (`/v1/realtime`) needs `aws-sdk-bedrock-runtime`,
which only ships in the `bedrock-realtime` extra. An image whose `uv sync` stages
omit the extra fails every Nova Sonic realtime session with
"Missing aws_sdk_bedrock_runtime. Install with: pip install aws-sdk-bedrock-runtime".
"""
import os
import re
from typing import Final
import pytest
REPO_ROOT: Final = os.path.join(os.path.dirname(__file__), "..", "..")
PROXY_DOCKERFILES: Final = (
"Dockerfile",
os.path.join("docker", "Dockerfile.non_root"),
os.path.join("docker", "Dockerfile.database"),
os.path.join("gateway", "Dockerfile"),
)
CONTINUED_LINE_RE: Final = re.compile(r"(?:\\\n|[^\n])+")
UV_SYNC_BOUNDARY_RE: Final = re.compile(r"(?=uv sync)")
def _uv_sync_invocations(dockerfile_text: str) -> tuple[str, ...]:
"""Return each `uv sync ...` command, split apart when one RUN holds several (if/else branches)."""
return tuple(
part
for line in CONTINUED_LINE_RE.finditer(dockerfile_text)
for part in UV_SYNC_BOUNDARY_RE.split(line.group(0))
if part.startswith("uv sync")
)
@pytest.mark.parametrize("relative_path", PROXY_DOCKERFILES)
def test_every_uv_sync_installs_bedrock_realtime_extra(relative_path: str):
dockerfile_path: Final = os.path.join(REPO_ROOT, relative_path)
if not os.path.exists(dockerfile_path):
pytest.skip(f"{relative_path} not present in this checkout")
with open(dockerfile_path, "r", encoding="utf-8") as f:
contents: Final = f.read()
invocations: Final = _uv_sync_invocations(contents)
assert invocations, f"{relative_path} has no `uv sync` invocation"
missing: Final = tuple(invocation for invocation in invocations if "--extra bedrock-realtime" not in invocation)
assert not missing, (
f"{relative_path}: {len(missing)} of {len(invocations)} `uv sync` invocations omit "
"`--extra bedrock-realtime`, so aws-sdk-bedrock-runtime is absent and Bedrock Nova Sonic "
"/v1/realtime sessions fail with 'Missing aws_sdk_bedrock_runtime'"
)

View file

@ -11595,3 +11595,138 @@ class TestTierParamsTheTargetAccepts:
accepted = router._tier_params_the_target_accepts("no-such-group", {"reasoning_effort": "max"}, {})
assert accepted == {"reasoning_effort": "max"}
class TestPreRoutingTierDrivesFallbacks:
"""#38832: a complexity/auto router picks a tier behind the router name, but fallback
lookup stayed on the router name, so the tier's configured chain never ran and a
provider failure on the tier's first hop was returned to the client."""
class _TierRouter(litellm.Router):
async def async_pre_routing_hook(
self, model, request_kwargs, messages=None, input=None, specific_deployment=False
):
from litellm.types.router import PreRoutingHookResponse
if model == "smart-router":
return PreRoutingHookResponse(model="tier1", messages=messages)
return None
@classmethod
def _router(cls, fallbacks) -> "litellm.Router":
return cls._TierRouter(
model_list=[
{
"model_name": "smart-router",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-x"},
},
{
"model_name": "tier1",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-x",
"mock_response": "litellm.RateLimitError",
},
},
{
"model_name": "backup-a",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-x",
"mock_response": "from backup-a",
},
},
{
"model_name": "backup-b",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-x",
"mock_response": "from backup-b",
},
},
{
"model_name": "failing-backup",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-x",
"mock_response": "litellm.RateLimitError",
},
},
{
"model_name": "plain",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "sk-x",
"mock_response": "litellm.RateLimitError",
},
},
],
fallbacks=fallbacks,
num_retries=0,
)
@pytest.mark.asyncio
async def test_the_selected_tier_fallback_chain_runs(self):
router = self._router([{"tier1": ["backup-a"]}])
response = await router.acompletion(
model="smart-router", messages=[{"role": "user", "content": "hi"}]
)
assert response.choices[0].message.content == "from backup-a"
@pytest.mark.asyncio
async def test_a_chain_keyed_on_the_router_name_is_not_used(self):
"""The router name has no chain of its own, so nothing should rescue this call."""
router = self._router([{"tier2": ["backup-a"]}])
with pytest.raises(litellm.RateLimitError):
await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}])
@pytest.mark.asyncio
async def test_a_chain_keyed_on_the_router_name_rescues_when_no_tier_chain_exists(self):
"""The documented contract: configs keyed on the requested name keep working behind auto-routers."""
router = self._router([{"smart-router": ["backup-a"]}])
response = await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}])
assert response.choices[0].message.content == "from backup-a"
@pytest.mark.asyncio
async def test_the_tier_chain_wins_over_the_router_name_chain(self):
router = self._router([{"tier1": ["backup-a"]}, {"smart-router": ["backup-b"]}])
response = await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}])
assert response.choices[0].message.content == "from backup-a"
@pytest.mark.asyncio
async def test_a_request_without_a_pre_routing_hook_still_uses_its_own_group(self):
router = self._router([{"tier1": ["backup-a"]}])
response = await router.acompletion(
model="tier1", messages=[{"role": "user", "content": "hi"}]
)
assert response.choices[0].message.content == "from backup-a"
@pytest.mark.asyncio
async def test_a_caller_cannot_pick_the_chain_by_sending_the_selection(self):
"""The metadata bucket carries caller-supplied keys, so only the hook may set the tier."""
router = self._router([{"tier1": ["backup-a"]}])
with pytest.raises(litellm.RateLimitError):
await router.acompletion(
model="plain",
messages=[{"role": "user", "content": "hi"}],
metadata={"pre_routing_selected_model": "tier1"},
)
@pytest.mark.asyncio
async def test_each_fallback_hop_resolves_its_own_chain(self):
"""The second hop must key off the group it is running, not the tier that failed."""
router = self._router([{"tier1": ["failing-backup"]}, {"failing-backup": ["backup-b"]}])
response = await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}])
assert response.choices[0].message.content == "from backup-b"

View file

@ -2281,3 +2281,83 @@ def test_every_declaring_deployment_is_named(caplog):
assert "azure-ptu-east" in warnings[0]
assert "azure-ptu-west" in warnings[0]
assert "plain-gpt-4o" not in warnings[0]
def _simulate_price_data_reload_with_provider_sets(monkeypatch, fetched_catalog):
"""Like `_simulate_price_data_reload`, plus the provider model-set refresh the proxy's
`_swap_in_model_cost_map` does before replaying, so bare names in the new catalog resolve."""
monkeypatch.setattr(litellm, "model_cost", fetched_catalog)
_invalidate_model_cost_lowercase_map()
litellm.add_known_models(model_cost_map=fetched_catalog)
reapply_runtime_model_cost_registrations()
def test_a_config_deployment_dropped_by_a_stale_cost_map_comes_back_on_reload(monkeypatch):
"""
Booting on the bundled backup, a bare model that only the remote catalog knows
cannot be provider-resolved, so the proxy router (ignore_invalid_deployments) drops
it. Once a reload brings in a catalog that knows the model, the deployment must be
served again with its access groups, and exactly once however many reloads follow.
"""
backend = "lit-5766-only-in-remote-catalog"
try:
router = Router(
model_list=[
{
"model_name": "new-model",
"litellm_params": {"model": backend, "api_key": "k"},
"model_info": {"id": "new-id", "access_groups": ["team-models"]},
},
{
"model_name": "control-model",
"litellm_params": {"model": "hosted_vllm/control-backend", "api_key": "k"},
"model_info": {"id": "control-id", "access_groups": ["team-models"]},
},
],
ignore_invalid_deployments=True,
)
assert router.get_model_names() == ["control-model"]
assert router.get_model_access_groups(model_name="new-model") == {}
fresh_catalog = {**litellm.model_cost, backend: {"litellm_provider": "openai", "mode": "chat"}}
_simulate_price_data_reload_with_provider_sets(monkeypatch, fresh_catalog)
_simulate_price_data_reload_with_provider_sets(monkeypatch, fresh_catalog)
assert sorted(router.get_model_names()) == ["control-model", "new-model"]
assert router.get_model_access_groups(model_name="new-model") == {"team-models": ["new-model"]}
assert [d["model_info"]["id"] for d in router.model_list] == ["control-id", "new-id"]
assert "new-id" in litellm.model_cost
finally:
litellm.open_ai_chat_completion_models.discard(backend)
litellm.models_by_provider["openai"].discard(backend)
def test_a_config_deployment_dropped_for_a_permanent_reason_is_not_retried_on_reload(monkeypatch):
"""
Only provider-resolution drops can be healed by a fresh catalog. A deployment that
fails after its provider resolved (here a pass-through vertex entry with no project)
has already touched router state, so replaying it on every reload would leak into
`deployment_names` each time.
"""
router = Router(
model_list=[
{
"model_name": "vertex-passthrough",
"litellm_params": {"model": "vertex_ai/gemini-2.5-flash", "use_in_pass_through": True},
"model_info": {"id": "vertex-id"},
},
{
"model_name": "control-model",
"litellm_params": {"model": "hosted_vllm/control-backend", "api_key": "k"},
"model_info": {"id": "control-id"},
},
],
ignore_invalid_deployments=True,
)
assert router.get_model_names() == ["control-model"]
names_after_boot = list(router.deployment_names)
_simulate_price_data_reload_with_provider_sets(monkeypatch, dict(litellm.model_cost))
assert router.get_model_names() == ["control-model"]
assert router.deployment_names == names_after_boot

View file

@ -6,12 +6,19 @@ should be tried first, and higher order deployments should be used as fallbacks
when lower order deployments fail.
"""
from typing import Optional
import json
from typing import Final, Optional
import httpx
import pytest
from openai import AsyncOpenAI
import litellm
from litellm import Router
from litellm.utils import _get_order_filtered_deployments
from litellm.integrations.custom_logger import CustomLogger
from litellm.router_utils.prompt_caching_cache import PromptCachingCache
from litellm.types.router import RouterRateLimitError
from litellm.utils import _get_deployment_order, _get_order_filtered_deployments
# ---------------------------------------------------------------------------
# Unit tests for _get_order_filtered_deployments
@ -49,13 +56,22 @@ class TestGetOrderFilteredDeployments:
assert len(result) == 1
assert result[0]["model_info"]["id"] == "b"
def test_target_order_no_match_returns_all(self):
def test_target_order_no_match_returns_empty(self):
deps = [
self._make_deployment(1, "a"),
self._make_deployment(2, "b"),
]
result = _get_order_filtered_deployments(deps, target_order=99)
assert len(result) == 2
assert result == []
def test_target_order_no_match_does_not_reselect_lower_order(self):
deps = [
self._make_deployment(1, "a"),
self._make_deployment(2, "b"),
]
remaining_after_pre_call = [deps[0]]
result = _get_order_filtered_deployments(remaining_after_pre_call, target_order=2)
assert result == []
def test_no_order_set_returns_all(self):
deps = [
@ -406,35 +422,239 @@ async def test_router_order_fallback_with_hidden_model_group_alias():
assert response._hidden_params["model_id"] == "2"
@pytest.mark.asyncio
async def test_router_order_fallback_does_not_reselect_order_1_when_order_2_is_filtered_out():
class _DropOrder2(CustomLogger):
async def async_filter_deployments(
self, model, healthy_deployments, messages, request_kwargs=None, parent_otel_span=None
):
return [d for d in healthy_deployments if _get_deployment_order(d) != 2]
drop_order_2: Final = _DropOrder2()
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "key",
"mock_response": "litellm.RateLimitError",
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "key",
"mock_response": "success from order 2",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
)
litellm.callbacks.append(drop_order_2)
try:
with pytest.raises(RouterRateLimitError, match="No deployments available") as exc_info:
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
assert "success from order 2" not in str(exc_info.value)
finally:
litellm.callbacks.remove(drop_order_2)
@pytest.mark.asyncio
async def test_router_order_fallback_ignores_prompt_cache_pin_on_target_order():
messages = [{"role": "user", "content": "word " * 5000}]
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("azure peak load"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "good",
"mock_response": "success from order 2",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
optional_pre_call_checks=["prompt_caching"],
)
await PromptCachingCache(cache=router.cache).async_add_model_id(
model_id="1",
messages=messages,
tools=None,
)
response = await router.acompletion(model="test-model", messages=messages)
assert response._hidden_params["model_id"] == "2"
@pytest.mark.asyncio
async def test_router_order_fallback_retries_keep_target_order():
seen_target_orders: Final = []
class _RecordTargetOrder(CustomLogger):
async def async_filter_deployments(
self, model, healthy_deployments, messages, request_kwargs=None, parent_otel_span=None
):
seen_target_orders.append((request_kwargs or {}).get("_target_order"))
return healthy_deployments
recorder: Final = _RecordTargetOrder()
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail order 1"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-4o",
"api_key": "bad",
"mock_response": Exception("fail order 2"),
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=1,
)
litellm.callbacks.append(recorder)
try:
with pytest.raises(Exception, match="fail order 2"):
await router.acompletion(
model="test-model",
messages=[{"role": "user", "content": "hi"}],
)
finally:
litellm.callbacks.remove(recorder)
assert seen_target_orders.count(2) >= 2
@pytest.mark.asyncio
async def test_generic_api_call_strips_target_order_from_provider_kwargs():
captured: Final = {}
async def _fake_provider(**provider_kwargs):
captured.update(provider_kwargs)
return "ok"
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {"model": "gpt-4o", "api_key": "key", "order": 2},
"model_info": {"id": "2"},
},
],
)
response = await router._ageneric_api_call_with_fallbacks_helper(
model="test-model",
original_generic_function=_fake_provider,
_target_order=2,
messages=[{"role": "user", "content": "hi"}],
)
assert response == "ok"
assert captured["model"] == "gpt-4o"
assert "_target_order" not in captured
@pytest.mark.asyncio
async def test_text_completion_order_fallback_hop_does_not_send_target_order_upstream():
upstream_bodies: Final[list[dict]] = []
def _upstream(request: httpx.Request) -> httpx.Response:
upstream_bodies.append(json.loads(request.content))
return httpx.Response(
200,
json={
"id": "cmpl-1",
"object": "text_completion",
"created": 0,
"model": "gpt-3.5-turbo-instruct",
"choices": [{"text": "ok from order 2", "index": 0, "logprobs": None, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
upstream_client: Final = AsyncOpenAI(
api_key="key",
base_url="http://upstream.test",
http_client=httpx.AsyncClient(transport=httpx.MockTransport(_upstream)),
)
router = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "text-completion-openai/gpt-3.5-turbo-instruct",
"api_key": "key",
"mock_response": Exception("fail order 1"),
"order": 1,
},
"model_info": {"id": "1"},
},
{
"model_name": "test-model",
"litellm_params": {
"model": "text-completion-openai/gpt-3.5-turbo-instruct",
"api_key": "key",
"api_base": "http://upstream.test",
"order": 2,
},
"model_info": {"id": "2"},
},
],
num_retries=0,
)
try:
response = await router.atext_completion(model="test-model", prompt="hi", client=upstream_client)
finally:
await upstream_client.close()
assert response._hidden_params["model_id"] == "2"
assert upstream_bodies
assert all("_target_order" not in body for body in upstream_bodies)
def test_check_non_standard_fallback_format():
from litellm.router_utils.fallback_event_handlers import (
_check_non_standard_fallback_format,
)
# Standard formats
assert (
_check_non_standard_fallback_format([{"gpt-3.5-turbo": ["claude-3-haiku"]}])
== False
)
assert _check_non_standard_fallback_format([{"gpt-3.5-turbo": ["claude-3-haiku"]}]) == False
assert _check_non_standard_fallback_format([{"model": ["qwen-backup"]}]) == False
assert (
_check_non_standard_fallback_format(
[{"model": ["qwen-backup"], "region": ["us-east-1"]}]
)
== False
)
assert _check_non_standard_fallback_format([{"model": ["qwen-backup"], "region": ["us-east-1"]}]) == False
# Non-standard formats
assert _check_non_standard_fallback_format([{"model": "qwen-backup"}]) == True
assert (
_check_non_standard_fallback_format(
[{"model": "qwen-backup", "messages": [{"role": "user", "content": "hi"}]}]
)
== True
)
assert (
_check_non_standard_fallback_format(
[{"model": ["qwen-backup"], "api_key": "some-key"}]
)
_check_non_standard_fallback_format([{"model": "qwen-backup", "messages": [{"role": "user", "content": "hi"}]}])
== True
)
assert _check_non_standard_fallback_format([{"model": ["qwen-backup"], "api_key": "some-key"}]) == True

View file

@ -140,6 +140,7 @@ const renderPanel = (canModify = true) =>
accessToken="token"
userRole="Admin"
userID="u-admin"
isViewOnly={false}
teams={null}
createScope={canModify ? "unscoped-ok" : "forbidden"}
/>,

View file

@ -21,12 +21,20 @@ interface AutoRoutersPanelProps {
accessToken: string;
userRole: string;
userID: string | null;
isViewOnly: boolean;
teams: Team[] | null;
/** Owned by the page, which knows how this caller must scope what they create. */
createScope: ModelWriteScope;
}
export function AutoRoutersPanel({ accessToken, userRole, userID, teams, createScope }: AutoRoutersPanelProps) {
export function AutoRoutersPanel({
accessToken,
userRole,
userID,
isViewOnly,
teams,
createScope,
}: AutoRoutersPanelProps) {
const canCreate = createScope !== "forbidden";
const { data: deployments, isLoading } = useAutoRouters();
const invalidateAutoRouters = useInvalidateAutoRouters();
@ -39,8 +47,8 @@ export function AutoRoutersPanel({ accessToken, userRole, userID, teams, createS
const [isDeleting, setIsDeleting] = useState(false);
const routers = useMemo(
() => toAutoRouterRows(deployments ?? [], { userRole, userID }, teams),
[deployments, userRole, userID, teams],
() => toAutoRouterRows(deployments ?? [], { userRole, userID, isViewOnly }, teams),
[deployments, userRole, userID, isViewOnly, teams],
);
const handleCreated = () => {

View file

@ -5,8 +5,9 @@ import { toAutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
// Existing cases assert resource classification, so they run as a proxy admin: the actor
// gate is then a pass-through and canEdit/canDelete still reflect the row itself.
const ADMIN = { userRole: "Admin", userID: "u-admin" };
const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" };
const ADMIN = { userRole: "Admin", userID: "u-admin", isViewOnly: false };
const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin", isViewOnly: false };
const VIEW_ONLY_ADMIN = { userRole: "Admin", userID: "u-viewer", isViewOnly: true };
const complexityDeployment = {
model_name: "tri-tier-router",
@ -224,7 +225,7 @@ describe("autoRouterRows actor gating", () => {
{ team_id: "team-1", members_with_roles: [{ user_id: "u-team-admin", user_email: "t@t", role: "admin" }] },
] as never;
const rowIn = (actor: { userRole: string; userID: string }, teamId: string | null) =>
const rowIn = (actor: { userRole: string; userID: string; isViewOnly: boolean }, teamId: string | null) =>
toAutoRouterRow(
{ ...complexityDeployment, model_info: { id: "cid-1", db_model: true, team_id: teamId } },
0,
@ -259,4 +260,12 @@ describe("autoRouterRows actor gating", () => {
expect(row.canEdit).toBe(true);
expect(row.canDelete).toBe(true);
});
// A proxy_admin_viewer session reads "Admin" through the masquerade, but PATCH and
// DELETE both 403 it, so its rows must not offer the affordances.
it("hides write affordances from a view-only admin session", () => {
const row = rowIn(VIEW_ONLY_ADMIN, null);
expect(row.canEdit).toBe(false);
expect(row.canDelete).toBe(false);
});
});

View file

@ -38,8 +38,17 @@ vi.mock("./useModelDashboardData", () => ({
useModelDashboardData: () => ({ availableModelAccessGroups: [], allModelsOnProxy: [], availableModelGroups: [] }),
}));
const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false };
const NON_ADMIN = { accessToken: "at", token: "t", userRole: "Internal User", userId: "u1", premiumUser: false };
const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false, isViewOnly: false };
const NON_ADMIN = {
accessToken: "at",
token: "t",
userRole: "Internal User",
userId: "u1",
premiumUser: false,
isViewOnly: false,
};
// A proxy_admin_viewer session: effectiveSessionRole masquerades the role as "Admin".
const VIEW_ONLY_ADMIN = { ...ADMIN, isViewOnly: true };
const renderPage = () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
@ -99,6 +108,22 @@ describe("ModelsAndEndpointsPage", () => {
expect(screen.queryByRole("tab", { name: "Health Status" })).not.toBeInTheDocument();
});
// POST /model/new 403s a proxy_admin_viewer, so the form's tab must not render for one.
it("hides the Add Model tab for a view-only admin session", () => {
mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
renderPage();
expect(screen.queryByRole("tab", { name: "Add Model" })).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument();
});
// Read parity: the Auto-Routers list stays reachable for a view-only admin; only the
// create affordance inside it is withheld, which AutoRoutersTabPanel decides.
it("keeps the Auto-Routers tab for a view-only admin session", () => {
mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
renderPage();
expect(screen.getByRole("tab", { name: /Auto-Routers/ })).toBeInTheDocument();
});
// Auto-routers are excluded from the All Models table, so this tab is their home: the only
// place in the product to list, create, edit or delete one.
describe("Auto-Routers tab", () => {

View file

@ -80,7 +80,7 @@ const renderPanel = (key: string) => {
};
export default function ModelsAndEndpointsPage() {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
const { accessToken, userRole, userId: userID, premiumUser, isViewOnly } = useAuthorized();
const { data: teams } = useTeams();
const { data: uiSettings } = useUISettings();
const queryClient = useQueryClient();
@ -92,7 +92,7 @@ export default function ModelsAndEndpointsPage() {
const isInternalUser = userRole && internalUserRoles.includes(userRole);
const canCreate = canCreateModels(
{ userRole, userID },
{ userRole, userID, isViewOnly },
{
teams: teams ?? null,
disabledForInternalUsers:
@ -182,6 +182,7 @@ export default function ModelsAndEndpointsPage() {
accessToken={accessToken}
userID={userID}
userRole={userRole}
isViewOnly={isViewOnly}
onModelUpdate={invalidateModels}
modelAccessGroups={availableModelAccessGroups}
/>

View file

@ -0,0 +1,39 @@
/* @vitest-environment jsdom */
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import AutoRoutersTabPanel from "./AutoRoutersTabPanel";
const panelProps = vi.fn();
vi.mock("../components/AutoRouters/AutoRoutersPanel", () => ({
AutoRoutersPanel: (props: Record<string, unknown>) => {
panelProps(props);
return <div data-testid="auto-routers-panel" />;
},
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() }));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => ({ data: { values: {} } }),
}));
const SESSION = { accessToken: "at", userRole: "Admin", userId: "u1", isViewOnly: false };
const lastProps = () => panelProps.mock.calls.at(-1)?.[0] as { createScope: string };
describe("AutoRoutersTabPanel", () => {
it("grants an unscoped create to a real proxy admin", () => {
mockUseAuthorized.mockReturnValue(SESSION);
render(<AutoRoutersTabPanel />);
expect(lastProps().createScope).toBe("unscoped-ok");
});
// The masqueraded "Admin" a proxy_admin_viewer session carries: POST /model/new 403s it,
// so the panel must not be told it may create.
it("withholds the create affordance from a view-only admin session", () => {
mockUseAuthorized.mockReturnValue({ ...SESSION, isViewOnly: true });
render(<AutoRoutersTabPanel />);
expect(lastProps().createScope).toBe("forbidden");
});
});

View file

@ -15,13 +15,13 @@ import { AutoRoutersPanel } from "../components/AutoRouters/AutoRoutersPanel";
* Viewer roles reach the list without write affordances.
*/
export default function AutoRoutersTabPanel() {
const { accessToken, userRole, userId: userID } = useAuthorized();
const { accessToken, userRole, userId: userID, isViewOnly } = useAuthorized();
const { data: teams } = useTeams();
const { data: uiSettings } = useUISettings();
const isInternalUser = userRole != null && internalUserRoles.includes(userRole);
const scope = modelCreationScope(
{ userRole, userID },
{ userRole, userID, isViewOnly },
{
teams: teams ?? null,
disabledForInternalUsers: isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true,
@ -33,6 +33,7 @@ export default function AutoRoutersTabPanel() {
accessToken={accessToken}
userRole={userRole ?? ""}
userID={userID ?? null}
isViewOnly={isViewOnly}
teams={teams ?? null}
createScope={scope}
/>

View file

@ -82,7 +82,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
// Using a unique ID to force the ConnectionErrorDisplay to remount and run a fresh test
const [connectionTestId, setConnectionTestId] = useState<string>("");
const { accessToken, userRole, premiumUser, userId } = useAuthorized();
const { accessToken, userRole, premiumUser, userId, isViewOnly } = useAuthorized();
const {
data: providerMetadata,
isLoading: isProviderMetadataLoading,
@ -157,7 +157,10 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
const isTeamAdmin = isUserTeamAdminForAnyTeam(teams, userId);
// Same owner the Auto-Routers tab uses, so the two creation forms cannot disagree about
// who has to name a team. This form is only reachable when creation is allowed at all.
const createScope = modelCreationScope({ userRole, userID: userId }, { teams, disabledForInternalUsers: false });
const createScope = modelCreationScope(
{ userRole, userID: userId, isViewOnly },
{ teams, disabledForInternalUsers: false },
);
const requiresTeamScope = createScope === "team-required";
return (

View file

@ -101,18 +101,24 @@ vi.mock("./build_complexity_router_config", async (importOriginal) => {
});
// A real TeamDropdown fetches teams and renders an antd Select; the wiring under test is
// whether team_id is registered, validated and forwarded, so a plain control stands in.
// whether team_id is registered, validated and forwarded, so a plain control stands in. The
// clear button mirrors the real dropdown's x, which emits null rather than a string.
vi.mock("../common_components/team_dropdown", () => ({
default: ({ value, onChange }: { value?: string; onChange?: (next: string) => void }) => (
<select
data-testid="team-dropdown"
value={value ?? ""}
onChange={(event) => onChange?.(event.target.value)}
aria-label="Select Team"
>
<option value="">none</option>
<option value="team-1">team-1</option>
</select>
default: ({ value, onChange }: { value?: string; onChange?: (next: string | null) => void }) => (
<>
<select
data-testid="team-dropdown"
value={value ?? ""}
onChange={(event) => onChange?.(event.target.value)}
aria-label="Select Team"
>
<option value="">none</option>
<option value="team-1">team-1</option>
</select>
<button type="button" data-testid="team-dropdown-clear" onClick={() => onChange?.(null)}>
clear team
</button>
</>
),
}));
@ -354,6 +360,25 @@ describe("AddAutoRouterTab", () => {
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
});
// The shared dropdown emits null on clear while this form's schema wants a string, so the
// form maps null back to "": the user sees the pick-a-team message, not a zod type error.
it("treats a team picked and then cleared like no team at all", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
renderWithProviders(
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
);
await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router");
await user.selectOptions(screen.getByTestId("team-dropdown"), "team-1");
await user.click(screen.getByTestId("team-dropdown-clear"));
await user.click(screen.getByRole("button", { name: /add auto router/i }));
expect(await screen.findByText("Please select a team to continue")).toBeInTheDocument();
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
});
it("defaults a new router to session affinity off, matching the backend field default", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);

View file

@ -548,7 +548,9 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
)}
>
{({ id, value, onChange }) => <TeamDropdown id={id} value={value} onChange={onChange} />}
{({ id, value, onChange }) => (
<TeamDropdown id={id} value={value} onChange={(next) => onChange(next ?? "")} />
)}
</FormField>
)}

View file

@ -5,6 +5,7 @@ import React, { useState, useEffect } from "react";
import { alertingSettingsCall, updateConfigFieldSetting } from "../networking";
import DynamicForm from "./dynamic_form";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { toast } from "@/lib/toast";
interface alertingSettingsItem {
field_name: string;
@ -43,7 +44,7 @@ const AlertingSettings: React.FC<AlertingSettingsProps> = ({ accessToken, premiu
setAlertingSettings(updatedSettings);
};
const handleSubmit = (formValues: Record<string, any>) => {
const handleSubmit = async (formValues: Record<string, any>) => {
if (!accessToken) {
return;
}
@ -64,18 +65,18 @@ const AlertingSettings: React.FC<AlertingSettingsProps> = ({ accessToken, premiu
const mergedFormValues = { ...formValues, ...initialFormValues };
const { slack_alerting, ...alertingArgs } = mergedFormValues;
try {
updateConfigFieldSetting(accessToken, "alerting_args", alertingArgs);
await updateConfigFieldSetting(accessToken, "alerting_args", alertingArgs);
if (typeof slack_alerting === "boolean") {
if (slack_alerting == true) {
updateConfigFieldSetting(accessToken, "alerting", ["slack"]);
await updateConfigFieldSetting(accessToken, "alerting", ["slack"]);
} else {
updateConfigFieldSetting(accessToken, "alerting", []);
await updateConfigFieldSetting(accessToken, "alerting", []);
}
}
// update value in state
toast.success("Wait 10s for proxy to update.");
} catch (error) {
// do something
toast.error(extractProxyErrorMessage(error));
}
};

View file

@ -38,6 +38,14 @@ const SETTINGS: Setting[] = [
stored_in_db: null,
premium_field: false,
},
{
field_name: "daily_spend_per_user_threshold",
field_description: "Daily spend threshold per user",
field_type: "Float",
field_value: 5.5,
stored_in_db: true,
premium_field: false,
},
];
const renderForm = (
@ -170,6 +178,19 @@ describe("DynamicForm change notifications", () => {
expect(handleInputChange).toHaveBeenCalledWith("daily_report_frequency", 128);
});
it("renders a Float field as a decimal-friendly number input and reports changes as numbers", async () => {
const user = userEvent.setup();
const { handleInputChange } = renderForm();
const input = screen.getByDisplayValue("5.5");
expect(input).toHaveAttribute("type", "number");
expect(input).toHaveAttribute("step", "any");
await user.type(input, "1");
expect(handleInputChange).toHaveBeenCalledWith("daily_spend_per_user_threshold", 5.51);
});
it("reports a reset with the field name and its row index", async () => {
const user = userEvent.setup();
const { handleResetField } = renderForm();
@ -216,7 +237,7 @@ describe("DynamicForm presentation", () => {
expect(screen.getByText("daily_report_frequency")).toBeInTheDocument();
expect(screen.getByText("How often the report runs")).toBeInTheDocument();
expect(screen.getByText("In DB")).toBeInTheDocument();
expect(screen.getAllByText("In DB")).toHaveLength(2);
expect(screen.getByText("In Config")).toBeInTheDocument();
expect(screen.getByText("Not Set")).toBeInTheDocument();
});

View file

@ -63,11 +63,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({
};
const renderControl = (setting: AlertingSetting) => {
if (setting.field_type === "Integer") {
if (setting.field_type === "Integer" || setting.field_type === "Float") {
return (
<Input
type="number"
step={1}
step={setting.field_type === "Integer" ? 1 : "any"}
value={setting.field_value ?? ""}
onChange={(event) => handleNumericChange(setting, event.target.value)}
/>

View file

@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { chooseSelectOption } from "../../../tests/test-utils";
import type { Team } from "../key_team_helpers/key_list";
import TeamDropdown from "./team_dropdown";
const TEAMS = [
{ team_id: "team-1", team_alias: "Alpha Team" },
{ team_id: "team-2", team_alias: "Beta Team" },
] as unknown as Team[];
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useInfiniteTeams: () => ({
data: { pages: [{ teams: TEAMS }] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
describe("TeamDropdown", () => {
it("emits the picked team's id and full object", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onTeamSelect = vi.fn();
render(<TeamDropdown onChange={onChange} onTeamSelect={onTeamSelect} />);
await chooseSelectOption(user, screen.getByRole("combobox"), /^Beta Team/);
expect(onChange).toHaveBeenCalledWith("team-2");
expect(onTeamSelect).toHaveBeenCalledWith(TEAMS[1]);
});
it("emits null, never the empty string, when the selection is cleared", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onTeamSelect = vi.fn();
render(<TeamDropdown value="team-1" onChange={onChange} onTeamSelect={onTeamSelect} />);
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onChange).toHaveBeenCalledWith(null);
expect(onTeamSelect).toHaveBeenCalledWith(null);
});
});

View file

@ -5,7 +5,7 @@ import { Team } from "../key_team_helpers/key_list";
interface TeamDropdownProps {
value?: string;
onChange?: (value: string) => void;
onChange?: (value: string | null) => void;
/** Callback with the full Team object (or null on clear). */
onTeamSelect?: (team: Team | null) => void;
disabled?: boolean;
@ -47,7 +47,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
}, [data]);
const handleChange = (teamId: string) => {
onChange?.(teamId);
onChange?.(teamId || null);
if (onTeamSelect) {
onTeamSelect(teamId ? teams.find((t) => t.team_id === teamId) ?? null : null);
}

View file

@ -87,6 +87,7 @@ describe("ModelInfoView", () => {
accessToken: "test-token",
userID: "123",
userRole: "Admin",
isViewOnly: false,
onModelUpdate: vi.fn(),
modelAccessGroups: ["group1", "group2"],
};
@ -328,6 +329,16 @@ describe("ModelInfoView", () => {
});
});
// A proxy_admin_viewer session reads "Admin" through effectiveSessionRole, but the update
// and delete endpoints 403 it, so the write buttons must not be offered.
it("should disable delete and update buttons for a view-only admin session", async () => {
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} isViewOnly={true} />, { wrapper });
await waitFor(() => {
expect(screen.getByTestId("delete-model-button")).toBeDisabled();
});
expect(screen.getByTestId("update-api-key-button")).toBeDisabled();
});
it("should disable delete button when model is not a DB model", async () => {
const nonDbModelData = {
...defaultModelData,

View file

@ -53,6 +53,7 @@ interface ModelInfoViewProps {
accessToken: string | null;
userID: string | null;
userRole: string | null;
isViewOnly: boolean;
onModelUpdate?: (updatedModel: any) => void;
modelAccessGroups: string[] | null;
}
@ -117,6 +118,7 @@ export default function ModelInfoView({
accessToken,
userID,
userRole,
isViewOnly,
onModelUpdate,
modelAccessGroups,
}: ModelInfoViewProps) {
@ -167,7 +169,7 @@ export default function ModelInfoView({
// Keep modelData variable name for backwards compatibility
const modelData = transformedModelData;
const canEditModel = canModifyModel({ userRole, userID }, teams ?? null, {
const canEditModel = canModifyModel({ userRole, userID, isViewOnly }, teams ?? null, {
teamId: modelData?.model_info?.team_id,
isDbModel: modelData?.model_info?.db_model === true,
});

Some files were not shown because too many files have changed in this diff Show more