mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
merge litellm_internal_staging: resolve registry conflicts, defer to upstream gemini 3.5 transcribe entries
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
8a12cae14d
89 changed files with 9320 additions and 1572 deletions
|
|
@ -10,6 +10,11 @@
|
|||
-- partitioned, so existing installs are unaffected until you run this.
|
||||
--
|
||||
-- IMPORTANT
|
||||
-- * After partitioning, `prisma db push` (including the proxy's
|
||||
-- --use_prisma_db_push startup mode) is NOT supported: it tries to rewrite
|
||||
-- the primary key back to ("request_id"), which Postgres rejects on a
|
||||
-- partitioned table. The proxy detects this and exits with guidance.
|
||||
-- Use the default startup path (`prisma migrate deploy`) instead.
|
||||
-- * Test on a staging copy first and take a backup.
|
||||
-- * Postgres cannot convert a populated table to partitioned in place, so this
|
||||
-- renames the old table aside and creates a fresh partitioned table.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.60"
|
||||
version = "0.1.61"
|
||||
description = "Package for LiteLLM Enterprise features"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.1.60"
|
||||
version = "0.1.61"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,65 @@ def _get_prisma_env() -> dict:
|
|||
|
||||
_MIGRATION_TS_RE = re.compile(r"^(\d{14})_")
|
||||
|
||||
_SPEND_LOGS_ALTER_RE = re.compile(r'^ALTER\s+TABLE\s+"LiteLLM_SpendLogs"\s', re.IGNORECASE)
|
||||
_SPEND_LOGS_ARTIFACT_DROP_RE = re.compile(
|
||||
r'^DROP\s+TABLE\s+"LiteLLM_SpendLogs_[^"]*"', re.IGNORECASE
|
||||
)
|
||||
_SPEND_LOGS_PK_CLAUSE_RE = re.compile(
|
||||
r'^(?:DROP\s+CONSTRAINT\s+"[^"]*_pkey"'
|
||||
r'|ADD\s+(?:CONSTRAINT\s+"[^"]*"\s+)?PRIMARY\s+KEY\s*\([^)]*\))$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
PARTITIONED_SPEND_LOGS_PUSH_ERROR = (
|
||||
"LiteLLM_SpendLogs is a partitioned table (see db_scripts/partition_spend_logs.sql), "
|
||||
"so its primary key must include the partition key (\"startTime\"). `prisma db push` "
|
||||
"reconciles the database against schema.prisma, which declares the unpartitioned "
|
||||
"primary key (\"request_id\"), and Postgres rejects that rewrite with: unique "
|
||||
"constraint on partitioned table must include all partitioning columns. Start the "
|
||||
"proxy without --use_prisma_db_push so it uses `prisma migrate deploy`, which only "
|
||||
"applies shipped migrations and leaves the partitioned primary key alone."
|
||||
)
|
||||
|
||||
|
||||
def _without_sql_comments(statement: str) -> str:
|
||||
return "\n".join(
|
||||
line
|
||||
for line in statement.splitlines()
|
||||
if line.strip() and not line.strip().startswith("--")
|
||||
).strip()
|
||||
|
||||
|
||||
def _without_spend_logs_pk_clauses(statement: str) -> Optional[str]:
|
||||
prefix_match = _SPEND_LOGS_ALTER_RE.match(statement)
|
||||
if not prefix_match:
|
||||
return statement
|
||||
kept = tuple(
|
||||
clause.strip()
|
||||
for clause in statement[prefix_match.end():].split(",\n")
|
||||
if not _SPEND_LOGS_PK_CLAUSE_RE.match(clause.strip())
|
||||
)
|
||||
if not kept:
|
||||
return None
|
||||
return statement[: prefix_match.end()] + ",\n".join(kept)
|
||||
|
||||
|
||||
def filter_partitioned_spend_logs_diff(diff_sql: str) -> str:
|
||||
"""Drop statements from a `prisma migrate diff` script that fight the
|
||||
SpendLogs partitioning runbook (db_scripts/partition_spend_logs.sql): the
|
||||
primary-key rewrite on "LiteLLM_SpendLogs", which Postgres rejects on a
|
||||
partitioned table, and drops of runbook artifacts such as
|
||||
"LiteLLM_SpendLogs_legacy"."""
|
||||
kept = tuple(
|
||||
filtered
|
||||
for statement in diff_sql.split(";")
|
||||
for bare in (_without_sql_comments(statement),)
|
||||
if bare and not _SPEND_LOGS_ARTIFACT_DROP_RE.match(bare)
|
||||
for filtered in (_without_spend_logs_pk_clauses(bare),)
|
||||
if filtered is not None
|
||||
)
|
||||
return "".join(f"{statement};\n\n" for statement in kept)
|
||||
|
||||
|
||||
def _migration_timestamp(name: str) -> int:
|
||||
"""Extract the leading `YYYYMMDDHHMMSS` timestamp from a migration name.
|
||||
|
|
@ -355,7 +414,24 @@ class ProxyExtrasDBManager:
|
|||
return
|
||||
logger.info(f"Migration diff created at {diff_sql_path}")
|
||||
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
filtered_sql = filter_partitioned_spend_logs_diff(
|
||||
diff_sql_path.read_text()
|
||||
)
|
||||
diff_sql_path.write_text(filtered_sql)
|
||||
logger.info(
|
||||
"LiteLLM_SpendLogs is partitioned; removed its primary-key "
|
||||
"rewrite and partitioning artifacts from the drift script"
|
||||
)
|
||||
if not filtered_sql.strip():
|
||||
logger.info("Drift script is empty after filtering; nothing to apply")
|
||||
if not mark_all_applied:
|
||||
return
|
||||
ProxyExtrasDBManager._mark_migrations_applied(migrations_dir)
|
||||
return
|
||||
|
||||
# 2. Run prisma db execute to apply the migration
|
||||
applied_ok = False
|
||||
try:
|
||||
logger.info("Running prisma db execute to apply the migration diff...")
|
||||
result = subprocess.run(
|
||||
|
|
@ -376,6 +452,7 @@ class ProxyExtrasDBManager:
|
|||
)
|
||||
logger.info(f"prisma db execute stdout: {result.stdout}")
|
||||
logger.info("✅ Migration diff applied successfully")
|
||||
applied_ok = True
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning(f"Failed to apply migration diff: {e.stderr}")
|
||||
except subprocess.TimeoutExpired:
|
||||
|
|
@ -384,6 +461,16 @@ class ProxyExtrasDBManager:
|
|||
# 3. Mark all migrations as applied
|
||||
if not mark_all_applied:
|
||||
return
|
||||
if not applied_ok:
|
||||
logger.warning(
|
||||
"Drift script failed to apply; NOT marking migrations as "
|
||||
"applied so a later migration run can retry them"
|
||||
)
|
||||
return
|
||||
ProxyExtrasDBManager._mark_migrations_applied(migrations_dir)
|
||||
|
||||
@staticmethod
|
||||
def _mark_migrations_applied(migrations_dir: str):
|
||||
migration_names = ProxyExtrasDBManager._get_migration_names(migrations_dir)
|
||||
logger.info(f"Resolving {len(migration_names)} migrations")
|
||||
for migration_name in migration_names:
|
||||
|
|
@ -410,6 +497,55 @@ class ProxyExtrasDBManager:
|
|||
f"Failed to resolve migration {migration_name}: {e.stderr}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def spend_logs_is_partitioned() -> bool:
|
||||
"""True when the connected database's LiteLLM_SpendLogs is a
|
||||
partitioned table in Prisma's target schema (the `schema` URL param,
|
||||
falling back to Prisma's default target, public), i.e. the operator
|
||||
ran db_scripts/partition_spend_logs.sql. Returns False when psycopg is
|
||||
unavailable or the database cannot be reached, preserving the
|
||||
pre-existing behavior in those cases."""
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
return False
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
cleaned_url = ProxyExtrasDBManager._strip_prisma_query_params(database_url)
|
||||
try:
|
||||
with psycopg.connect(
|
||||
cleaned_url, connect_timeout=10, autocommit=True
|
||||
) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 "
|
||||
"FROM pg_partitioned_table pt "
|
||||
"JOIN pg_class c ON c.oid = pt.partrelid "
|
||||
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.relname = 'LiteLLM_SpendLogs' "
|
||||
" AND n.nspname = %s",
|
||||
(
|
||||
ProxyExtrasDBManager._prisma_schema_param(database_url)
|
||||
or "public",
|
||||
),
|
||||
).fetchone()
|
||||
except (psycopg.OperationalError, psycopg.DatabaseError):
|
||||
return False
|
||||
return row is not None
|
||||
|
||||
@staticmethod
|
||||
def _prisma_schema_param(url: str) -> Optional[str]:
|
||||
"""The `schema` query param Prisma uses to pick its target schema,
|
||||
or None when the URL does not set one."""
|
||||
from urllib.parse import urlparse, parse_qsl
|
||||
|
||||
return next(
|
||||
(v for k, v in parse_qsl(urlparse(url).query) if k == "schema"),
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _strip_prisma_query_params(url: str) -> str:
|
||||
"""Remove Prisma-specific query params (connection_limit, pool_timeout,
|
||||
|
|
@ -528,7 +664,8 @@ class ProxyExtrasDBManager:
|
|||
migrations_dir = ProxyExtrasDBManager._get_prisma_dir()
|
||||
|
||||
if not use_migrate:
|
||||
# Preserve `prisma db push` path unchanged.
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
|
||||
original_dir = os.getcwd()
|
||||
os.chdir(migrations_dir)
|
||||
try:
|
||||
|
|
@ -972,6 +1109,8 @@ class ProxyExtrasDBManager:
|
|||
)
|
||||
raise
|
||||
else:
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
|
||||
# Use prisma db push with increased timeout
|
||||
subprocess.run(
|
||||
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.89"
|
||||
version = "0.4.90"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.89"
|
||||
version = "0.4.90"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -445,6 +445,7 @@ max_ui_session_budget: Optional[float] = (
|
|||
1.0 # USD budget for each dashboard login session (playground, test connection)
|
||||
)
|
||||
internal_user_budget_duration: Optional[str] = None
|
||||
budget_rollover: bool = False # carry spend beyond max_budget into the next window instead of zeroing it
|
||||
tag_budget_config: Optional[Dict[str, "BudgetConfig"]] = None
|
||||
max_end_user_budget: Optional[float] = None
|
||||
max_end_user_budget_id: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -1652,6 +1652,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES: Final = [
|
|||
"enable_anthropic_prompt_caching",
|
||||
"anthropic_prompt_caching_ttl",
|
||||
"max_ui_session_budget",
|
||||
"budget_rollover",
|
||||
]
|
||||
SPECIAL_LITELLM_AUTH_TOKEN: Final = ["ui-token"]
|
||||
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60))
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ class SpanEmitter:
|
|||
For callers that own and manage their own span lifecycle. ``tracer``
|
||||
overrides the bound tracer for this span only, used for per-request
|
||||
multi-tenant credential routing. ``links`` records related-but-not-parent
|
||||
spans (e.g. the transport span of an MCP message, per MCP semconv).
|
||||
spans (e.g. the trace context an MCP client propagated in ``params._meta``).
|
||||
"""
|
||||
return (tracer or self._tracer).start_span(
|
||||
name,
|
||||
|
|
@ -196,8 +196,8 @@ class SpanEmitter:
|
|||
|
||||
Return the span, or ``None`` if it was deduplicated away. ``tracer``
|
||||
overrides the bound tracer for this span, used for per-request routing.
|
||||
``links`` records related-but-not-parent spans (the transport span of an
|
||||
MCP message).
|
||||
``links`` records related-but-not-parent spans (e.g. the trace context an
|
||||
MCP client propagated in ``params._meta``).
|
||||
"""
|
||||
# LLM-call and MCP tool-call spans carry a dedup key (their request's
|
||||
# call id), so a sync+async double-firing coalesces. ``isinstance`` narrows
|
||||
|
|
|
|||
|
|
@ -390,10 +390,10 @@ class OpenTelemetryV2(CustomLogger):
|
|||
|
||||
MCP tool calls reach the success/failure callbacks like any other request
|
||||
(with ``call_type`` ``call_mcp_tool``), but they are not LLM calls and have
|
||||
no ``pre_call`` carrier — so they get their own CLIENT span here. Per the MCP
|
||||
semconv it parents to the trace context the client propagated in
|
||||
``params._meta`` (or starts a new root) and links the transport span, rather
|
||||
than nesting under the HTTP/session span. Returns whether it handled the
|
||||
no ``pre_call`` carrier — so they get their own CLIENT span here. It nests
|
||||
under the transport span of the request carrying this message, and trace
|
||||
context the client propagated in ``params._meta`` is recorded as a span
|
||||
link (see ``resolve_mcp_span_context``). Returns whether it handled the
|
||||
event, so the caller skips the LLM-call path. The whole span is emitted at
|
||||
once (there is no boundary to open it at), deduped on the call id.
|
||||
"""
|
||||
|
|
@ -436,9 +436,9 @@ class OpenTelemetryV2(CustomLogger):
|
|||
|
||||
Like a tool call, listing reaches the success/failure callbacks (here with
|
||||
``call_type`` ``list_mcp_tools``) with no ``pre_call`` carrier, so it gets its
|
||||
own CLIENT span. Per the MCP semconv it parents to the ``params._meta`` trace
|
||||
context (or starts a new root) and links the transport span, rather than
|
||||
nesting under the HTTP/session span. Returns whether it handled the event so
|
||||
own CLIENT span, nested under the transport span of the request carrying
|
||||
this message with any ``params._meta`` trace context recorded as a span
|
||||
link (see ``resolve_mcp_span_context``). Returns whether it handled the event so
|
||||
the caller skips the LLM-call path.
|
||||
"""
|
||||
raw_payload: Final = kwargs.get("standard_logging_object")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ Canonical hierarchy::
|
|||
│ └── DB_CALL (CLIENT) # its key/user/team lookups nest here
|
||||
├── GUARDRAIL (INTERNAL) # request-lifecycle hook, sibling of LLM_CALL
|
||||
├── LLM_CALL (CLIENT)
|
||||
├── MCP_TOOL_CALL (CLIENT) # nests under the POST carrying the message
|
||||
├── MCP_LIST_TOOLS (CLIENT) # (client-propagated context is a span link)
|
||||
└── DB_CALL (CLIENT) # e.g. the spend-log write
|
||||
|
||||
Guardrails parent to PROXY_REQUEST, not LLM_CALL: pre/during/post-call guardrail
|
||||
|
|
@ -18,14 +20,14 @@ before the LLM call even starts), so a guardrail is a sibling of the LLM call,
|
|||
not a child of it. The emitter parents every span to the ambient OTel context
|
||||
(the active server span), which matches this.
|
||||
|
||||
MCP spans (``MCP_TOOL_CALL``, ``MCP_LIST_TOOLS``) have two shapes, chosen at emit
|
||||
time by :func:`resolve_mcp_span_context`. When the client propagates trace context
|
||||
in ``params._meta`` MCP and the HTTP transport are independent contexts per the
|
||||
OTel GenAI MCP semconv, so the span parents to that propagated context and records
|
||||
the ``PROXY_REQUEST`` transport span as a span *link*, never a parent — the shape
|
||||
this registry's ``parent=None, links=PROXY_REQUEST`` entry encodes. When nothing is
|
||||
propagated (the common case) the span nests under the transport span of the request
|
||||
carrying that message, so the tool call stays in one trace.
|
||||
MCP spans (``MCP_TOOL_CALL``, ``MCP_LIST_TOOLS``) are parented at emit time by
|
||||
:func:`resolve_mcp_span_context`: they nest under the ``PROXY_REQUEST`` transport
|
||||
span of the request carrying that message, so the tool call stays in one trace.
|
||||
Trace context the client propagated in ``params._meta`` (SEP-414) is recorded as
|
||||
a span *link*, never the parent — a remote parent would root the span in a trace
|
||||
whose root never reaches the gateway's tracing backend. Links always target that
|
||||
remote client context, never a registry role, so ``SpanSpec`` declares no link
|
||||
field; the concrete transport parent is resolved per message at emit time.
|
||||
|
||||
Not every service call becomes a span — :func:`span_role_for_service` decides:
|
||||
|
||||
|
|
@ -85,25 +87,19 @@ class SpanSpec:
|
|||
role: SpanRole
|
||||
kind: LiteLLMSpanKind
|
||||
parent: SpanRole | None
|
||||
links: SpanRole | None = None
|
||||
|
||||
|
||||
SPAN_REGISTRY: Final[dict[SpanRole, SpanSpec]] = {
|
||||
SpanRole.PROXY_REQUEST: SpanSpec(SpanRole.PROXY_REQUEST, LiteLLMSpanKind.SERVER, parent=None),
|
||||
SpanRole.LLM_CALL: SpanSpec(SpanRole.LLM_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
|
||||
# The proxy is an MCP client to the upstream server, so MCP spans are CLIENT
|
||||
# spans. With trace context propagated in ``params._meta``, MCP and the HTTP
|
||||
# transport are independent contexts (OTel GenAI MCP semconv): the span parents
|
||||
# to the propagated context and records the PROXY_REQUEST transport span as a
|
||||
# span *link*, never a parent — the shape ``parent=None, links=PROXY_REQUEST``
|
||||
# encodes. With nothing propagated, ``resolve_mcp_span_context`` nests the span
|
||||
# under that message's transport span instead, keeping the call in one trace.
|
||||
SpanRole.MCP_TOOL_CALL: SpanSpec(
|
||||
SpanRole.MCP_TOOL_CALL, LiteLLMSpanKind.CLIENT, parent=None, links=SpanRole.PROXY_REQUEST
|
||||
),
|
||||
SpanRole.MCP_LIST_TOOLS: SpanSpec(
|
||||
SpanRole.MCP_LIST_TOOLS, LiteLLMSpanKind.CLIENT, parent=None, links=SpanRole.PROXY_REQUEST
|
||||
),
|
||||
# spans. ``resolve_mcp_span_context`` nests them under the PROXY_REQUEST
|
||||
# transport span of the request carrying that message (resolved per message at
|
||||
# emit time), keeping the call in one trace. Trace context the client
|
||||
# propagated in ``params._meta`` becomes a span *link* to that remote context,
|
||||
# which is not a registry role, so ``SpanSpec`` has no link field.
|
||||
SpanRole.MCP_TOOL_CALL: SpanSpec(SpanRole.MCP_TOOL_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
|
||||
SpanRole.MCP_LIST_TOOLS: SpanSpec(SpanRole.MCP_LIST_TOOLS, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
|
||||
SpanRole.GUARDRAIL: SpanSpec(SpanRole.GUARDRAIL, LiteLLMSpanKind.INTERNAL, parent=SpanRole.PROXY_REQUEST),
|
||||
SpanRole.DB_CALL: SpanSpec(SpanRole.DB_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
|
||||
SpanRole.SERVICE: SpanSpec(SpanRole.SERVICE, LiteLLMSpanKind.INTERNAL, parent=SpanRole.PROXY_REQUEST),
|
||||
|
|
@ -209,8 +205,8 @@ def service_span_name(data: "ServiceSpanData") -> str:
|
|||
|
||||
|
||||
def root_roles() -> list[SpanRole]:
|
||||
"""Roles with no in-process parent. They start a new trace unless they adopt a
|
||||
remote parent (e.g. an MCP span joining the client's propagated context)."""
|
||||
"""Roles with no in-process parent, i.e. they start a new trace (only the
|
||||
instrumentor-owned ``PROXY_REQUEST`` server span today)."""
|
||||
return [role for role, spec in SPAN_REGISTRY.items() if spec.parent is None]
|
||||
|
||||
|
||||
|
|
@ -227,8 +223,6 @@ def validate_registry(
|
|||
raise ValueError(f"SPAN_REGISTRY[{role}] has mismatched role {spec.role}")
|
||||
if spec.parent is not None and spec.parent not in reg:
|
||||
raise ValueError(f"span role {role} declares unknown parent {spec.parent}")
|
||||
if spec.links is not None and spec.links not in reg:
|
||||
raise ValueError(f"span role {role} declares unknown link target {spec.links}")
|
||||
missing: Final = [role for role in SpanRole if role not in reg]
|
||||
if missing:
|
||||
raise ValueError(f"SPAN_REGISTRY is missing roles: {missing}")
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ def request_root_span() -> "Span | None":
|
|||
|
||||
# The W3C trace-context carrier (``traceparent``/``tracestate``/``baggage``) the
|
||||
# MCP client propagated in the current request's ``params._meta``. The MCP gateway
|
||||
# sets it per message so the MCP span can parent to the client's span rather than
|
||||
# to the transport. A ``ContextVar`` because, like the root-span anchor, it must
|
||||
# sets it per message so the MCP span can record the client's span as a span
|
||||
# link. A ``ContextVar`` because, like the root-span anchor, it must
|
||||
# ride the request task and be readable by the inline success-logging callback.
|
||||
_mcp_message_trace_carrier: Final["ContextVar[Mapping[str, str] | None]"] = ContextVar(
|
||||
"litellm_otel_mcp_message_trace_carrier", default=None
|
||||
|
|
@ -148,10 +148,10 @@ def _mcp_transport_span_context() -> "SpanContext | None":
|
|||
|
||||
Prefers the transport the gateway published for this specific message; falls
|
||||
back to the ambient request anchor for paths that emit an MCP span on the
|
||||
request task itself (the REST MCP endpoints, the SDK). Parenting and linking
|
||||
only need the immutable context, and unlike ``mcp_message_transport_span`` they
|
||||
stay correct against a transport that has already finished, so this does not
|
||||
require the span to still be recording.
|
||||
request task itself (the REST MCP endpoints). Parenting needs only the
|
||||
immutable context, and unlike ``mcp_message_transport_span`` it stays correct
|
||||
against a transport that has already finished, so this does not require the
|
||||
span to still be recording.
|
||||
"""
|
||||
published: Final = _mcp_message_transport_span.get()
|
||||
if published is not None:
|
||||
|
|
@ -222,25 +222,31 @@ def resolve_mcp_span_context(
|
|||
) -> "tuple[Context, tuple[Link, ...]]":
|
||||
"""Parent context + links for an MCP message span.
|
||||
|
||||
The span always nests under the transport span of the request carrying this
|
||||
message, so a tool call and the ``POST`` that carried it stay in one trace.
|
||||
The transport comes from :func:`_mcp_transport_span_context`, which is the
|
||||
*current message's* POST rather than whatever request happened to open the
|
||||
session, so a long-lived session does not glue every message under its first
|
||||
request.
|
||||
|
||||
When the client propagates W3C trace context in the request's ``params._meta``
|
||||
(SEP-414), MCP and the underlying transport are independent lifecycles — one
|
||||
streamable-HTTP session multiplexes many messages, and the client's own span is
|
||||
the truthful parent. So, per the OTel GenAI MCP semconv:
|
||||
(SEP-414), that remote context is recorded as a span *link*, never the parent.
|
||||
The OTel GenAI MCP semconv prefers the inverse (remote parent, transport link),
|
||||
but the gateway's tracing backend only ever receives the gateway's half of such
|
||||
a trace: parenting into the client's trace id roots the span in a trace whose
|
||||
root span never reaches the backend, so the span is unreachable from the trace
|
||||
view and the transport transaction shows a dangling link (observed with
|
||||
clients that propagate synthetic trace ids). Anchoring to the gateway's own
|
||||
request and linking the client's context keeps every trace renderable while
|
||||
preserving the client-side correlation.
|
||||
|
||||
* parent to the trace context the client propagated (a *remote* parent), and
|
||||
* record the transport span as a *link*, never the parent.
|
||||
|
||||
Almost no client implements SEP-414 yet, so in practice nothing is propagated.
|
||||
Rooting the span there splits a single tool call into two disconnected traces
|
||||
joined only by a link, which is how it surfaces in APM: the ``POST`` transaction
|
||||
and the ``tools/call`` span share no trace. With no remote parent to honor,
|
||||
parent to the transport span of the request carrying this message instead, so
|
||||
the call stays in one trace; no link is added since the transport is now the
|
||||
real parent. The transport comes from :func:`_mcp_transport_span_context`, which
|
||||
is the *current message's* POST rather than whatever request happened to open
|
||||
the session, so a long-lived session does not glue every message under its
|
||||
first request. With neither a remote parent nor a transport the returned context
|
||||
carries no span and the span legitimately starts its own root trace.
|
||||
With no transport at all the span starts its own root trace, still carrying
|
||||
the link — the client context is only ever a link, so this event keeps one
|
||||
shape everywhere. Both returned contexts are built on an explicitly empty
|
||||
base, so ambient (stale session) state can never leak in, and the span
|
||||
inherits the transport's sampling decision exactly like every other
|
||||
request-level span — a client's sampled flag neither forces nor suppresses
|
||||
recording.
|
||||
|
||||
Only trace context (``traceparent``/``tracestate``) is extracted, never the
|
||||
client's W3C Baggage: ``params._meta`` is caller-controlled, and the otel
|
||||
|
|
@ -251,13 +257,12 @@ def resolve_mcp_span_context(
|
|||
never fall through to the ambient (stale session) span.
|
||||
"""
|
||||
source: Final = carrier if carrier is not None else _mcp_message_trace_carrier.get()
|
||||
parent: Final = _PROPAGATOR.extract(dict(source or {}), context=Context())
|
||||
propagated: Final = get_current_span(_PROPAGATOR.extract(dict(source or {}), context=Context()))
|
||||
links: Final = (Link(propagated.get_span_context()),) if is_recordable_span(propagated) else ()
|
||||
transport: Final = _mcp_transport_span_context()
|
||||
if is_recordable_span(get_current_span(parent)):
|
||||
return parent, (Link(transport),) if transport is not None else ()
|
||||
if transport is not None:
|
||||
return context_from_span(NonRecordingSpan(transport)), ()
|
||||
return parent, ()
|
||||
if transport is None:
|
||||
return Context(), links
|
||||
return context_from_span(NonRecordingSpan(transport), context=Context()), links
|
||||
|
||||
|
||||
def is_recordable_span(obj: object) -> bool:
|
||||
|
|
|
|||
|
|
@ -330,6 +330,24 @@ class RealTimeStreaming:
|
|||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
def _flush_unbilled_transcription_usage(self) -> None:
|
||||
if self.provider_config is None:
|
||||
return
|
||||
usage: Final = self.provider_config.unbilled_usage_on_session_close(self.model)
|
||||
if usage is None:
|
||||
return
|
||||
flush_event: Final = (
|
||||
cast( # cast-ok: usage-only partial event, the same shape _capture_transcription_usage logs
|
||||
OpenAIRealtimeEvents,
|
||||
{
|
||||
"type": "conversation.item.input_audio_transcription.completed",
|
||||
"usage": usage,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.store_message(flush_event)
|
||||
self._capture_transcription_usage(flush_event)
|
||||
|
||||
def _collect_tool_calls_from_response_done(self, event_obj: dict | OpenAIRealtimeEvents) -> None:
|
||||
"""Extract function_call items from response.done events for spend logging."""
|
||||
try:
|
||||
|
|
@ -1069,6 +1087,7 @@ class RealTimeStreaming:
|
|||
except Exception as e:
|
||||
verbose_logger.exception("Error in backend to client send messages: %s", e)
|
||||
finally:
|
||||
self._flush_unbilled_transcription_usage()
|
||||
await self.log_messages()
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import httpx
|
|||
|
||||
from litellm.types.llms.openai import OpenAIRealtimeStreamSessionEvents
|
||||
from litellm.types.realtime import (
|
||||
RealtimeInputAudioTranscriptionUsage,
|
||||
RealtimeResponseTransformInput,
|
||||
RealtimeResponseTypedDict,
|
||||
)
|
||||
|
|
@ -70,6 +71,9 @@ class BaseRealtimeConfig(ABC):
|
|||
def session_configuration_request(self, model: str) -> str | None: # message sent to setup the realtime session
|
||||
return None
|
||||
|
||||
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
|
||||
return None
|
||||
|
||||
def transform_session_created_event(
|
||||
self,
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -1191,6 +1191,9 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
}
|
||||
return usage
|
||||
|
||||
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
|
||||
return self._consume_input_transcription_usage_estimate(model)
|
||||
|
||||
def transform_realtime_response(
|
||||
self,
|
||||
message: str | bytes,
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,7 @@ def _register_custom_pricing_for_request(
|
|||
shared_key: CustomPricingLiteLLMParams.strip_custom_pricing_fields(entry),
|
||||
},
|
||||
persist_across_reloads=False,
|
||||
warning_display_name=shared_key,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5256,7 +5256,9 @@ class MCPServerManager:
|
|||
proxy_logging_obj: Optional ProxyLogging object for hook integration
|
||||
host_progress_callback: Optional callback for progress updates
|
||||
hook_extra_headers: Optional headers injected by pre_mcp_call guardrail
|
||||
hooks. Merged last (highest priority) into outbound request headers.
|
||||
hooks. Merged last into outbound request headers, except a hook
|
||||
Authorization header is dropped when an upstream credential already
|
||||
occupies the Authorization slot.
|
||||
|
||||
Returns:
|
||||
CallToolResult from the MCP server
|
||||
|
|
@ -5347,27 +5349,26 @@ class MCPServerManager:
|
|||
if hook_extra_headers:
|
||||
if extra_headers is None:
|
||||
extra_headers = {}
|
||||
if "Authorization" in hook_extra_headers:
|
||||
if "Authorization" in extra_headers:
|
||||
verbose_logger.warning(
|
||||
"MCPServerManager: hook_extra_headers 'Authorization' will overwrite "
|
||||
"the existing Authorization header from static_headers. "
|
||||
"The hook JWT will take precedence."
|
||||
)
|
||||
elif server_auth_header is not None:
|
||||
# server_auth_header is passed separately to _create_mcp_client as
|
||||
# auth_value. Both will reach the upstream server — warn so admins
|
||||
# know two Authorization credentials are being sent.
|
||||
verbose_logger.warning(
|
||||
"MCPServerManager: hook_extra_headers injects 'Authorization' while "
|
||||
"server '%s' already has a configured authentication_token. "
|
||||
"Both credentials will be sent; the hook header is in extra_headers "
|
||||
"and the server token is in auth_value — the upstream server decides "
|
||||
"which one wins. Consider unsetting authentication_token if you want "
|
||||
"the hook JWT to be the sole credential.",
|
||||
mcp_server.server_name or mcp_server.name,
|
||||
)
|
||||
extra_headers.update(hook_extra_headers)
|
||||
hook_has_authorization: Final = any(k.lower() == "authorization" for k in hook_extra_headers)
|
||||
existing_has_authorization: Final = any(k.lower() == "authorization" for k in extra_headers)
|
||||
server_auth_occupies_authorization: Final = (
|
||||
any(k.lower() == "authorization" for k in server_auth_header)
|
||||
if isinstance(server_auth_header, dict)
|
||||
else server_auth_header is not None and mcp_server.auth_type != MCPAuth.api_key
|
||||
)
|
||||
if hook_has_authorization and (existing_has_authorization or server_auth_occupies_authorization):
|
||||
# Mirror the tools/list signer guard: an upstream credential (user OAuth,
|
||||
# static header, or configured authentication_token) already occupies the
|
||||
# Authorization slot, so the hook must not replace it.
|
||||
verbose_logger.warning(
|
||||
"MCPServerManager: dropping hook-injected 'Authorization' header for "
|
||||
"server '%s' because an upstream credential already occupies the "
|
||||
"Authorization slot; the existing credential is kept.",
|
||||
mcp_server.server_name or mcp_server.name,
|
||||
)
|
||||
extra_headers.update({k: v for k, v in hook_extra_headers.items() if k.lower() != "authorization"})
|
||||
else:
|
||||
extra_headers.update(hook_extra_headers)
|
||||
|
||||
# Reset to None if no headers were actually added
|
||||
if extra_headers is not None and len(extra_headers) == 0:
|
||||
|
|
|
|||
|
|
@ -246,11 +246,12 @@ def _mcp_meta_trace_carrier(req_ctx: object) -> dict[str, str] | None:
|
|||
"""The W3C trace context (``traceparent``/``tracestate``) the MCP client
|
||||
propagated in the request's ``params._meta`` (SEP-414), or ``None``.
|
||||
|
||||
When present, per the OTel MCP semconv the MCP span parents to this propagated
|
||||
context rather than to the HTTP transport (which is recorded as a link instead).
|
||||
When absent, the span nests under the transport span of the request carrying
|
||||
this specific message, so a streamable-HTTP session that multiplexes many
|
||||
messages still does not glue every message under the session's first request;
|
||||
When present, the MCP span records this propagated context as a span *link*,
|
||||
never the parent — a remote parent would root the span in a trace whose root
|
||||
never reaches the gateway's tracing backend. The span itself nests under the
|
||||
transport span of the request carrying this specific message, so a
|
||||
streamable-HTTP session that multiplexes many messages still does not glue
|
||||
every message under the session's first request;
|
||||
see ``resolve_mcp_span_context``. The client's W3C Baggage is
|
||||
deliberately excluded: it is caller-controlled, and the otel baggage processor
|
||||
stamps allowlisted baggage keys (``litellm.team.id``, ``litellm.metadata.*``,
|
||||
|
|
|
|||
|
|
@ -2510,6 +2510,18 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
"are skipped for on-demand GET /health as well as the background health loop."
|
||||
),
|
||||
)
|
||||
background_health_check_model_groups: tuple[str, ...] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Opt-in allowlist of model group names for background health checks and "
|
||||
"health-check routing. When set, the background loop probes only deployments "
|
||||
"whose model_name is listed, and enable_health_check_routing filters unhealthy "
|
||||
"deployments only within the listed groups; every other group, including newly "
|
||||
"added deployments, is skipped and keeps its configured routing strategy. "
|
||||
"When unset, all deployments participate (opt out per deployment via "
|
||||
"model_info.disable_background_health_check)."
|
||||
),
|
||||
)
|
||||
model_list_healthy_only: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -1769,7 +1769,12 @@ async def _user_api_key_auth_builder(
|
|||
|
||||
return valid_token
|
||||
|
||||
if valid_token is not None and isinstance(valid_token, UserAPIKeyAuth) and valid_token.team_id is not None:
|
||||
if (
|
||||
valid_token is not None
|
||||
and isinstance(valid_token, UserAPIKeyAuth)
|
||||
and valid_token.team_id is not None
|
||||
and valid_token.team_id != UI_TEAM_ID
|
||||
):
|
||||
## UPDATE TEAM VALUES BASED ON CACHED TEAM OBJECT - allows `/team/update` values to work for cached token
|
||||
try:
|
||||
team_obj: Final[LiteLLM_TeamTableCachedObj] = await get_team_object(
|
||||
|
|
@ -2149,6 +2154,8 @@ async def _user_api_key_auth_builder(
|
|||
# Check 6: Additional Common Checks across jwt + key auth
|
||||
if valid_token.team_id is not None:
|
||||
try:
|
||||
if valid_token.team_id == UI_TEAM_ID:
|
||||
raise TeamNotFoundError(team_id=UI_TEAM_ID)
|
||||
with tracer.trace("litellm.proxy.auth.get_team_object"):
|
||||
_team_obj = await get_team_object(
|
||||
team_id=valid_token.team_id,
|
||||
|
|
@ -2443,7 +2450,7 @@ async def _run_centralized_common_checks(
|
|||
)
|
||||
|
||||
fetch_coros: Final = []
|
||||
if user_api_key_auth_obj.team_id is not None:
|
||||
if user_api_key_auth_obj.team_id is not None and user_api_key_auth_obj.team_id != UI_TEAM_ID:
|
||||
fetch_coros.append(
|
||||
_safe_fetch(
|
||||
"team",
|
||||
|
|
@ -2567,7 +2574,9 @@ async def _run_centralized_common_checks(
|
|||
else:
|
||||
raise team_result
|
||||
else:
|
||||
team_object = team_result
|
||||
team_object = (
|
||||
_team_obj_from_token(user_api_key_auth_obj) if user_api_key_auth_obj.team_id == UI_TEAM_ID else team_result
|
||||
)
|
||||
|
||||
user_object: LiteLLM_UserTable | None = None if isinstance(user_result, BaseException) else user_result
|
||||
project_object: Final[LiteLLM_ProjectTableCachedObj | None] = (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -45,6 +46,7 @@ from litellm.repositories.table_repositories import (
|
|||
)
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.repositories.unit_of_work import (
|
||||
LinkedSpendResetWrites,
|
||||
budget_cascade_unit_of_work,
|
||||
spend_reset_unit_of_work,
|
||||
)
|
||||
|
|
@ -59,7 +61,15 @@ _LINKED_KEYS_WHERE: Final[Mapping[str, object]] = MappingProxyType({"budget_dura
|
|||
_SPENT_ROWS_WHERE: Final[Mapping[str, object]] = MappingProxyType({"spend": {"gt": 0}})
|
||||
|
||||
|
||||
class _TeamMembershipRow(Protocol):
|
||||
class _BudgetLinkedRow(Protocol):
|
||||
@property
|
||||
def spend(self) -> float | None: ...
|
||||
|
||||
@property
|
||||
def budget_id(self) -> str | None: ...
|
||||
|
||||
|
||||
class _TeamMembershipRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def user_id(self) -> str: ...
|
||||
|
||||
|
|
@ -67,26 +77,48 @@ class _TeamMembershipRow(Protocol):
|
|||
def team_id(self) -> str: ...
|
||||
|
||||
|
||||
class _KeyRow(Protocol):
|
||||
class _KeyRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def token(self) -> str: ...
|
||||
|
||||
|
||||
class _OrgRow(Protocol):
|
||||
class _OrgRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def organization_id(self) -> str: ...
|
||||
|
||||
|
||||
class _TagRow(Protocol):
|
||||
class _TagRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def tag_name(self) -> str: ...
|
||||
|
||||
|
||||
class _EndUserRow(Protocol):
|
||||
class _EndUserRow(_BudgetLinkedRow, Protocol):
|
||||
@property
|
||||
def user_id(self) -> str: ...
|
||||
|
||||
|
||||
def _rollover_enabled() -> bool:
|
||||
return litellm.budget_rollover is True
|
||||
|
||||
|
||||
def _rollover_cap(max_budget: float | None) -> float | None:
|
||||
if max_budget is None or not math.isfinite(max_budget):
|
||||
return None
|
||||
return max_budget
|
||||
|
||||
|
||||
def _carried_spend(spend: float | None, cap: float | None) -> float:
|
||||
if cap is None:
|
||||
return 0.0
|
||||
return max(0.0, (spend or 0.0) - cap)
|
||||
|
||||
|
||||
def _row_carried_spend(row: _BudgetLinkedRow, caps: Mapping[str, float]) -> float:
|
||||
if not caps:
|
||||
return 0.0
|
||||
return _carried_spend(row.spend, caps.get(row.budget_id) if row.budget_id is not None else None)
|
||||
|
||||
|
||||
def _team_membership_counter_key(row: _TeamMembershipRow) -> str:
|
||||
return f"spend:team_member:{row.user_id}:{row.team_id}"
|
||||
|
||||
|
|
@ -129,6 +161,59 @@ def _budget_link_where(
|
|||
return {"budget_id": {"in": list(budget_ids)}, **extra}
|
||||
|
||||
|
||||
def _queue_budget_linked_resets(
|
||||
writes: LinkedSpendResetWrites,
|
||||
cascade: "_BudgetCascade",
|
||||
extra: Mapping[str, object] = MappingProxyType({}),
|
||||
) -> None:
|
||||
"""Reset one linked table's spend for every expiring tier: tiers with a
|
||||
rollover cap keep spend beyond the cap (decrement preserves writes racing
|
||||
the reset), everything else is zeroed as before. Zero the under-cap rows
|
||||
BEFORE decrementing the over-cap ones: the statements run sequentially in
|
||||
one transaction, so the reverse order lets the zero re-match a row the
|
||||
decrement just moved into the (0, cap] range and erase its carried spend."""
|
||||
for budget_id, cap in cascade.rollover_caps.items():
|
||||
writes.queue_spend_zero(
|
||||
where={"budget_id": budget_id, **extra, "spend": {"gt": 0, "lte": cap}}
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
writes.queue_spend_decrement(
|
||||
where={"budget_id": budget_id, **extra, "spend": {"gt": cap}}, amount=cap
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
plain_ids: Final = tuple(bid for bid in cascade.budget_ids if bid not in cascade.rollover_caps)
|
||||
if plain_ids:
|
||||
writes.queue_spend_zero(where=_budget_link_where(plain_ids, extra))
|
||||
|
||||
|
||||
def _queue_enduser_resets(writes: LinkedSpendResetWrites, cascade: "_BudgetCascade") -> None:
|
||||
"""End users are matched by id rather than budget link: rows with no
|
||||
budget_id ride the default budget tier (litellm.max_end_user_budget_id).
|
||||
Zero-before-decrement ordering matters here too (see
|
||||
_queue_budget_linked_resets)."""
|
||||
if not cascade.rollover_caps:
|
||||
if cascade.endusers:
|
||||
writes.queue_spend_zero(
|
||||
where={"user_id": {"in": [row.user_id for row in cascade.endusers]}}
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
return
|
||||
tiered: Final = tuple((row.budget_id or litellm.max_end_user_budget_id, row.user_id) for row in cascade.endusers)
|
||||
for budget_id, cap in cascade.rollover_caps.items():
|
||||
if not (
|
||||
user_ids := [uid for bid, uid in tiered if bid == budget_id]
|
||||
): # mutable-ok: prisma "in" filter takes a list
|
||||
continue
|
||||
writes.queue_spend_zero(
|
||||
where={"user_id": {"in": user_ids}, "spend": {"lte": cap}}
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
writes.queue_spend_decrement(
|
||||
where={"user_id": {"in": user_ids}, "spend": {"gt": cap}}, amount=cap
|
||||
) # mutable-ok: prisma where filter must be a dict
|
||||
plain: Final = [
|
||||
uid for bid, uid in tiered if bid is None or bid not in cascade.rollover_caps
|
||||
] # mutable-ok: prisma "in" filter takes a list
|
||||
if plain:
|
||||
writes.queue_spend_zero(where={"user_id": {"in": plain}}) # mutable-ok: prisma where filter must be a dict
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _BudgetCascade:
|
||||
"""Everything one budget-tier reset touches, resolved before any write."""
|
||||
|
|
@ -137,8 +222,9 @@ class _BudgetCascade:
|
|||
budget_ids: tuple[str, ...] = ()
|
||||
budget_resets: tuple[tuple[str, datetime], ...] = ()
|
||||
endusers: tuple[_EndUserRow, ...] = ()
|
||||
counter_keys: tuple[str, ...] = ()
|
||||
counter_resets: tuple[tuple[str, float], ...] = ()
|
||||
cache_keys: tuple[str, ...] = ()
|
||||
rollover_caps: Mapping[str, float] = MappingProxyType({})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -404,8 +490,10 @@ class ResetBudgetJob:
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
async def _invalidate_spend_counter(counter_key: str) -> None:
|
||||
"""Zero a spend counter so a DB-row reset takes effect immediately.
|
||||
async def _invalidate_spend_counter(counter_key: str, new_spend: float = 0.0) -> None:
|
||||
"""Overwrite a spend counter with the post-reset value (0, or the carried
|
||||
overage when budget rollover is enabled) so a DB-row reset takes effect
|
||||
immediately.
|
||||
|
||||
Call AFTER the DB write commits. Clearing Redis before the DB
|
||||
commit opens a window where get_current_spend reads 0 from Redis
|
||||
|
|
@ -414,10 +502,10 @@ class ResetBudgetJob:
|
|||
try:
|
||||
from litellm.proxy.proxy_server import spend_counter_cache
|
||||
|
||||
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0, ttl=60)
|
||||
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=new_spend, ttl=60)
|
||||
if spend_counter_cache.redis_cache is not None:
|
||||
try:
|
||||
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=0.0, ttl=60)
|
||||
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=new_spend, ttl=60)
|
||||
except Exception as redis_err:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to reset spend counter %s in Redis: %s. "
|
||||
|
|
@ -522,6 +610,15 @@ class ResetBudgetJob:
|
|||
where=_budget_link_where(budget_ids, _SPENT_ROWS_WHERE),
|
||||
log_subject="tags",
|
||||
)
|
||||
rollover_caps: Final[Mapping[str, float]] = MappingProxyType(
|
||||
{ # mutable-ok: MappingProxyType wraps a one-shot dict comprehension
|
||||
b.budget_id: cap
|
||||
for b in budgets_to_reset
|
||||
if b.budget_id is not None and (cap := _rollover_cap(b.max_budget)) is not None
|
||||
}
|
||||
if _rollover_enabled()
|
||||
else {} # mutable-ok: empty sentinel immediately frozen by MappingProxyType
|
||||
)
|
||||
return _BudgetCascade(
|
||||
budgets=tuple(budgets_to_reset),
|
||||
budget_ids=budget_ids,
|
||||
|
|
@ -534,12 +631,16 @@ class ResetBudgetJob:
|
|||
if b.budget_id is not None and b.budget_duration is not None
|
||||
),
|
||||
endusers=await self._collect_endusers_to_reset(budget_ids),
|
||||
counter_keys=(
|
||||
*(_team_membership_counter_key(row) for row in team_memberships),
|
||||
*(_key_counter_key(row) for row in keys),
|
||||
*(_org_counter_key(row) for row in orgs),
|
||||
*(_tag_counter_key(row) for row in tags),
|
||||
counter_resets=(
|
||||
*(
|
||||
(_team_membership_counter_key(row), _row_carried_spend(row, rollover_caps))
|
||||
for row in team_memberships
|
||||
),
|
||||
*((_key_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in keys),
|
||||
*((_org_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in orgs),
|
||||
*((_tag_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in tags),
|
||||
),
|
||||
rollover_caps=rollover_caps,
|
||||
cache_keys=(
|
||||
*(key for row in team_memberships for key in _team_membership_cache_keys(row)),
|
||||
*(key for row in keys for key in _key_cache_keys(row)),
|
||||
|
|
@ -565,20 +666,18 @@ class ResetBudgetJob:
|
|||
)
|
||||
|
||||
async def _commit_budget_cascade_once(self, cascade: _BudgetCascade) -> None:
|
||||
enduser_ids: Final = tuple(row.user_id for row in cascade.endusers)
|
||||
async with budget_cascade_unit_of_work(self.prisma_client.db.batch_) as uow:
|
||||
uow.team_memberships.queue_spend_zero(where=_budget_link_where(cascade.budget_ids))
|
||||
uow.keys.queue_spend_zero(where=_budget_link_where(cascade.budget_ids, _LINKED_KEYS_WHERE))
|
||||
uow.organizations.queue_spend_zero(where=_budget_link_where(cascade.budget_ids, _SPENT_ROWS_WHERE))
|
||||
uow.tags.queue_spend_zero(where=_budget_link_where(cascade.budget_ids, _SPENT_ROWS_WHERE))
|
||||
if enduser_ids:
|
||||
uow.endusers.queue_spend_zero(where={"user_id": {"in": list(enduser_ids)}})
|
||||
_queue_budget_linked_resets(uow.team_memberships, cascade)
|
||||
_queue_budget_linked_resets(uow.keys, cascade, extra=_LINKED_KEYS_WHERE)
|
||||
_queue_budget_linked_resets(uow.organizations, cascade, extra=_SPENT_ROWS_WHERE)
|
||||
_queue_budget_linked_resets(uow.tags, cascade, extra=_SPENT_ROWS_WHERE)
|
||||
_queue_enduser_resets(uow.endusers, cascade)
|
||||
for budget_id, budget_reset_at in cascade.budget_resets:
|
||||
uow.budgets.queue_window_advance(budget_id=budget_id, budget_reset_at=budget_reset_at)
|
||||
|
||||
async def _invalidate_budget_cascade_caches(self, cascade: _BudgetCascade) -> None:
|
||||
for counter_key in cascade.counter_keys:
|
||||
await self._invalidate_spend_counter(counter_key)
|
||||
for counter_key, new_spend in cascade.counter_resets:
|
||||
await self._invalidate_spend_counter(counter_key, new_spend=new_spend)
|
||||
for cache_key in cascade.cache_keys:
|
||||
await self._invalidate_user_api_key_cache_entry(cache_key)
|
||||
|
||||
|
|
@ -708,7 +807,11 @@ class ResetBudgetJob:
|
|||
for k in updated_keys:
|
||||
if k.token is None:
|
||||
continue
|
||||
uow.keys.queue_spend_reset(token=k.token, budget_reset_at=k.budget_reset_at)
|
||||
uow.keys.queue_spend_reset(
|
||||
token=k.token,
|
||||
budget_reset_at=k.budget_reset_at,
|
||||
spend_decrement=k.max_budget if (k.spend or 0.0) > 0.0 else None,
|
||||
)
|
||||
|
||||
async def _write_user_reset_updates(self, updated_users: list[LiteLLM_UserTable]) -> None:
|
||||
"""
|
||||
|
|
@ -726,7 +829,11 @@ class ResetBudgetJob:
|
|||
async def _write_user_reset_updates_once(self, updated_users: list[LiteLLM_UserTable]) -> None:
|
||||
async with spend_reset_unit_of_work(self.prisma_client.db.batch_) as uow:
|
||||
for u in updated_users:
|
||||
uow.users.queue_spend_reset(user_id=u.user_id, budget_reset_at=u.budget_reset_at)
|
||||
uow.users.queue_spend_reset(
|
||||
user_id=u.user_id,
|
||||
budget_reset_at=u.budget_reset_at,
|
||||
spend_decrement=u.max_budget if (u.spend or 0.0) > 0.0 else None,
|
||||
)
|
||||
|
||||
async def _write_team_reset_updates(self, updated_teams: list[LiteLLM_TeamTable]) -> None:
|
||||
"""
|
||||
|
|
@ -744,7 +851,11 @@ class ResetBudgetJob:
|
|||
async def _write_team_reset_updates_once(self, updated_teams: list[LiteLLM_TeamTable]) -> None:
|
||||
async with spend_reset_unit_of_work(self.prisma_client.db.batch_) as uow:
|
||||
for t in updated_teams:
|
||||
uow.teams.queue_spend_reset(team_id=t.team_id, budget_reset_at=t.budget_reset_at)
|
||||
uow.teams.queue_spend_reset(
|
||||
team_id=t.team_id,
|
||||
budget_reset_at=t.budget_reset_at,
|
||||
spend_decrement=t.max_budget if (t.spend or 0.0) > 0.0 else None,
|
||||
)
|
||||
|
||||
def _emit_phase_failure(
|
||||
self,
|
||||
|
|
@ -820,7 +931,7 @@ class ResetBudgetJob:
|
|||
for k in updated_keys:
|
||||
token = getattr(k, "token", None)
|
||||
if token:
|
||||
await self._invalidate_spend_counter(f"spend:key:{token}")
|
||||
await self._invalidate_spend_counter(f"spend:key:{token}", new_spend=k.spend or 0.0)
|
||||
|
||||
end_time = time.time()
|
||||
outcome: Final = _ChunkOutcome(
|
||||
|
|
@ -925,7 +1036,7 @@ class ResetBudgetJob:
|
|||
for u in updated_users:
|
||||
user_id = getattr(u, "user_id", None)
|
||||
if user_id:
|
||||
await self._invalidate_spend_counter(f"spend:user:{user_id}")
|
||||
await self._invalidate_spend_counter(f"spend:user:{user_id}", new_spend=u.spend or 0.0)
|
||||
if user_id == LITELLM_PROXY_BUDGET_NAME:
|
||||
await self._invalidate_global_proxy_spend_cache()
|
||||
|
||||
|
|
@ -1034,7 +1145,7 @@ class ResetBudgetJob:
|
|||
for t in updated_teams:
|
||||
team_id = getattr(t, "team_id", None)
|
||||
if team_id:
|
||||
await self._invalidate_spend_counter(f"spend:team:{team_id}")
|
||||
await self._invalidate_spend_counter(f"spend:team:{team_id}", new_spend=t.spend or 0.0)
|
||||
|
||||
end_time = time.time()
|
||||
outcome: Final = _ChunkOutcome(
|
||||
|
|
@ -1107,10 +1218,11 @@ class ResetBudgetJob:
|
|||
reset_at: Final = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
if reset_at > now:
|
||||
return False
|
||||
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0)
|
||||
new_value: Final = await ResetBudgetJob._window_carried_spend(window, counter_key, spend_counter_cache)
|
||||
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=new_value)
|
||||
if spend_counter_cache.redis_cache is not None:
|
||||
try:
|
||||
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=0.0)
|
||||
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=new_value)
|
||||
except Exception as redis_err:
|
||||
verbose_proxy_logger.warning("Failed to reset Redis counter %s: %s", counter_key, redis_err)
|
||||
window["reset_at"] = compute_budget_reset_at(
|
||||
|
|
@ -1118,6 +1230,27 @@ class ResetBudgetJob:
|
|||
).isoformat()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def _window_carried_spend(
|
||||
window: Mapping[str, object], counter_key: str, spend_counter_cache: DualCache
|
||||
) -> float:
|
||||
"""Per-window spend lives only in the counter, so the carried overage is
|
||||
read from it before the reset overwrites it."""
|
||||
if not _rollover_enabled():
|
||||
return 0.0
|
||||
window_max: Final = window.get("max_budget")
|
||||
cap: Final = _rollover_cap(window_max) if isinstance(window_max, (int, float)) else None
|
||||
if cap is None:
|
||||
return 0.0
|
||||
try:
|
||||
current: Final = await spend_counter_cache.async_get_cache(key=counter_key)
|
||||
except Exception as e: # noqa: BLE001 # an unreadable counter falls back to a plain zero reset
|
||||
verbose_proxy_logger.warning("Failed to read spend counter %s for rollover: %s", counter_key, e)
|
||||
return 0.0
|
||||
if not isinstance(current, (int, float)):
|
||||
return 0.0
|
||||
return _carried_spend(float(current), cap)
|
||||
|
||||
async def reset_budget_windows(self) -> None:
|
||||
"""
|
||||
For keys and teams with budget_limits, reset any individual windows where
|
||||
|
|
@ -1222,7 +1355,7 @@ class ResetBudgetJob:
|
|||
still holds the pre-reset value, admitting requests past the cap.
|
||||
"""
|
||||
try:
|
||||
item.spend = 0.0
|
||||
item.spend = _carried_spend(item.spend, _rollover_cap(item.max_budget)) if _rollover_enabled() else 0.0
|
||||
if hasattr(item, "budget_duration") and item.budget_duration is not None:
|
||||
item.budget_reset_at = compute_budget_reset_at(
|
||||
budget_duration=item.budget_duration, settings=reset_settings
|
||||
|
|
|
|||
|
|
@ -887,6 +887,22 @@ class PrismaManager:
|
|||
return
|
||||
ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
|
||||
|
||||
@staticmethod
|
||||
def _raise_if_partitioned_spend_logs() -> None:
|
||||
"""`prisma db push` rewrites a doc-partitioned LiteLLM_SpendLogs
|
||||
primary key back to ("request_id"), which Postgres rejects. Fail fast
|
||||
with guidance instead of retrying into that raw error. No-op when
|
||||
litellm-proxy-extras is absent."""
|
||||
try:
|
||||
from litellm_proxy_extras.utils import (
|
||||
PARTITIONED_SPEND_LOGS_PUSH_ERROR,
|
||||
ProxyExtrasDBManager,
|
||||
)
|
||||
except ImportError:
|
||||
return
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
|
||||
|
||||
@staticmethod
|
||||
def setup_database(use_migrate: bool = False, use_v2_resolver: bool = False) -> bool:
|
||||
"""
|
||||
|
|
@ -921,6 +937,7 @@ class PrismaManager:
|
|||
use_v2_resolver=use_v2_resolver,
|
||||
)
|
||||
else:
|
||||
PrismaManager._raise_if_partitioned_spend_logs()
|
||||
# Use prisma db push with increased timeout
|
||||
subprocess.run(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ import sys
|
|||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING, Final, TypeVar
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
|
||||
|
|
@ -16,6 +19,7 @@ if TYPE_CHECKING:
|
|||
from litellm.router import Router
|
||||
|
||||
logger: Final = logging.getLogger(__name__)
|
||||
_DeploymentT: Final = TypeVar("_DeploymentT", bound=Mapping[str, object])
|
||||
from litellm.constants import (
|
||||
BACKGROUND_HEALTH_CHECK_MAX_TOKENS,
|
||||
BACKGROUND_HEALTH_CHECK_MAX_TOKENS_REASONING,
|
||||
|
|
@ -167,6 +171,38 @@ def health_check_filter_kwargs_from_general_settings(
|
|||
}
|
||||
|
||||
|
||||
def parse_background_health_check_model_groups(
|
||||
general_settings: Mapping[str, object] | None,
|
||||
) -> frozenset[str] | None:
|
||||
"""
|
||||
Read ``general_settings.background_health_check_model_groups``.
|
||||
|
||||
``None`` means the allowlist is unset and every deployment participates
|
||||
(legacy behavior). A list scopes background health checks and health-check
|
||||
routing to deployments whose ``model_name`` is listed. A malformed value
|
||||
raises so the proxy fails at startup instead of silently probing everything.
|
||||
"""
|
||||
raw: Final = (general_settings or {}).get("background_health_check_model_groups")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return frozenset(TypeAdapter(list[str]).validate_python(raw))
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
"general_settings.background_health_check_model_groups must be a list of model group names"
|
||||
) from e
|
||||
|
||||
|
||||
def filter_deployments_to_model_groups(
|
||||
model_list: Sequence[_DeploymentT],
|
||||
model_groups: AbstractSet[str] | None,
|
||||
) -> tuple[_DeploymentT, ...]:
|
||||
"""Deployments whose ``model_name`` is in ``model_groups``; all of them when unset."""
|
||||
if model_groups is None:
|
||||
return tuple(model_list)
|
||||
return tuple(x for x in model_list if x.get("model_name") in model_groups)
|
||||
|
||||
|
||||
def filter_deployments_by_id(
|
||||
model_list: Sequence[Mapping[str, object]],
|
||||
) -> list:
|
||||
|
|
|
|||
|
|
@ -2190,7 +2190,7 @@ async def _get_and_validate_existing_key(
|
|||
|
||||
existing_key_row: Final[LiteLLM_VerificationToken | None] = await _prisma_table(
|
||||
VerificationTokenRepository(prisma_client)
|
||||
).find_unique(where={"token": hashed_token})
|
||||
).find_unique(where={"token": hashed_token}, include={"object_permission": True})
|
||||
|
||||
if existing_key_row is None:
|
||||
raise ProxyException(
|
||||
|
|
@ -2442,11 +2442,13 @@ async def _validate_mcp_servers_for_key_update(
|
|||
check_db_only=True,
|
||||
)
|
||||
object_permission_dict: Final = _object_permission_to_dict(data.object_permission)
|
||||
team_unchanged: Final = data.team_id is None or data.team_id == existing_key_row.team_id
|
||||
normalized_object_permission: Final = await validate_key_mcp_servers_against_team(
|
||||
object_permission=object_permission_dict,
|
||||
team_obj=effective_team_obj,
|
||||
prisma_client=prisma_client,
|
||||
is_proxy_admin=is_proxy_admin,
|
||||
existing_key_object_permission=existing_key_row.object_permission if team_unchanged else None,
|
||||
)
|
||||
await validate_key_search_tools_against_team(
|
||||
object_permission=object_permission_dict,
|
||||
|
|
|
|||
|
|
@ -447,6 +447,36 @@ async def enforce_all_proxy_mcp_servers_grant_is_admin_only(
|
|||
)
|
||||
|
||||
|
||||
async def _get_grandfathered_key_mcp_server_ids(
|
||||
existing_object_permission: Optional["LiteLLM_ObjectPermissionTable"],
|
||||
prisma_client: PrismaClient | None,
|
||||
) -> frozenset[str]:
|
||||
"""
|
||||
Resolve the canonical MCP server IDs a key's stored object_permission already
|
||||
grants. Updates that keep or shrink those grants stay valid even when the
|
||||
team allowlist has since changed; sentinels are excluded so they cannot
|
||||
grandfather anything.
|
||||
"""
|
||||
if existing_object_permission is None or prisma_client is None:
|
||||
return frozenset()
|
||||
raw_tool_perms: Final = existing_object_permission.mcp_tool_permissions or {}
|
||||
tool_perm_keys: Final[frozenset[str]] = frozenset(
|
||||
json.loads(raw_tool_perms).keys() if isinstance(raw_tool_perms, str) else raw_tool_perms.keys()
|
||||
)
|
||||
identifiers: Final = (frozenset(existing_object_permission.mcp_servers or []) | tool_perm_keys) - {
|
||||
SpecialMCPServerNames.no_mcp_servers.value,
|
||||
SpecialMCPServerName.all_proxy_servers.value,
|
||||
}
|
||||
return frozenset(
|
||||
_flatten_resolved_mcp_server_ids(
|
||||
await _resolve_mcp_server_identifiers_to_ids(
|
||||
identifiers=set(identifiers),
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _get_team_allowed_mcp_servers(
|
||||
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
|
||||
prisma_client: PrismaClient | None = None,
|
||||
|
|
@ -527,10 +557,16 @@ async def validate_key_mcp_servers_against_team(
|
|||
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
|
||||
prisma_client: PrismaClient | None = None,
|
||||
is_proxy_admin: bool = False,
|
||||
existing_key_object_permission: Optional["LiteLLM_ObjectPermissionTable"] = None,
|
||||
) -> ObjectPermissionDict | None:
|
||||
"""
|
||||
Validate that MCP servers requested on a key are within the allowed scope.
|
||||
|
||||
When ``existing_key_object_permission`` is provided (key updates), servers
|
||||
the key already holds are grandfathered: keeping or removing them stays valid
|
||||
even if the team allowlist has since shrunk, while adding new servers outside
|
||||
the allowlist is still rejected.
|
||||
|
||||
Rules:
|
||||
- If key is in a team: key's mcp_servers must be a subset of
|
||||
(team's allowed servers + allow_all_keys servers)
|
||||
|
|
@ -589,7 +625,11 @@ async def validate_key_mcp_servers_against_team(
|
|||
if teamless_admin_assignment:
|
||||
allowed_servers = all_allowed_servers | active_requested_servers
|
||||
|
||||
disallowed_servers: Final = active_requested_servers - allowed_servers
|
||||
grandfathered_servers: Final = await _get_grandfathered_key_mcp_server_ids(
|
||||
existing_object_permission=existing_key_object_permission,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
disallowed_servers: Final = active_requested_servers - allowed_servers - grandfathered_servers
|
||||
if disallowed_servers:
|
||||
if team_obj is not None:
|
||||
team_id = team_obj.team_id
|
||||
|
|
|
|||
|
|
@ -1321,10 +1321,10 @@ def run_server(
|
|||
use_v2_resolver=use_v2_migration_resolver,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
# v2 resolver raises on unrecoverable migration errors
|
||||
# (e.g. non-idempotent failures, permission issues).
|
||||
# v1 never raises here, so this only fires when the
|
||||
# operator opted into v2.
|
||||
# Raised on unrecoverable migration errors: the v2
|
||||
# resolver's non-idempotent failures and permission
|
||||
# issues, and any `prisma db push` against a
|
||||
# partitioned LiteLLM_SpendLogs.
|
||||
print(
|
||||
f"\033[1;31mLiteLLM Proxy: Database migration cannot proceed. {e}\033[0m",
|
||||
file=sys.stderr,
|
||||
|
|
|
|||
|
|
@ -411,7 +411,9 @@ from litellm.proxy.guardrails.init_guardrails import (
|
|||
initialize_guardrails,
|
||||
)
|
||||
from litellm.proxy.health_check import (
|
||||
filter_deployments_to_model_groups,
|
||||
health_check_filter_kwargs_from_general_settings,
|
||||
parse_background_health_check_model_groups,
|
||||
perform_health_check,
|
||||
)
|
||||
from litellm.proxy.health_endpoints._health_endpoints import router as health_router
|
||||
|
|
@ -3660,6 +3662,13 @@ async def _run_background_health_check():
|
|||
_llm_model_list = [
|
||||
m for m in _llm_model_list if not m.get("model_info", {}).get("disable_background_health_check", False)
|
||||
]
|
||||
scoped_model_groups = llm_router.background_health_check_model_groups if llm_router is not None else None
|
||||
_llm_model_list = list(filter_deployments_to_model_groups(_llm_model_list, scoped_model_groups))
|
||||
if scoped_model_groups is not None and not _llm_model_list:
|
||||
verbose_proxy_logger.warning(
|
||||
"background_health_check_model_groups matched no deployments; groups=%s",
|
||||
sorted(scoped_model_groups),
|
||||
)
|
||||
model_count_enabled = len(_llm_model_list)
|
||||
expected_peak_in_flight = model_count_enabled
|
||||
if isinstance(health_check_concurrency, int) and health_check_concurrency > 0 and model_count_enabled > 0:
|
||||
|
|
@ -5239,6 +5248,7 @@ class ProxyConfig:
|
|||
general_settings = config.get("general_settings", {})
|
||||
if general_settings is None:
|
||||
general_settings = {}
|
||||
_bg_hc_model_groups: Final = parse_background_health_check_model_groups(general_settings)
|
||||
_enable_hc_routing = False
|
||||
_hc_staleness = None
|
||||
_hc_ignore_transient = False
|
||||
|
|
@ -5434,13 +5444,14 @@ class ProxyConfig:
|
|||
_hc_staleness = general_settings.get("health_check_staleness_threshold", None)
|
||||
_hc_ignore_transient = general_settings.get("health_check_ignore_transient_errors", False)
|
||||
verbose_proxy_logger.info(
|
||||
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s health_check_routing=%s",
|
||||
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s health_check_routing=%s model_groups=%s",
|
||||
use_background_health_checks,
|
||||
use_shared_health_check,
|
||||
health_check_interval,
|
||||
health_check_concurrency,
|
||||
health_check_details,
|
||||
_enable_hc_routing,
|
||||
sorted(_bg_hc_model_groups) if _bg_hc_model_groups is not None else None,
|
||||
)
|
||||
|
||||
### RBAC ###
|
||||
|
|
@ -5472,6 +5483,8 @@ class ProxyConfig:
|
|||
router_params["health_check_staleness_threshold"] = _hc_staleness
|
||||
if _hc_ignore_transient:
|
||||
router_params["health_check_ignore_transient_errors"] = True
|
||||
if _bg_hc_model_groups is not None:
|
||||
router_params["background_health_check_model_groups"] = sorted(_bg_hc_model_groups)
|
||||
## MODEL LIST
|
||||
model_list: Final = config.get("model_list", None)
|
||||
if model_list:
|
||||
|
|
@ -9240,6 +9253,7 @@ class ProxyStartupEvent:
|
|||
prisma_client,
|
||||
pod_lock_manager=proxy_logging_obj.db_spend_update_writer.pod_lock_manager,
|
||||
alert=_alert_ptu_rollup_failure,
|
||||
router=llm_router,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
|
|
@ -16402,6 +16416,13 @@ _GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFie
|
|||
"tab": "prompt_caching",
|
||||
"description": "Empty uses Anthropic's 5m default. 1h suits long sessions but doubles the cache write cost.",
|
||||
},
|
||||
"budget_rollover": { # mutable-ok: registry literal, frozen with its siblings below
|
||||
"type": "Boolean",
|
||||
"description": (
|
||||
"Carry spend beyond max_budget into the next window when budgets reset, instead of "
|
||||
"forgiving it. Applies to key, user, team, team member, org, tag and end-user budgets."
|
||||
),
|
||||
},
|
||||
"max_ui_session_budget": {
|
||||
"type": "Dollar",
|
||||
"default": 1.0,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ and share the existing unique constraint.
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
|
|
@ -326,16 +325,6 @@ class _LoadedDeployments:
|
|||
scanned_ids: frozenset[str]
|
||||
|
||||
|
||||
def _running_router() -> object | None:
|
||||
"""The proxy's router, or None outside a running proxy.
|
||||
|
||||
Read out of ``sys.modules`` rather than imported, so a rollup driven from a test or a
|
||||
script does not pull the whole proxy server in behind it.
|
||||
"""
|
||||
proxy_server: Final = sys.modules.get("litellm.proxy.proxy_server")
|
||||
return getattr(proxy_server, "llm_router", None) if proxy_server is not None else None
|
||||
|
||||
|
||||
def _config_deployments(router: object | None, *, owned_by_db: frozenset[str]) -> tuple[_PTUDeployment, ...]:
|
||||
"""Deployments the router holds that no ``LiteLLM_ProxyModelTable`` row owns.
|
||||
|
||||
|
|
@ -356,15 +345,17 @@ def _config_deployments(router: object | None, *, owned_by_db: frozenset[str]) -
|
|||
)
|
||||
|
||||
|
||||
async def _load_ptu_models(prisma_client: "PrismaClient") -> _LoadedDeployments:
|
||||
async def _load_ptu_models(prisma_client: "PrismaClient", *, router: object | None) -> _LoadedDeployments:
|
||||
"""Every deployment carrying valid manual PTU config, and every id the scan saw.
|
||||
|
||||
Reserved capacity is billed by the provider whichever file declared it, so a
|
||||
deployment the proxy only knows from config.yaml accrues alongside the stored ones.
|
||||
The router is handed in rather than read off the proxy module, so a run prices exactly
|
||||
the deployments its caller declares and nothing a co-resident process left behind.
|
||||
"""
|
||||
rows: Final = await prisma_client.db.litellm_proxymodeltable.find_many()
|
||||
db_ids: Final = frozenset(model_id for row in rows if (model_id := str(getattr(row, "model_id", "") or "")))
|
||||
config_records: Final = _config_deployments(_running_router(), owned_by_db=db_ids)
|
||||
config_records: Final = _config_deployments(router, owned_by_db=db_ids)
|
||||
models: Final = tuple(
|
||||
parsed for parsed in (_parse_ptu_model(row) for row in (*rows, *config_records)) if parsed is not None
|
||||
)
|
||||
|
|
@ -380,6 +371,7 @@ async def run_ptu_flat_cost_rollup(
|
|||
prisma_client: "PrismaClient",
|
||||
target_date: date | None = None,
|
||||
may_prune: bool = True,
|
||||
router: object | None = None,
|
||||
) -> RollupResult:
|
||||
"""Rollup one UTC day of flat PTU cost across all PTU-configured model deployments.
|
||||
|
||||
|
|
@ -406,7 +398,7 @@ async def run_ptu_flat_cost_rollup(
|
|||
date_str: Final = day.isoformat()
|
||||
run_started: Final = datetime.now(timezone.utc)
|
||||
|
||||
loaded: Final = await _load_ptu_models(prisma_client)
|
||||
loaded: Final = await _load_ptu_models(prisma_client, router=router)
|
||||
ptu_models: Final = loaded.models
|
||||
charges: Final = _aggregate_charges(ptu_models, day)
|
||||
|
||||
|
|
@ -527,6 +519,7 @@ async def _existing_sentinel_keys(
|
|||
async def run_ptu_flat_cost_backfill(
|
||||
prisma_client: "PrismaClient",
|
||||
today: date | None = None,
|
||||
router: object | None = None,
|
||||
) -> BackfillResult:
|
||||
"""Price the elapsed days of every PTU window that carry no sentinel row yet.
|
||||
|
||||
|
|
@ -546,7 +539,7 @@ async def run_ptu_flat_cost_backfill(
|
|||
verbose_proxy_logger.warning("PTU backfill: prisma_client is None, skipping")
|
||||
return BackfillResult(start=end, end=end, days_scanned=0, rows_written=0)
|
||||
|
||||
ptu_models: Final = (await _load_ptu_models(prisma_client)).models
|
||||
ptu_models: Final = (await _load_ptu_models(prisma_client, router=router)).models
|
||||
days: Final = _backfill_window(ptu_models, end)
|
||||
|
||||
if not days:
|
||||
|
|
@ -591,6 +584,7 @@ async def run_scheduled_ptu_rollup(
|
|||
pod_lock_manager: "PodLockManager | None" = None,
|
||||
target_date: date | None = None,
|
||||
alert: Callable[[str], Awaitable[None]] | None = None,
|
||||
router: object | None = None,
|
||||
) -> RollupResult | None:
|
||||
"""Run the daily rollup under a cross-pod lock so only one proxy reconciles a day.
|
||||
|
||||
|
|
@ -615,7 +609,7 @@ async def run_scheduled_ptu_rollup(
|
|||
return None
|
||||
|
||||
if pod_lock_manager is None or pod_lock_manager.redis_cache is None:
|
||||
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=False)
|
||||
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=False, router=router)
|
||||
|
||||
if not await pod_lock_manager.acquire_lock(cronjob_id=PTU_ROLLUP_JOB_ID, ttl=PTU_ROLLUP_LOCK_TTL_SECONDS):
|
||||
if await _lock_is_held(pod_lock_manager):
|
||||
|
|
@ -629,10 +623,10 @@ async def run_scheduled_ptu_rollup(
|
|||
"PTU rollup: could not take the rollup lock and no other pod holds it, "
|
||||
"running unguarded rather than skipping the day"
|
||||
)
|
||||
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=False)
|
||||
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=False, router=router)
|
||||
|
||||
try:
|
||||
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=True)
|
||||
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=True, router=router)
|
||||
finally:
|
||||
await pod_lock_manager.release_lock(cronjob_id=PTU_ROLLUP_JOB_ID)
|
||||
|
||||
|
|
@ -657,6 +651,7 @@ async def _run_and_alert(
|
|||
target_date: date | None,
|
||||
alert: "Callable[[str], Awaitable[None]] | None",
|
||||
may_prune: bool = True,
|
||||
router: object | None = None,
|
||||
) -> RollupResult:
|
||||
"""Reconcile the day, catch up any days left unpriced, and alert on charges that did not land.
|
||||
|
||||
|
|
@ -669,7 +664,9 @@ async def _run_and_alert(
|
|||
explicit date means reconcile exactly that day, so it stays a single-day operation.
|
||||
Its failure is contained: the day's own result is returned either way.
|
||||
"""
|
||||
result: Final = await run_ptu_flat_cost_rollup(prisma_client, target_date=target_date, may_prune=may_prune)
|
||||
result: Final = await run_ptu_flat_cost_rollup(
|
||||
prisma_client, target_date=target_date, may_prune=may_prune, router=router
|
||||
)
|
||||
if result.rows_failed:
|
||||
await _deliver_alert(
|
||||
alert,
|
||||
|
|
@ -686,7 +683,7 @@ async def _run_and_alert(
|
|||
"by the provider with nothing attributing it here. Extend the window, or retire the deployment.",
|
||||
)
|
||||
if target_date is None:
|
||||
await _backfill_and_alert(prisma_client, alert=alert)
|
||||
await _backfill_and_alert(prisma_client, alert=alert, router=router)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -694,6 +691,7 @@ async def _backfill_and_alert(
|
|||
prisma_client: "PrismaClient",
|
||||
*,
|
||||
alert: "Callable[[str], Awaitable[None]] | None",
|
||||
router: object | None = None,
|
||||
) -> None:
|
||||
"""Catch up unpriced PTU days, alerting on charges that did not land.
|
||||
|
||||
|
|
@ -701,7 +699,7 @@ async def _backfill_and_alert(
|
|||
caller whatever the catch-up pass does.
|
||||
"""
|
||||
try:
|
||||
backfill: Final = await run_ptu_flat_cost_backfill(prisma_client)
|
||||
backfill: Final = await run_ptu_flat_cost_backfill(prisma_client, router=router)
|
||||
except Exception as exc: # noqa: BLE001 # the catch-up pass must not fail the day's rollup
|
||||
verbose_proxy_logger.error("PTU backfill: catch-up pass failed, the day's rollup still stands: %s", exc)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -19,32 +19,57 @@ from collections.abc import AsyncGenerator, Callable, Mapping
|
|||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
||||
from litellm.repositories.prisma_protocols import BatchTable, PrismaBatch
|
||||
|
||||
|
||||
def _spend_reset_data(budget_reset_at: datetime | None, spend_decrement: float | None) -> Mapping[str, object]:
|
||||
spend: Final[object] = (
|
||||
{"decrement": spend_decrement} # mutable-ok: prisma update payload must be a dict
|
||||
if spend_decrement is not None
|
||||
else 0
|
||||
)
|
||||
return {"spend": spend, "budget_reset_at": budget_reset_at} # mutable-ok: prisma update payload must be a dict
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeySpendResetWrites:
|
||||
table: BatchTable
|
||||
|
||||
def queue_spend_reset(self, token: str, budget_reset_at: datetime | None) -> None:
|
||||
self.table.update(where={"token": token}, data={"spend": 0, "budget_reset_at": budget_reset_at})
|
||||
def queue_spend_reset(
|
||||
self, token: str, budget_reset_at: datetime | None, spend_decrement: float | None = None
|
||||
) -> None:
|
||||
self.table.update(
|
||||
where={"token": token}, # mutable-ok: prisma where filter must be a dict
|
||||
data=_spend_reset_data(budget_reset_at, spend_decrement),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserSpendResetWrites:
|
||||
table: BatchTable
|
||||
|
||||
def queue_spend_reset(self, user_id: str, budget_reset_at: datetime | None) -> None:
|
||||
self.table.update(where={"user_id": user_id}, data={"spend": 0, "budget_reset_at": budget_reset_at})
|
||||
def queue_spend_reset(
|
||||
self, user_id: str, budget_reset_at: datetime | None, spend_decrement: float | None = None
|
||||
) -> None:
|
||||
self.table.update(
|
||||
where={"user_id": user_id}, # mutable-ok: prisma where filter must be a dict
|
||||
data=_spend_reset_data(budget_reset_at, spend_decrement),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TeamSpendResetWrites:
|
||||
table: BatchTable
|
||||
|
||||
def queue_spend_reset(self, team_id: str, budget_reset_at: datetime | None) -> None:
|
||||
self.table.update(where={"team_id": team_id}, data={"spend": 0, "budget_reset_at": budget_reset_at})
|
||||
def queue_spend_reset(
|
||||
self, team_id: str, budget_reset_at: datetime | None, spend_decrement: float | None = None
|
||||
) -> None:
|
||||
self.table.update(
|
||||
where={"team_id": team_id}, # mutable-ok: prisma where filter must be a dict
|
||||
data=_spend_reset_data(budget_reset_at, spend_decrement),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -54,6 +79,14 @@ class LinkedSpendResetWrites:
|
|||
def queue_spend_zero(self, where: Mapping[str, object]) -> None:
|
||||
self.table.update_many(where=where, data={"spend": 0})
|
||||
|
||||
def queue_spend_decrement(self, where: Mapping[str, object], amount: float) -> None:
|
||||
"""``decrement`` rather than a read-then-set, so spend written between the
|
||||
cascade's read and its commit survives the reset instead of being erased."""
|
||||
self.table.update_many(
|
||||
where=where,
|
||||
data={"spend": {"decrement": amount}}, # mutable-ok: prisma update payload must be a dict
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BudgetWindowWrites:
|
||||
|
|
|
|||
|
|
@ -602,6 +602,7 @@ class Router:
|
|||
enable_health_check_routing: bool = False,
|
||||
health_check_staleness_threshold: int | None = None,
|
||||
health_check_ignore_transient_errors: bool = False,
|
||||
background_health_check_model_groups: Sequence[str] | None = None,
|
||||
enable_weighted_failover: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
|
|
@ -811,6 +812,11 @@ class Router:
|
|||
self.enable_health_check_routing = enable_health_check_routing
|
||||
self.enable_weighted_failover = enable_weighted_failover
|
||||
self.health_check_ignore_transient_errors = health_check_ignore_transient_errors
|
||||
self.background_health_check_model_groups: frozenset[str] | None = (
|
||||
frozenset(background_health_check_model_groups)
|
||||
if background_health_check_model_groups is not None
|
||||
else None
|
||||
)
|
||||
_staleness: Final = health_check_staleness_threshold or (
|
||||
DEFAULT_HEALTH_CHECK_INTERVAL * DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER
|
||||
)
|
||||
|
|
@ -9210,7 +9216,11 @@ class Router:
|
|||
}
|
||||
|
||||
if model_id is not None:
|
||||
litellm.register_model(model_cost={model_id: model_info}, persist_across_reloads=False)
|
||||
litellm.register_model(
|
||||
model_cost={model_id: model_info},
|
||||
persist_across_reloads=False,
|
||||
warning_display_name=model,
|
||||
)
|
||||
|
||||
## OLD MODEL REGISTRATION ## Kept to prevent breaking changes
|
||||
backend_keys: Final = Router._backend_cost_map_keys(model=model, custom_llm_provider=custom_llm_provider)
|
||||
|
|
@ -12719,6 +12729,10 @@ class Router:
|
|||
"""
|
||||
Filter out deployments marked unhealthy by background health checks.
|
||||
No-op when enable_health_check_routing is False.
|
||||
When background_health_check_model_groups is set, only deployments in the
|
||||
listed model groups are filtered; every other group keeps its configured
|
||||
routing strategy untouched, and a router-level allowed_fails_policy no
|
||||
longer disables the filter for the listed groups.
|
||||
Returns all deployments if health state is unavailable, stale, or would
|
||||
exclude every candidate (safety net).
|
||||
"""
|
||||
|
|
@ -12727,8 +12741,10 @@ class Router:
|
|||
|
||||
# When allowed_fails_policy is set, cooldown is the sole routing exclusion
|
||||
# mechanism -- skip the binary health check filter so the policy threshold
|
||||
# is respected before any deployment is excluded.
|
||||
if self.allowed_fails_policy is not None:
|
||||
# is respected before any deployment is excluded. With a model-group
|
||||
# allowlist the filter is already scoped, so listed groups keep it.
|
||||
scoped_groups: Final = self.background_health_check_model_groups
|
||||
if self.allowed_fails_policy is not None and scoped_groups is None:
|
||||
return healthy_deployments
|
||||
|
||||
unhealthy_ids: Final = await self.health_state_cache.async_get_unhealthy_deployment_ids(
|
||||
|
|
@ -12737,7 +12753,12 @@ class Router:
|
|||
if not unhealthy_ids:
|
||||
return healthy_deployments
|
||||
|
||||
filtered: Final = [d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids]
|
||||
filtered: Final = [
|
||||
d
|
||||
for d in healthy_deployments
|
||||
if d["model_info"]["id"] not in unhealthy_ids
|
||||
or (scoped_groups is not None and d["model_name"] not in scoped_groups)
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
|
||||
|
|
@ -12754,14 +12775,20 @@ class Router:
|
|||
if not self.enable_health_check_routing:
|
||||
return healthy_deployments
|
||||
|
||||
if self.allowed_fails_policy is not None:
|
||||
scoped_groups: Final = self.background_health_check_model_groups
|
||||
if self.allowed_fails_policy is not None and scoped_groups is None:
|
||||
return healthy_deployments
|
||||
|
||||
unhealthy_ids: Final = self.health_state_cache.get_unhealthy_deployment_ids(parent_otel_span=parent_otel_span)
|
||||
if not unhealthy_ids:
|
||||
return healthy_deployments
|
||||
|
||||
filtered: Final = [d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids]
|
||||
filtered: Final = [
|
||||
d
|
||||
for d in healthy_deployments
|
||||
if d["model_info"]["id"] not in unhealthy_ids
|
||||
or (scoped_groups is not None and d["model_name"] not in scoped_groups)
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
|
||||
|
|
|
|||
|
|
@ -43,12 +43,33 @@ class DeploymentHealthCache:
|
|||
self.staleness_threshold = staleness_threshold
|
||||
|
||||
def set_deployment_health_states(self, states: dict[str, DeploymentHealthStateValue]) -> None:
|
||||
"""Bulk-write all deployment health states as a single cache entry."""
|
||||
"""Merge the given states into the shared cache entry, pruning expired ones.
|
||||
|
||||
Merging instead of replacing lets writers probing different deployment
|
||||
scopes (e.g. pods with different background health check allowlists)
|
||||
coexist on the one shared entry without erasing each other's results.
|
||||
The snapshot is read from Redis when available, since a pod-local read
|
||||
would only ever see this writer's own previous merge. When the Redis
|
||||
read comes back empty (a miss, or a swallowed connection error), the
|
||||
pod-local copy of the last merge is used so peers are not erased.
|
||||
"""
|
||||
try:
|
||||
redis_raw: Final = (
|
||||
self.cache.redis_cache.get_cache(self.CACHE_KEY) if self.cache.redis_cache is not None else None
|
||||
)
|
||||
raw: Final = redis_raw if isinstance(redis_raw, dict) else self.cache.get_cache(key=self.CACHE_KEY)
|
||||
existing: Final = raw if isinstance(raw, dict) else {}
|
||||
expiry_seconds: Final = self.staleness_threshold * 1.5
|
||||
now: Final = time.time()
|
||||
merged: Final = {
|
||||
model_id: state
|
||||
for model_id, state in {**existing, **states}.items()
|
||||
if isinstance(state, dict) and (now - state.get("timestamp", 0)) < expiry_seconds
|
||||
}
|
||||
self.cache.set_cache(
|
||||
key=self.CACHE_KEY,
|
||||
value=states,
|
||||
ttl=int(self.staleness_threshold * 1.5),
|
||||
value=merged,
|
||||
ttl=int(expiry_seconds),
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.error(
|
||||
|
|
|
|||
|
|
@ -2948,7 +2948,12 @@ def reapply_runtime_model_cost_registrations() -> None:
|
|||
register_model(model_cost=dict(_runtime_registered_model_cost)) # mutable-ok: snapshot, replay rewrites it
|
||||
|
||||
|
||||
def register_model(model_cost: str | dict, *, persist_across_reloads: bool = True):
|
||||
def register_model(
|
||||
model_cost: str | dict,
|
||||
*,
|
||||
persist_across_reloads: bool = True,
|
||||
warning_display_name: str | None = None,
|
||||
):
|
||||
"""
|
||||
Register new / Override existing models (and their pricing) to specific providers.
|
||||
Provide EITHER a model cost dictionary or a url to a hosted json blob
|
||||
|
|
@ -2968,6 +2973,10 @@ def register_model(model_cost: str | dict, *, persist_across_reloads: bool = Tru
|
|||
registering a model is declaring durable intent. Pass False for a
|
||||
registration that only describes one request, so it is dropped rather than
|
||||
re-asserted over every future catalog.
|
||||
|
||||
``warning_display_name`` names the model in the missing-cache-pricing
|
||||
warning instead of the registered key, for callers that register under an
|
||||
opaque key (e.g. the router's hashed deployment ids).
|
||||
"""
|
||||
|
||||
loaded_model_cost = {}
|
||||
|
|
@ -3014,10 +3023,15 @@ def register_model(model_cost: str | dict, *, persist_across_reloads: bool = Tru
|
|||
elif (
|
||||
value.get("cache_creation_input_token_cost") is None
|
||||
and value.get("cache_read_input_token_cost") is None
|
||||
and value.get("tiered_pricing") is None
|
||||
and (
|
||||
value.get("input_cost_per_token") is not None
|
||||
or value.get("output_cost_per_token") is not None
|
||||
)
|
||||
):
|
||||
verbose_logger.warning(
|
||||
"register_model: model=%s not in built-in cost map and no prefix/region variant matched; cache cost fields will default to 0. To track cache cost, add cache_creation_input_token_cost and cache_read_input_token_cost to model_info",
|
||||
key,
|
||||
"register_model: model=%s has custom pricing but not in built-in cost map and no prefix/region variant matched; cache_creation_input_token_cost and cache_read_input_token_cost will default to 0 for this model (input/output cost tracking is unaffected). To track cache cost, add them to model_info",
|
||||
warning_display_name or key,
|
||||
)
|
||||
# ``get_model_info`` returns ``litellm_provider: None`` when the
|
||||
# provider is unknown (e.g. custom deployments registered via
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -67,8 +67,8 @@ proxy = [
|
|||
"azure-identity>=1.25.2,<2.0",
|
||||
"azure-storage-blob>=12.28.0,<13.0",
|
||||
"mcp>=1.28.1,<2.0",
|
||||
"litellm-proxy-extras==0.4.89",
|
||||
"litellm-enterprise==0.1.60",
|
||||
"litellm-proxy-extras==0.4.90",
|
||||
"litellm-enterprise==0.1.61",
|
||||
"RestrictedPython>=8.1,<9.0",
|
||||
"rich>=13.9.4,<14.0",
|
||||
"InquirerPy>=0.3.4,<1.0",
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import json
|
|||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from typing import Callable, Dict, Final, List, Optional, Protocol, Set, Tuple
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
import requests
|
||||
|
|
@ -37,6 +38,13 @@ DEFAULT_TRANSITIVE_PIN_PACKAGES = (
|
|||
# of the identifier, not an operator.
|
||||
_SPDX_OPERATOR_SPLIT = re.compile(r"\s+(?:OR|AND)\s+")
|
||||
_SPDX_WITH_SUFFIX = re.compile(r"\s+WITH\s+.*", re.DOTALL)
|
||||
_PYPI_FETCH_ATTEMPTS: Final[int] = 3
|
||||
_PYPI_FETCH_BACKOFF_SECONDS: Final[float] = 0.5
|
||||
|
||||
|
||||
class _HttpGet(Protocol):
|
||||
def __call__(self, url: str, *, timeout: float) -> requests.Response:
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -50,7 +58,10 @@ class PackageLicense:
|
|||
|
||||
class LicenseChecker:
|
||||
def __init__(
|
||||
self, config_file: Path = Path("./tests/code_coverage_tests/liccheck.ini")
|
||||
self,
|
||||
config_file: Path = Path("./tests/code_coverage_tests/liccheck.ini"),
|
||||
http_get: Optional[_HttpGet] = None,
|
||||
sleep: Optional[Callable[[float], None]] = None,
|
||||
):
|
||||
if not config_file.exists():
|
||||
print(f"Error: Config file {config_file} not found")
|
||||
|
|
@ -79,6 +90,8 @@ class LicenseChecker:
|
|||
|
||||
# Track package results
|
||||
self.package_results: List[PackageLicense] = []
|
||||
self._http_get = http_get
|
||||
self._sleep = sleep
|
||||
|
||||
@staticmethod
|
||||
def _normalize_package_name(package_name: str) -> str:
|
||||
|
|
@ -123,21 +136,38 @@ class LicenseChecker:
|
|||
last resort derives the license from the ``License :: OSI Approved ::
|
||||
...`` trove classifiers.
|
||||
"""
|
||||
try:
|
||||
url = f"https://pypi.org/pypi/{package_name}/{version}/json"
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
info = response.json().get("info", {}) or {}
|
||||
return (
|
||||
info.get("license_expression")
|
||||
or info.get("license")
|
||||
or self._license_from_classifiers(info.get("classifiers") or [])
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Warning: Failed to fetch license for {package_name} {version}: {str(e)}"
|
||||
)
|
||||
return None
|
||||
url = f"https://pypi.org/pypi/{package_name}/{version}/json"
|
||||
http_get = self._http_get if self._http_get is not None else requests.get
|
||||
sleep = self._sleep if self._sleep is not None else time.sleep
|
||||
|
||||
for attempt in range(_PYPI_FETCH_ATTEMPTS):
|
||||
try:
|
||||
response = http_get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
info = response.json().get("info", {}) or {}
|
||||
return (
|
||||
info.get("license_expression")
|
||||
or info.get("license")
|
||||
or self._license_from_classifiers(info.get("classifiers") or [])
|
||||
)
|
||||
except Exception as error:
|
||||
if self._is_retryable_pypi_error(error) and attempt < _PYPI_FETCH_ATTEMPTS - 1:
|
||||
sleep(_PYPI_FETCH_BACKOFF_SECONDS)
|
||||
continue
|
||||
print(
|
||||
f"Warning: Failed to fetch license for {package_name} {version}: {str(error)}"
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable_pypi_error(error: Exception) -> bool:
|
||||
if isinstance(error, (requests.ConnectionError, requests.Timeout)):
|
||||
return True
|
||||
if not isinstance(error, requests.HTTPError) or error.response is None:
|
||||
return False
|
||||
status_code = error.response.status_code
|
||||
return status_code == 429 or status_code >= 500
|
||||
|
||||
@staticmethod
|
||||
def _license_from_classifiers(classifiers: List[str]) -> Optional[str]:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,21 @@ from __future__ import annotations
|
|||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import jwt
|
||||
|
||||
from e2e_config import MASTER_KEY
|
||||
from proxy_client import ProxyClient
|
||||
from e2e_http import NoBody, ProbeResult, Result, StreamingResponse, Success, UnknownApiError, unwrap
|
||||
from e2e_http import (
|
||||
AuthHeaders,
|
||||
NetworkError,
|
||||
NoBody,
|
||||
ProbeResult,
|
||||
Result,
|
||||
StreamingResponse,
|
||||
Success,
|
||||
UnknownApiError,
|
||||
unwrap,
|
||||
)
|
||||
from models import (
|
||||
ChatBody,
|
||||
ChatMessage,
|
||||
|
|
@ -50,6 +63,9 @@ from models import (
|
|||
TeamNewBody,
|
||||
TeamNewResponse,
|
||||
TeamUpdateBody,
|
||||
UiLoginBody,
|
||||
UiLoginResponse,
|
||||
UiSessionClaims,
|
||||
UserDeleteBody,
|
||||
UserDeleteResponse,
|
||||
UserInfoParams,
|
||||
|
|
@ -63,38 +79,73 @@ from models import (
|
|||
|
||||
MODEL_ACCESS_DENIED_MARKER = "key_model_access_denied"
|
||||
ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route"
|
||||
DASHBOARD_SESSION_TEAM_ID = "litellm-dashboard"
|
||||
_TEAM_READY_ATTEMPTS = 15
|
||||
_TEAM_READY_SLEEP_SECONDS = 0.4
|
||||
_KEY_WRITE_ATTEMPTS = 5
|
||||
_TRANSIENT_BACKEND_MARKERS = ("connecting to redis", "name resolution")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DashboardSession:
|
||||
"""What a dashboard sign-in hands the Admin UI: the session key it sends as
|
||||
its bearer on every subsequent call, the claims it renders the signed-in user
|
||||
from, and where it lands the browser."""
|
||||
|
||||
session_key: str
|
||||
claims: UiSessionClaims
|
||||
redirect_url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagementClient:
|
||||
proxy: ProxyClient
|
||||
master_key: str
|
||||
|
||||
def llm_only_key(self) -> str:
|
||||
return self.proxy.generate_key(KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"]))
|
||||
|
||||
def update_key_models(self, key: str, models: list[str]) -> None:
|
||||
last: Result[NoBody] | None = None
|
||||
for attempt in range(5):
|
||||
def generate_key(self, body: KeyGenerateBody, *, caller_key: str | None = None) -> Result[KeyGenerateResponse]:
|
||||
"""POST /key/generate. `caller_key` is who is creating the key: the master
|
||||
key by default, or a virtual key (an admin filling in Create New Key on the
|
||||
dashboard creates it under the session key their sign-in minted). Returns
|
||||
the outcome rather than unwrapping it, so a caller can poll a route that is
|
||||
only transiently refusing."""
|
||||
headers = self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key)
|
||||
return self.proxy.transport.post(
|
||||
"/key/generate",
|
||||
headers=headers,
|
||||
json=body,
|
||||
response_type=KeyGenerateResponse,
|
||||
)
|
||||
|
||||
def update_key(self, body: KeyUpdateBody, *, caller_key: str | None = None) -> Result[NoBody]:
|
||||
"""POST /key/update. `caller_key` is who is editing: the master key by
|
||||
default, or a virtual key (the dashboard edits under the session key its
|
||||
sign-in minted, never the master key). Returns the outcome rather than
|
||||
unwrapping it, so a caller can poll a route that is only transiently
|
||||
refusing; `update_key_models` is the unwrapping shorthand."""
|
||||
headers = self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key)
|
||||
last: Result[NoBody] = NetworkError(message="/key/update was never attempted")
|
||||
for attempt in range(_KEY_WRITE_ATTEMPTS):
|
||||
last = self.proxy.transport.post(
|
||||
"/key/update",
|
||||
headers=self.proxy.transport.master,
|
||||
json=KeyUpdateBody(key=key, models=models),
|
||||
headers=headers,
|
||||
json=body,
|
||||
response_type=NoBody,
|
||||
)
|
||||
match last:
|
||||
case Success():
|
||||
return
|
||||
case UnknownApiError(body=body) if (
|
||||
"connecting to redis" in body.lower() or "name resolution" in body.lower()
|
||||
case UnknownApiError(body=error_body) if any(
|
||||
marker in error_body.lower() for marker in _TRANSIENT_BACKEND_MARKERS
|
||||
):
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
continue
|
||||
case _:
|
||||
break
|
||||
assert last is not None
|
||||
raise AssertionError(last)
|
||||
return last
|
||||
|
||||
def update_key_models(self, key: str, models: list[str]) -> None:
|
||||
_ = unwrap(self.update_key(KeyUpdateBody(key=key, models=models)))
|
||||
|
||||
def delete_key_strict(self, key: str) -> None:
|
||||
"""Strict delete for the act phase of a test: a failed delete is a hard
|
||||
|
|
@ -150,15 +201,42 @@ class ManagementClient:
|
|||
)
|
||||
).key
|
||||
|
||||
def key_list(self, key_alias: str, *, caller_key: str | None = None) -> Result[KeyListResponse]:
|
||||
"""GET /key/list, the Virtual Keys page's own inventory call. `caller_key` is
|
||||
who is asking: the master key by default, or a virtual key."""
|
||||
headers = self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key)
|
||||
return self.proxy.transport.get(
|
||||
"/key/list",
|
||||
headers=headers,
|
||||
params=KeyListParams(key_alias=key_alias),
|
||||
response_type=KeyListResponse,
|
||||
)
|
||||
|
||||
def key_alias_count(self, key_alias: str) -> int:
|
||||
return unwrap(
|
||||
self.proxy.transport.get(
|
||||
"/key/list",
|
||||
headers=self.proxy.transport.master,
|
||||
params=KeyListParams(key_alias=key_alias),
|
||||
response_type=KeyListResponse,
|
||||
return unwrap(self.key_list(key_alias)).total_count
|
||||
|
||||
def dashboard_login(self, username: str, password: str) -> DashboardSession:
|
||||
"""POST /v2/login, the call the Admin UI's sign-in form makes.
|
||||
|
||||
The proxy authenticates the credentials, mints a UI session key for the
|
||||
signed-in user, and hands it back inside a JWT signed with the master key.
|
||||
Decoding that JWT is the only way to reach the session key, and it is what
|
||||
the dashboard itself does before it can call a single management route."""
|
||||
response = unwrap(
|
||||
self.proxy.transport.post(
|
||||
"/v2/login",
|
||||
headers=AuthHeaders(),
|
||||
json=UiLoginBody(username=username, password=password),
|
||||
response_type=UiLoginResponse,
|
||||
)
|
||||
).total_count
|
||||
)
|
||||
decoded: object = jwt.decode(response.token, self.master_key, algorithms=["HS256"])
|
||||
claims = UiSessionClaims.model_validate(decoded)
|
||||
return DashboardSession(
|
||||
session_key=claims.key,
|
||||
claims=claims,
|
||||
redirect_url=response.redirect_url,
|
||||
)
|
||||
|
||||
def create_team(self, body: TeamNewBody) -> str:
|
||||
team_id = unwrap(
|
||||
|
|
@ -465,4 +543,4 @@ class ManagementClient:
|
|||
|
||||
|
||||
def build_client(proxy: ProxyClient) -> ManagementClient:
|
||||
return ManagementClient(proxy=proxy)
|
||||
return ManagementClient(proxy=proxy, master_key=MASTER_KEY)
|
||||
|
|
|
|||
|
|
@ -15,15 +15,30 @@ from collections.abc import Callable
|
|||
|
||||
import pytest
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import StreamingResponse
|
||||
from e2e_config import UI_PASSWORD, UI_USERNAME, unique_marker
|
||||
from e2e_http import StreamingResponse, Success
|
||||
from lifecycle import ResourceManager
|
||||
from management_client import (
|
||||
DASHBOARD_SESSION_TEAM_ID,
|
||||
MODEL_ACCESS_DENIED_MARKER,
|
||||
ROUTE_NOT_ALLOWED_MARKER,
|
||||
ManagementClient,
|
||||
)
|
||||
from models import KeyGenerateBody, OrgInfoResponse, OrgNewBody, OrgUpdateBody, TagListEntry, TagNewBody, TeamNewBody, TeamUpdateBody, UserNewBody, UserUpdateBody, LiteLLMParamsBody, ModelInfoEntry
|
||||
from models import (
|
||||
KeyGenerateBody,
|
||||
KeyUpdateBody,
|
||||
LiteLLMParamsBody,
|
||||
ModelInfoEntry,
|
||||
OrgInfoResponse,
|
||||
OrgNewBody,
|
||||
OrgUpdateBody,
|
||||
TagListEntry,
|
||||
TagNewBody,
|
||||
TeamNewBody,
|
||||
TeamUpdateBody,
|
||||
UserNewBody,
|
||||
UserUpdateBody,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
|
@ -199,6 +214,132 @@ class TestKeyRoutes:
|
|||
return True if client.proxy.key_info(key).blocked else None
|
||||
|
||||
_ = _poll(client, blocked, "/key/info never reported the key blocked after /key/block before the deadline")
|
||||
|
||||
|
||||
class TestDashboardKeyRoutes:
|
||||
"""The /key writes as the Admin UI makes them. Signing in mints the session key
|
||||
the dashboard authenticates with, and every key an admin creates or edits in the
|
||||
browser is written under that session key rather than the master key, so these
|
||||
are the same routes the API-surface tests cover with a different caller."""
|
||||
|
||||
@pytest.mark.covers("mgmt.key.generate.happy_path")
|
||||
def test_creating_a_key_from_the_dashboard_persists_and_works(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
session = client.dashboard_login(UI_USERNAME, UI_PASSWORD)
|
||||
resources.defer(lambda: client.proxy.delete_key(session.session_key))
|
||||
|
||||
assert session.claims.login_method == "username_password", (
|
||||
f"/v2/login reports login_method {session.claims.login_method!r} for a username/password sign-in"
|
||||
)
|
||||
assert session.claims.user_role == "proxy_admin", (
|
||||
f"/v2/login reports user_role {session.claims.user_role!r} for the admin credentials, "
|
||||
"expected 'proxy_admin'"
|
||||
)
|
||||
assert session.redirect_url.endswith("/ui?login=success"), (
|
||||
f"/v2/login sends the browser to {session.redirect_url!r} instead of the dashboard"
|
||||
)
|
||||
|
||||
session_info = client.proxy.key_info(session.session_key)
|
||||
assert session_info.team_id == DASHBOARD_SESSION_TEAM_ID, (
|
||||
f"the minted session key reports team_id {session_info.team_id!r}, expected the dashboard's "
|
||||
f"{DASHBOARD_SESSION_TEAM_ID!r}"
|
||||
)
|
||||
|
||||
alias = f"e2e-mgmt-uicreate-{unique_marker()}"
|
||||
|
||||
def dashboard_creates_the_key() -> str | None:
|
||||
match client.generate_key(
|
||||
KeyGenerateBody(models=["gemini-2.5-flash"], key_alias=alias, tpm_limit=100),
|
||||
caller_key=session.session_key,
|
||||
):
|
||||
case Success(data=created):
|
||||
return created.key
|
||||
case _:
|
||||
return None
|
||||
|
||||
created = _poll(
|
||||
client,
|
||||
dashboard_creates_the_key,
|
||||
"the dashboard session key was never accepted on /key/generate before the deadline",
|
||||
)
|
||||
resources.defer(lambda: client.proxy.delete_key(created))
|
||||
|
||||
created_info = client.proxy.key_info(created)
|
||||
assert created_info.key_alias == alias, (
|
||||
f"/key/info reports key_alias {created_info.key_alias!r} for the key the dashboard created, "
|
||||
f"expected {alias!r}"
|
||||
)
|
||||
assert created_info.models == ["gemini-2.5-flash"], (
|
||||
f"/key/info reports models {created_info.models} for the key the dashboard created"
|
||||
)
|
||||
assert created_info.tpm_limit == 100, (
|
||||
f"/key/info reports tpm_limit {created_info.tpm_limit} for the key the dashboard created, expected 100"
|
||||
)
|
||||
|
||||
def dashboard_lists_the_key() -> bool | None:
|
||||
match client.key_list(alias, caller_key=session.session_key):
|
||||
case Success(data=listing) if listing.total_count == 1:
|
||||
return True
|
||||
case _:
|
||||
return None
|
||||
|
||||
_ = _poll(
|
||||
client,
|
||||
dashboard_lists_the_key,
|
||||
f"the session key never saw {alias!r} in /key/list before the deadline, so the dashboard "
|
||||
"would render no keys",
|
||||
)
|
||||
|
||||
_poll_chat_ok(client, created, "gemini-2.5-flash")
|
||||
_assert_model_denied(client.chat_status(created, "gpt-5.5", f"say hi {unique_marker()}"), "gpt-5.5")
|
||||
|
||||
@pytest.mark.covers("mgmt.key.update.happy_path")
|
||||
def test_editing_a_key_from_the_dashboard_persists_and_is_enforced(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
alias = f"e2e-mgmt-uiedit-{unique_marker()}"
|
||||
target = _generate_key(
|
||||
client,
|
||||
resources,
|
||||
KeyGenerateBody(models=["gemini-2.5-flash"], key_alias=alias, tpm_limit=100, rpm_limit=200),
|
||||
)
|
||||
_poll_chat_ok(client, target, "gemini-2.5-flash")
|
||||
_assert_model_denied(client.chat_status(target, "gpt-5.5", f"say hi {unique_marker()}"), "gpt-5.5")
|
||||
|
||||
session = client.dashboard_login(UI_USERNAME, UI_PASSWORD)
|
||||
resources.defer(lambda: client.proxy.delete_key(session.session_key))
|
||||
|
||||
def dashboard_saves_the_edit() -> bool | None:
|
||||
match client.update_key(
|
||||
KeyUpdateBody(key=target, models=["gpt-5.5"], tpm_limit=300, rpm_limit=400),
|
||||
caller_key=session.session_key,
|
||||
):
|
||||
case Success():
|
||||
return True
|
||||
case _:
|
||||
return None
|
||||
|
||||
_ = _poll(
|
||||
client,
|
||||
dashboard_saves_the_edit,
|
||||
"the dashboard session key was never accepted on /key/update before the deadline",
|
||||
)
|
||||
|
||||
info = client.proxy.key_info(target)
|
||||
assert info.models == ["gpt-5.5"], (
|
||||
f"/key/info reports models {info.models} after the dashboard edit to ['gpt-5.5']"
|
||||
)
|
||||
assert info.tpm_limit == 300, f"/key/info reports tpm_limit {info.tpm_limit} after the dashboard edit to 300"
|
||||
assert info.rpm_limit == 400, f"/key/info reports rpm_limit {info.rpm_limit} after the dashboard edit to 400"
|
||||
assert info.key_alias == alias, (
|
||||
f"the dashboard edit renamed the key to {info.key_alias!r}, it should still be {alias!r}"
|
||||
)
|
||||
|
||||
_poll_model_access_granted(client, target, "gpt-5.5")
|
||||
_poll_chat_denied(client, target, "gemini-2.5-flash")
|
||||
|
||||
|
||||
class TestKeyRegeneration:
|
||||
@pytest.mark.covers("mgmt.key.regenerate.happy_path")
|
||||
def test_regenerate_rotates_to_a_working_new_key(
|
||||
|
|
|
|||
|
|
@ -893,7 +893,10 @@ class CredentialCreateResponse(BaseModel):
|
|||
|
||||
class KeyUpdateBody(BaseModel):
|
||||
key: str
|
||||
models: list[str]
|
||||
models: list[str] | None = None
|
||||
key_alias: str | None = None
|
||||
tpm_limit: int | None = None
|
||||
rpm_limit: int | None = None
|
||||
|
||||
|
||||
class KeyBlockBody(BaseModel):
|
||||
|
|
@ -908,6 +911,27 @@ class KeyListResponse(BaseModel):
|
|||
total_count: int
|
||||
|
||||
|
||||
# ---------- admin UI session ----------
|
||||
|
||||
|
||||
class UiLoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UiLoginResponse(BaseModel):
|
||||
token: str
|
||||
redirect_url: str
|
||||
|
||||
|
||||
class UiSessionClaims(BaseModel):
|
||||
user_id: str
|
||||
key: str
|
||||
user_role: str
|
||||
login_method: Literal["sso", "username_password"]
|
||||
exp: int
|
||||
|
||||
|
||||
class TeamMemberEntry(BaseModel):
|
||||
role: Literal["admin", "user"]
|
||||
user_id: str
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { expect, test, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
|
||||
/**
|
||||
* Opens Add Auto Router and returns the Template select's trigger, which is the
|
||||
* shallowest real page that renders SelectContent with tall multi-line options.
|
||||
*/
|
||||
async function openTemplateSelect(page: PlaywrightPage) {
|
||||
await navigateToPage(page, Page.Models);
|
||||
await page.getByRole("tab", { name: "Auto-Routers" }).click();
|
||||
await page.getByRole("button", { name: "Add Auto Router" }).click();
|
||||
|
||||
const trigger = page.getByTestId("template-selector");
|
||||
await expect(trigger).toBeVisible();
|
||||
return trigger;
|
||||
}
|
||||
|
||||
test.describe("Auto Router template select anchoring", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("opens the options below the trigger rather than over it", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const trigger = await openTemplateSelect(page);
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
|
||||
await trigger.click();
|
||||
const popup = page.locator('[data-slot="select-content"]');
|
||||
await expect(popup).toBeVisible();
|
||||
const popupBox = await popup.boundingBox();
|
||||
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(popupBox).not.toBeNull();
|
||||
|
||||
// Item-aligned mode reports "none" and puts the active item over the trigger.
|
||||
await expect(popup).toHaveAttribute("data-side", "bottom");
|
||||
expect(popupBox!.y).toBeGreaterThanOrEqual(triggerBox!.y + triggerBox!.height);
|
||||
});
|
||||
|
||||
test("flips above the trigger instead of covering it when there is no room below", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 560 });
|
||||
const trigger = await openTemplateSelect(page);
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
|
||||
await trigger.click();
|
||||
const popup = page.locator('[data-slot="select-content"]');
|
||||
await expect(popup).toBeVisible();
|
||||
const popupBox = await popup.boundingBox();
|
||||
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(popupBox).not.toBeNull();
|
||||
|
||||
const overlaps =
|
||||
popupBox!.y < triggerBox!.y + triggerBox!.height && popupBox!.y + popupBox!.height > triggerBox!.y;
|
||||
expect(overlaps).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,7 +12,11 @@ sys.path.insert(
|
|||
),
|
||||
)
|
||||
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager
|
||||
from litellm_proxy_extras.utils import (
|
||||
PARTITIONED_SPEND_LOGS_PUSH_ERROR,
|
||||
ProxyExtrasDBManager,
|
||||
filter_partitioned_spend_logs_diff,
|
||||
)
|
||||
|
||||
# Path to the migrations directory
|
||||
_MIGRATIONS_DIR = os.path.abspath(
|
||||
|
|
@ -475,3 +479,205 @@ class TestMigrationGuardScope:
|
|||
if not self._run_rules([(TestMigrationGuardScope._NEW, by_name[name])])
|
||||
]
|
||||
assert not redundant, f"these no longer violate and should be removed: {redundant}"
|
||||
|
||||
|
||||
_PARTITIONED_DRIFT_SQL = """-- AlterTable
|
||||
ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_SpendLogs" DROP CONSTRAINT "LiteLLM_SpendLogs_pkey",
|
||||
ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD CONSTRAINT "LiteLLM_SpendLogs_pkey" PRIMARY KEY ("request_id");
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "LiteLLM_SpendLogs_legacy";
|
||||
"""
|
||||
|
||||
|
||||
class TestPartitionedSpendLogsDriftFilter:
|
||||
"""A doc-partitioned LiteLLM_SpendLogs (db_scripts/partition_spend_logs.sql) has a
|
||||
composite primary key that schema.prisma cannot express, so `prisma migrate diff`
|
||||
emits a primary-key rewrite that Postgres rejects, aborting the whole drift script
|
||||
before its legitimate statements run."""
|
||||
|
||||
def test_pk_rewrite_and_runbook_artifact_drops_are_removed(self):
|
||||
filtered = filter_partitioned_spend_logs_diff(_PARTITIONED_DRIFT_SQL)
|
||||
assert 'DROP CONSTRAINT "LiteLLM_SpendLogs_pkey"' not in filtered
|
||||
assert 'PRIMARY KEY ("request_id")' not in filtered
|
||||
assert "LiteLLM_SpendLogs_legacy" not in filtered
|
||||
|
||||
def test_legitimate_statements_in_the_same_script_are_kept(self):
|
||||
filtered = filter_partitioned_spend_logs_diff(_PARTITIONED_DRIFT_SQL)
|
||||
assert 'ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;' in filtered
|
||||
assert 'ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP' in filtered
|
||||
assert 'ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP' in filtered
|
||||
assert filtered.count('ALTER TABLE "LiteLLM_SpendLogs"') == 1
|
||||
|
||||
def test_an_alter_containing_only_the_pk_rewrite_is_dropped_entirely(self):
|
||||
sql = (
|
||||
'ALTER TABLE "LiteLLM_SpendLogs" DROP CONSTRAINT "LiteLLM_SpendLogs_pkey",\n'
|
||||
'ADD CONSTRAINT "LiteLLM_SpendLogs_pkey" PRIMARY KEY ("request_id");\n'
|
||||
)
|
||||
assert filter_partitioned_spend_logs_diff(sql).strip() == ""
|
||||
|
||||
def test_other_tables_pk_changes_are_untouched(self):
|
||||
sql = (
|
||||
'ALTER TABLE "LiteLLM_TeamTable" DROP CONSTRAINT "LiteLLM_TeamTable_pkey",\n'
|
||||
'ADD CONSTRAINT "LiteLLM_TeamTable_pkey" PRIMARY KEY ("team_id");\n'
|
||||
)
|
||||
filtered = filter_partitioned_spend_logs_diff(sql)
|
||||
assert 'DROP CONSTRAINT "LiteLLM_TeamTable_pkey"' in filtered
|
||||
assert 'PRIMARY KEY ("team_id")' in filtered
|
||||
|
||||
|
||||
class _FakeCompleted:
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
|
||||
class TestResolveAllMigrationsLedger:
|
||||
def _run(self, monkeypatch, tmp_path, partitioned, execute_fails):
|
||||
import subprocess as subprocess_module
|
||||
|
||||
import litellm_proxy_extras.utils as utils_module
|
||||
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:5432/db")
|
||||
monkeypatch.delenv("DIRECT_URL", raising=False)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: partitioned)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_get_migration_names",
|
||||
staticmethod(lambda migrations_dir: ["20250326162113_baseline"]),
|
||||
)
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
if "diff" in cmd:
|
||||
kwargs["stdout"].write(_PARTITIONED_DRIFT_SQL)
|
||||
return _FakeCompleted()
|
||||
if "execute" in cmd:
|
||||
executed_sql = open(cmd[cmd.index("--file") + 1]).read()
|
||||
calls.append(("executed_sql", executed_sql))
|
||||
if execute_fails:
|
||||
raise subprocess_module.CalledProcessError(1, cmd, stderr="boom")
|
||||
return _FakeCompleted()
|
||||
return _FakeCompleted()
|
||||
|
||||
monkeypatch.setattr(utils_module.subprocess, "run", fake_run)
|
||||
ProxyExtrasDBManager._resolve_all_migrations(str(tmp_path), "schema.prisma")
|
||||
return calls
|
||||
|
||||
def _resolved(self, calls):
|
||||
return [c for c in calls if isinstance(c, list) and "resolve" in c]
|
||||
|
||||
def _executed_sql(self, calls):
|
||||
return next(c[1] for c in calls if isinstance(c, tuple) and c[0] == "executed_sql")
|
||||
|
||||
def test_failed_drift_apply_does_not_mark_migrations_applied(self, monkeypatch, tmp_path):
|
||||
calls = self._run(monkeypatch, tmp_path, partitioned=False, execute_fails=True)
|
||||
assert self._resolved(calls) == []
|
||||
|
||||
def test_successful_drift_apply_still_marks_migrations_applied(self, monkeypatch, tmp_path):
|
||||
calls = self._run(monkeypatch, tmp_path, partitioned=False, execute_fails=False)
|
||||
assert len(self._resolved(calls)) == 1
|
||||
|
||||
def test_partitioned_spend_logs_gets_the_filtered_drift_script(self, monkeypatch, tmp_path):
|
||||
calls = self._run(monkeypatch, tmp_path, partitioned=True, execute_fails=False)
|
||||
executed_sql = self._executed_sql(calls)
|
||||
assert 'PRIMARY KEY ("request_id")' not in executed_sql
|
||||
assert "LiteLLM_SpendLogs_legacy" not in executed_sql
|
||||
assert 'ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;' in executed_sql
|
||||
assert 'ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP' in executed_sql
|
||||
assert len(self._resolved(calls)) == 1
|
||||
|
||||
def test_unpartitioned_spend_logs_drift_script_is_untouched(self, monkeypatch, tmp_path):
|
||||
calls = self._run(monkeypatch, tmp_path, partitioned=False, execute_fails=False)
|
||||
assert self._executed_sql(calls) == _PARTITIONED_DRIFT_SQL
|
||||
|
||||
|
||||
class TestPartitionedSpendLogsPushGuard:
|
||||
def _forbid_subprocess(self, monkeypatch):
|
||||
import litellm_proxy_extras.utils as utils_module
|
||||
|
||||
def fail_run(cmd, **kwargs):
|
||||
raise AssertionError(f"subprocess.run should not be called, got: {cmd}")
|
||||
|
||||
monkeypatch.setattr(utils_module.subprocess, "run", fail_run)
|
||||
|
||||
def test_v1_db_push_fails_fast_with_guidance(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: True)
|
||||
)
|
||||
self._forbid_subprocess(monkeypatch)
|
||||
with pytest.raises(RuntimeError) as err:
|
||||
ProxyExtrasDBManager._run_migrations(use_migrate=False, use_v2_resolver=False)
|
||||
assert str(err.value) == PARTITIONED_SPEND_LOGS_PUSH_ERROR
|
||||
|
||||
def test_v2_db_push_fails_fast_with_guidance(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: True)
|
||||
)
|
||||
self._forbid_subprocess(monkeypatch)
|
||||
with pytest.raises(RuntimeError) as err:
|
||||
ProxyExtrasDBManager._setup_database_v2(use_migrate=False)
|
||||
assert str(err.value) == PARTITIONED_SPEND_LOGS_PUSH_ERROR
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
def fetchone(self):
|
||||
return (1,)
|
||||
|
||||
|
||||
class _FakePsycopgConn:
|
||||
def __init__(self, executed):
|
||||
self._executed = executed
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def execute(self, query, params):
|
||||
self._executed.append((query, params))
|
||||
return _FakeCursor()
|
||||
|
||||
|
||||
class TestSpendLogsPartitionDetectionSchemaScope:
|
||||
"""A same-named LiteLLM_SpendLogs in another schema must not trip the
|
||||
detector: the catalog lookup has to be scoped to Prisma's target schema."""
|
||||
|
||||
def _detect(self, monkeypatch, database_url):
|
||||
import sys
|
||||
import types
|
||||
|
||||
executed = []
|
||||
fake_psycopg = types.ModuleType("psycopg")
|
||||
fake_psycopg.connect = lambda url, **kwargs: _FakePsycopgConn(executed)
|
||||
fake_psycopg.OperationalError = type("OperationalError", (Exception,), {})
|
||||
fake_psycopg.DatabaseError = type("DatabaseError", (Exception,), {})
|
||||
monkeypatch.setitem(sys.modules, "psycopg", fake_psycopg)
|
||||
monkeypatch.setenv("DATABASE_URL", database_url)
|
||||
assert ProxyExtrasDBManager.spend_logs_is_partitioned() is True
|
||||
return executed[0]
|
||||
|
||||
def test_lookup_is_scoped_to_the_schema_url_param(self, monkeypatch):
|
||||
query, params = self._detect(
|
||||
monkeypatch, "postgresql://u:p@localhost:5432/db?schema=tenant_a"
|
||||
)
|
||||
assert "pg_namespace" in query
|
||||
assert "n.nspname = %s" in query
|
||||
assert params == ("tenant_a",)
|
||||
|
||||
def test_lookup_falls_back_to_public_without_a_schema_param(self, monkeypatch):
|
||||
query, params = self._detect(monkeypatch, "postgresql://u:p@localhost:5432/db")
|
||||
assert "n.nspname = %s" in query
|
||||
assert params == ("public",)
|
||||
|
||||
def test_only_partitioned_relations_match(self, monkeypatch):
|
||||
query, _ = self._detect(monkeypatch, "postgresql://u:p@localhost:5432/db")
|
||||
assert "pg_partitioned_table" in query
|
||||
|
|
|
|||
|
|
@ -45,6 +45,22 @@ def setup_and_teardown():
|
|||
asyncio.set_event_loop(None) # Remove the reference to the loop
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
async def drain_logging_worker():
|
||||
"""
|
||||
The logging queue is bound to the running loop, so anything left queued when a test's loop
|
||||
goes away is carried onto the next test's loop and fires against its callbacks.
|
||||
"""
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
yield
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.clear_queue(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
# Separate tests in 'test_amazing_proxy_custom_logger.py' and other tests
|
||||
custom_logger_tests = [
|
||||
|
|
|
|||
|
|
@ -375,6 +375,9 @@ def isolate_litellm_state():
|
|||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
image_handling_module.in_memory_cache.flush_cache()
|
||||
_reset_module_level_aws_auth_caches()
|
||||
# litellm.get_model_info() memoizes ModelInfo built from litellm.model_cost, so a
|
||||
# test that rebinds the cost map leaves later tests pricing against the old map.
|
||||
litellm_utils_module._invalidate_model_cost_lowercase_map()
|
||||
|
||||
# Clear all callback lists to prevent cross-test contamination
|
||||
if hasattr(litellm, "callbacks"):
|
||||
|
|
@ -418,6 +421,7 @@ def isolate_litellm_state():
|
|||
|
||||
litellm_utils_module._runtime_registered_model_cost.clear()
|
||||
litellm_utils_module._runtime_registered_model_cost.update(original_runtime_registered_model_cost)
|
||||
litellm_utils_module._invalidate_model_cost_lowercase_map()
|
||||
|
||||
for _router in tuple(litellm_router_module._live_routers):
|
||||
litellm_router_module._live_routers.discard(_router)
|
||||
|
|
|
|||
|
|
@ -778,11 +778,15 @@ def test_mcp_span_roots_without_transport_or_propagated_context(
|
|||
|
||||
|
||||
@pytest.mark.parametrize("make_payload, span_name", _MCP_SPAN_CASES)
|
||||
def test_mcp_span_parents_to_propagated_meta_trace_context(make_payload, span_name):
|
||||
def test_mcp_span_links_propagated_meta_trace_context_and_nests_under_transport(
|
||||
make_payload, span_name
|
||||
):
|
||||
"""When the client propagates W3C trace context in the request's
|
||||
``params._meta`` (SEP-414), the MCP span parents to it (one distributed trace)
|
||||
and still links the transport span — never falling through to the
|
||||
ambient/session span."""
|
||||
``params._meta`` (SEP-414), the MCP span still nests under the gateway's own
|
||||
transport span — one renderable trace — and records the client's context as a
|
||||
span *link*. Parenting to the remote context instead would root the span in a
|
||||
trace whose root span never reaches the gateway's tracing backend, leaving the
|
||||
span unreachable from the trace view."""
|
||||
logger, exporter = _logger()
|
||||
transport = logger._emitter.start_span(
|
||||
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
|
|
@ -801,12 +805,65 @@ def test_mcp_span_parents_to_propagated_meta_trace_context(make_payload, span_na
|
|||
reset_mcp_message_trace_carrier(token)
|
||||
transport.end()
|
||||
span = next(s for s in exporter.get_finished_spans() if s.name == span_name)
|
||||
assert span.context.trace_id == 0x11111111111111111111111111111111
|
||||
assert span.parent is not None
|
||||
assert span.parent.span_id == 0x2222222222222222
|
||||
assert [link.context.span_id for link in span.links] == [
|
||||
transport.get_span_context().span_id
|
||||
assert span.parent.span_id == transport.get_span_context().span_id
|
||||
assert span.context.trace_id == transport.get_span_context().trace_id
|
||||
assert [link.context.trace_id for link in span.links] == [
|
||||
0x11111111111111111111111111111111
|
||||
]
|
||||
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make_payload, span_name", _MCP_SPAN_CASES)
|
||||
def test_mcp_span_without_transport_roots_and_links_propagated_context(
|
||||
make_payload, span_name
|
||||
):
|
||||
"""With no transport span at all there is nothing of the gateway's to anchor
|
||||
to, so the span starts its own root trace — and the client context stays a
|
||||
span link there too, so the event keeps one shape everywhere."""
|
||||
logger, exporter = _logger()
|
||||
token = set_mcp_message_trace_carrier(
|
||||
{"traceparent": "00-11111111111111111111111111111111-2222222222222222-01"}
|
||||
)
|
||||
try:
|
||||
asyncio.run(
|
||||
logger.async_log_success_event(
|
||||
{"standard_logging_object": make_payload()}, None, None, None
|
||||
)
|
||||
)
|
||||
finally:
|
||||
reset_mcp_message_trace_carrier(token)
|
||||
span = next(s for s in exporter.get_finished_spans() if s.name == span_name)
|
||||
assert span.parent is None
|
||||
assert span.context.trace_id != 0x11111111111111111111111111111111
|
||||
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
|
||||
|
||||
|
||||
def test_mcp_span_links_unsampled_client_traceparent():
|
||||
"""A client traceparent with the sampled flag off ('-00') still yields a valid
|
||||
remote context, so the link is recorded; the span's own recording follows the
|
||||
transport's sampling decision, never the client's flag."""
|
||||
logger, exporter = _logger()
|
||||
transport = logger._emitter.start_span(
|
||||
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
)
|
||||
set_request_root_span(transport)
|
||||
token = set_mcp_message_trace_carrier(
|
||||
{"traceparent": "00-11111111111111111111111111111111-2222222222222222-00"}
|
||||
)
|
||||
try:
|
||||
asyncio.run(
|
||||
logger.async_log_success_event(
|
||||
{"standard_logging_object": _mcp_list_payload()}, None, None, None
|
||||
)
|
||||
)
|
||||
finally:
|
||||
reset_mcp_message_trace_carrier(token)
|
||||
transport.end()
|
||||
span = next(s for s in exporter.get_finished_spans() if s.name == "tools/list")
|
||||
assert span.parent is not None
|
||||
assert span.parent.span_id == transport.get_span_context().span_id
|
||||
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make_payload, span_name", _MCP_SPAN_CASES)
|
||||
|
|
@ -839,8 +896,11 @@ def test_mcp_span_ignores_client_supplied_baggage(make_payload, span_name):
|
|||
reset_mcp_message_trace_carrier(token)
|
||||
transport.end()
|
||||
span = next(s for s in exporter.get_finished_spans() if s.name == span_name)
|
||||
# Trace context still honored: proves the carrier was processed, not dropped wholesale.
|
||||
assert span.parent is not None and span.parent.span_id == 0x2222222222222222
|
||||
# Trace context still honored (as a link): proves the carrier was processed,
|
||||
# not dropped wholesale.
|
||||
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
|
||||
assert span.parent is not None
|
||||
assert span.parent.span_id == transport.get_span_context().span_id
|
||||
# Identity is the authenticated payload's team, never the client's spoofed value.
|
||||
assert span.attributes[LiteLLM.TEAM_ID] == "t1"
|
||||
assert "litellm.metadata.user_api_key_user_id" not in span.attributes
|
||||
|
|
@ -888,10 +948,10 @@ def test_mcp_span_malformed_traceparent_nests_under_transport():
|
|||
assert span.links == ()
|
||||
|
||||
|
||||
def test_mcp_span_links_this_messages_transport_when_context_is_propagated():
|
||||
"""On the semconv path the transport is recorded as a link, and that link must
|
||||
point at the POST carrying this message too. Reading the stale session anchor
|
||||
would attribute the tool call to whichever request opened the session."""
|
||||
def test_mcp_span_with_propagated_context_nests_under_this_messages_transport():
|
||||
"""With client context propagated, the span must still anchor to the POST
|
||||
carrying this message, not the stale session anchor — otherwise the tool call
|
||||
is attributed to whichever request opened the session."""
|
||||
logger, exporter = _logger()
|
||||
session_opener = logger._emitter.start_span(
|
||||
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
|
|
@ -916,10 +976,10 @@ def test_mcp_span_links_this_messages_transport_when_context_is_propagated():
|
|||
session_opener.end()
|
||||
this_message.end()
|
||||
span = next(s for s in exporter.get_finished_spans() if s.name == "tools/list")
|
||||
assert span.parent is not None and span.parent.span_id == 0x2222222222222222
|
||||
assert [link.context.span_id for link in span.links] == [
|
||||
this_message.get_span_context().span_id
|
||||
]
|
||||
assert span.parent is not None
|
||||
assert span.parent.span_id == this_message.get_span_context().span_id
|
||||
assert span.context.trace_id == this_message.get_span_context().trace_id
|
||||
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
|
||||
|
||||
|
||||
def test_pre_call_idempotent_keeps_first_span():
|
||||
|
|
|
|||
|
|
@ -107,32 +107,29 @@ def test_registry_parent_integrity_no_orphans():
|
|||
|
||||
|
||||
def test_registry_hierarchy_shape():
|
||||
# MCP roles have no in-process parent: per the MCP semconv they root (or adopt
|
||||
# the client's propagated _meta context), so they sit alongside PROXY_REQUEST.
|
||||
assert set(root_roles()) == {
|
||||
SpanRole.PROXY_REQUEST,
|
||||
SpanRole.MCP_TOOL_CALL,
|
||||
SpanRole.MCP_LIST_TOOLS,
|
||||
}
|
||||
assert set(root_roles()) == {SpanRole.PROXY_REQUEST}
|
||||
# Guardrails parent to the request span, not the LLM call: a pre-call
|
||||
# guardrail runs before the LLM call exists, so it's a sibling of it.
|
||||
# guardrail runs before the LLM call exists, so it's a sibling of it. MCP
|
||||
# spans nest under the transport span of the request carrying that message.
|
||||
assert set(child_roles(SpanRole.PROXY_REQUEST)) == {
|
||||
SpanRole.LLM_CALL,
|
||||
SpanRole.GUARDRAIL,
|
||||
SpanRole.DB_CALL,
|
||||
SpanRole.SERVICE,
|
||||
SpanRole.MCP_TOOL_CALL,
|
||||
SpanRole.MCP_LIST_TOOLS,
|
||||
}
|
||||
assert SPAN_REGISTRY[SpanRole.LLM_CALL].kind is LiteLLMSpanKind.CLIENT
|
||||
# The proxy is an MCP client to the upstream tool server: CLIENT span. Listing
|
||||
# tools is the same client relationship, so it's a CLIENT span too.
|
||||
assert SPAN_REGISTRY[SpanRole.MCP_TOOL_CALL].kind is LiteLLMSpanKind.CLIENT
|
||||
assert SPAN_REGISTRY[SpanRole.MCP_LIST_TOOLS].kind is LiteLLMSpanKind.CLIENT
|
||||
# MCP spans don't nest under the transport: they link the PROXY_REQUEST span
|
||||
# instead of parenting to it (OTel GenAI MCP semconv).
|
||||
assert SPAN_REGISTRY[SpanRole.MCP_TOOL_CALL].parent is None
|
||||
assert SPAN_REGISTRY[SpanRole.MCP_LIST_TOOLS].parent is None
|
||||
assert SPAN_REGISTRY[SpanRole.MCP_TOOL_CALL].links is SpanRole.PROXY_REQUEST
|
||||
assert SPAN_REGISTRY[SpanRole.MCP_LIST_TOOLS].links is SpanRole.PROXY_REQUEST
|
||||
# MCP spans nest under the transport span of the request carrying that
|
||||
# message (resolved per message at emit time); a client-propagated context
|
||||
# becomes a span link to that remote context, which is not a registry role
|
||||
# (SpanSpec declares no link field at all).
|
||||
assert SPAN_REGISTRY[SpanRole.MCP_TOOL_CALL].parent is SpanRole.PROXY_REQUEST
|
||||
assert SPAN_REGISTRY[SpanRole.MCP_LIST_TOOLS].parent is SpanRole.PROXY_REQUEST
|
||||
assert SPAN_REGISTRY[SpanRole.PROXY_REQUEST].kind is LiteLLMSpanKind.SERVER
|
||||
assert SPAN_REGISTRY[SpanRole.GUARDRAIL].parent is SpanRole.PROXY_REQUEST
|
||||
# An outbound datastore call is a CLIENT span; an internal service is INTERNAL.
|
||||
|
|
|
|||
|
|
@ -3019,3 +3019,95 @@ async def test_provider_config_path_captures_transcription_usage():
|
|||
and message.get("usage") == usage
|
||||
)
|
||||
assert len(usage_events) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_close_flushes_unbilled_transcription_usage():
|
||||
"""Trailing audio appended after the last transcript frame must still be billed:
|
||||
on session close the provider's unbilled estimate is flushed into the logged
|
||||
messages before log_messages runs, and never forwarded to the client."""
|
||||
from typing import Final
|
||||
|
||||
from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage
|
||||
|
||||
client_ws: Final = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
backend_ws: Final = MagicMock()
|
||||
backend_ws.recv = AsyncMock(side_effect=ConnectionClosed(None, None))
|
||||
logging_obj: Final = MagicMock()
|
||||
logging_obj.async_success_handler = AsyncMock()
|
||||
logging_obj.success_handler = MagicMock()
|
||||
|
||||
usage: Final[RealtimeInputAudioTranscriptionUsage] = {
|
||||
"type": "tokens",
|
||||
"input_tokens": 153,
|
||||
"output_tokens": 18,
|
||||
"total_tokens": 171,
|
||||
"input_token_details": {"text_tokens": 0, "audio_tokens": 153},
|
||||
}
|
||||
provider_config: Final = MagicMock()
|
||||
provider_config.unbilled_usage_on_session_close = MagicMock(return_value=usage)
|
||||
|
||||
streaming: Final = RealTimeStreaming(
|
||||
client_ws,
|
||||
backend_ws,
|
||||
logging_obj,
|
||||
provider_config=provider_config,
|
||||
model="gemini-3.5-transcribe-live",
|
||||
)
|
||||
logged_snapshots: Final[list[tuple]] = []
|
||||
|
||||
original_log_messages: Final = streaming.log_messages
|
||||
|
||||
async def _snapshot_then_log():
|
||||
logged_snapshots.append(tuple(streaming.messages))
|
||||
await original_log_messages()
|
||||
|
||||
streaming.log_messages = _snapshot_then_log
|
||||
|
||||
await streaming.backend_to_client_send_messages()
|
||||
|
||||
provider_config.unbilled_usage_on_session_close.assert_called_once_with("gemini-3.5-transcribe-live")
|
||||
flushed: Final = tuple(
|
||||
message
|
||||
for message in streaming.messages
|
||||
if isinstance(message, dict)
|
||||
and message.get("type") == "conversation.item.input_audio_transcription.completed"
|
||||
and message.get("usage") == usage
|
||||
)
|
||||
assert len(flushed) == 1
|
||||
assert flushed[0] in logged_snapshots[0]
|
||||
assert not client_ws.send_text.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_close_flush_noop_without_unbilled_usage():
|
||||
"""Everything already billed mid-stream: the session-close flush must not append
|
||||
a duplicate transcription event."""
|
||||
from typing import Final
|
||||
|
||||
client_ws: Final = MagicMock()
|
||||
client_ws.send_text = AsyncMock()
|
||||
backend_ws: Final = MagicMock()
|
||||
backend_ws.recv = AsyncMock(side_effect=ConnectionClosed(None, None))
|
||||
logging_obj: Final = MagicMock()
|
||||
logging_obj.async_success_handler = AsyncMock()
|
||||
logging_obj.success_handler = MagicMock()
|
||||
|
||||
provider_config: Final = MagicMock()
|
||||
provider_config.unbilled_usage_on_session_close = MagicMock(return_value=None)
|
||||
|
||||
streaming: Final = RealTimeStreaming(
|
||||
client_ws,
|
||||
backend_ws,
|
||||
logging_obj,
|
||||
provider_config=provider_config,
|
||||
model="gemini-3.5-transcribe-live",
|
||||
)
|
||||
|
||||
await streaming.backend_to_client_send_messages()
|
||||
|
||||
assert not any(
|
||||
isinstance(message, dict) and message.get("type") == "conversation.item.input_audio_transcription.completed"
|
||||
for message in streaming.messages
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,17 +59,17 @@ class GptProfile(NamedTuple):
|
|||
GPT_5_6_PROFILES = [
|
||||
GptProfile(
|
||||
model_id="us.openai.gpt-5.6-sol",
|
||||
input_cost=5.5e-06, input_cost_above_272k=1.1e-05,
|
||||
cache_write=6.875e-06, cache_write_above_272k=1.375e-05,
|
||||
cache_read=5.5e-07, cache_read_above_272k=1.1e-06,
|
||||
output_cost=3.3e-05, output_cost_above_272k=4.95e-05,
|
||||
input_cost=4.4e-06, input_cost_above_272k=8.8e-06,
|
||||
cache_write=5.5e-06, cache_write_above_272k=1.1e-05,
|
||||
cache_read=4.4e-07, cache_read_above_272k=8.8e-07,
|
||||
output_cost=2.2e-05, output_cost_above_272k=3.3e-05,
|
||||
),
|
||||
GptProfile(
|
||||
model_id="global.openai.gpt-5.6-sol",
|
||||
input_cost=5e-06, input_cost_above_272k=1e-05,
|
||||
cache_write=6.25e-06, cache_write_above_272k=1.25e-05,
|
||||
cache_read=5e-07, cache_read_above_272k=1e-06,
|
||||
output_cost=3e-05, output_cost_above_272k=4.5e-05,
|
||||
input_cost=4e-06, input_cost_above_272k=8e-06,
|
||||
cache_write=5e-06, cache_write_above_272k=1e-05,
|
||||
cache_read=4e-07, cache_read_above_272k=8e-07,
|
||||
output_cost=2e-05, output_cost_above_272k=3e-05,
|
||||
),
|
||||
GptProfile(
|
||||
model_id="us.openai.gpt-5.6-terra",
|
||||
|
|
@ -221,7 +221,7 @@ def test_bedrock_gpt_5_6_above_272k_tier_applies_to_cost(local_model_cost_map):
|
|||
custom_llm_provider="bedrock",
|
||||
)
|
||||
|
||||
assert cost == pytest.approx((300000 * 1.1e-05) + (1000 * 4.95e-05), rel=1e-9)
|
||||
assert cost == pytest.approx((300000 * 8.8e-06) + (1000 * 3.3e-05), rel=1e-9)
|
||||
|
||||
|
||||
def test_bedrock_gpt_5_6_bills_cache_read_tokens(local_model_cost_map):
|
||||
|
|
@ -241,10 +241,10 @@ def test_bedrock_gpt_5_6_bills_cache_read_tokens(local_model_cost_map):
|
|||
custom_llm_provider="bedrock",
|
||||
)
|
||||
|
||||
expected = (2 * 5.5e-06) + (15609 * 5.5e-07) + (5 * 3.3e-05)
|
||||
expected = (2 * 4.4e-06) + (15609 * 4.4e-07) + (5 * 2.2e-05)
|
||||
assert cost == pytest.approx(expected, rel=1e-9)
|
||||
# Without cache_read_input_token_cost the cached prefix bills at zero.
|
||||
assert cost > (15611 * 5.5e-06) * 0.1
|
||||
assert cost > (15611 * 4.4e-06) * 0.1
|
||||
|
||||
|
||||
def test_bedrock_gpt_5_6_bills_cache_write_tokens(local_model_cost_map):
|
||||
|
|
@ -263,7 +263,7 @@ def test_bedrock_gpt_5_6_bills_cache_write_tokens(local_model_cost_map):
|
|||
custom_llm_provider="bedrock",
|
||||
)
|
||||
|
||||
expected = (2 * 5.5e-06) + (15609 * 6.875e-06) + (5 * 3.3e-05)
|
||||
expected = (2 * 4.4e-06) + (15609 * 5.5e-06) + (5 * 2.2e-05)
|
||||
assert cost == pytest.approx(expected, rel=1e-9)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1683,10 +1683,19 @@ class TestBedrockMantleResponsesPricing:
|
|||
assert info["cache_read_input_token_cost"] == pytest.approx(2.75e-07)
|
||||
assert info["max_input_tokens"] == 1050000
|
||||
|
||||
def test_gpt_5_6_cyber_pricing_and_mode(self, local_cost_map):
|
||||
info = litellm.get_model_info("bedrock_mantle/openai.gpt-5.6-cyber")
|
||||
assert info["mode"] == "responses"
|
||||
assert info["input_cost_per_token"] == pytest.approx(1.375e-05)
|
||||
assert info["cache_creation_input_token_cost"] == pytest.approx(1.71875e-05)
|
||||
assert info["cache_read_input_token_cost"] == pytest.approx(1.375e-06)
|
||||
assert info["output_cost_per_token"] == pytest.approx(8.25e-05)
|
||||
assert info["max_input_tokens"] == 272000
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model, input_cost, cache_creation_cost, cache_read_cost, output_cost",
|
||||
[
|
||||
("openai.gpt-5.6-sol", 5.5e-06, 6.875e-06, 5.5e-07, 3.3e-05),
|
||||
("openai.gpt-5.6-sol", 4.4e-06, 5.5e-06, 4.4e-07, 2.2e-05),
|
||||
("openai.gpt-5.6-terra", 2.2e-06, 2.75e-06, 2.2e-07, 1.32e-05),
|
||||
("openai.gpt-5.6-luna", 2.2e-07, 2.75e-07, 2.2e-08, 1.32e-06),
|
||||
],
|
||||
|
|
@ -1709,7 +1718,7 @@ class TestBedrockMantleResponsesPricing:
|
|||
@pytest.mark.parametrize(
|
||||
"model, input_cost, output_cost",
|
||||
[
|
||||
("openai.gpt-5.6-sol", 5.5e-06, 3.3e-05),
|
||||
("openai.gpt-5.6-sol", 4.4e-06, 2.2e-05),
|
||||
("openai.gpt-5.6-terra", 2.2e-06, 1.32e-05),
|
||||
("openai.gpt-5.6-luna", 2.2e-07, 1.32e-06),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ PUBLISHED_DBU_PER_MILLION: Final = {
|
|||
"databricks/databricks-gemini-3-1-flash-lite": ("4.464", "26.786", "4.464", "0.446"),
|
||||
"databricks/databricks-gemini-2-5-pro": ("22.321", "178.571", "22.321", "2.232"),
|
||||
"databricks/databricks-gemini-2-5-flash": ("5.357", "44.643", "5.357", "0.536"),
|
||||
"databricks/databricks-kimi-k3": ("42.857", "214.286", "42.857", "4.286"),
|
||||
"databricks/databricks-glm-5-2": ("20.000", "62.857", "20.000", "3.714"),
|
||||
}
|
||||
PROMOTIONAL_DISCOUNT: Final = 0.80
|
||||
PROMOTION_EXPIRES: Final = "2027-01-31"
|
||||
|
|
|
|||
|
|
@ -2119,3 +2119,27 @@ def test_non_transcription_live_model_completed_event_has_no_usage(patch_gemini_
|
|||
)
|
||||
assert len(completed) == 1
|
||||
assert "usage" not in completed[0]
|
||||
|
||||
|
||||
def test_unbilled_usage_on_session_close_flushes_trailing_audio(patch_gemini_transcribe_live_cost_map_entry):
|
||||
"""Audio appended after the last transcript frame is still unbilled when the
|
||||
session closes; the session-close hook must hand back the estimate exactly once
|
||||
so the streaming layer can bill it (144000 pcm16 bytes = 3s -> 75 in / 9 out)."""
|
||||
from typing import Final
|
||||
|
||||
from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage
|
||||
|
||||
config: Final = GeminiRealtimeConfig()
|
||||
config.transform_realtime_request(_input_audio_append_message(144000), "gemini-3.5-transcribe-live")
|
||||
|
||||
usage: Final = config.unbilled_usage_on_session_close("gemini-3.5-transcribe-live")
|
||||
|
||||
expected: Final[RealtimeInputAudioTranscriptionUsage] = {
|
||||
"type": "tokens",
|
||||
"input_tokens": 75,
|
||||
"output_tokens": 9,
|
||||
"total_tokens": 84,
|
||||
"input_token_details": {"text_tokens": 0, "audio_tokens": 75},
|
||||
}
|
||||
assert usage == expected
|
||||
assert config.unbilled_usage_on_session_close("gemini-3.5-transcribe-live") is None
|
||||
|
|
|
|||
75
tests/test_litellm/llms/xai/test_xai_model_registry.py
Normal file
75
tests/test_litellm/llms/xai/test_xai_model_registry.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""
|
||||
Registry regression tests for xAI entries in the model cost map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[4]
|
||||
PRICES_PATH = REPO_ROOT / "model_prices_and_context_window.json"
|
||||
BACKUP_PRICES_PATH = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json"
|
||||
|
||||
# Retired by xAI and no longer served: requests to these slugs 404 rather than
|
||||
# redirecting, and they are absent from https://docs.x.ai/docs/models
|
||||
RETIRED_MODELS = (
|
||||
"xai/grok-2",
|
||||
"xai/grok-2-1212",
|
||||
"xai/grok-2-latest",
|
||||
"xai/grok-2-vision",
|
||||
"xai/grok-2-vision-1212",
|
||||
"xai/grok-2-vision-latest",
|
||||
"xai/grok-beta",
|
||||
"xai/grok-vision-beta",
|
||||
)
|
||||
|
||||
# https://docs.x.ai/developers/model-capabilities/text/multi-agent
|
||||
# "The multi-agent model does not work with the OpenAI Chat Completions API."
|
||||
RESPONSES_ONLY_MODELS = (
|
||||
"xai/grok-4.20-multi-agent-0309",
|
||||
"xai/grok-4.20-multi-agent-beta-0309",
|
||||
)
|
||||
|
||||
MAP_PATHS = (PRICES_PATH, BACKUP_PRICES_PATH)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=[p.name for p in MAP_PATHS])
|
||||
def cost_map(request: pytest.FixtureRequest) -> dict:
|
||||
path = next(p for p in MAP_PATHS if p.name == request.param)
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", RETIRED_MODELS)
|
||||
def test_retired_xai_models_are_not_advertised(cost_map: dict, model: str):
|
||||
assert model not in cost_map
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", RESPONSES_ONLY_MODELS)
|
||||
def test_multi_agent_models_are_responses_only(cost_map: dict, model: str):
|
||||
entry = cost_map[model]
|
||||
assert entry["supported_endpoints"] == ["/v1/responses"]
|
||||
assert entry["mode"] == "responses"
|
||||
assert "/v1/chat/completions" not in entry["supported_endpoints"]
|
||||
|
||||
|
||||
def test_surviving_xai_chat_models_still_serve_chat_completions(cost_map: dict):
|
||||
"""Guard against the removal above over-reaching into live models."""
|
||||
chat_models = [
|
||||
key
|
||||
for key, value in cost_map.items()
|
||||
if isinstance(value, dict) and value.get("litellm_provider") == "xai" and value.get("mode") == "chat"
|
||||
]
|
||||
assert "xai/grok-4.3" in chat_models
|
||||
assert "xai/grok-4.6" in chat_models
|
||||
assert not any(key.startswith("xai/grok-2") for key in chat_models)
|
||||
|
||||
|
||||
def test_both_cost_maps_agree_on_xai_entries():
|
||||
prices = json.loads(PRICES_PATH.read_text(encoding="utf-8"))
|
||||
backup = json.loads(BACKUP_PRICES_PATH.read_text(encoding="utf-8"))
|
||||
xai_keys = {k for k, v in prices.items() if isinstance(v, dict) and v.get("litellm_provider") == "xai"}
|
||||
assert xai_keys
|
||||
assert {k: prices[k] for k in xai_keys} == {k: backup[k] for k in xai_keys}
|
||||
|
|
@ -5,7 +5,8 @@ Validates that:
|
|||
1. _convert_mcp_hook_response_to_kwargs extracts extra_headers from hook response
|
||||
2. pre_call_tool_check returns hook-provided extra_headers AND modified arguments
|
||||
3. call_tool flows hook headers and modified arguments downstream
|
||||
4. Hook-provided headers take highest priority (merge after static_headers)
|
||||
4. Hook-provided headers merge after static_headers, but a hook Authorization
|
||||
header never displaces an existing upstream Authorization credential
|
||||
5. OpenAPI-backed servers log a warning and continue (skip injection) when hook headers are present
|
||||
6. JWT claims are propagated in both standard and virtual-key fast paths
|
||||
7. Backward compatibility: hooks without extra_headers continue to work
|
||||
|
|
@ -487,8 +488,8 @@ class TestHookHeaderMergePriority:
|
|||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_headers_override_static_headers(self):
|
||||
"""Hook headers should take precedence over static_headers."""
|
||||
async def test_hook_authorization_does_not_override_static_authorization(self):
|
||||
"""A hook Authorization must not displace a static_headers Authorization (LIT-6321)."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server(static_headers={"Authorization": "Bearer static-token", "X-Static": "yes"})
|
||||
|
||||
|
|
@ -521,7 +522,7 @@ class TestHookHeaderMergePriority:
|
|||
pass
|
||||
|
||||
headers = captured_extra_headers.get("value", {})
|
||||
assert headers["Authorization"] == "Bearer hook-signed-jwt"
|
||||
assert headers["Authorization"] == "Bearer static-token"
|
||||
assert headers["X-Static"] == "yes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -560,8 +561,8 @@ class TestHookHeaderMergePriority:
|
|||
assert headers == {"X-Static": "static-value"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_headers_merge_with_oauth2(self):
|
||||
"""Hook headers merge on top of OAuth2 headers."""
|
||||
async def test_hook_authorization_does_not_override_oauth2_authorization(self):
|
||||
"""tools/call keeps the user's OAuth Authorization; only non-auth hook headers merge (LIT-6321)."""
|
||||
manager = MCPServerManager()
|
||||
server = MCPServer(
|
||||
server_id="test-id",
|
||||
|
|
@ -570,6 +571,8 @@ class TestHookHeaderMergePriority:
|
|||
url="https://example.com",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="authorization_code",
|
||||
delegate_auth_to_upstream=True,
|
||||
)
|
||||
|
||||
captured_extra_headers: Dict[str, Any] = {}
|
||||
|
|
@ -605,10 +608,245 @@ class TestHookHeaderMergePriority:
|
|||
pass
|
||||
|
||||
headers = captured_extra_headers.get("value", {})
|
||||
assert headers["Authorization"] == "Bearer hook-jwt"
|
||||
assert headers["Authorization"] == "Bearer oauth2-token"
|
||||
assert headers["X-OAuth"] == "yes"
|
||||
assert headers["X-Trace-Id"] == "trace-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_authorization_used_when_no_upstream_credential(self):
|
||||
"""With no upstream credential, the signer JWT is still injected."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
captured_extra_headers: Dict[str, Optional[Dict[str, str]]] = {}
|
||||
|
||||
async def fake_create_mcp_client(server, mcp_auth_header=None, extra_headers=None, stdio_env=None, **kwargs):
|
||||
captured_extra_headers["value"] = extra_headers
|
||||
mock_client = MagicMock()
|
||||
mock_client.call_tool = AsyncMock(return_value=MagicMock())
|
||||
return mock_client
|
||||
|
||||
with patch.object(manager, "_create_mcp_client", side_effect=fake_create_mcp_client):
|
||||
with patch.object(manager, "_build_stdio_env", return_value=None):
|
||||
try:
|
||||
await manager._call_regular_mcp_tool(
|
||||
mcp_server=server,
|
||||
original_tool_name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
tasks=[],
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
proxy_logging_obj=None,
|
||||
hook_extra_headers={"Authorization": "Bearer hook-jwt"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = captured_extra_headers.get("value") or {}
|
||||
assert headers["Authorization"] == "Bearer hook-jwt"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_authorization_dropped_when_server_auth_header_present(self):
|
||||
"""With a configured authentication_token (auth_value), the hook Authorization is dropped."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
captured: Dict[str, object] = {}
|
||||
|
||||
async def fake_create_mcp_client(server, mcp_auth_header=None, extra_headers=None, stdio_env=None, **kwargs):
|
||||
captured["extra_headers"] = extra_headers
|
||||
captured["mcp_auth_header"] = mcp_auth_header
|
||||
mock_client = MagicMock()
|
||||
mock_client.call_tool = AsyncMock(return_value=MagicMock())
|
||||
return mock_client
|
||||
|
||||
with patch.object(manager, "_create_mcp_client", side_effect=fake_create_mcp_client):
|
||||
with patch.object(manager, "_build_stdio_env", return_value=None):
|
||||
try:
|
||||
await manager._call_regular_mcp_tool(
|
||||
mcp_server=server,
|
||||
original_tool_name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
tasks=[],
|
||||
mcp_auth_header="server-static-token",
|
||||
mcp_server_auth_headers=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
proxy_logging_obj=None,
|
||||
hook_extra_headers={
|
||||
"Authorization": "Bearer hook-jwt",
|
||||
"X-Trace-Id": "trace-123",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = captured.get("extra_headers") or {}
|
||||
assert isinstance(headers, dict)
|
||||
assert "Authorization" not in headers
|
||||
assert headers.get("X-Trace-Id") == "trace-123"
|
||||
assert captured.get("mcp_auth_header") == "server-static-token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_authorization_case_insensitive_conflict(self):
|
||||
"""Authorization conflicts are matched case-insensitively."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server(static_headers={"authorization": "Bearer static-token"})
|
||||
|
||||
captured_extra_headers: Dict[str, Optional[Dict[str, str]]] = {}
|
||||
|
||||
async def fake_create_mcp_client(server, mcp_auth_header=None, extra_headers=None, stdio_env=None, **kwargs):
|
||||
captured_extra_headers["value"] = extra_headers
|
||||
mock_client = MagicMock()
|
||||
mock_client.call_tool = AsyncMock(return_value=MagicMock())
|
||||
return mock_client
|
||||
|
||||
with patch.object(manager, "_create_mcp_client", side_effect=fake_create_mcp_client):
|
||||
with patch.object(manager, "_build_stdio_env", return_value=None):
|
||||
try:
|
||||
await manager._call_regular_mcp_tool(
|
||||
mcp_server=server,
|
||||
original_tool_name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
tasks=[],
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
proxy_logging_obj=None,
|
||||
hook_extra_headers={"Authorization": "Bearer hook-jwt"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = captured_extra_headers.get("value") or {}
|
||||
assert headers.get("authorization") == "Bearer static-token"
|
||||
assert "Authorization" not in headers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_authorization_kept_with_api_key_server_credential(self):
|
||||
"""An api_key credential maps to X-API-Key, so the hook Authorization is kept."""
|
||||
manager = MCPServerManager()
|
||||
server = MCPServer(
|
||||
server_id="test-id",
|
||||
name="Test Server",
|
||||
server_name="test_server",
|
||||
url="https://example.com",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.api_key,
|
||||
)
|
||||
|
||||
captured: Dict[str, object] = {}
|
||||
|
||||
async def fake_create_mcp_client(server, mcp_auth_header=None, extra_headers=None, stdio_env=None, **kwargs):
|
||||
captured["extra_headers"] = extra_headers
|
||||
captured["mcp_auth_header"] = mcp_auth_header
|
||||
mock_client = MagicMock()
|
||||
mock_client.call_tool = AsyncMock(return_value=MagicMock())
|
||||
return mock_client
|
||||
|
||||
with patch.object(manager, "_create_mcp_client", side_effect=fake_create_mcp_client):
|
||||
with patch.object(manager, "_build_stdio_env", return_value=None):
|
||||
try:
|
||||
await manager._call_regular_mcp_tool(
|
||||
mcp_server=server,
|
||||
original_tool_name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
tasks=[],
|
||||
mcp_auth_header="server-api-key",
|
||||
mcp_server_auth_headers=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
proxy_logging_obj=None,
|
||||
hook_extra_headers={"Authorization": "Bearer hook-jwt"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = captured.get("extra_headers") or {}
|
||||
assert isinstance(headers, dict)
|
||||
assert headers.get("Authorization") == "Bearer hook-jwt"
|
||||
assert captured.get("mcp_auth_header") == "server-api-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_authorization_kept_with_non_authorization_server_header_dict(self):
|
||||
"""A per-server header dict without Authorization does not block the hook JWT."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
captured: Dict[str, object] = {}
|
||||
|
||||
async def fake_create_mcp_client(server, mcp_auth_header=None, extra_headers=None, stdio_env=None, **kwargs):
|
||||
captured["extra_headers"] = extra_headers
|
||||
captured["mcp_auth_header"] = mcp_auth_header
|
||||
mock_client = MagicMock()
|
||||
mock_client.call_tool = AsyncMock(return_value=MagicMock())
|
||||
return mock_client
|
||||
|
||||
with patch.object(manager, "_create_mcp_client", side_effect=fake_create_mcp_client):
|
||||
with patch.object(manager, "_build_stdio_env", return_value=None):
|
||||
try:
|
||||
await manager._call_regular_mcp_tool(
|
||||
mcp_server=server,
|
||||
original_tool_name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
tasks=[],
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers={"test_server": {"X-API-Key": "per-server-key"}},
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
proxy_logging_obj=None,
|
||||
hook_extra_headers={"Authorization": "Bearer hook-jwt"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = captured.get("extra_headers") or {}
|
||||
assert isinstance(headers, dict)
|
||||
assert headers.get("Authorization") == "Bearer hook-jwt"
|
||||
assert captured.get("mcp_auth_header") == {"X-API-Key": "per-server-key"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_authorization_dropped_with_authorization_server_header_dict(self):
|
||||
"""A per-server header dict carrying Authorization blocks the hook JWT."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
captured: Dict[str, object] = {}
|
||||
|
||||
async def fake_create_mcp_client(server, mcp_auth_header=None, extra_headers=None, stdio_env=None, **kwargs):
|
||||
captured["extra_headers"] = extra_headers
|
||||
captured["mcp_auth_header"] = mcp_auth_header
|
||||
mock_client = MagicMock()
|
||||
mock_client.call_tool = AsyncMock(return_value=MagicMock())
|
||||
return mock_client
|
||||
|
||||
with patch.object(manager, "_create_mcp_client", side_effect=fake_create_mcp_client):
|
||||
with patch.object(manager, "_build_stdio_env", return_value=None):
|
||||
try:
|
||||
await manager._call_regular_mcp_tool(
|
||||
mcp_server=server,
|
||||
original_tool_name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
tasks=[],
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers={"test_server": {"authorization": "Bearer per-server-token"}},
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
proxy_logging_obj=None,
|
||||
hook_extra_headers={"Authorization": "Bearer hook-jwt", "X-Trace-Id": "trace-123"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = captured.get("extra_headers") or {}
|
||||
assert isinstance(headers, dict)
|
||||
assert "Authorization" not in headers
|
||||
assert headers.get("X-Trace-Id") == "trace-123"
|
||||
assert captured.get("mcp_auth_header") == {"authorization": "Bearer per-server-token"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_m2m_oauth2_does_not_forward_litellm_caller_authorization(self):
|
||||
"""M2M must not put caller Bearer (LiteLLM API key) into extra_headers (#23652)."""
|
||||
|
|
|
|||
|
|
@ -4972,6 +4972,143 @@ async def test_centralized_common_checks_ui_sentinel_team_vouches_despite_absent
|
|||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_centralized_common_checks_ui_sentinel_team_skips_db_lookup():
|
||||
"""LIT-6297 / GH#28775: ``UI_TEAM_ID`` never has a team row and the
|
||||
not-found path bypasses the DB throttle, so building the team fetch for it
|
||||
cost one guaranteed-miss ``LiteLLM_TeamTable.find_unique`` plus a 404 debug
|
||||
log on every dashboard request. The gate must not call ``get_team_object``
|
||||
for the sentinel at all, while the token-derived team object still reaches
|
||||
``common_checks``."""
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from litellm.proxy._types import UI_TEAM_ID, LiteLLM_TeamTableCachedObj
|
||||
|
||||
token = UserAPIKeyAuth(
|
||||
api_key="sk-test",
|
||||
user_id="ui-session-user",
|
||||
team_id=UI_TEAM_ID,
|
||||
models=[],
|
||||
team_models=[],
|
||||
)
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/user/info")
|
||||
request._body = b"{}"
|
||||
|
||||
received_team_objects: list[LiteLLM_TeamTableCachedObj | None] = []
|
||||
|
||||
async def _capturing_common_checks(*_args, **kwargs) -> bool:
|
||||
received_team_objects.append(kwargs.get("team_object"))
|
||||
return True
|
||||
|
||||
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
|
||||
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
|
||||
try:
|
||||
for k, v in attrs.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
with (
|
||||
patch( # test-quality-ok: the regression IS that this DB lookup is never made for the sentinel
|
||||
"litellm.proxy.auth.user_api_key_auth.get_team_object",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_team_object,
|
||||
patch( # test-quality-ok: capture the team_object the consumer receives without a DB
|
||||
"litellm.proxy.auth.user_api_key_auth.common_checks",
|
||||
_capturing_common_checks,
|
||||
),
|
||||
):
|
||||
await _run_centralized_common_checks(
|
||||
user_api_key_auth_obj=token,
|
||||
request=request,
|
||||
request_data={},
|
||||
route="/user/info",
|
||||
)
|
||||
mock_get_team_object.assert_not_awaited()
|
||||
assert len(received_team_objects) == 1
|
||||
received_team_object = received_team_objects[0]
|
||||
assert received_team_object is not None
|
||||
assert received_team_object.team_id == UI_TEAM_ID
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builder_ui_sentinel_team_never_hits_get_team_object(): # test-quality-ok: absence of the guaranteed-miss DB call is the observable being pinned
|
||||
"""Companion to the centralized-gate test for the builder path: the cached
|
||||
UI session token's team refresh and the post-validation team fetch must
|
||||
both skip ``get_team_object`` for ``UI_TEAM_ID`` instead of 404ing on
|
||||
every request."""
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from litellm.proxy._types import UI_TEAM_ID
|
||||
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
|
||||
from litellm.proxy.proxy_server import hash_token
|
||||
|
||||
api_key = "sk-test-ui-session-key"
|
||||
cached_token = UserAPIKeyAuth(
|
||||
api_key=api_key,
|
||||
token=hash_token(api_key),
|
||||
user_id="ui-session-user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
team_id=UI_TEAM_ID,
|
||||
)
|
||||
|
||||
mock_proxy_logging_obj = MagicMock()
|
||||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
||||
|
||||
attrs = {
|
||||
"prisma_client": MagicMock(),
|
||||
"user_api_key_cache": DualCache(),
|
||||
"proxy_logging_obj": mock_proxy_logging_obj,
|
||||
"master_key": "sk-master-key",
|
||||
"general_settings": {},
|
||||
"llm_model_list": [],
|
||||
"llm_router": None,
|
||||
"open_telemetry_logger": None,
|
||||
"model_max_budget_limiter": MagicMock(),
|
||||
"user_custom_auth": None,
|
||||
"jwt_handler": None,
|
||||
"litellm_proxy_admin_name": "admin",
|
||||
}
|
||||
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
|
||||
try:
|
||||
for k, v in attrs.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/user/info")
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: seed the cached UI session token without a DB
|
||||
"litellm.proxy.auth.resolvers.store.IdentityStore._resolve_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=cached_token,
|
||||
),
|
||||
patch( # test-quality-ok: the regression IS that this DB lookup is never made for the sentinel
|
||||
"litellm.proxy.auth.user_api_key_auth.get_team_object",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_team_object,
|
||||
):
|
||||
result = await _user_api_key_auth_builder(
|
||||
request=request,
|
||||
api_key=f"Bearer {api_key}",
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=None,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data={},
|
||||
)
|
||||
assert result.team_id == UI_TEAM_ID
|
||||
mock_get_team_object.assert_not_awaited()
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_centralized_common_checks_user_http_exception_isolates_to_user_only():
|
||||
"""Per-fetch isolation, mirror of the team case: an HTTPException
|
||||
|
|
|
|||
|
|
@ -1458,7 +1458,7 @@ def test_budget_cascade_writes_land_in_a_single_transaction(reset_budget_job, mo
|
|||
budget = _budget_row(budget_id="budget-1", budget_duration="7d")
|
||||
mock_prisma_client.data["budget"] = [budget]
|
||||
mock_prisma_client.data["enduser"] = [
|
||||
type("EndUser", (), {"spend": 5.0, "litellm_budget_table": budget, "user_id": "enduser-1"})
|
||||
type("EndUser", (), {"spend": 5.0, "litellm_budget_table": budget, "user_id": "enduser-1", "budget_id": "budget-1"})
|
||||
]
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
|
@ -2588,3 +2588,303 @@ def test_ambiguous_commit_replay_does_not_erase_newly_accrued_spend(
|
|||
assert client.key_spend == expected_spend
|
||||
assert client.commit_attempts == expected_commits
|
||||
assert client.reconnect_reasons == expected_reconnects
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Budget rollover (LIT-3085): overage beyond max_budget carries into the next
|
||||
# window instead of being forgiven
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rollover_enabled(monkeypatch):
|
||||
import litellm
|
||||
|
||||
monkeypatch.setattr(litellm, "budget_rollover", True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"run_phase, table, id_field, id_value, row_factory",
|
||||
[
|
||||
(
|
||||
lambda job: job.reset_budget_for_litellm_keys(),
|
||||
"key",
|
||||
"token",
|
||||
"tok-roll",
|
||||
lambda now: type(
|
||||
"Key",
|
||||
(),
|
||||
{
|
||||
"spend": 150.0,
|
||||
"max_budget": 100.0,
|
||||
"budget_duration": "1d",
|
||||
"budget_reset_at": now,
|
||||
"token": "tok-roll",
|
||||
},
|
||||
),
|
||||
),
|
||||
(
|
||||
lambda job: job.reset_budget_for_litellm_users(),
|
||||
"user",
|
||||
"user_id",
|
||||
"user-roll",
|
||||
lambda now: type(
|
||||
"User",
|
||||
(),
|
||||
{
|
||||
"spend": 150.0,
|
||||
"max_budget": 100.0,
|
||||
"budget_duration": "30d",
|
||||
"budget_reset_at": now,
|
||||
"user_id": "user-roll",
|
||||
},
|
||||
),
|
||||
),
|
||||
(
|
||||
lambda job: job.reset_budget_for_litellm_teams(),
|
||||
"team",
|
||||
"team_id",
|
||||
"team-roll",
|
||||
lambda now: type(
|
||||
"Team",
|
||||
(),
|
||||
{
|
||||
"spend": 150.0,
|
||||
"max_budget": 100.0,
|
||||
"budget_duration": "1mo",
|
||||
"budget_reset_at": now,
|
||||
"team_id": "team-roll",
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_direct_reset_carries_overage_when_rollover_enabled(
|
||||
rollover_enabled, reset_budget_job, mock_prisma_client, monkeypatch, run_phase, table, id_field, id_value, row_factory
|
||||
):
|
||||
"""spend=150 against max_budget=100 must decrement by the cap (leaving 50)
|
||||
rather than zero the row, and the spend counter must be seeded with 50."""
|
||||
counter_cache = _make_counter_invalidation_job(monkeypatch)
|
||||
now = datetime.now(timezone.utc)
|
||||
mock_prisma_client.data[table] = [row_factory(now)]
|
||||
|
||||
asyncio.run(run_phase(reset_budget_job))
|
||||
|
||||
writes = _batch_writes(mock_prisma_client, table)
|
||||
assert len(writes) == 1
|
||||
assert writes[0]["where"] == {id_field: id_value}
|
||||
assert writes[0]["data"]["spend"] == {"decrement": 100.0}
|
||||
assert writes[0]["data"]["budget_reset_at"] > now
|
||||
counter_prefix = {"key": "spend:key", "user": "spend:user", "team": "spend:team"}[table]
|
||||
counter_cache.in_memory_cache.set_cache.assert_any_call(key=f"{counter_prefix}:{id_value}", value=50.0, ttl=60)
|
||||
|
||||
|
||||
def test_direct_reset_zeroes_under_budget_row_even_with_rollover(
|
||||
rollover_enabled, reset_budget_job, mock_prisma_client, monkeypatch
|
||||
):
|
||||
counter_cache = _make_counter_invalidation_job(monkeypatch)
|
||||
now = datetime.now(timezone.utc)
|
||||
mock_prisma_client.data["key"] = [
|
||||
type(
|
||||
"Key",
|
||||
(),
|
||||
{"spend": 40.0, "max_budget": 100.0, "budget_duration": "1d", "budget_reset_at": now, "token": "tok-under"},
|
||||
)
|
||||
]
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_keys())
|
||||
|
||||
assert _batch_writes(mock_prisma_client, "key")[0]["data"]["spend"] == 0
|
||||
counter_cache.in_memory_cache.set_cache.assert_any_call(key="spend:key:tok-under", value=0.0, ttl=60)
|
||||
|
||||
|
||||
def test_direct_reset_zeroes_row_without_max_budget_even_with_rollover(
|
||||
rollover_enabled, reset_budget_job, mock_prisma_client, monkeypatch
|
||||
):
|
||||
"""No cap means nothing to carry against: reset to zero as before."""
|
||||
_make_counter_invalidation_job(monkeypatch)
|
||||
now = datetime.now(timezone.utc)
|
||||
mock_prisma_client.data["key"] = [
|
||||
type(
|
||||
"Key",
|
||||
(),
|
||||
{"spend": 150.0, "max_budget": None, "budget_duration": "1d", "budget_reset_at": now, "token": "tok-nocap"},
|
||||
)
|
||||
]
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_keys())
|
||||
|
||||
assert _batch_writes(mock_prisma_client, "key")[0]["data"]["spend"] == 0
|
||||
|
||||
|
||||
def test_budget_cascade_carries_overage_per_tier_when_rollover_enabled(
|
||||
rollover_enabled, reset_budget_job, mock_prisma_client, monkeypatch
|
||||
):
|
||||
"""A team member 5 over the tier cap keeps a spend of 5 in the next window:
|
||||
the cascade decrements over-cap rows by the cap, zeroes the rest, and seeds
|
||||
the spend counter with the carried amount."""
|
||||
counter_cache = _make_counter_invalidation_job(monkeypatch)
|
||||
budget = _budget_row(budget_id="budget-roll", budget_duration="7d", max_budget=10.0)
|
||||
mock_prisma_client.data["budget"] = [budget]
|
||||
membership = type(
|
||||
"Membership",
|
||||
(),
|
||||
{"user_id": "member-1", "team_id": "team-1", "spend": 15.0, "budget_id": "budget-roll"},
|
||||
)
|
||||
mock_prisma_client.db.litellm_teammembership.set_find_many_results([membership])
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
membership_writes = _batch_writes(mock_prisma_client, "team_membership")
|
||||
assert {
|
||||
"table": "team_membership",
|
||||
"op": "update_many",
|
||||
"where": {"budget_id": "budget-roll", "spend": {"gt": 10.0}},
|
||||
"data": {"spend": {"decrement": 10.0}},
|
||||
} in membership_writes
|
||||
assert {
|
||||
"table": "team_membership",
|
||||
"op": "update_many",
|
||||
"where": {"budget_id": "budget-roll", "spend": {"gt": 0, "lte": 10.0}},
|
||||
"data": {"spend": 0},
|
||||
} in membership_writes
|
||||
counter_cache.in_memory_cache.set_cache.assert_any_call(key="spend:team_member:member-1:team-1", value=5.0, ttl=60)
|
||||
|
||||
|
||||
def test_budget_cascade_carries_enduser_overage_when_rollover_enabled(
|
||||
rollover_enabled, reset_budget_job, mock_prisma_client, monkeypatch
|
||||
):
|
||||
_make_counter_invalidation_job(monkeypatch)
|
||||
budget = _budget_row(budget_id="budget-roll", budget_duration="1d", max_budget=10.0)
|
||||
mock_prisma_client.data["budget"] = [budget]
|
||||
mock_prisma_client.data["enduser"] = [
|
||||
type(
|
||||
"EndUser",
|
||||
(),
|
||||
{"spend": 15.0, "litellm_budget_table": budget, "user_id": "enduser-roll", "budget_id": "budget-roll"},
|
||||
)
|
||||
]
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
enduser_writes = _batch_writes(mock_prisma_client, "enduser")
|
||||
assert {
|
||||
"table": "enduser",
|
||||
"op": "update_many",
|
||||
"where": {"user_id": {"in": ["enduser-roll"]}, "spend": {"gt": 10.0}},
|
||||
"data": {"spend": {"decrement": 10.0}},
|
||||
} in enduser_writes
|
||||
assert {
|
||||
"table": "enduser",
|
||||
"op": "update_many",
|
||||
"where": {"user_id": {"in": ["enduser-roll"]}, "spend": {"lte": 10.0}},
|
||||
"data": {"spend": 0},
|
||||
} in enduser_writes
|
||||
|
||||
|
||||
def _replay_spend_writes(writes, spend):
|
||||
"""Apply the queued update_many statements in order, the way the DB
|
||||
transaction executes them, and return the row's final spend."""
|
||||
for write in writes:
|
||||
condition = write["where"].get("spend")
|
||||
if isinstance(condition, dict):
|
||||
if "gt" in condition and not spend > condition["gt"]:
|
||||
continue
|
||||
if "lte" in condition and not spend <= condition["lte"]:
|
||||
continue
|
||||
payload = write["data"]["spend"]
|
||||
spend = payload if not isinstance(payload, dict) else spend - payload["decrement"]
|
||||
return spend
|
||||
|
||||
|
||||
@pytest.mark.parametrize("table", ["team_membership", "enduser"])
|
||||
def test_cascade_rollover_writes_survive_sequential_execution(
|
||||
rollover_enabled, reset_budget_job, mock_prisma_client, monkeypatch, table
|
||||
):
|
||||
"""The statements run one after another inside a transaction, so a
|
||||
decrement-then-zero order would re-match the decremented row (now in the
|
||||
0..cap range) and erase the carried spend. Replaying the writes in queue
|
||||
order must leave the overage, for any spend between cap and twice the cap."""
|
||||
_make_counter_invalidation_job(monkeypatch)
|
||||
budget = _budget_row(budget_id="budget-roll", budget_duration="7d", max_budget=10.0)
|
||||
mock_prisma_client.data["budget"] = [budget]
|
||||
membership = type(
|
||||
"Membership",
|
||||
(),
|
||||
{"user_id": "member-1", "team_id": "team-1", "spend": 15.0, "budget_id": "budget-roll"},
|
||||
)
|
||||
mock_prisma_client.db.litellm_teammembership.set_find_many_results([membership])
|
||||
mock_prisma_client.data["enduser"] = [
|
||||
type(
|
||||
"EndUser",
|
||||
(),
|
||||
{"spend": 15.0, "litellm_budget_table": budget, "user_id": "enduser-roll", "budget_id": "budget-roll"},
|
||||
)
|
||||
]
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
writes = _batch_writes(mock_prisma_client, table)
|
||||
assert _replay_spend_writes(writes, 15.0) == 5.0
|
||||
assert _replay_spend_writes(writes, 8.0) == 0
|
||||
assert _replay_spend_writes(writes, 25.0) == 15.0
|
||||
|
||||
|
||||
def test_budget_cascade_zeroes_everything_when_rollover_disabled(reset_budget_job, mock_prisma_client, monkeypatch):
|
||||
"""Control: with the flag off the cascade keeps the plain zeroing writes."""
|
||||
_make_counter_invalidation_job(monkeypatch)
|
||||
budget = _budget_row(budget_id="budget-off", budget_duration="7d", max_budget=10.0)
|
||||
mock_prisma_client.data["budget"] = [budget]
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
membership_writes = _batch_writes(mock_prisma_client, "team_membership")
|
||||
assert membership_writes == [
|
||||
{
|
||||
"table": "team_membership",
|
||||
"op": "update_many",
|
||||
"where": {"budget_id": {"in": ["budget-off"]}},
|
||||
"data": {"spend": 0},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_window_reset_carries_counter_overage_when_rollover_enabled(rollover_enabled, monkeypatch):
|
||||
"""A per-window counter at 130 against a 100 cap restarts the window at 30."""
|
||||
now = datetime.utcnow()
|
||||
expired = (now - timedelta(minutes=5)).isoformat() + "Z"
|
||||
key_rows = [
|
||||
{
|
||||
"token": "sk-roll",
|
||||
"budget_limits": [{"budget_duration": "1d", "reset_at": expired, "max_budget": 100.0}],
|
||||
}
|
||||
]
|
||||
job, prisma_client, spend_counter_cache = _make_reset_budget_windows_job(
|
||||
monkeypatch, key_rows=key_rows, team_rows=[]
|
||||
)
|
||||
spend_counter_cache.async_get_cache = AsyncMock(return_value=130.0)
|
||||
|
||||
asyncio.run(job.reset_budget_windows())
|
||||
|
||||
prisma_client.db.litellm_verificationtoken.update.assert_awaited_once()
|
||||
spend_counter_cache.in_memory_cache.set_cache.assert_any_call(key="spend:key:sk-roll:window:1d", value=30.0)
|
||||
|
||||
|
||||
def test_window_reset_zeroes_counter_when_rollover_disabled(monkeypatch):
|
||||
now = datetime.utcnow()
|
||||
expired = (now - timedelta(minutes=5)).isoformat() + "Z"
|
||||
key_rows = [
|
||||
{
|
||||
"token": "sk-off",
|
||||
"budget_limits": [{"budget_duration": "1d", "reset_at": expired, "max_budget": 100.0}],
|
||||
}
|
||||
]
|
||||
job, prisma_client, spend_counter_cache = _make_reset_budget_windows_job(
|
||||
monkeypatch, key_rows=key_rows, team_rows=[]
|
||||
)
|
||||
spend_counter_cache.async_get_cache = AsyncMock(return_value=130.0)
|
||||
|
||||
asyncio.run(job.reset_budget_windows())
|
||||
|
||||
spend_counter_cache.in_memory_cache.set_cache.assert_any_call(key="spend:key:sk-off:window:1d", value=0.0)
|
||||
spend_counter_cache.async_get_cache.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -215,6 +215,44 @@ def test_db_push_applies_replica_identity_full_when_requested(monkeypatch):
|
|||
assert applied == [True]
|
||||
|
||||
|
||||
def test_db_push_is_rejected_when_spend_logs_is_partitioned(monkeypatch):
|
||||
"""A doc-partitioned LiteLLM_SpendLogs makes `prisma db push` rewrite the
|
||||
primary key back to ("request_id"), which Postgres rejects; the guard must
|
||||
fail fast with guidance instead of running the push."""
|
||||
from litellm.proxy.db.prisma_client import PrismaManager
|
||||
from litellm_proxy_extras.utils import (
|
||||
PARTITIONED_SPEND_LOGS_PUSH_ERROR,
|
||||
ProxyExtrasDBManager,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: True)
|
||||
)
|
||||
with patch( # test-quality-ok: subprocess.run is the external prisma CLI boundary, asserted never reached
|
||||
"litellm.proxy.db.prisma_client.subprocess.run"
|
||||
) as mock_run:
|
||||
with pytest.raises(RuntimeError) as err:
|
||||
PrismaManager.setup_database(use_migrate=False)
|
||||
|
||||
assert str(err.value) == PARTITIONED_SPEND_LOGS_PUSH_ERROR
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_db_push_proceeds_when_spend_logs_is_not_partitioned(monkeypatch):
|
||||
from litellm.proxy.db.prisma_client import PrismaManager
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager
|
||||
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: False)
|
||||
)
|
||||
with patch( # test-quality-ok: subprocess.run is the external prisma CLI boundary, not SDK logic
|
||||
"litellm.proxy.db.prisma_client.subprocess.run"
|
||||
) as mock_run:
|
||||
assert PrismaManager.setup_database(use_migrate=False) is True
|
||||
|
||||
assert mock_run.call_args[0][0][:3] == ["prisma", "db", "push"]
|
||||
|
||||
|
||||
def _entra_jwt(expires_in_seconds: int) -> str:
|
||||
"""A JWT shaped like a real Entra access token, expiring ``expires_in_seconds`` from now."""
|
||||
import base64
|
||||
|
|
|
|||
|
|
@ -730,6 +730,68 @@ async def test_update_key_personal_non_admin_denied_vector_stores(monkeypatch):
|
|||
assert "Vector stores" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_grandfathers_existing_mcp_servers(monkeypatch):
|
||||
"""/key/update on a team key that already holds MCP servers outside the
|
||||
team allowlist must accept re-sent or shrunk grants (LIT-6062). The wrapper
|
||||
must pass the existing key's object_permission row into the validator when
|
||||
the team is unchanged."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ObjectPermissionBase,
|
||||
UpdateKeyRequest,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_validate_mcp_servers_for_key_update,
|
||||
)
|
||||
|
||||
existing_row = MagicMock()
|
||||
existing_row.mcp_servers = ["server-a", "server-b"]
|
||||
existing_row.mcp_tool_permissions = {}
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[])
|
||||
|
||||
team_obj = MagicMock()
|
||||
team_obj.team_id = "team-1"
|
||||
team_obj.object_permission = None
|
||||
|
||||
existing_key_row = MagicMock(
|
||||
team_id="team-1",
|
||||
object_permission_id="perm-1",
|
||||
object_permission=existing_row,
|
||||
)
|
||||
|
||||
mock_server_a = MagicMock()
|
||||
mock_server_a.server_id = "server-a"
|
||||
mock_server_b = MagicMock()
|
||||
mock_server_b.server_id = "server-b"
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get_registry.return_value = {
|
||||
"server-a": mock_server_a,
|
||||
"server-b": mock_server_b,
|
||||
}
|
||||
mock_mgr.get_allow_all_keys_server_ids.return_value = []
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
||||
mock_mgr,
|
||||
)
|
||||
|
||||
result = await _validate_mcp_servers_for_key_update(
|
||||
data=UpdateKeyRequest(
|
||||
key="sk-team-key",
|
||||
object_permission=LiteLLM_ObjectPermissionBase(mcp_servers=["server-a"]),
|
||||
),
|
||||
team_obj=team_obj,
|
||||
existing_key_row=existing_key_row,
|
||||
prisma_client=mock_prisma,
|
||||
user_api_key_cache=MagicMock(),
|
||||
is_proxy_admin=False,
|
||||
)
|
||||
assert result is not None
|
||||
assert result["mcp_servers"] == ["server-a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_personal_non_admin_denied_access_groups(
|
||||
monkeypatch,
|
||||
|
|
@ -6552,7 +6614,7 @@ async def test_get_and_validate_existing_key():
|
|||
|
||||
assert result == mock_key
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique.assert_called_once_with(
|
||||
where={"token": "hashed-test-key-123"}
|
||||
where={"token": "hashed-test-key-123"}, include={"object_permission": True}
|
||||
)
|
||||
|
||||
# Test Case 2: Key not found raises ProxyException
|
||||
|
|
|
|||
|
|
@ -1213,6 +1213,124 @@ async def test_empty_object_permission_passes_for_personal_non_admin():
|
|||
)
|
||||
|
||||
|
||||
# ---- Tests for grandfathering existing key MCP servers on /key/update (LIT-6062) ----
|
||||
|
||||
|
||||
def _make_grandfather_fixtures(mcp_servers=None, mcp_tool_permissions=None):
|
||||
"""Mock prisma client plus the key's existing object permission row."""
|
||||
existing_row = MagicMock()
|
||||
existing_row.mcp_servers = mcp_servers or []
|
||||
existing_row.mcp_tool_permissions = mcp_tool_permissions or {}
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[])
|
||||
return mock_prisma, existing_row
|
||||
|
||||
|
||||
def _patch_grandfather_env(monkeypatch, mock_mgr):
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
||||
mock_mgr,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
|
||||
lambda: set(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_key_update_grandfathers_existing_servers(monkeypatch):
|
||||
"""A key already holding servers outside the team allowlist can re-send or
|
||||
shrink those grants on /key/update without a 403 (LIT-6062)."""
|
||||
_patch_grandfather_env(monkeypatch, _make_mock_mcp_manager("server-a", "server-b"))
|
||||
team_obj = _make_team_obj(mcp_servers=[])
|
||||
mock_prisma, existing_row = _make_grandfather_fixtures(mcp_servers=["server-a", "server-b"])
|
||||
resend = await validate_key_mcp_servers_against_team(
|
||||
object_permission={"mcp_servers": ["server-a", "server-b"]},
|
||||
team_obj=team_obj,
|
||||
prisma_client=mock_prisma,
|
||||
existing_key_object_permission=existing_row,
|
||||
)
|
||||
assert sorted(resend["mcp_servers"]) == ["server-a", "server-b"]
|
||||
shrink = await validate_key_mcp_servers_against_team(
|
||||
object_permission={"mcp_servers": ["server-a"]},
|
||||
team_obj=team_obj,
|
||||
prisma_client=mock_prisma,
|
||||
existing_key_object_permission=existing_row,
|
||||
)
|
||||
assert shrink["mcp_servers"] == ["server-a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_key_update_grandfather_does_not_allow_new_servers(monkeypatch):
|
||||
"""Grandfathering only covers servers the key already holds; adding a new
|
||||
server outside the team allowlist still raises 403."""
|
||||
_patch_grandfather_env(monkeypatch, _make_mock_mcp_manager("server-a", "server-new"))
|
||||
team_obj = _make_team_obj(mcp_servers=[])
|
||||
mock_prisma, existing_row = _make_grandfather_fixtures(mcp_servers=["server-a"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_key_mcp_servers_against_team(
|
||||
object_permission={"mcp_servers": ["server-a", "server-new"]},
|
||||
team_obj=team_obj,
|
||||
prisma_client=mock_prisma,
|
||||
existing_key_object_permission=existing_row,
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "server-new" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_key_update_without_existing_permission_still_raises(monkeypatch):
|
||||
"""Without an existing permission row (new grants or team change) the
|
||||
subset check stays strict."""
|
||||
_patch_grandfather_env(monkeypatch, _make_mock_mcp_manager("server-a"))
|
||||
team_obj = _make_team_obj(mcp_servers=[])
|
||||
mock_prisma, _ = _make_grandfather_fixtures(mcp_servers=["server-a"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_key_mcp_servers_against_team(
|
||||
object_permission={"mcp_servers": ["server-a"]},
|
||||
team_obj=team_obj,
|
||||
prisma_client=mock_prisma,
|
||||
existing_key_object_permission=None,
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_key_update_grandfathers_tool_permission_keys(monkeypatch):
|
||||
"""Servers granted only via mcp_tool_permissions keys on the existing row
|
||||
(stored as a JSON string) are grandfathered too."""
|
||||
_patch_grandfather_env(monkeypatch, _make_mock_mcp_manager("server-a"))
|
||||
team_obj = _make_team_obj(mcp_servers=[])
|
||||
mock_prisma, existing_row = _make_grandfather_fixtures(
|
||||
mcp_tool_permissions=json.dumps({"server-a": ["tool1"]})
|
||||
)
|
||||
result = await validate_key_mcp_servers_against_team(
|
||||
object_permission={"mcp_servers": ["server-a"]},
|
||||
team_obj=team_obj,
|
||||
prisma_client=mock_prisma,
|
||||
existing_key_object_permission=existing_row,
|
||||
)
|
||||
assert result["mcp_servers"] == ["server-a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_key_update_sentinels_do_not_grandfather(monkeypatch):
|
||||
"""Sentinels stored on the existing row must not grandfather anything."""
|
||||
_patch_grandfather_env(monkeypatch, _make_mock_mcp_manager("server-a"))
|
||||
team_obj = _make_team_obj(mcp_servers=[])
|
||||
mock_prisma, existing_row = _make_grandfather_fixtures(
|
||||
mcp_servers=[SpecialMCPServerName.all_proxy_servers.value, "no-mcp-servers"]
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_key_mcp_servers_against_team(
|
||||
object_permission={"mcp_servers": ["server-a"]},
|
||||
team_obj=team_obj,
|
||||
prisma_client=mock_prisma,
|
||||
existing_key_object_permission=existing_row,
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
def test_object_permission_dict_mirrors_pydantic_model():
|
||||
"""ObjectPermissionDict must stay field-for-field aligned with
|
||||
LiteLLM_ObjectPermissionBase. If a new field is added to the Pydantic
|
||||
|
|
|
|||
|
|
@ -581,3 +581,73 @@ async def test_run_background_health_check_runs_one_cycle_then_cancels(monkeypat
|
|||
"unhealthy_count": 1,
|
||||
"sleep_invoked": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_background_health_check_probes_only_listed_model_groups(monkeypatch):
|
||||
monkeypatch.setattr(proxy_server, "health_check_interval", 60)
|
||||
monkeypatch.setattr(proxy_server, "health_check_concurrency", 1)
|
||||
monkeypatch.setattr(proxy_server, "health_check_details", True)
|
||||
monkeypatch.setattr(proxy_server, "use_shared_health_check", False)
|
||||
monkeypatch.setattr(proxy_server, "redis_usage_cache", None)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
||||
monkeypatch.setattr(proxy_server, "background_health_check_loop_active", False)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"llm_router",
|
||||
SimpleNamespace(background_health_check_model_groups=frozenset({"prod-openai"})),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"llm_model_list",
|
||||
[
|
||||
{"model_name": "prod-openai", "model_info": {"id": "listed-1"}},
|
||||
{"model_name": "prod-openai", "model_info": {"id": "listed-2"}},
|
||||
{"model_name": "internal-claude", "model_info": {"id": "unlisted-1"}},
|
||||
{
|
||||
"model_name": "prod-openai",
|
||||
"model_info": {
|
||||
"id": "listed-disabled",
|
||||
"disable_background_health_check": True,
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"health_check_results",
|
||||
{"healthy_endpoints": [], "unhealthy_endpoints": []},
|
||||
)
|
||||
|
||||
probed = {}
|
||||
|
||||
async def _fake_direct(model_list, *_a, **_kw):
|
||||
probed["ids"] = [m["model_info"]["id"] for m in model_list]
|
||||
return ([], [], {})
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"_run_direct_health_check_with_instrumentation",
|
||||
_fake_direct,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "_schedule_background_health_check_db_save", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server, "_write_health_state_to_router_cache", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server,
|
||||
"health_check_filter_kwargs_from_general_settings",
|
||||
lambda _gs: {},
|
||||
)
|
||||
|
||||
async def _stop_sleep(_seconds):
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
monkeypatch.setattr(proxy_server.asyncio, "sleep", _stop_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await _run_background_health_check()
|
||||
|
||||
assert probed["ids"] == ["listed-1", "listed-2"]
|
||||
|
|
|
|||
|
|
@ -1767,16 +1767,20 @@ async def test_the_prune_cutoff_allows_for_clock_skew_between_hosts():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_run_pricing_config_cannot_prune_a_row_it_did_not_scan(monkeypatch):
|
||||
async def test_a_run_pricing_config_cannot_prune_a_row_it_did_not_scan():
|
||||
"""Staleness alone stops being evidence once two hosts hold different configuration: a
|
||||
row this run never considered belongs to a deployment another host is pricing from its
|
||||
own file, and sweeping it drops that charge."""
|
||||
table = _FakeSentinelTable()
|
||||
table.seed("t", DAY, "dep-elsewhere", 480.0, updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc))
|
||||
entry = _router_entry(model_id="cfg-here", model_info=dict(_VALID_PTU))
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(entry))
|
||||
|
||||
await run_scheduled_ptu_rollup(_prisma_for([], table), pod_lock_manager=_pod_lock(acquired=True), target_date=DAY)
|
||||
await run_scheduled_ptu_rollup(
|
||||
_prisma_for([], table),
|
||||
pod_lock_manager=_pod_lock(acquired=True),
|
||||
target_date=DAY,
|
||||
router=_router_holding(entry),
|
||||
)
|
||||
|
||||
assert ("t", DAY.isoformat(), PTU_SENTINEL_API_KEY, "dep-elsewhere") in table.rows
|
||||
assert ("t", DAY.isoformat(), PTU_SENTINEL_API_KEY, "cfg-here") in table.rows
|
||||
|
|
@ -1784,7 +1788,7 @@ async def test_a_run_pricing_config_cannot_prune_a_row_it_did_not_scan(monkeypat
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_deployment_deleted_from_the_table_keeps_the_day_it_was_charged(monkeypatch):
|
||||
async def test_a_deployment_deleted_from_the_table_keeps_the_day_it_was_charged():
|
||||
"""The accepted cost of bounding the prune, driven through the sequence that produces
|
||||
it: charge the day while the deployment exists, remove it, run the day again. Nothing
|
||||
scans it now, so nothing may judge its row, and the amount it was billed stands."""
|
||||
|
|
@ -1793,18 +1797,19 @@ async def test_a_deployment_deleted_from_the_table_keeps_the_day_it_was_charged(
|
|||
live_row = _model_row(model_id="dep-live", model_info=ptu)
|
||||
doomed_row = _model_row(model_id="dep-doomed", model_info=ptu)
|
||||
charged_key = ("t", DAY.isoformat(), PTU_SENTINEL_API_KEY, "dep-doomed")
|
||||
monkeypatch.setattr(
|
||||
ptu_rollup, "_running_router", lambda: _router_holding(_router_entry(model_id="cfg", model_info=dict(ptu)))
|
||||
)
|
||||
router = _router_holding(_router_entry(model_id="cfg", model_info=dict(ptu)))
|
||||
|
||||
await run_scheduled_ptu_rollup(
|
||||
_prisma_for([live_row, doomed_row], table), pod_lock_manager=_pod_lock(acquired=True), target_date=DAY
|
||||
_prisma_for([live_row, doomed_row], table),
|
||||
pod_lock_manager=_pod_lock(acquired=True),
|
||||
target_date=DAY,
|
||||
router=router,
|
||||
)
|
||||
billed = table.rows[charged_key]["ptu_flat_cost"]
|
||||
table.rows[charged_key]["updated_at"] = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
await run_scheduled_ptu_rollup(
|
||||
_prisma_for([live_row], table), pod_lock_manager=_pod_lock(acquired=True), target_date=DAY
|
||||
_prisma_for([live_row], table), pod_lock_manager=_pod_lock(acquired=True), target_date=DAY, router=router
|
||||
)
|
||||
|
||||
assert table.rows[charged_key]["ptu_flat_cost"] == billed
|
||||
|
|
@ -1843,7 +1848,7 @@ async def test_every_deployment_that_prices_is_inside_the_set_that_bounds_the_pr
|
|||
table,
|
||||
)
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(prisma)
|
||||
loaded = await ptu_rollup._load_ptu_models(prisma, router=None)
|
||||
|
||||
assert {model.model_id for model in loaded.models} <= loaded.scanned_ids
|
||||
assert loaded.scanned_ids == {"dep-a", "dep-b", "dep-unpriced"}
|
||||
|
|
@ -1859,7 +1864,7 @@ async def test_a_priced_deployment_is_in_the_bound_even_with_an_id_the_scan_skip
|
|||
_FakeSentinelTable(),
|
||||
)
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(prisma)
|
||||
loaded = await ptu_rollup._load_ptu_models(prisma, router=None)
|
||||
|
||||
assert {model.model_id for model in loaded.models} <= loaded.scanned_ids
|
||||
|
||||
|
|
@ -1873,13 +1878,13 @@ async def test_the_prune_splits_the_id_set_across_statements(monkeypatch):
|
|||
table = _FakeSentinelTable()
|
||||
ptu = {"ptu_count": 5, "cost_per_ptu_per_hour": 2.0, "team_id": "t"}
|
||||
deployments = [_model_row(model_id=f"dep-{n}", model_info=ptu) for n in range(4)]
|
||||
monkeypatch.setattr(
|
||||
ptu_rollup, "_running_router", lambda: _router_holding(_router_entry(model_id="dep-4", model_info=dict(ptu)))
|
||||
)
|
||||
table.seed("t", DAY, "dep-3", 480.0, updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc))
|
||||
|
||||
await run_scheduled_ptu_rollup(
|
||||
_prisma_for(deployments, table), pod_lock_manager=_pod_lock(acquired=True), target_date=DAY
|
||||
_prisma_for(deployments, table),
|
||||
pod_lock_manager=_pod_lock(acquired=True),
|
||||
target_date=DAY,
|
||||
router=_router_holding(_router_entry(model_id="dep-4", model_info=dict(ptu))),
|
||||
)
|
||||
|
||||
chunks = [call["model"]["in"] for call in table.delete_many_calls]
|
||||
|
|
@ -1912,140 +1917,123 @@ def _router_holding(*entries):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_config_declared_deployment_is_priced(monkeypatch):
|
||||
async def test_a_config_declared_deployment_is_priced():
|
||||
"""The whole point. A PTU deployment the proxy only knows from config.yaml is not in
|
||||
LiteLLM_ProxyModelTable, so a DB-only scan bills the provider's reservation to nobody."""
|
||||
entry = _router_entry(model_id="cfg-1", model_name="gpt-4o-ptu", model_info=dict(_VALID_PTU))
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(entry))
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([], _FakeSentinelTable()))
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([], _FakeSentinelTable()), router=_router_holding(entry))
|
||||
|
||||
assert [(m.model_id, m.model_name, m.team_id) for m in loaded.models] == [("cfg-1", "gpt-4o-ptu", "t")]
|
||||
assert "cfg-1" in loaded.scanned_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_database_backed_router_entry_is_not_counted_twice(monkeypatch):
|
||||
async def test_a_database_backed_router_entry_is_not_counted_twice():
|
||||
"""Every deployment loaded from the table is also in the router, flagged db_model. Pricing
|
||||
both copies would write two charges for one reservation."""
|
||||
row = _model_row(model_id="db-1", model_info=dict(_VALID_PTU))
|
||||
mirrored = _router_entry(model_id="db-1", model_info={**_VALID_PTU, "db_model": True})
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(mirrored))
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([row], _FakeSentinelTable()))
|
||||
|
||||
assert [m.model_id for m in loaded.models] == ["db-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_router_entry_sharing_an_id_with_the_table_is_priced_once(monkeypatch):
|
||||
"""db_model is data the router carries rather than something this module controls, so the
|
||||
id anti-join is what actually maps onto the failure: two charges under one id."""
|
||||
row = _model_row(model_id="both-1", model_info=dict(_VALID_PTU))
|
||||
unflagged = _router_entry(model_id="both-1", model_info=dict(_VALID_PTU))
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(unflagged))
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([row], _FakeSentinelTable()))
|
||||
|
||||
assert [m.model_id for m in loaded.models] == ["both-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_client_credential_clone_is_not_priced(monkeypatch):
|
||||
"""Supplying an api_key on a request mints a clone of the deployment under a fresh id,
|
||||
carrying the source's PTU config. Pricing it bills one reservation per distinct caller key."""
|
||||
source = _router_entry(model_id="cfg-1", model_info=dict(_VALID_PTU))
|
||||
clone = _router_entry(model_id="cfg-1-clone", model_info={**_VALID_PTU, "original_model_id": "cfg-1"})
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(source, clone))
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([], _FakeSentinelTable()))
|
||||
|
||||
assert [m.model_id for m in loaded.models] == ["cfg-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_config_deployment_without_ptu_config_is_scanned_but_not_priced(monkeypatch):
|
||||
"""It has to stay in the scanned set or its leftover sentinel rows become unprunable."""
|
||||
entry = _router_entry(model_id="cfg-plain", model_info={"team_id": "t"})
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(entry))
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([], _FakeSentinelTable()))
|
||||
|
||||
assert loaded.models == ()
|
||||
assert "cfg-plain" in loaded.scanned_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_router_in_the_process_prices_the_database_alone(monkeypatch):
|
||||
"""The rollup is importable and callable outside a running proxy."""
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: None)
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(
|
||||
_prisma_for([_model_row(model_id="db-1", model_info=dict(_VALID_PTU))], _FakeSentinelTable())
|
||||
_prisma_for([row], _FakeSentinelTable()), router=_router_holding(mirrored)
|
||||
)
|
||||
|
||||
assert [m.model_id for m in loaded.models] == ["db-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_config_deployment_is_charged_end_to_end(monkeypatch):
|
||||
async def test_a_router_entry_sharing_an_id_with_the_table_is_priced_once():
|
||||
"""db_model is data the router carries rather than something this module controls, so the
|
||||
id anti-join is what actually maps onto the failure: two charges under one id."""
|
||||
row = _model_row(model_id="both-1", model_info=dict(_VALID_PTU))
|
||||
unflagged = _router_entry(model_id="both-1", model_info=dict(_VALID_PTU))
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(
|
||||
_prisma_for([row], _FakeSentinelTable()), router=_router_holding(unflagged)
|
||||
)
|
||||
|
||||
assert [m.model_id for m in loaded.models] == ["both-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_client_credential_clone_is_not_priced():
|
||||
"""Supplying an api_key on a request mints a clone of the deployment under a fresh id,
|
||||
carrying the source's PTU config. Pricing it bills one reservation per distinct caller key."""
|
||||
source = _router_entry(model_id="cfg-1", model_info=dict(_VALID_PTU))
|
||||
clone = _router_entry(model_id="cfg-1-clone", model_info={**_VALID_PTU, "original_model_id": "cfg-1"})
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(
|
||||
_prisma_for([], _FakeSentinelTable()), router=_router_holding(source, clone)
|
||||
)
|
||||
|
||||
assert [m.model_id for m in loaded.models] == ["cfg-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_config_deployment_without_ptu_config_is_scanned_but_not_priced():
|
||||
"""It has to stay in the scanned set or its leftover sentinel rows become unprunable."""
|
||||
entry = _router_entry(model_id="cfg-plain", model_info={"team_id": "t"})
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([], _FakeSentinelTable()), router=_router_holding(entry))
|
||||
|
||||
assert loaded.models == ()
|
||||
assert "cfg-plain" in loaded.scanned_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_router_in_the_process_prices_the_database_alone():
|
||||
"""The rollup is importable and callable outside a running proxy."""
|
||||
loaded = await ptu_rollup._load_ptu_models(
|
||||
_prisma_for([_model_row(model_id="db-1", model_info=dict(_VALID_PTU))], _FakeSentinelTable()), router=None
|
||||
)
|
||||
|
||||
assert [m.model_id for m in loaded.models] == ["db-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_config_deployment_is_charged_end_to_end():
|
||||
"""Through the scheduled entry point, so the charge lands in a sentinel row rather than
|
||||
stopping at the loader."""
|
||||
table = _FakeSentinelTable()
|
||||
entry = _router_entry(model_id="cfg-1", model_name="gpt-4o-ptu", model_info=dict(_VALID_PTU))
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(entry))
|
||||
|
||||
await run_scheduled_ptu_rollup(_prisma_for([], table), pod_lock_manager=_pod_lock(acquired=True), target_date=DAY)
|
||||
await run_scheduled_ptu_rollup(
|
||||
_prisma_for([], table),
|
||||
pod_lock_manager=_pod_lock(acquired=True),
|
||||
target_date=DAY,
|
||||
router=_router_holding(entry),
|
||||
)
|
||||
|
||||
assert ("t", DAY.isoformat(), PTU_SENTINEL_API_KEY, "cfg-1") in table.rows
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stale_database_backed_router_entry_is_not_treated_as_config(monkeypatch):
|
||||
async def test_a_stale_database_backed_router_entry_is_not_treated_as_config():
|
||||
"""The reconcile can leave a deployment on the router after its row is gone. The id
|
||||
anti-join cannot see that one, so the flag is what keeps it from being priced as though
|
||||
config.yaml had declared it."""
|
||||
stale = _router_entry(model_id="db-gone", model_info={**_VALID_PTU, "db_model": True})
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(stale))
|
||||
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([], _FakeSentinelTable()))
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([], _FakeSentinelTable()), router=_router_holding(stale))
|
||||
|
||||
assert loaded.models == ()
|
||||
|
||||
|
||||
def test_the_router_lookup_reads_the_proxys_own_global():
|
||||
"""Every other config test replaces this helper, so without one test driving the real
|
||||
body a typo in the module path or the attribute name leaves the whole feature dead in
|
||||
production with the suite still green."""
|
||||
import sys
|
||||
import types as _types
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_router_left_on_the_proxy_module_is_not_scanned(monkeypatch):
|
||||
"""A run scans the router its caller hands it and nothing else. Reading the proxy module's
|
||||
global instead made every run depend on whatever else in the process had set one, which
|
||||
is what a caller passing no router is asking not to happen."""
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
assert ptu_rollup._running_router() is None or "litellm.proxy.proxy_server" in sys.modules
|
||||
ambient = _router_holding(_router_entry(model_id="ambient-1", model_info=dict(_VALID_PTU)))
|
||||
monkeypatch.setattr(proxy_server, "llm_router", ambient, raising=False)
|
||||
|
||||
sentinel = object()
|
||||
stub = _types.SimpleNamespace(llm_router=sentinel)
|
||||
real = sys.modules.get("litellm.proxy.proxy_server")
|
||||
sys.modules["litellm.proxy.proxy_server"] = stub
|
||||
try:
|
||||
assert ptu_rollup._running_router() is sentinel
|
||||
del stub.llm_router
|
||||
assert ptu_rollup._running_router() is None
|
||||
finally:
|
||||
if real is None:
|
||||
del sys.modules["litellm.proxy.proxy_server"]
|
||||
else:
|
||||
sys.modules["litellm.proxy.proxy_server"] = real
|
||||
loaded = await ptu_rollup._load_ptu_models(_prisma_for([], _FakeSentinelTable()), router=None)
|
||||
|
||||
|
||||
def test_the_router_lookup_returns_none_outside_a_proxy():
|
||||
import sys
|
||||
|
||||
real = sys.modules.pop("litellm.proxy.proxy_server", None)
|
||||
try:
|
||||
assert ptu_rollup._running_router() is None
|
||||
finally:
|
||||
if real is not None:
|
||||
sys.modules["litellm.proxy.proxy_server"] = real
|
||||
assert loaded.models == ()
|
||||
assert loaded.scanned_ids == frozenset()
|
||||
|
||||
|
||||
def test_the_prune_filter_is_a_plain_dict():
|
||||
|
|
@ -2074,7 +2062,7 @@ async def test_a_run_that_scanned_nothing_issues_no_delete_statements():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_catch_up_pass_reaches_a_config_declared_deployment(monkeypatch):
|
||||
async def test_the_catch_up_pass_reaches_a_config_declared_deployment():
|
||||
"""The catch-up shares the loader, so config deployments join it without being wired in.
|
||||
That is what prices the elapsed days of a reservation declared before today."""
|
||||
table = _FakeSentinelTable()
|
||||
|
|
@ -2084,9 +2072,10 @@ async def test_the_catch_up_pass_reaches_a_config_declared_deployment(monkeypatc
|
|||
model_id="cfg-back",
|
||||
model_info={"ptu_count": 100, "cost_per_ptu_per_hour": 0.02, "team_id": "t", "ptu_effective_from": started},
|
||||
)
|
||||
monkeypatch.setattr(ptu_rollup, "_running_router", lambda: _router_holding(entry))
|
||||
|
||||
await run_scheduled_ptu_rollup(_prisma_for([], table), pod_lock_manager=_pod_lock(acquired=True))
|
||||
await run_scheduled_ptu_rollup(
|
||||
_prisma_for([], table), pod_lock_manager=_pod_lock(acquired=True), router=_router_holding(entry)
|
||||
)
|
||||
|
||||
charged = sorted(day for (_, day, _, model) in table.rows if model == "cfg-back")
|
||||
yesterday = (now.date() - timedelta(days=1)).isoformat()
|
||||
|
|
|
|||
|
|
@ -623,5 +623,53 @@ async def test_perform_health_check_and_save_forwards_skip_disabled_background_f
|
|||
assert call_kwargs["health_check_skip_disabled_background_models"] is True
|
||||
|
||||
|
||||
def test_parse_background_health_check_model_groups_unset_returns_none():
|
||||
from litellm.proxy.health_check import parse_background_health_check_model_groups
|
||||
|
||||
assert parse_background_health_check_model_groups(None) is None
|
||||
assert parse_background_health_check_model_groups({}) is None
|
||||
assert (
|
||||
parse_background_health_check_model_groups(
|
||||
{"background_health_check_model_groups": None}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_parse_background_health_check_model_groups_list_returns_frozenset():
|
||||
from litellm.proxy.health_check import parse_background_health_check_model_groups
|
||||
|
||||
parsed = parse_background_health_check_model_groups(
|
||||
{"background_health_check_model_groups": ["prod-openai", "prod-claude"]}
|
||||
)
|
||||
assert parsed == frozenset({"prod-openai", "prod-claude"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_value", ["prod-openai", 42, {"a": 1}, [1, 2], [None]])
|
||||
def test_parse_background_health_check_model_groups_malformed_raises(bad_value):
|
||||
from litellm.proxy.health_check import parse_background_health_check_model_groups
|
||||
|
||||
with pytest.raises(ValueError, match="must be a list of model group names"):
|
||||
parse_background_health_check_model_groups(
|
||||
{"background_health_check_model_groups": bad_value}
|
||||
)
|
||||
|
||||
|
||||
def test_filter_deployments_to_model_groups():
|
||||
from litellm.proxy.health_check import filter_deployments_to_model_groups
|
||||
|
||||
model_list = [
|
||||
{"model_name": "prod-openai", "model_info": {"id": "a"}},
|
||||
{"model_name": "internal-claude", "model_info": {"id": "b"}},
|
||||
{"model_name": "prod-openai", "model_info": {"id": "c"}},
|
||||
]
|
||||
|
||||
assert filter_deployments_to_model_groups(model_list, None) == tuple(model_list)
|
||||
assert filter_deployments_to_model_groups(
|
||||
model_list, frozenset({"prod-openai"})
|
||||
) == (model_list[0], model_list[2])
|
||||
assert filter_deployments_to_model_groups(model_list, frozenset()) == ()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
|
|
|||
|
|
@ -11245,6 +11245,35 @@ async def test_ptu_rollup_job_registered_at_startup(monkeypatch):
|
|||
assert scheduler.get_job(PTU_ROLLUP_JOB_ID) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ptu_rollup_job_hands_the_rollup_the_proxys_router(monkeypatch):
|
||||
"""The rollup prices PTU deployments declared in config.yaml, which only the router
|
||||
knows about. It takes the router as an argument, so nothing but this call site puts the
|
||||
proxy's own router in front of it: without it that half of the feature is dead."""
|
||||
monkeypatch.delenv("STORE_MODEL_IN_DB", raising=False)
|
||||
from litellm.proxy.spend_tracking import ptu_flat_cost_rollup
|
||||
from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR
|
||||
from litellm.proxy.spend_tracking.ptu_flat_cost_rollup import PTU_ROLLUP_JOB_ID
|
||||
|
||||
monkeypatch.setenv(PTU_COST_ATTRIBUTION_ENV_VAR, "true")
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
ptu_flat_cost_rollup,
|
||||
"run_scheduled_ptu_rollup",
|
||||
AsyncMock(side_effect=lambda *args, **kwargs: calls.append(kwargs)),
|
||||
)
|
||||
|
||||
scheduler = await _run_scheduled_background_jobs()
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
router = MagicMock()
|
||||
monkeypatch.setattr(ps, "llm_router", router)
|
||||
await scheduler.get_job(PTU_ROLLUP_JOB_ID).func()
|
||||
|
||||
assert [call["router"] for call in calls] == [router]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ptu_rollup_job_not_registered_without_opt_in(monkeypatch):
|
||||
"""Without LITELLM_ENABLE_PTU_COST_ATTRIBUTION the rollup never runs, so no sentinel row
|
||||
|
|
|
|||
|
|
@ -502,6 +502,68 @@ class TestHealthCheckFilterBypassWithPolicy:
|
|||
)
|
||||
assert len(result) == 2
|
||||
|
||||
def _make_scoped_router_with_unhealthy(self, policy) -> Router:
|
||||
import time
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.router_utils.health_state_cache import DeploymentHealthCache
|
||||
|
||||
router = Router(
|
||||
model_list=[
|
||||
_make_model("bad-listed"),
|
||||
_make_model("ok-listed"),
|
||||
_make_model("bad-unlisted", "gpt-5"),
|
||||
],
|
||||
allowed_fails_policy=policy,
|
||||
enable_health_check_routing=True,
|
||||
background_health_check_model_groups=["gpt-4"],
|
||||
)
|
||||
cache = DualCache()
|
||||
health_cache = DeploymentHealthCache(cache=cache, staleness_threshold=60.0)
|
||||
health_cache.set_deployment_health_states(
|
||||
{
|
||||
model_id: {
|
||||
"is_healthy": False,
|
||||
"timestamp": time.time(),
|
||||
"reason": "test",
|
||||
}
|
||||
for model_id in ("bad-listed", "bad-unlisted")
|
||||
}
|
||||
)
|
||||
router.health_state_cache = health_cache
|
||||
return router
|
||||
|
||||
def test_filter_with_policy_still_applies_to_listed_groups(self):
|
||||
"""A model-group allowlist keeps the filter active for listed groups even with a policy set."""
|
||||
router = self._make_scoped_router_with_unhealthy(
|
||||
AllowedFailsPolicy(AuthenticationErrorAllowedFails=3)
|
||||
)
|
||||
deployments = [
|
||||
_make_model("bad-listed"),
|
||||
_make_model("ok-listed"),
|
||||
_make_model("bad-unlisted", "gpt-5"),
|
||||
]
|
||||
|
||||
result = router._filter_health_check_unhealthy_deployments(deployments)
|
||||
assert [d["model_info"]["id"] for d in result] == ["ok-listed", "bad-unlisted"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_filter_with_policy_still_applies_to_listed_groups(self):
|
||||
"""Async version: listed groups stay filtered with a policy set, unlisted stay untouched."""
|
||||
router = self._make_scoped_router_with_unhealthy(
|
||||
AllowedFailsPolicy(TimeoutErrorAllowedFails=2)
|
||||
)
|
||||
deployments = [
|
||||
_make_model("bad-listed"),
|
||||
_make_model("ok-listed"),
|
||||
_make_model("bad-unlisted", "gpt-5"),
|
||||
]
|
||||
|
||||
result = await router._async_filter_health_check_unhealthy_deployments(
|
||||
deployments
|
||||
)
|
||||
assert [d["model_info"]["id"] for d in result] == ["ok-listed", "bad-unlisted"]
|
||||
|
||||
|
||||
class TestAllDeploymentsInCooldownSafetyNet:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -111,3 +111,84 @@ def test_malformed_state_entries_are_skipped(health_cache):
|
|||
health_cache.set_deployment_health_states(states)
|
||||
result = health_cache.get_unhealthy_deployment_ids()
|
||||
assert result == {"deploy-1"}
|
||||
|
||||
|
||||
def test_set_merges_states_from_scoped_writers(health_cache):
|
||||
"""A writer covering one scope must not erase another scope's fresh states."""
|
||||
now = time.time()
|
||||
health_cache.set_deployment_health_states(
|
||||
{"listed-bad": {"is_healthy": False, "timestamp": now, "reason": "check_failed"}}
|
||||
)
|
||||
health_cache.set_deployment_health_states(
|
||||
{"other-ok": {"is_healthy": True, "timestamp": now, "reason": ""}}
|
||||
)
|
||||
assert health_cache.get_unhealthy_deployment_ids() == {"listed-bad"}
|
||||
|
||||
|
||||
def test_set_prunes_expired_entries(health_cache, cache):
|
||||
"""Entries older than 1.5x the staleness threshold are dropped on write."""
|
||||
expired_time = time.time() - 100 # threshold 60s, prune horizon 90s
|
||||
health_cache.set_deployment_health_states(
|
||||
{"gone": {"is_healthy": False, "timestamp": expired_time, "reason": "check_failed"}}
|
||||
)
|
||||
now = time.time()
|
||||
health_cache.set_deployment_health_states(
|
||||
{"fresh": {"is_healthy": False, "timestamp": now, "reason": "check_failed"}}
|
||||
)
|
||||
stored = cache.get_cache(key=DeploymentHealthCache.CACHE_KEY)
|
||||
assert set(stored.keys()) == {"fresh"}
|
||||
|
||||
|
||||
class _SharedRedisFake:
|
||||
"""Shared get/set key-value store standing in for the Redis layer of a DualCache."""
|
||||
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
self.fail_get = False
|
||||
|
||||
def get_cache(self, key, parent_otel_span=None, **kwargs):
|
||||
if self.fail_get:
|
||||
return None # RedisCache.get_cache swallows connection errors and returns None
|
||||
return self.store.get(key)
|
||||
|
||||
def set_cache(self, key, value, **kwargs):
|
||||
self.store[key] = value
|
||||
|
||||
|
||||
def test_scoped_writers_on_shared_redis_preserve_each_other():
|
||||
"""Pods with different allowlists share one Redis entry; each merge must keep the peer's scope."""
|
||||
redis_fake = _SharedRedisFake()
|
||||
pod_a = DeploymentHealthCache(cache=DualCache(redis_cache=redis_fake), staleness_threshold=60.0)
|
||||
pod_b = DeploymentHealthCache(cache=DualCache(redis_cache=redis_fake), staleness_threshold=60.0)
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
pod_b.set_deployment_health_states(
|
||||
{"internal-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "timeout"}}
|
||||
)
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
assert set(redis_fake.store[DeploymentHealthCache.CACHE_KEY]) == {"prod-bad", "internal-bad"}
|
||||
assert pod_a.get_unhealthy_deployment_ids() == {"prod-bad", "internal-bad"}
|
||||
|
||||
|
||||
def test_failed_redis_read_falls_back_to_local_copy():
|
||||
"""A swallowed Redis GET error must not make a writer erase peer scopes it already saw."""
|
||||
redis_fake = _SharedRedisFake()
|
||||
pod_a = DeploymentHealthCache(cache=DualCache(redis_cache=redis_fake), staleness_threshold=60.0)
|
||||
pod_b = DeploymentHealthCache(cache=DualCache(redis_cache=redis_fake), staleness_threshold=60.0)
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
pod_b.set_deployment_health_states(
|
||||
{"internal-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "timeout"}}
|
||||
)
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
redis_fake.fail_get = True
|
||||
pod_a.set_deployment_health_states(
|
||||
{"prod-bad": {"is_healthy": False, "timestamp": time.time(), "reason": "check_failed"}}
|
||||
)
|
||||
assert set(redis_fake.store[DeploymentHealthCache.CACHE_KEY]) == {"prod-bad", "internal-bad"}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,12 @@ def _make_health_cache(
|
|||
class TestFilterHealthCheckUnhealthyDeployments:
|
||||
"""Test the sync filter method."""
|
||||
|
||||
def _make_router_like(self, enable: bool, health_cache: DeploymentHealthCache):
|
||||
def _make_router_like(
|
||||
self,
|
||||
enable: bool,
|
||||
health_cache: DeploymentHealthCache,
|
||||
model_groups: frozenset[str] | None = None,
|
||||
):
|
||||
"""Create a minimal object that behaves like Router for filter testing."""
|
||||
|
||||
class FakeRouter:
|
||||
|
|
@ -51,6 +56,7 @@ class TestFilterHealthCheckUnhealthyDeployments:
|
|||
self.enable_health_check_routing = enable
|
||||
self.health_state_cache = health_cache
|
||||
self.allowed_fails_policy = None
|
||||
self.background_health_check_model_groups = model_groups
|
||||
|
||||
# Import the actual method and bind it
|
||||
from litellm.router import Router
|
||||
|
|
@ -115,11 +121,50 @@ class TestFilterHealthCheckUnhealthyDeployments:
|
|||
result = router._filter_health_check_unhealthy_deployments(deployments)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_filter_scoped_to_listed_model_groups(self):
|
||||
"""With an allowlist, only deployments in listed groups are filtered on health."""
|
||||
health_cache = _make_health_cache(unhealthy_ids={"bad-listed", "bad-unlisted"})
|
||||
router = self._make_router_like(
|
||||
enable=True, health_cache=health_cache, model_groups=frozenset({"prod"})
|
||||
)
|
||||
|
||||
deployments = [
|
||||
_make_deployment("bad-listed", model_name="prod"),
|
||||
_make_deployment("ok-listed", model_name="prod"),
|
||||
_make_deployment("bad-unlisted", model_name="other"),
|
||||
_make_deployment("ok-unlisted", model_name="other"),
|
||||
]
|
||||
result = router._filter_health_check_unhealthy_deployments(deployments)
|
||||
assert [d["model_info"]["id"] for d in result] == [
|
||||
"ok-listed",
|
||||
"bad-unlisted",
|
||||
"ok-unlisted",
|
||||
]
|
||||
|
||||
def test_filter_unscoped_when_model_groups_unset(self):
|
||||
"""Without an allowlist, unhealthy deployments in every group are filtered."""
|
||||
health_cache = _make_health_cache(unhealthy_ids={"bad-listed", "bad-unlisted"})
|
||||
router = self._make_router_like(enable=True, health_cache=health_cache)
|
||||
|
||||
deployments = [
|
||||
_make_deployment("bad-listed", model_name="prod"),
|
||||
_make_deployment("ok-listed", model_name="prod"),
|
||||
_make_deployment("bad-unlisted", model_name="other"),
|
||||
_make_deployment("ok-unlisted", model_name="other"),
|
||||
]
|
||||
result = router._filter_health_check_unhealthy_deployments(deployments)
|
||||
assert [d["model_info"]["id"] for d in result] == ["ok-listed", "ok-unlisted"]
|
||||
|
||||
|
||||
class TestAsyncFilterHealthCheckUnhealthyDeployments:
|
||||
"""Test the async filter method."""
|
||||
|
||||
def _make_router_like(self, enable: bool, health_cache: DeploymentHealthCache):
|
||||
def _make_router_like(
|
||||
self,
|
||||
enable: bool,
|
||||
health_cache: DeploymentHealthCache,
|
||||
model_groups: frozenset[str] | None = None,
|
||||
):
|
||||
from litellm.router import Router
|
||||
|
||||
class FakeRouter:
|
||||
|
|
@ -127,6 +172,7 @@ class TestAsyncFilterHealthCheckUnhealthyDeployments:
|
|||
self.enable_health_check_routing = enable
|
||||
self.health_state_cache = health_cache
|
||||
self.allowed_fails_policy = None
|
||||
self.background_health_check_model_groups = model_groups
|
||||
|
||||
fake = FakeRouter()
|
||||
fake._async_filter_health_check_unhealthy_deployments = (
|
||||
|
|
@ -168,6 +214,29 @@ class TestAsyncFilterHealthCheckUnhealthyDeployments:
|
|||
)
|
||||
assert len(result) == 2 # safety net
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_filter_scoped_to_listed_model_groups(self):
|
||||
"""Async version: only deployments in listed groups are filtered on health."""
|
||||
health_cache = _make_health_cache(unhealthy_ids={"bad-listed", "bad-unlisted"})
|
||||
router = self._make_router_like(
|
||||
enable=True, health_cache=health_cache, model_groups=frozenset({"prod"})
|
||||
)
|
||||
|
||||
deployments = [
|
||||
_make_deployment("bad-listed", model_name="prod"),
|
||||
_make_deployment("ok-listed", model_name="prod"),
|
||||
_make_deployment("bad-unlisted", model_name="other"),
|
||||
_make_deployment("ok-unlisted", model_name="other"),
|
||||
]
|
||||
result = await router._async_filter_health_check_unhealthy_deployments(
|
||||
healthy_deployments=deployments
|
||||
)
|
||||
assert [d["model_info"]["id"] for d in result] == [
|
||||
"ok-listed",
|
||||
"bad-unlisted",
|
||||
"ok-unlisted",
|
||||
]
|
||||
|
||||
|
||||
class TestBuildDeploymentHealthStates:
|
||||
"""Test the build_deployment_health_states function."""
|
||||
|
|
|
|||
|
|
@ -87,3 +87,56 @@ def test_anthropic_sonnet_1hr_cache_write_pricing(
|
|||
), f"{model_key}: long-context 1hr/5min ratio is {ratio_lc}, expected 1.6"
|
||||
else:
|
||||
assert "cache_creation_input_token_cost_above_1hr_above_200k_tokens" not in info
|
||||
|
||||
|
||||
CLAUDE_3_EXPECTED = [
|
||||
("claude-3-haiku-20240307", 5e-07),
|
||||
("claude-3-opus-20240229", 3e-05),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_key, expected_1hr", CLAUDE_3_EXPECTED)
|
||||
def test_claude_3_1hr_cache_write_pricing(model_data, model_key, expected_1hr):
|
||||
"""Haiku 3 and Opus 3 both carried Sonnet's 6e-06 1hr rate, overbilling Haiku 3
|
||||
1-hour cache writes 12x and underbilling Opus 3 5x."""
|
||||
info = model_data[model_key]
|
||||
|
||||
assert info["cache_creation_input_token_cost_above_1hr"] == expected_1hr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_key, expected_1hr", CLAUDE_3_EXPECTED)
|
||||
def test_backup_matches_main_for_claude_3_1hr_cache_write(model_key, expected_1hr):
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../litellm/model_prices_and_context_window_backup.json",
|
||||
)
|
||||
with open(json_path) as f:
|
||||
backup = json.load(f)
|
||||
|
||||
assert (
|
||||
backup[model_key]["cache_creation_input_token_cost_above_1hr"] == expected_1hr
|
||||
)
|
||||
|
||||
|
||||
def test_first_party_anthropic_1hr_cache_writes_are_2x_base_input(model_data):
|
||||
"""Anthropic charges 1-hour cache writes at 2x base input for every first-party
|
||||
model, so any entry that drifts off that multiple is a copy-paste error."""
|
||||
offenders = tuple(
|
||||
(
|
||||
model_key,
|
||||
info["input_cost_per_token"],
|
||||
info["cache_creation_input_token_cost_above_1hr"],
|
||||
)
|
||||
for model_key, info in model_data.items()
|
||||
if isinstance(info, dict)
|
||||
and info.get("litellm_provider") == "anthropic"
|
||||
and info.get("input_cost_per_token")
|
||||
and info.get("cache_creation_input_token_cost_above_1hr")
|
||||
and abs(
|
||||
info["cache_creation_input_token_cost_above_1hr"]
|
||||
- 2 * info["input_cost_per_token"]
|
||||
)
|
||||
> 1e-12
|
||||
)
|
||||
|
||||
assert offenders == (), f"1hr cache write is not 2x base input for: {offenders}"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import os
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
_CODE_COVERAGE_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "code_coverage_tests"
|
||||
)
|
||||
|
|
@ -122,6 +124,75 @@ def test_get_license_returns_none_on_request_failure(monkeypatch):
|
|||
assert checker.get_package_license_from_pypi("pkg", "1.0.0") is None
|
||||
|
||||
|
||||
def test_get_license_retries_connection_error_then_resolves_license():
|
||||
responses = iter(
|
||||
(
|
||||
requests.ConnectionError("connection reset"),
|
||||
requests.ConnectionError("connection reset"),
|
||||
_FakeResponse({"info": {"license_expression": "MIT"}}),
|
||||
)
|
||||
)
|
||||
calls = []
|
||||
sleeps = []
|
||||
|
||||
def _fake_get(url, timeout=None):
|
||||
calls.append((url, timeout))
|
||||
response = next(responses)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response
|
||||
|
||||
checker = check_licenses.LicenseChecker(
|
||||
config_file=_LICCHECK_INI,
|
||||
http_get=_fake_get,
|
||||
sleep=sleeps.append,
|
||||
)
|
||||
|
||||
assert checker.get_package_license_from_pypi("pkg", "1.0.0") == "MIT"
|
||||
assert len(calls) == 3
|
||||
assert len(sleeps) == 2
|
||||
|
||||
|
||||
def test_get_license_does_not_retry_not_found_http_error():
|
||||
response = requests.Response()
|
||||
response.status_code = 404
|
||||
calls = []
|
||||
sleeps = []
|
||||
|
||||
def _fake_get(url, timeout=None):
|
||||
calls.append((url, timeout))
|
||||
raise requests.HTTPError("not found", response=response)
|
||||
|
||||
checker = check_licenses.LicenseChecker(
|
||||
config_file=_LICCHECK_INI,
|
||||
http_get=_fake_get,
|
||||
sleep=sleeps.append,
|
||||
)
|
||||
|
||||
assert checker.get_package_license_from_pypi("pkg", "1.0.0") is None
|
||||
assert len(calls) == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_get_license_returns_none_after_connection_retry_limit():
|
||||
calls = []
|
||||
sleeps = []
|
||||
|
||||
def _fake_get(url, timeout=None):
|
||||
calls.append((url, timeout))
|
||||
raise requests.ConnectionError("connection reset")
|
||||
|
||||
checker = check_licenses.LicenseChecker(
|
||||
config_file=_LICCHECK_INI,
|
||||
http_get=_fake_get,
|
||||
sleep=sleeps.append,
|
||||
)
|
||||
|
||||
assert checker.get_package_license_from_pypi("pkg", "1.0.0") is None
|
||||
assert len(calls) == 3
|
||||
assert len(sleeps) == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# is_license_acceptable: SPDX identifiers and compound expressions
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
|
|||
86
tests/test_litellm/test_fireworks_serverless_model_costs.py
Normal file
86
tests/test_litellm/test_fireworks_serverless_model_costs.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""
|
||||
Validate the Fireworks AI Serverless entry added for #37274 exists in
|
||||
`model_prices_and_context_window.json` and that the bare Fireworks model ID
|
||||
resolves through `get_model_info`.
|
||||
|
||||
Pricing as published at https://docs.fireworks.ai/serverless/pricing
|
||||
(USD per 1M tokens, uncached input / cached input / output):
|
||||
|
||||
accounts/fireworks/models/deepseek-v4-pro-0813 -> $1.32 / $0.044 / $3.96
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.utils import get_model_info
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _local_model_cost_map():
|
||||
"""
|
||||
Point litellm at the bundled cost map for the duration of this module
|
||||
only. ``mp.undo()`` restores both the environment variable and
|
||||
``litellm.model_cost`` so nothing leaks into later tests.
|
||||
"""
|
||||
mp = pytest.MonkeyPatch()
|
||||
mp.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
mp.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
get_model_info.cache_clear()
|
||||
yield
|
||||
mp.undo()
|
||||
get_model_info.cache_clear()
|
||||
|
||||
|
||||
NEW_ENTRIES = {
|
||||
"fireworks_ai/accounts/fireworks/models/deepseek-v4-pro-0813": {
|
||||
"input_cost_per_token": 1.32e-06,
|
||||
"cache_read_input_token_cost": 4.4e-08,
|
||||
"output_cost_per_token": 3.96e-06,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 131072,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def model_data():
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(__file__), "../../model_prices_and_context_window.json"
|
||||
)
|
||||
with open(json_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def test_fireworks_serverless_entries_exist(model_data):
|
||||
"""The new prefixed entry carries the pricing and metadata from #37274."""
|
||||
for key, expected in NEW_ENTRIES.items():
|
||||
assert key in model_data, f"{key} is missing from model_prices_and_context_window.json"
|
||||
entry = model_data[key]
|
||||
for field, value in expected.items():
|
||||
assert entry[field] == pytest.approx(value), f"{key}.{field}"
|
||||
assert entry["litellm_provider"] == "fireworks_ai"
|
||||
assert entry["mode"] == "chat"
|
||||
assert entry["supports_function_calling"] is True
|
||||
assert entry["supports_vision"] is False
|
||||
|
||||
|
||||
def test_bare_fireworks_ids_resolve_through_prefixed_entries():
|
||||
"""Bare IDs from #37274 resolve via the provider-prefix lookup path."""
|
||||
for bare_id, prefixed_key in [
|
||||
(
|
||||
"accounts/fireworks/models/deepseek-v4-pro-0813",
|
||||
"fireworks_ai/accounts/fireworks/models/deepseek-v4-pro-0813",
|
||||
),
|
||||
]:
|
||||
info = get_model_info(model=bare_id, custom_llm_provider="fireworks_ai")
|
||||
expected = NEW_ENTRIES[prefixed_key]
|
||||
assert info.get("key") == prefixed_key
|
||||
assert info["litellm_provider"] == "fireworks_ai"
|
||||
assert info["input_cost_per_token"] == pytest.approx(expected["input_cost_per_token"])
|
||||
assert info["cache_read_input_token_cost"] == pytest.approx(expected["cache_read_input_token_cost"])
|
||||
assert info["output_cost_per_token"] == pytest.approx(expected["output_cost_per_token"])
|
||||
assert info["max_input_tokens"] == expected["max_input_tokens"]
|
||||
assert info["max_output_tokens"] == expected["max_output_tokens"]
|
||||
|
|
@ -435,6 +435,151 @@ def test_register_model_warns_when_no_builtin_match_for_cache_pricing(caplog):
|
|||
litellm.model_cost.pop(registered_key, None)
|
||||
|
||||
|
||||
def test_register_model_no_warning_without_custom_pricing(caplog):
|
||||
"""LIT-6318: an entry with no custom pricing (e.g. router deployment
|
||||
metadata) never drives cost calculation, so registering it under an
|
||||
unmatched key must not emit the missing-cache-pricing warning.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
registered_key = "azure/lit6318-deployment-without-pricing"
|
||||
litellm.model_cost.pop(registered_key, None)
|
||||
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
|
||||
litellm.register_model(
|
||||
{
|
||||
registered_key: {
|
||||
"litellm_provider": "azure",
|
||||
"base_model": "azure/text-embedding-3-large",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert not any("register_model" in record.message for record in caplog.records), (
|
||||
"entry without custom pricing must register silently"
|
||||
)
|
||||
finally:
|
||||
litellm.model_cost.pop(registered_key, None)
|
||||
|
||||
|
||||
def test_register_model_no_warning_for_tiered_pricing_without_cache_costs(caplog):
|
||||
"""LIT-6318: tiered pricing bills cache reads at the tier's input rate when
|
||||
cache costs are omitted, so a tiered entry must not trigger the
|
||||
cache-defaults-to-0 warning.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
registered_key = "bedrock/lit6318-tiered-priced-model"
|
||||
litellm.model_cost.pop(registered_key, None)
|
||||
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
|
||||
litellm.register_model(
|
||||
{
|
||||
registered_key: {
|
||||
"litellm_provider": "bedrock",
|
||||
"tiered_pricing": [
|
||||
{
|
||||
"range": [0, 200000],
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 5e-06,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert not any("register_model" in record.message for record in caplog.records), (
|
||||
"tiered pricing entry must register silently"
|
||||
)
|
||||
finally:
|
||||
litellm.model_cost.pop(registered_key, None)
|
||||
|
||||
|
||||
def test_router_deployment_without_custom_pricing_registers_silently(caplog):
|
||||
"""LIT-6318: the router registers every deployment under its hashed id and
|
||||
its backend key. Deployments without custom pricing are costed at request
|
||||
time from the underlying model name, so startup must not warn about them.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from litellm import Router
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
deployment_model = "azure/lit6318-my-deployment-name"
|
||||
deployment_id = "lit6318-no-pricing-deployment"
|
||||
snapshot = _snapshot_model_cost_entries([deployment_model, deployment_id])
|
||||
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
|
||||
Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "indexing",
|
||||
"litellm_params": {
|
||||
"model": deployment_model,
|
||||
"api_base": "https://example.openai.azure.com",
|
||||
"api_key": "fake-key",
|
||||
},
|
||||
"model_info": {
|
||||
"id": deployment_id,
|
||||
"base_model": "azure/text-embedding-3-large",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
register_warnings = [record.message for record in caplog.records if "register_model" in record.message]
|
||||
assert not register_warnings, register_warnings
|
||||
finally:
|
||||
_restore_model_cost_entries(snapshot)
|
||||
|
||||
|
||||
def test_router_custom_priced_deployment_warning_names_model_not_hash(caplog):
|
||||
"""LIT-6318: when a custom-priced deployment genuinely lacks cache pricing
|
||||
and no built-in entry matches, the warning must name the deployment's
|
||||
model rather than its opaque hashed id.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from litellm import Router
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
deployment_model = "bedrock/lit6318-totally-made-up-model"
|
||||
deployment_id = "lit6318-custom-priced-deployment-hash"
|
||||
snapshot = _snapshot_model_cost_entries([deployment_model, deployment_id])
|
||||
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger=verbose_logger.name):
|
||||
Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "made-up",
|
||||
"litellm_params": {
|
||||
"model": deployment_model,
|
||||
"aws_region_name": "us-east-1",
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 5e-06,
|
||||
},
|
||||
"model_info": {"id": deployment_id},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
register_warnings = [record.message for record in caplog.records if "register_model" in record.message]
|
||||
assert register_warnings, "expected a warning for missing cache pricing"
|
||||
for message in register_warnings:
|
||||
assert deployment_id not in message, message
|
||||
assert deployment_model in message, message
|
||||
finally:
|
||||
_restore_model_cost_entries(snapshot)
|
||||
|
||||
|
||||
def test_register_model_router_add_deployment_custom_pricing_applies():
|
||||
"""End-to-end regression for https://github.com/BerriAI/litellm/issues/28336.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,8 @@ COST_MAP_ADAPTER: Final = TypeAdapter(CostMap)
|
|||
SERVERLESS_CHAT_MODELS: Final = (
|
||||
"together_ai/moonshotai/Kimi-K3",
|
||||
"together_ai/zai-org/GLM-5.2",
|
||||
"together_ai/deepseek-ai/DeepSeek-V4-Pro",
|
||||
"together_ai/deepseek-ai/DeepSeek-V4-Pro-0813",
|
||||
"together_ai/deepseek-ai/DeepSeek-V4-Flash-0731",
|
||||
"together_ai/moonshotai/Kimi-K2.7-Code",
|
||||
"together_ai/MiniMaxAI/MiniMax-M3",
|
||||
"together_ai/thinkingmachines/Inkling",
|
||||
"together_ai/thinkingmachines/Inkling-Small",
|
||||
|
|
@ -27,20 +25,22 @@ SERVERLESS_CHAT_MODELS: Final = (
|
|||
"together_ai/Qwen/Qwen3.7-Plus",
|
||||
"together_ai/Qwen/Qwen3.6-Plus",
|
||||
"together_ai/Qwen/Qwen3.5-9B",
|
||||
"together_ai/nvidia/nemotron-3-ultra-550b-a55b",
|
||||
"together_ai/meta-models/Muse-Glimmer-30B",
|
||||
"together_ai/google/gemma-4-31B-it",
|
||||
"together_ai/pearl-ai/gemma-4-31b-it",
|
||||
"together_ai/google/gemma-3n-E4B-it",
|
||||
"together_ai/arize-ai/qwen-2-1.5b-instruct",
|
||||
"together_ai/Prism-ML/Ternary-Bonsai-27B",
|
||||
"together_ai/meta-llama/Llama-Guard-4-12B",
|
||||
"together_ai/openai/gpt-oss-120b",
|
||||
"together_ai/openai/gpt-oss-20b",
|
||||
"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
)
|
||||
|
||||
DEPRECATED_MODELS: Final = {
|
||||
"together_ai/nvidia/nemotron-3-ultra-550b-a55b": "2026-08-27",
|
||||
"together_ai/pearl-ai/gemma-4-31b-it": "2026-08-27",
|
||||
"together_ai/deepseek-ai/DeepSeek-V4-Pro": "2026-08-27",
|
||||
"together_ai/moonshotai/Kimi-K2.7-Code": "2026-08-27",
|
||||
"together_ai/google/gemma-3n-E4B-it": "2026-08-25",
|
||||
"together_ai/meta-llama/Llama-Guard-4-12B": "2026-08-25",
|
||||
"together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": "2026-07-10",
|
||||
"together_ai/Qwen/Qwen3.5-397B-A17B": "2026-06-29",
|
||||
"together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": "2026-06-04",
|
||||
|
|
|
|||
|
|
@ -1106,7 +1106,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{GUARDRAIL_MODES.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value} title={mode.label}>
|
||||
{mode.label}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
|
|||
<SelectTrigger size="sm" className="w-[150px]" aria-label="Severity Threshold">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{SEVERITY_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
@ -93,7 +93,7 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
|
|||
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
|
|||
<SelectTrigger id={`${fieldId}-type`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{INTENT_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value} title={type.label}>
|
||||
{type.label}
|
||||
|
|
@ -268,7 +268,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
|
|||
<SelectTrigger id={`${fieldId}-competitor-comparison`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{COMPETITOR_COMPARISON_POLICIES.map((policy) => (
|
||||
<SelectItem key={policy.value} value={policy.value} title={policy.label}>
|
||||
{policy.label}
|
||||
|
|
@ -292,7 +292,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
|
|||
<SelectTrigger id={`${fieldId}-possible-competitor-comparison`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{POSSIBLE_COMPETITOR_COMPARISON_POLICIES.map((policy) => (
|
||||
<SelectItem key={policy.value} value={policy.value} title={policy.label}>
|
||||
{policy.label}
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
|
|||
<SelectTrigger size="sm" className="w-full" aria-label="Action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<Badge variant={item.value === "BLOCK" ? "destructive" : "secondary"}>{item.value}</Badge>
|
||||
|
|
@ -224,7 +224,7 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
|
|||
<SelectTrigger size="sm" className="w-full" aria-label="Severity Threshold">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{SEVERITY_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ const CustomPatternModal: React.FC<CustomPatternModalProps> = ({
|
|||
<SelectTrigger className="w-full" aria-label="Action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
|
|||
<SelectTrigger className="w-full" aria-label="Action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const KeywordTable: React.FC<KeywordTableProps> = ({ keywords, onActionChange, o
|
|||
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ const PatternModal: React.FC<PatternModalProps> = ({
|
|||
<SelectTrigger className="w-full" aria-label="Action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, o
|
|||
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -556,7 +556,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
|
|||
<SelectTrigger className="w-full" aria-label="Template">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>STANDARD</SelectLabel>
|
||||
{TEMPLATE_ITEMS.map((template) => (
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export const PiiEntityList: React.FC<PiiEntityListProps> = ({
|
|||
<SelectTrigger className={`w-[120px] ${isSelected ? "" : "opacity-50"}`} aria-label="Action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{actions.map((action) => (
|
||||
<SelectItem key={action} value={action}>
|
||||
<span className="flex items-center">
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
|
|||
<SelectTrigger className="w-[200px]" aria-label="Decision">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{DECISION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
@ -313,7 +313,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
|
|||
<SelectTrigger className="w-full" aria-label="Default action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{DECISION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
@ -350,7 +350,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
|
|||
<SelectTrigger className="w-full" aria-label="On disallowed action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{ON_DISALLOWED_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
<SelectTrigger id="vector-store-provider" className="w-full">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{providerItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<Logo src={vectorStoreProviderLogoMap[item.label]} label={item.label} className="w-5 h-5" />
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => (
|
||||
<SelectItem key={providerEnum} value={vectorStoreProviderMap[providerEnum]}>
|
||||
<Logo
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
{Object.entries(Providers)
|
||||
.filter(([providerEnum]) => providerEnum === "Bedrock")
|
||||
.map(([providerEnum, providerDisplayName]) => (
|
||||
|
|
|
|||
|
|
@ -142,6 +142,154 @@ describe("PaginatedSearchSelect", () => {
|
|||
expect(onValueChange).toHaveBeenCalledWith("alias-beta");
|
||||
});
|
||||
|
||||
it("keeps the typed query when a refreshed page of options arrives while a value is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
|
||||
function ServerBacked() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [value, setValue] = useState("alias-alpha");
|
||||
const freshlyBuiltOptions = OPTIONS.filter((option) => option.label.includes(search)).map((option) => ({
|
||||
...option,
|
||||
}));
|
||||
return (
|
||||
<PaginatedSearchSelect
|
||||
options={freshlyBuiltOptions}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onSearchChange={(query) => {
|
||||
onSearchChange(query);
|
||||
setSearch(query);
|
||||
}}
|
||||
onLoadMore={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<ServerBacked />);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
await user.type(input, "gamma");
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
|
||||
await waitFor(() => expect(input).toHaveValue("gamma"));
|
||||
expect(await screen.findByText("gamma-key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the selection again after the popup closes with the query abandoned", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelect({ value: "alias-alpha" });
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await user.type(input, "gamma");
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => expect(input).toHaveValue("alias-alpha"));
|
||||
});
|
||||
|
||||
it("puts the unfiltered page back when a typed query is abandoned", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange });
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
await user.type(input, "gamma");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"));
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""));
|
||||
});
|
||||
|
||||
it("puts the unfiltered page back once an option found by typing is picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange });
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
await user.type(input, "gamma");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"));
|
||||
|
||||
await user.click(await screen.findByText("gamma-key"));
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""));
|
||||
});
|
||||
|
||||
it("keeps the first character when typing is what opened the list", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange });
|
||||
|
||||
await user.tab();
|
||||
await user.keyboard("gamma");
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("gamma");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
|
||||
});
|
||||
|
||||
it("keeps showing a picked option's label after it drops out of the loaded page", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function Refetching() {
|
||||
const [options, setOptions] = useState<SearchSelectOption[]>([{ label: "Beta Team", value: "team-2" }]);
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<>
|
||||
<PaginatedSearchSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onSearchChange={vi.fn()}
|
||||
onLoadMore={vi.fn()}
|
||||
/>
|
||||
<button type="button" onClick={() => setOptions([])}>
|
||||
refetch
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Refetching />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByText("Beta Team"));
|
||||
await user.click(screen.getByRole("button", { name: "refetch" }));
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Beta Team");
|
||||
});
|
||||
|
||||
it("starts a fresh query when typing lands after the selected label", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange, value: "alias-alpha" });
|
||||
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement;
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
await user.keyboard("gamma");
|
||||
|
||||
expect(input).toHaveValue("gamma");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
|
||||
});
|
||||
|
||||
it("starts a fresh query when typing lands inside the selected label", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange, value: "alias-alpha" });
|
||||
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement;
|
||||
input.focus();
|
||||
input.setSelectionRange(3, 3);
|
||||
await user.keyboard("g");
|
||||
|
||||
expect(input).toHaveValue("g");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("g"));
|
||||
});
|
||||
|
||||
it("surfaces loading and fetching-more affordances", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
|
|
@ -35,6 +35,19 @@ interface PaginatedSearchSelectProps {
|
|||
"aria-describedby"?: string;
|
||||
}
|
||||
|
||||
const typedInsertion = (previous: string, next: string): string => {
|
||||
let start = 0;
|
||||
while (start < previous.length && start < next.length && previous[start] === next[start]) start++;
|
||||
let end = 0;
|
||||
while (
|
||||
end < previous.length - start &&
|
||||
end < next.length - start &&
|
||||
previous[previous.length - 1 - end] === next[next.length - 1 - end]
|
||||
)
|
||||
end++;
|
||||
return next.slice(start, next.length - end);
|
||||
};
|
||||
|
||||
export function PaginatedSearchSelect({
|
||||
options,
|
||||
value,
|
||||
|
|
@ -54,10 +67,15 @@ export function PaginatedSearchSelect({
|
|||
"aria-invalid": ariaInvalid,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
}: PaginatedSearchSelectProps) {
|
||||
const [pickedOption, setPickedOption] = useState<SearchSelectOption | null>(null);
|
||||
|
||||
const selected = useMemo<SearchSelectOption | null>(() => {
|
||||
if (value === undefined || value === "") return null;
|
||||
return options.find((option) => option.value === value) ?? { label: value, value };
|
||||
}, [options, value]);
|
||||
return (
|
||||
options.find((option) => option.value === value) ??
|
||||
(pickedOption?.value === value ? pickedOption : { label: value, value })
|
||||
);
|
||||
}, [options, value, pickedOption]);
|
||||
|
||||
const items = useMemo<SearchSelectOption[]>(() => {
|
||||
if (selected === null) return options;
|
||||
|
|
@ -66,14 +84,24 @@ export function PaginatedSearchSelect({
|
|||
}, [options, selected]);
|
||||
|
||||
const pagination = { onSearchChange, onLoadMore, hasNextPage, isFetchingNextPage };
|
||||
const { handleInputValueChange, handleScroll } = usePaginatedCombobox(pagination);
|
||||
const { typedQuery, handleInputValueChange, handleOpenChange, handleScroll } = usePaginatedCombobox(pagination);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
|
||||
onInputValueChange={(next, eventDetails) => handleInputValueChange(next, eventDetails.reason)}
|
||||
inputValue={typedQuery ?? selected?.label ?? ""}
|
||||
onValueChange={(item: SearchSelectOption | null) => {
|
||||
setPickedOption(item);
|
||||
onValueChange(item?.value ?? "");
|
||||
}}
|
||||
onInputValueChange={(next, eventDetails) =>
|
||||
handleInputValueChange(
|
||||
typedQuery === null ? typedInsertion(selected?.label ?? "", next) : next,
|
||||
eventDetails.reason,
|
||||
)
|
||||
}
|
||||
onOpenChange={(nextOpen, eventDetails) => handleOpenChange(nextOpen, eventDetails.reason)}
|
||||
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
|
||||
itemToStringLabel={(item: SearchSelectOption) => item.label}
|
||||
filter={null}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import type { UIEvent } from "react";
|
||||
import { useState, type UIEvent } from "react";
|
||||
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
|
||||
|
|
@ -23,12 +23,27 @@ export function usePaginatedCombobox({
|
|||
isFetchingNextPage,
|
||||
}: PaginatedComboboxCallbacks) {
|
||||
const debouncedSearch = useDebouncedCallback(onSearchChange, { wait: DEBOUNCE_WAIT_MS });
|
||||
const [typedQuery, setTypedQuery] = useState<string | null>(null);
|
||||
|
||||
const handleInputValueChange = (next: string, reason: string) => {
|
||||
if (!SEARCH_REASONS.has(reason)) return;
|
||||
if (!SEARCH_REASONS.has(reason)) {
|
||||
setTypedQuery(null);
|
||||
return;
|
||||
}
|
||||
setTypedQuery(next);
|
||||
debouncedSearch(next);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean, reason: string) => {
|
||||
if (!open) {
|
||||
if (typedQuery) debouncedSearch("");
|
||||
setTypedQuery(null);
|
||||
return;
|
||||
}
|
||||
const openedByTyping = SEARCH_REASONS.has(reason);
|
||||
if (!openedByTyping) setTypedQuery("");
|
||||
};
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (target.scrollHeight === 0) return;
|
||||
|
|
@ -38,5 +53,5 @@ export function usePaginatedCombobox({
|
|||
}
|
||||
};
|
||||
|
||||
return { handleInputValueChange, handleScroll };
|
||||
return { typedQuery, handleInputValueChange, handleOpenChange, handleScroll };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const ENVIRONMENTS = [
|
||||
|
|
@ -63,3 +64,40 @@ describe("SelectValue label resolution", () => {
|
|||
expect(screen.getByTestId("trigger")).toHaveTextContent("Any environment");
|
||||
});
|
||||
});
|
||||
|
||||
function renderOpenableSelect(contentProps?: React.ComponentProps<typeof SelectContent>) {
|
||||
return render(
|
||||
<Select value={null} items={ENVIRONMENTS}>
|
||||
<SelectTrigger data-testid="trigger">
|
||||
<SelectValue placeholder="Pick an environment" />
|
||||
</SelectTrigger>
|
||||
<SelectContent data-testid="content" {...contentProps}>
|
||||
{ENVIRONMENTS.map((environment) => (
|
||||
<SelectItem key={environment.value} value={environment.value}>
|
||||
{environment.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SelectContent anchoring", () => {
|
||||
it("anchors to the edge of the trigger rather than over it by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOpenableSelect();
|
||||
|
||||
await user.click(screen.getByTestId("trigger"));
|
||||
|
||||
expect(await screen.findByTestId("content")).toHaveAttribute("data-align-trigger", "false");
|
||||
});
|
||||
|
||||
it("still lets a caller opt into item-aligned anchoring", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOpenableSelect({ alignItemWithTrigger: true });
|
||||
|
||||
await user.click(screen.getByTestId("trigger"));
|
||||
|
||||
expect(await screen.findByTestId("content")).toHaveAttribute("data-align-trigger", "true");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ function SelectContent({
|
|||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
alignItemWithTrigger = false,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<SelectPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger">) {
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -24878,6 +24878,11 @@ export interface components {
|
|||
* @description If True, a user's personal max_budget is enforced on every request they make, including requests made with a team-scoped key. Defaults to False, where a team-scoped key is governed only by the team and team-member budgets and the key owner's personal max_budget does not apply (see GitHub issue #12905).
|
||||
*/
|
||||
apply_user_budget_to_team_keys?: boolean | null;
|
||||
/**
|
||||
* Background Health Check Model Groups
|
||||
* @description Opt-in allowlist of model group names for background health checks and health-check routing. When set, the background loop probes only deployments whose model_name is listed, and enable_health_check_routing filters unhealthy deployments only within the listed groups; every other group, including newly added deployments, is skipped and keeps its configured routing strategy. When unset, all deployments participate (opt out per deployment via model_info.disable_background_health_check).
|
||||
*/
|
||||
background_health_check_model_groups?: string[] | null;
|
||||
/**
|
||||
* Background Health Checks
|
||||
* @description run health checks in background
|
||||
|
|
|
|||
6
uv.lock
generated
6
uv.lock
generated
|
|
@ -10,7 +10,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-08-23T20:15:58.934396Z"
|
||||
exclude-newer = "2026-08-24T20:19:42.376246Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -4665,12 +4665,12 @@ proxy-dev = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.60"
|
||||
version = "0.1.61"
|
||||
source = { editable = "enterprise" }
|
||||
|
||||
[[package]]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.89"
|
||||
version = "0.4.90"
|
||||
source = { editable = "litellm-proxy-extras" }
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue