mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
Merge remote-tracking branch 'origin/main' into litellm_registry_audit_2026_09_14
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> # Conflicts: # tests/test_litellm/llms/gemini/test_cost_calculator.py
This commit is contained in:
commit
ae5451b07d
87 changed files with 2920 additions and 1910 deletions
|
|
@ -25,6 +25,8 @@ Same thing for bug fixes. The tests should make it so that this specific bug can
|
|||
|
||||
Never test structure of code only function of it
|
||||
|
||||
A test must only fail when litellm code changes. Never pin facts we don't own (a vendor's price, a third party's field, an upstream default, today's date) as literals or as "X must be absent"; assert the invariant our code guarantees instead, e.g. two rows agree, a value is within range, a field is derived from another. If an outside fact is truly load-bearing, cite its source and date next to the assertion so a reader can tell stale from broken
|
||||
|
||||
`tests/test_litellm/` mirrors `litellm/` in a parallel path (see `tests/test_litellm/readme.md`). Name tests `test_<filename>.py`, but always match the existing test file in the directory you touch — many provider dirs use longer descriptive names (e.g. `test_anthropic_chat_transformation.py`) to avoid ambiguity across sibling folders. For bug fixes, extend the existing mapped test file rather than creating a new one. Only create a new test file for a new feature (provider, endpoint, or transformation module) that has no mapped test yet, following that directory's naming convention (or `test_<filename>.py` if you're the first test there). One focused regression test beats many shallow ones
|
||||
|
||||
End-to-end tests belong in `tests/e2e/` and must follow the harness conventions documented in that directory's `CLAUDE.md`
|
||||
|
|
|
|||
|
|
@ -214,26 +214,33 @@ def _message_has_cache_control(message: Mapping[str, object]) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _cached_prefix_indices(messages: Sequence[Mapping[str, object]]) -> tuple[int, ...]:
|
||||
last_breakpoint: Final = max(
|
||||
(index for index, msg in enumerate(messages) if _message_has_cache_control(msg)),
|
||||
default=-1,
|
||||
)
|
||||
return tuple(range(last_breakpoint + 1))
|
||||
|
||||
|
||||
def get_protected_indices(messages: Sequence[Mapping[str, object]]) -> tuple[int, ...]:
|
||||
"""
|
||||
Return indices of messages that must never be compressed:
|
||||
- All system messages
|
||||
- The last user message
|
||||
- The last assistant message
|
||||
- Any message carrying an Anthropic cache_control breakpoint
|
||||
- Every message up to and including the last one carrying an Anthropic cache_control breakpoint
|
||||
|
||||
The last user message is what the model is being asked to act on right now,
|
||||
so compressing it replaces the live instruction with a marker. Compression
|
||||
guardrails share this policy; see the Headroom guardrail. A cache_control
|
||||
breakpoint pins the provider's prompt-cache prefix to that row's exact
|
||||
bytes, so rewriting a marked row anywhere in history turns the next
|
||||
request's cache read into a cache write.
|
||||
breakpoint pins the provider's prompt-cache prefix to the exact bytes of every
|
||||
row up to it, so rewriting any row inside that prefix turns the next request's
|
||||
cache read into a cache write.
|
||||
"""
|
||||
system_indices: Final = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "system")
|
||||
last_user: Final = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "user")[-1:]
|
||||
assistant_indices: Final = tuple(index for index, msg in enumerate(messages) if msg.get("role", "") == "assistant")
|
||||
cache_control_indices: Final = tuple(index for index, msg in enumerate(messages) if _message_has_cache_control(msg))
|
||||
return tuple(dict.fromkeys(system_indices + last_user + assistant_indices[-1:] + cache_control_indices))
|
||||
return tuple(dict.fromkeys(system_indices + last_user + assistant_indices[-1:] + _cached_prefix_indices(messages)))
|
||||
|
||||
|
||||
def _combine_scores(
|
||||
|
|
|
|||
|
|
@ -2610,12 +2610,6 @@ class PrometheusLogger(CustomLogger):
|
|||
StandardLoggingPayloadSetup,
|
||||
)
|
||||
|
||||
if self._should_skip_metrics_for_invalid_key(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
exception=original_exception,
|
||||
):
|
||||
return
|
||||
|
||||
status_code: Final = self._extract_status_code(exception=original_exception)
|
||||
|
||||
try:
|
||||
|
|
@ -2633,7 +2627,7 @@ class PrometheusLogger(CustomLogger):
|
|||
end_user=user_api_key_dict.end_user_id,
|
||||
user=user_api_key_dict.user_id,
|
||||
user_email=user_api_key_dict.user_email,
|
||||
hashed_api_key=user_api_key_dict.api_key,
|
||||
hashed_api_key=None if status_code == 401 else user_api_key_dict.api_key,
|
||||
api_key_alias=user_api_key_dict.key_alias,
|
||||
team=user_api_key_dict.team_id,
|
||||
team_alias=user_api_key_dict.team_alias,
|
||||
|
|
|
|||
|
|
@ -644,6 +644,24 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
"""Keep ``_response_ms`` / ``litellm_overhead_time_ms`` for a result that has no ``_hidden_params``."""
|
||||
self.response_timing_metrics = dict(timing_metrics) # mutable-ok: kept deep-copyable
|
||||
|
||||
def add_dynamic_callback(self, callback: CustomLogger) -> None:
|
||||
self.dynamic_input_callbacks = self._with_dynamic_callback(self.dynamic_input_callbacks, callback)
|
||||
self.dynamic_success_callbacks = self._with_dynamic_callback(self.dynamic_success_callbacks, callback)
|
||||
self.dynamic_async_success_callbacks = self._with_dynamic_callback(
|
||||
self.dynamic_async_success_callbacks, callback
|
||||
)
|
||||
self.dynamic_failure_callbacks = self._with_dynamic_callback(self.dynamic_failure_callbacks, callback)
|
||||
self.dynamic_async_failure_callbacks = self._with_dynamic_callback(
|
||||
self.dynamic_async_failure_callbacks, callback
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _with_dynamic_callback(
|
||||
callbacks: Sequence[str | Callable | CustomLogger] | None, callback: CustomLogger
|
||||
) -> list[str | Callable | CustomLogger]:
|
||||
existing: Final = tuple(callbacks or ())
|
||||
return [*existing, *(() if callback in existing else (callback,))]
|
||||
|
||||
def process_dynamic_callbacks(self):
|
||||
"""
|
||||
Initializes CustomLogger compatible callbacks in self.dynamic_* callbacks
|
||||
|
|
|
|||
|
|
@ -4716,6 +4716,7 @@ class JWTAuthBuilderResult(TypedDict):
|
|||
org_id: str | None
|
||||
team_membership: LiteLLM_TeamMembership | None
|
||||
jwt_claims: dict # Decoded JWT token claims (avoids re-decoding)
|
||||
agent_id: ReadOnly[str | None]
|
||||
|
||||
|
||||
class ClientSideFallbackModel(TypedDict, total=False):
|
||||
|
|
@ -4954,6 +4955,14 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
|
|||
user_allowed_roles: list[str] | None = None
|
||||
user_id_upsert: bool = Field(default=False, description="If user doesn't exist, upsert them into the db.")
|
||||
end_user_id_jwt_field: str | None = None
|
||||
agent_id_jwt_field: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"The field in the JWT token that identifies the calling agent (e.g. 'azp' for a Microsoft Entra ID "
|
||||
"app token). Supports dot notation. The value is matched against a registered agent's agent_id, "
|
||||
"then agent_name, and the request is rejected when it matches neither."
|
||||
),
|
||||
)
|
||||
public_key_ttl: float = 600
|
||||
public_key_stale_ttl: float = Field(
|
||||
default=DEFAULT_JWKS_STALE_TTL,
|
||||
|
|
|
|||
|
|
@ -5773,8 +5773,7 @@ async def _organization_max_budget_check(
|
|||
if org_table.litellm_budget_table is not None:
|
||||
org_max_budget = org_table.litellm_budget_table.max_budget
|
||||
|
||||
# Only check if organization has a valid max_budget set
|
||||
if org_max_budget is None or org_max_budget <= 0:
|
||||
if org_max_budget is None:
|
||||
return
|
||||
|
||||
# Read spend from cross-pod counter (Redis-first) or cached object (fallback)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from litellm.proxy.auth.auth_utils import (
|
|||
_get_request_ip_address,
|
||||
is_invalid_virtual_key_error,
|
||||
mark_invalid_virtual_key_error,
|
||||
normalize_request_route,
|
||||
)
|
||||
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
||||
from litellm.types.services import ServiceTypes
|
||||
|
|
@ -172,7 +173,7 @@ class UserAPIKeyAuthExceptionHandler:
|
|||
# so the handler is side-effect-free for the caller's identity object.
|
||||
user_api_key_dict = resolved_identity.model_copy() if resolved_identity is not None else UserAPIKeyAuth()
|
||||
user_api_key_dict.parent_otel_span = parent_otel_span
|
||||
user_api_key_dict.request_route = route
|
||||
user_api_key_dict.request_route = normalize_request_route(route)
|
||||
user_api_key_dict.api_key = user_api_key_dict.api_key or UserAPIKeyAuth(api_key=api_key).api_key
|
||||
|
||||
# Stamp identity onto the request's server span now, before the request
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import hashlib
|
|||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from typing import Any, Final, Literal, NoReturn, Protocol, TypeVar, cast
|
||||
|
||||
import httpx
|
||||
|
|
@ -61,6 +61,7 @@ from litellm.proxy.common_utils.user_api_key_cache import (
|
|||
)
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
from litellm.types.agents import AgentResponse
|
||||
|
||||
from .auth_checks import (
|
||||
_allowed_routes_check,
|
||||
|
|
@ -127,6 +128,26 @@ class _UserInfoResponse(Protocol):
|
|||
def json(self) -> dict[str, object]: ...
|
||||
|
||||
|
||||
class AgentLookup(Protocol):
|
||||
"""The registered-agent lookups a JWT agent claim is matched against."""
|
||||
|
||||
def get_agent_by_id(self, agent_id: str) -> AgentResponse | None:
|
||||
"""The agent registered under ``agent_id``, if any."""
|
||||
|
||||
def get_agent_by_name(self, agent_name: str) -> AgentResponse | None:
|
||||
"""The agent registered under ``agent_name``, if any."""
|
||||
|
||||
|
||||
class _NoRegisteredAgents:
|
||||
"""The lookup in force until the proxy binds its agent registry: no agent is registered, so no claim matches."""
|
||||
|
||||
def get_agent_by_id(self, agent_id: str) -> None:
|
||||
return None
|
||||
|
||||
def get_agent_by_name(self, agent_name: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _discovery_document(response: _OIDCDiscoveryResponse) -> _OIDCDiscoveryBody:
|
||||
"""Decode an OIDC discovery response body."""
|
||||
return response.json()
|
||||
|
|
@ -198,6 +219,10 @@ class JWTHandler:
|
|||
self.leeway = 0
|
||||
# Per-cache-key locks so a TTL lapse triggers one refresh instead of one per in-flight request.
|
||||
self._refresh_locks: dict[str, asyncio.Lock] = {} # mutable-ok: lock registry, keyed by JWKS url
|
||||
self.agent_lookup: AgentLookup = _NoRegisteredAgents()
|
||||
|
||||
def bind_agent_lookup(self, agent_lookup: AgentLookup) -> None:
|
||||
self.agent_lookup = agent_lookup
|
||||
|
||||
def update_environment(
|
||||
self,
|
||||
|
|
@ -623,6 +648,12 @@ class JWTHandler:
|
|||
object_id = default_value
|
||||
return object_id
|
||||
|
||||
def get_agent_claim(self, token: Mapping[str, object]) -> str | None:
|
||||
if self.litellm_jwtauth.agent_id_jwt_field is None:
|
||||
return None
|
||||
claim: Final[object] = get_nested_value(data=token, key_path=self.litellm_jwtauth.agent_id_jwt_field)
|
||||
return claim if isinstance(claim, str) and claim else None
|
||||
|
||||
def get_org_id(self, token: dict, default_value: str | None) -> str | None:
|
||||
if self._has_trusted_issuer_normalized_claim(token=token, claim=self.LITELLM_ORG_ID_CLAIM):
|
||||
return token.get(self.LITELLM_ORG_ID_CLAIM)
|
||||
|
|
@ -1380,6 +1411,7 @@ class JWTAuthManager:
|
|||
api_key: str,
|
||||
jwt_valid_token: dict | None = None,
|
||||
user_email: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
) -> JWTAuthBuilderResult | None:
|
||||
"""Check admin status and route access permissions"""
|
||||
if not jwt_handler.is_admin(scopes=scopes):
|
||||
|
|
@ -1409,8 +1441,28 @@ class JWTAuthManager:
|
|||
org_id=org_id,
|
||||
team_membership=None,
|
||||
jwt_claims=jwt_valid_token or {},
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve_agent_id(
|
||||
jwt_handler: JWTHandler,
|
||||
jwt_valid_token: Mapping[str, object],
|
||||
agent_registry: AgentLookup,
|
||||
) -> str | None:
|
||||
agent_claim: Final = jwt_handler.get_agent_claim(token=jwt_valid_token)
|
||||
if agent_claim is None:
|
||||
return None
|
||||
agent: Final = agent_registry.get_agent_by_id(agent_id=agent_claim) or agent_registry.get_agent_by_name(
|
||||
agent_name=agent_claim
|
||||
)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"No registered agent matches JWT claim {jwt_handler.litellm_jwtauth.agent_id_jwt_field}={agent_claim}",
|
||||
)
|
||||
return agent.agent_id
|
||||
|
||||
@staticmethod
|
||||
async def find_and_validate_specific_team_id(
|
||||
jwt_handler: JWTHandler,
|
||||
|
|
@ -2268,9 +2320,23 @@ class JWTAuthManager:
|
|||
elif rbac_role == LitellmUserRoles.INTERNAL_USER:
|
||||
user_id = object_id
|
||||
|
||||
agent_id: Final = JWTAuthManager.resolve_agent_id(
|
||||
jwt_handler=jwt_handler,
|
||||
jwt_valid_token=jwt_valid_token,
|
||||
agent_registry=jwt_handler.agent_lookup,
|
||||
)
|
||||
|
||||
# Check admin access
|
||||
admin_result: Final = await JWTAuthManager.check_admin_access(
|
||||
jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token, user_email=user_email
|
||||
jwt_handler,
|
||||
scopes,
|
||||
route,
|
||||
user_id,
|
||||
org_id,
|
||||
api_key,
|
||||
jwt_valid_token,
|
||||
user_email=user_email,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
if admin_result:
|
||||
await JWTAuthManager._attach_team_from_header_for_admin(
|
||||
|
|
@ -2514,4 +2580,5 @@ class JWTAuthManager:
|
|||
token=api_key,
|
||||
team_membership=team_membership_object,
|
||||
jwt_claims=jwt_valid_token,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -852,6 +852,7 @@ async def _auto_register_jwt_mapping(
|
|||
user_id: str | None = None,
|
||||
org_id: str | None = None,
|
||||
end_user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
) -> UserAPIKeyAuth | None:
|
||||
"""
|
||||
Auto-register: create a new virtual key + mapping for an unrecognised JWT
|
||||
|
|
@ -884,6 +885,7 @@ async def _auto_register_jwt_mapping(
|
|||
team_id=team_id,
|
||||
user_id=user_id,
|
||||
organization_id=org_id,
|
||||
agent_id=agent_id,
|
||||
metadata={
|
||||
"auto_registered": True,
|
||||
"jwt_claim_field": virtual_key_claim_field,
|
||||
|
|
@ -1567,6 +1569,7 @@ async def _user_api_key_auth_builder(
|
|||
org_id: Final = result["org_id"]
|
||||
team_membership: Final[LiteLLM_TeamMembership | None] = result.get("team_membership", None)
|
||||
jwt_claims = result.get("jwt_claims", None)
|
||||
agent_id: Final[str | None] = result.get("agent_id")
|
||||
|
||||
if is_proxy_admin:
|
||||
# Proxy admins authenticate via auth_builder (full
|
||||
|
|
@ -1592,6 +1595,7 @@ async def _user_api_key_auth_builder(
|
|||
end_user_id=end_user_id,
|
||||
parent_otel_span=parent_otel_span,
|
||||
jwt_claims=jwt_claims,
|
||||
agent_id=agent_id,
|
||||
**team_grants(team_object=team_object, team_membership=team_membership, user_id=user_id),
|
||||
)
|
||||
|
||||
|
|
@ -1612,6 +1616,7 @@ async def _user_api_key_auth_builder(
|
|||
user_rpm_limit=(user_object.rpm_limit if user_object is not None else None),
|
||||
user_model_max_budget=(user_object.model_max_budget if user_object is not None else None),
|
||||
jwt_claims=jwt_claims,
|
||||
agent_id=agent_id,
|
||||
**team_grants(team_object=team_object, team_membership=team_membership, user_id=user_id),
|
||||
)
|
||||
|
||||
|
|
@ -1635,6 +1640,7 @@ async def _user_api_key_auth_builder(
|
|||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
end_user_id=end_user_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
if auto_registered is not None:
|
||||
auto_registered.jwt_claims = jwt_claims
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ class WriterPinnedClient:
|
|||
self.db: Final = db.writer if isinstance(db, RoutingPrismaWrapper) and not db.writer_unavailable else db
|
||||
|
||||
|
||||
def writer_wrapper(db: "PrismaWrapper | RoutingPrismaWrapper") -> PrismaWrapper:
|
||||
"""Unlike `WriterPinnedClient`, ignores `writer_unavailable`: a raw SQL write has no replica fallback."""
|
||||
return db.writer if isinstance(db, RoutingPrismaWrapper) else db
|
||||
|
||||
|
||||
class RoutingPrismaWrapper:
|
||||
"""
|
||||
Routes Prisma operations between a writer and a reader Prisma client.
|
||||
|
|
|
|||
|
|
@ -1856,9 +1856,9 @@ def validate_team_org_change(
|
|||
|
||||
# Check if the team's budget is less than the org's max_budget
|
||||
if (
|
||||
team.max_budget
|
||||
and organization.litellm_budget_table
|
||||
and organization.litellm_budget_table.max_budget
|
||||
team.max_budget is not None
|
||||
and organization.litellm_budget_table is not None
|
||||
and organization.litellm_budget_table.max_budget is not None
|
||||
and team.max_budget > organization.litellm_budget_table.max_budget
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ from litellm.proxy._types import (
|
|||
from litellm.proxy.auth.auth_checks import (
|
||||
_delete_cache_access_object, # pyright: ignore[reportPrivateUsage] # the access-group endpoints reach for this same cache primitive
|
||||
)
|
||||
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient
|
||||
from litellm.proxy.db.routing_prisma_wrapper import writer_wrapper
|
||||
from litellm.repositories.table_repositories import AccessGroupRepository
|
||||
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ _REPOINT_KEY_SQL: Final = (
|
|||
def _raw_executor(prisma_client: object) -> _RawExecutor:
|
||||
"""Narrow the untyped Prisma client down to the raw-query call this module makes, pinned to the writer."""
|
||||
db: Final = AccessGroupRepository(prisma_client).prisma_client.db # pyright: ignore[reportAny] # untyped Prisma client
|
||||
return WriterPinnedClient(db).db # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
|
||||
return writer_wrapper(db) # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
|
||||
|
||||
|
||||
async def _invalidate_access_group_cache(access_group_id: str) -> None:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from typing import Final, Protocol
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient
|
||||
from litellm.proxy.db.routing_prisma_wrapper import writer_wrapper
|
||||
from litellm.proxy.management_helpers.access_group_team_sync import invalidate_access_group_caches
|
||||
from litellm.repositories.table_repositories import AccessGroupRepository
|
||||
from litellm.router import Router
|
||||
|
|
@ -56,7 +56,7 @@ _REMOVE_MODEL_NAME_SQL: Final = (
|
|||
|
||||
def _raw_executor(prisma_client: object) -> _RawExecutor:
|
||||
db: Final = AccessGroupRepository(prisma_client).prisma_client.db # pyright: ignore[reportAny] # untyped Prisma client
|
||||
return WriterPinnedClient(db).db # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
|
||||
return writer_wrapper(db) # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
|
||||
|
||||
|
||||
def _config_sourced_sibling(llm_router: Router, deployment_id: str, model_id: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -9516,6 +9516,9 @@ class ProxyStartupEvent:
|
|||
user_api_key_cache=user_api_key_cache,
|
||||
litellm_jwtauth=litellm_jwtauth,
|
||||
)
|
||||
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
|
||||
|
||||
jwt_handler.bind_agent_lookup(global_agent_registry)
|
||||
|
||||
@classmethod
|
||||
def _add_proxy_budget_to_db(cls):
|
||||
|
|
|
|||
|
|
@ -430,6 +430,14 @@ def _enrich_http_exception_with_guardrail_context(exc: BaseException, callback:
|
|||
detail.setdefault("guardrail_mode", event_hook)
|
||||
|
||||
|
||||
def _is_client_error_exception(exc: Exception) -> bool:
|
||||
if isinstance(exc, HTTPException):
|
||||
return exc.status_code < 500
|
||||
if isinstance(exc, ProxyException):
|
||||
return not (exc.code.isdigit() and int(exc.code) >= 500)
|
||||
return False
|
||||
|
||||
|
||||
def _exception_changes_request_flow(exc: BaseException) -> bool:
|
||||
"""
|
||||
True for guardrail exceptions the proxy turns into an alternate request flow
|
||||
|
|
@ -2886,9 +2894,7 @@ class ProxyLogging:
|
|||
|
||||
### ALERTING ###
|
||||
await self.update_request_status(litellm_call_id=request_data.get("litellm_call_id", ""), status="fail")
|
||||
if AlertType.llm_exceptions in self.alert_types and not isinstance(
|
||||
original_exception, (HTTPException, ProxyException)
|
||||
):
|
||||
if AlertType.llm_exceptions in self.alert_types and not _is_client_error_exception(original_exception):
|
||||
"""
|
||||
Just alert on LLM API exceptions. Do not alert on user errors
|
||||
|
||||
|
|
|
|||
|
|
@ -1622,6 +1622,24 @@ class Router:
|
|||
return
|
||||
await selector.async_pre_call_check(deployment, parent_otel_span)
|
||||
|
||||
def _bind_override_selector_to_request(
|
||||
self, strategy: str, selector: RouterStrategySelector | None, request_kwargs: Mapping[str, object] | None
|
||||
) -> None:
|
||||
if selector is None or request_kwargs is None or strategy in self._globally_registered_strategies():
|
||||
return
|
||||
logging_obj: Final = request_kwargs.get("litellm_logging_obj")
|
||||
if isinstance(logging_obj, LiteLLMLogging):
|
||||
logging_obj.add_dynamic_callback(selector)
|
||||
|
||||
def _globally_registered_strategies(self) -> frozenset[str]:
|
||||
configured: Final = (
|
||||
self.routing_strategy,
|
||||
*(group.routing_strategy for group in self._routing_groups.values()),
|
||||
)
|
||||
return frozenset(
|
||||
normalized for normalized in map(self._normalize_strategy, configured) if normalized is not None
|
||||
)
|
||||
|
||||
def _get_routing_context(
|
||||
self, model: str, request_kwargs: dict | None = None
|
||||
) -> tuple[str | None, RouterStrategySelector | None]:
|
||||
|
|
@ -1647,7 +1665,9 @@ class Router:
|
|||
override: Final = self._get_request_routing_strategy_override(request_kwargs)
|
||||
if override is not None:
|
||||
verbose_router_logger.debug("routing_group=request-override model=%s strategy=%s", model, override)
|
||||
return override, self._get_override_strategy_selector(override)
|
||||
override_selector: Final = self._get_override_strategy_selector(override)
|
||||
self._bind_override_selector_to_request(override, override_selector, request_kwargs)
|
||||
return override, override_selector
|
||||
|
||||
group_name: Final = model if self.get_routing_group(model) is not None else self._model_to_group.get(model)
|
||||
if group_name is None:
|
||||
|
|
@ -2461,7 +2481,7 @@ class Router:
|
|||
|
||||
### DEPLOYMENT-SPECIFIC PRE-CALL CHECKS ### (e.g. update rpm pre-call. Raise error, if deployment over limit)
|
||||
## only run if model group given, not model id
|
||||
if not self.has_model_id(model):
|
||||
if model in self.model_names or not self.has_model_id(model):
|
||||
self.routing_strategy_pre_call_checks(deployment=deployment)
|
||||
|
||||
input_kwargs: Final = {
|
||||
|
|
@ -12512,7 +12532,7 @@ class Router:
|
|||
# check if aliases set on litellm model alias map
|
||||
if specific_deployment is True:
|
||||
return model, self._get_deployment_by_litellm_model(model=model)
|
||||
elif self.has_model_id(model):
|
||||
elif model not in self.model_names and self.has_model_id(model):
|
||||
deployment: Final = self.get_deployment(model_id=model)
|
||||
if deployment is not None:
|
||||
deployment_model: Final = deployment.litellm_params.model
|
||||
|
|
|
|||
|
|
@ -68,6 +68,117 @@ still resolve to a deployment in `model_list`; this configuration does not creat
|
|||
- abc
|
||||
```
|
||||
|
||||
### Capability forecasting
|
||||
|
||||
Set `classifier_type: capability` to use
|
||||
[NVIDIA NeMo Switchyard's packaged capability classifier](https://github.com/NVIDIA-NeMo/Switchyard/blob/main/crates/libsy/src/prompts/capability-classifier/prompt.md).
|
||||
The classifier forecasts the probability that an efficient model completes
|
||||
the whole task, identifies the capability-card boundary that applies, and leaves the
|
||||
route choice to a deterministic threshold policy
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: smart-router
|
||||
litellm_params:
|
||||
model: auto_router/complexity_router
|
||||
complexity_router_config:
|
||||
classifier_type: capability
|
||||
classifier_llm_config:
|
||||
model: classifier-model
|
||||
capability_classifier_config:
|
||||
efficient_tier: SIMPLE
|
||||
capable_tier: REASONING
|
||||
base_threshold: 0.5
|
||||
threshold_step: 0.1
|
||||
tiers:
|
||||
SIMPLE:
|
||||
- efficient-model-a
|
||||
- efficient-model-b
|
||||
REASONING: capable-model
|
||||
```
|
||||
|
||||
The structured classifier verdict contains `crux`, `primary_rule`,
|
||||
`capability_boundary`, and `p_solve`. The policy computes the required solve
|
||||
probability as follows
|
||||
|
||||
- `supported`: `base_threshold`
|
||||
- `uncertain` or `unmatched`: `base_threshold + threshold_step`
|
||||
- `unsupported`: `base_threshold + 2 * threshold_step`
|
||||
|
||||
The efficient tier is selected when `p_solve` is greater than or equal to the
|
||||
adjusted threshold. Otherwise the capable tier is selected. A malformed,
|
||||
inconsistent, empty, or unavailable verdict always fails closed to the capable
|
||||
tier. `base_threshold` is required, `threshold_step` defaults to `0`, and their
|
||||
maximum adjusted threshold must not exceed `1`
|
||||
|
||||
The classifier receives the packaged Switchyard system prompt, the opening user
|
||||
task, and the latest user follow-up when present. Caller system messages,
|
||||
assistant turns, and intermediate tool results are not sent. The classifier call
|
||||
uses strict JSON Schema output and the existing classifier timeout, circuit
|
||||
breaker, attribution, redaction, reasoning-effort, and optional vision settings
|
||||
|
||||
`efficient_tier` and `capable_tier` name built-in complexity tiers with configured
|
||||
model pools. The forecast still makes one binary quality decision, while the
|
||||
ordinary tier pool may contain multiple equivalent deployments. Session affinity,
|
||||
keyword overrides, plan-mode floors, modality checks, and other post-classification
|
||||
complexity-router controls continue to apply
|
||||
|
||||
Routing decisions record the adjusted threshold and the complete valid forecast:
|
||||
`classifier_p_solve`, `classifier_capability_boundary`, `classifier_primary_rule`,
|
||||
and `classifier_crux`. Prompt redaction removes `classifier_crux` while retaining
|
||||
the derived fields needed to audit the decision
|
||||
|
||||
#### Calibrating solve probabilities
|
||||
|
||||
Supply a fitted monotone logit calibration under `capability_classifier_config`
|
||||
to transform the forecast before applying the threshold. Calibration is opt-in;
|
||||
without it the router uses the raw probability. Fit coefficients on benchmark
|
||||
outcomes from separate training repositories, select thresholds on a validation
|
||||
split, and report quality and cost on an untouched evaluation split
|
||||
|
||||
```yaml
|
||||
capability_classifier_config:
|
||||
efficient_tier: SIMPLE
|
||||
capable_tier: REASONING
|
||||
base_threshold: 0.66
|
||||
threshold_step: 0
|
||||
max_output_tokens: 512
|
||||
response_format: json_object
|
||||
calibration:
|
||||
version: your-benchmark-artifact-v1
|
||||
slope: 1.0
|
||||
intercept: 0.0
|
||||
```
|
||||
|
||||
The example coefficients are an identity mapping, not a trained calibration.
|
||||
The mapping is `sigmoid(slope * logit(clip(p_solve, 1e-6, 1-1e-6)) + intercept)`.
|
||||
The slope must be nonnegative, so calibration cannot improve ranking. It can
|
||||
make probabilities more accurate and thresholds easier to interpret. The version
|
||||
is recorded for auditing; the router does not check whether an artifact matches
|
||||
the judge, capability card, efficient solver, or agent harness. Operators must
|
||||
keep those aligned and refit when they change
|
||||
|
||||
Logs retain `classifier_p_solve` and add `classifier_calibrated_p_solve` and
|
||||
`classifier_calibration_version`. `classifier_threshold` is compared to the
|
||||
calibrated probability. Invalid verdicts still route to the capable tier
|
||||
|
||||
`response_format` defaults to `json_schema`. For endpoints that support JSON
|
||||
objects but not strict schemas, `json_object` appends the same schema to the
|
||||
unchanged capability prompt and retains strict local validation. Set
|
||||
`classifier_llm_config.timeout_ms` to cover the measured judge latency; a local
|
||||
judge may need longer than the default 3000 ms. `max_output_tokens` still defaults
|
||||
to 4096; 512 is an explicit benchmark setting for a short, non-reasoning judge
|
||||
|
||||
For a controlled whole-task benchmark, use `adaptive: false`,
|
||||
`session_affinity: true`, and a unique session ID for every task and policy arm.
|
||||
Disable keyword, plan-mode, housekeeping, and other optional overrides when
|
||||
measuring only the capability policy. When adaptive selection is enabled, it
|
||||
cannot select below the capability decision, including a capable-tier fallback
|
||||
|
||||
Configure capability forecasting through YAML or the model-management API.
|
||||
The dashboard preserves its classifier and calibration on an untouched save;
|
||||
it does not provide a capability-card editor
|
||||
|
||||
### Heuristic v2
|
||||
|
||||
Set `classifier_type: heuristic_v2` to classify with the bundled calibrated
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from litellm.router_strategy.complexity_router.complexity_router import (
|
|||
from litellm.router_strategy.complexity_router.config import (
|
||||
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
|
||||
DEFAULT_COMPLEXITY_CONFIG,
|
||||
CapabilityCalibrationConfig,
|
||||
CapabilityClassifierConfig,
|
||||
ClassificationRubric,
|
||||
ComplexityRouterConfig,
|
||||
ComplexityTier,
|
||||
|
|
@ -28,6 +30,8 @@ from litellm.router_strategy.complexity_router.config import (
|
|||
__all__ = [
|
||||
"DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE",
|
||||
"DEFAULT_COMPLEXITY_CONFIG",
|
||||
"CapabilityCalibrationConfig",
|
||||
"CapabilityClassifierConfig",
|
||||
"ClassificationRubric",
|
||||
"ComplexityRouter",
|
||||
"ComplexityRouterConfig",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Capability forecast contract and routing policy adapted from NVIDIA NeMo Switchyard."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from sys import float_info
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, NamedTuple, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictFloat, TypeAdapter, model_validator
|
||||
|
||||
CapabilityBoundary: TypeAlias = Literal["supported", "uncertain", "unsupported", "unmatched"]
|
||||
CapabilityRule: TypeAlias = Literal[
|
||||
"SUP-1",
|
||||
"SUP-2",
|
||||
"SUP-3",
|
||||
"SUP-4",
|
||||
"SUP-5",
|
||||
"UNC-1",
|
||||
"UNC-2",
|
||||
"LIM-1",
|
||||
"LIM-2",
|
||||
"none",
|
||||
]
|
||||
|
||||
CAPABILITY_CLASSIFIER_SYSTEM_PROMPT: Final = """You are a task-level probability forecaster for a model router. You receive the
|
||||
task's opening instruction and, when present, its latest user follow-up, plus
|
||||
the qualitative capability card below.
|
||||
|
||||
Forecast one binary event:
|
||||
|
||||
SUCCESS means that the efficient agent completes the whole task correctly on
|
||||
one fresh run under the actual harness, tools, and budget, as judged by the
|
||||
final verifier. FAILURE means any other outcome. The two outcomes are
|
||||
exhaustive.
|
||||
|
||||
Use only evidence in the instruction and the capability card. Do not assume
|
||||
hidden repository state, unmentioned tools, validators, documentation, access,
|
||||
or future work habits. Do not invent empirical counts, success rates, or base
|
||||
rates. The capability card is qualitative evidence, not a measured prior.
|
||||
|
||||
# Assessment procedure
|
||||
|
||||
1. State the crux: the hardest material requirement for whole-task success.
|
||||
2. Select the one capability rule that best describes the crux. Use
|
||||
primary_rule=none and capability_boundary=unmatched when no rule applies.
|
||||
Rule ids are opaque labels. Do not infer a boundary from an id's spelling.
|
||||
3. Privately identify the strongest instruction-visible reasons for SUCCESS
|
||||
and FAILURE, then imagine the most likely concrete failure.
|
||||
4. Privately consider material unknowns. Missing information should limit
|
||||
extreme estimates, but it is not evidence that p_solve must equal 0.50.
|
||||
5. Estimate p_solve last. It is the probability of whole-task SUCCESS, not
|
||||
confidence in this assessment, a route recommendation, or a cost judgment.
|
||||
|
||||
Interpret probabilities as natural frequencies. If p_solve is 0.70 for 100
|
||||
comparable fresh runs, about 70 should succeed and 30 should fail. Use the full
|
||||
range when justified. Reserve 0.00 and 1.00 for outcomes that are logically
|
||||
impossible or certain under the visible contract. Supported does not mean 1.00,
|
||||
and unsupported does not mean 0.00. The downstream routing threshold is not
|
||||
part of this forecast.
|
||||
|
||||
# Efficient-agent capability card
|
||||
|
||||
The route verbs in this source card are inherited qualitative descriptions.
|
||||
They do not ask you to output a route and do not assign a fixed probability to
|
||||
any boundary.
|
||||
|
||||
- SUP-1 [supported]: Route to the Efficient model when the task provides a complete output contract and a deterministic local validator that covers the material requirements.
|
||||
- SUP-2 [supported]: Route to the Efficient model when all required inputs are available, the target environment can be inspected, and correctness can be verified end-to-end without inaccessible external state.
|
||||
- SUP-3 [supported]: Route to the Efficient model when mathematical behavior, interfaces, shapes, data types, tolerances, and performance requirements are explicit and exercised by a representative harness.
|
||||
- SUP-4 [supported]: Route to the Efficient model when the required mechanism is identified, the relevant search space is bounded, and the success condition is executable. Do not infer this rule merely from the task's technical domain.
|
||||
- SUP-5 [supported]: Route to the Efficient model when reconstruction or behavioral reproduction is constrained by an executable reference, parser, format specification, or checker strong enough to distinguish correct from merely plausible output.
|
||||
- UNC-1 [uncertain]: Treat the route as uncertain when multiple reasonable interpretations of preprocessing, representation, indexing, naming, or output placement would produce different results and neither the instructions nor a validator resolve the choice.
|
||||
- UNC-2 [uncertain]: Treat the route as uncertain when success requires finding every relevant item across heterogeneous inputs or environment state, but the task does not define the search boundary or provide a completeness check.
|
||||
- LIM-1 [unsupported]: Prefer the Capable model when correctness depends primarily on extracting precise information from noisy visual, temporal, or rendered media and no machine-checkable extraction or replay mechanism is available.
|
||||
- LIM-2 [unsupported]: Prefer the Capable model when success depends on reproducing undocumented reference behavior, hidden intermediate state, or an unknown configuration, and small deviations fail despite satisfying the visible specification.
|
||||
|
||||
# Output
|
||||
|
||||
Return exactly one JSON object matching the response schema supplied with the
|
||||
request. Do not include markdown or commentary.
|
||||
|
||||
p_solve must be between 0.00 and 1.00. p_fail is exactly 1.00 - p_solve and
|
||||
must not be emitted separately. Do not output recommended_route, confidence,
|
||||
abstain, counts, task totals, empirical rates, or any other field."""
|
||||
|
||||
_BOUNDARY_STEPS: Final = MappingProxyType(
|
||||
{
|
||||
"supported": 0,
|
||||
"uncertain": 1,
|
||||
"unmatched": 1,
|
||||
"unsupported": 2,
|
||||
}
|
||||
)
|
||||
|
||||
_RULE_BOUNDARIES: Final = MappingProxyType(
|
||||
{
|
||||
"SUP-1": "supported",
|
||||
"SUP-2": "supported",
|
||||
"SUP-3": "supported",
|
||||
"SUP-4": "supported",
|
||||
"SUP-5": "supported",
|
||||
"UNC-1": "uncertain",
|
||||
"UNC-2": "uncertain",
|
||||
"LIM-1": "unsupported",
|
||||
"LIM-2": "unsupported",
|
||||
"none": "unmatched",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CapabilityClassifierVerdict(BaseModel):
|
||||
"""Strict structured verdict returned by the capability forecaster."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
crux: str = Field(min_length=1)
|
||||
primary_rule: CapabilityRule
|
||||
capability_boundary: CapabilityBoundary
|
||||
p_solve: StrictFloat = Field(ge=0.0, le=1.0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_rule_boundary_pair(self) -> "CapabilityClassifierVerdict":
|
||||
if not self.crux.strip():
|
||||
raise ValueError("crux must contain non-whitespace text")
|
||||
expected: Final = _RULE_BOUNDARIES[self.primary_rule]
|
||||
if self.capability_boundary != expected:
|
||||
raise ValueError(
|
||||
f"primary_rule {self.primary_rule!r} requires capability_boundary {expected!r}, "
|
||||
f"got {self.capability_boundary!r}"
|
||||
)
|
||||
return self
|
||||
|
||||
def routing_threshold(self, base_threshold: float, threshold_step: float) -> float:
|
||||
"""Required efficient-model solve probability for this boundary."""
|
||||
return base_threshold + _BOUNDARY_STEPS[self.capability_boundary] * threshold_step
|
||||
|
||||
def meets_routing_threshold(self, threshold: float) -> bool:
|
||||
"""Inclusive comparison with Switchyard's one-epsilon rounding guard."""
|
||||
return self.p_solve >= threshold or abs(threshold - self.p_solve) <= float_info.epsilon
|
||||
|
||||
|
||||
class CapabilityClassifierForecast(NamedTuple):
|
||||
verdict: CapabilityClassifierVerdict
|
||||
threshold: float
|
||||
p_solve: float
|
||||
calibration_version: str | None
|
||||
|
||||
def meets_routing_threshold(self) -> bool:
|
||||
return self.p_solve >= self.threshold or abs(self.threshold - self.p_solve) <= float_info.epsilon
|
||||
|
||||
|
||||
_CAPABILITY_CLASSIFIER_RESPONSE_FORMAT_JSON: Final = """{
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "CapabilityClassifierDecision",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["crux", "primary_rule", "capability_boundary", "p_solve"],
|
||||
"properties": {
|
||||
"crux": {"type": "string", "minLength": 1},
|
||||
"primary_rule": {
|
||||
"type": "string",
|
||||
"enum": ["SUP-1", "SUP-2", "SUP-3", "SUP-4", "SUP-5", "UNC-1", "UNC-2", "LIM-1", "LIM-2", "none"]
|
||||
},
|
||||
"capability_boundary": {
|
||||
"type": "string",
|
||||
"enum": ["supported", "uncertain", "unsupported", "unmatched"]
|
||||
},
|
||||
"p_solve": {"type": "number", "minimum": 0.0, "maximum": 1.0}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
_RESPONSE_FORMAT_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
def capability_classifier_response_format(
|
||||
mode: Literal["json_schema", "json_object"] = "json_schema",
|
||||
) -> Mapping[str, object]:
|
||||
"""Fresh copy of Switchyard's packaged strict JSON Schema wrapper."""
|
||||
return (
|
||||
_RESPONSE_FORMAT_ADAPTER.validate_json('{"type": "json_object"}')
|
||||
if mode == "json_object"
|
||||
else _RESPONSE_FORMAT_ADAPTER.validate_json(_CAPABILITY_CLASSIFIER_RESPONSE_FORMAT_JSON)
|
||||
)
|
||||
|
||||
|
||||
def capability_classifier_system_prompt(mode: Literal["json_schema", "json_object"]) -> str:
|
||||
if mode == "json_schema":
|
||||
return CAPABILITY_CLASSIFIER_SYSTEM_PROMPT
|
||||
wrapper: Final = _RESPONSE_FORMAT_ADAPTER.validate_python(capability_classifier_response_format()["json_schema"])
|
||||
return (
|
||||
CAPABILITY_CLASSIFIER_SYSTEM_PROMPT
|
||||
+ "\n\nReturn exactly one JSON object matching this JSON Schema:\n"
|
||||
+ json.dumps(wrapper["schema"], indent=2, sort_keys=True)
|
||||
)
|
||||
|
||||
|
||||
def parse_capability_classifier_verdict(content: str) -> CapabilityClassifierVerdict:
|
||||
"""Parse raw JSON or the fenced JSON shape tolerated by Switchyard."""
|
||||
text: Final = content.strip()
|
||||
if not text.startswith("```"):
|
||||
return CapabilityClassifierVerdict.model_validate_json(text)
|
||||
unfenced: Final = text.removeprefix("```").removeprefix("json").lstrip("\n\r")
|
||||
return CapabilityClassifierVerdict.model_validate_json(unfenced.removesuffix("```").strip())
|
||||
|
|
@ -5,8 +5,9 @@ A rule-based routing strategy that uses weighted scoring across multiple dimensi
|
|||
to classify requests by complexity and route them to appropriate models.
|
||||
|
||||
By default, scoring is local (regex/keyword-based) with no external API calls and <1ms
|
||||
latency. Optionally, classifier_type="llm" routes classification through a configured
|
||||
model instead, trading that latency/cost guarantee for potentially better accuracy.
|
||||
latency. Optionally, classifier_type="llm" selects a tier through a configured model,
|
||||
while classifier_type="capability" forecasts efficient-model success and applies a
|
||||
Switchyard-compatible threshold policy.
|
||||
keyword_tier_rules (lexical or, with semantic_keyword_matching, embedding-based) are
|
||||
evaluated before either classification strategy and force a tier outright when matched.
|
||||
|
||||
|
|
@ -73,6 +74,12 @@ from litellm.types.utils import (
|
|||
StandardLoggingRoutingDecisionTierBoundaries,
|
||||
)
|
||||
|
||||
from .capability_classifier import (
|
||||
CapabilityClassifierForecast,
|
||||
capability_classifier_response_format,
|
||||
capability_classifier_system_prompt,
|
||||
parse_capability_classifier_verdict,
|
||||
)
|
||||
from .classification_rubrics import BUSINESS_TIER_CRITERIA, calibration_examples_section
|
||||
from .config import (
|
||||
CALIBRATION_EXAMPLES_HEADING,
|
||||
|
|
@ -994,20 +1001,49 @@ class ClassificationOutcome(NamedTuple):
|
|||
"heuristic_v2",
|
||||
"reasoning_override",
|
||||
"llm_classifier",
|
||||
"capability_classifier",
|
||||
"heuristic_first_short_circuit",
|
||||
"hybrid_short_circuit",
|
||||
"housekeeping",
|
||||
"classifier_plugin",
|
||||
"classifier_fallback",
|
||||
"capability_classifier_fallback",
|
||||
"default_model_fallback",
|
||||
]
|
||||
classifier_cost: float | None = None
|
||||
capability_forecast: CapabilityClassifierForecast | None = None
|
||||
|
||||
|
||||
def _with_signal(outcome: ClassificationOutcome, signal: str | None) -> ClassificationOutcome:
|
||||
return outcome if signal is None else outcome._replace(signals=(*outcome.signals, signal))
|
||||
|
||||
|
||||
def _with_capability_forecast(
|
||||
decision: StandardLoggingRoutingDecision, outcome: ClassificationOutcome
|
||||
) -> StandardLoggingRoutingDecision:
|
||||
"""Attach the validated capability verdict and applied threshold to its decision record."""
|
||||
forecast: Final = outcome.capability_forecast
|
||||
if forecast is None:
|
||||
return decision
|
||||
verdict: Final = forecast.verdict
|
||||
enriched: Final[StandardLoggingRoutingDecision] = { # mutable-ok: routing decisions are JSON TypedDict records
|
||||
**decision,
|
||||
"classifier_crux": verdict.crux,
|
||||
"classifier_primary_rule": verdict.primary_rule,
|
||||
"classifier_capability_boundary": verdict.capability_boundary,
|
||||
"classifier_p_solve": verdict.p_solve,
|
||||
"classifier_threshold": forecast.threshold,
|
||||
}
|
||||
if forecast.calibration_version is None:
|
||||
return enriched
|
||||
calibrated: Final[StandardLoggingRoutingDecision] = {
|
||||
**enriched,
|
||||
"classifier_calibrated_p_solve": forecast.p_solve,
|
||||
"classifier_calibration_version": forecast.calibration_version,
|
||||
}
|
||||
return calibrated
|
||||
|
||||
|
||||
class _ClassifierCircuitBreaker:
|
||||
"""Process-local timeout breaker for one complexity-router classifier.
|
||||
|
||||
|
|
@ -1276,8 +1312,15 @@ class ComplexityRouter(CustomLogger):
|
|||
self._classifier_system_prompt: str | None = (
|
||||
self._build_classifier_system_prompt() if llm_classifier_configured else None
|
||||
)
|
||||
capability_config: Final = self.config.capability_classifier_config
|
||||
self._classifier_response_format: Mapping[str, object] | None = (
|
||||
type_to_response_format_param(_tier_classification_model(self.config.classifier_wire_labels()))
|
||||
(
|
||||
capability_classifier_response_format(
|
||||
capability_config.response_format if capability_config is not None else "json_schema"
|
||||
)
|
||||
if self.config.classifier_type == "capability"
|
||||
else type_to_response_format_param(_tier_classification_model(self.config.classifier_wire_labels()))
|
||||
)
|
||||
if llm_classifier_configured
|
||||
else None
|
||||
)
|
||||
|
|
@ -1303,6 +1346,11 @@ class ComplexityRouter(CustomLogger):
|
|||
llm_config: Final = self.config.classifier_llm_config
|
||||
if llm_config is None:
|
||||
raise ValueError("classifier_llm_config is not set")
|
||||
if self.config.classifier_type == "capability":
|
||||
capability: Final = self.config.capability_classifier_config
|
||||
return capability_classifier_system_prompt(
|
||||
capability.response_format if capability is not None else "json_schema"
|
||||
)
|
||||
definitions: Final = self.config.tier_definitions
|
||||
if definitions is not None:
|
||||
return custom_tier_classification_prompt(
|
||||
|
|
@ -1720,6 +1768,8 @@ class ComplexityRouter(CustomLogger):
|
|||
return await self._classify_heuristic_first(prompt, system_prompt, request_kwargs, messages)
|
||||
if self.config.classifier_type == "hybrid" and self.config.classifier_llm_config is not None:
|
||||
return await self._classify_hybrid(prompt, system_prompt, request_kwargs, messages)
|
||||
if self.config.classifier_type == "capability" and self.config.classifier_llm_config is not None:
|
||||
return await self._capability_classifier_outcome(prompt, request_kwargs, messages)
|
||||
if self.config.classifier_type != "llm" or self.config.classifier_llm_config is None:
|
||||
tier, score, signals, cause = self._score_and_classify(prompt, system_prompt)
|
||||
return ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause)
|
||||
|
|
@ -1831,6 +1881,66 @@ class ComplexityRouter(CustomLogger):
|
|||
)
|
||||
)
|
||||
|
||||
async def _capability_classifier_outcome(
|
||||
self,
|
||||
prompt: str,
|
||||
request_kwargs: Mapping[str, object] | None,
|
||||
messages: Sequence[Mapping[str, object]] | None,
|
||||
) -> ClassificationOutcome:
|
||||
"""Forecast efficient-tier success, then apply the deterministic boundary policy."""
|
||||
breaker: Final = self._classifier_circuit_breaker
|
||||
permit: Final = breaker.acquire_permit() if breaker is not None else None
|
||||
if breaker is not None and permit is None:
|
||||
return self._capability_classifier_failure_outcome(
|
||||
"capability classifier circuit is open", signal=_CLASSIFIER_CIRCUIT_OPEN_SIGNAL
|
||||
)
|
||||
try:
|
||||
tier, classifier_cost, forecast = await self._classify_with_capability_llm(prompt, request_kwargs, messages)
|
||||
if breaker is not None and permit is not None:
|
||||
breaker.record_success(permit)
|
||||
return ClassificationOutcome(
|
||||
tier=tier,
|
||||
score=None,
|
||||
signals=(
|
||||
f"capability-boundary:{forecast.verdict.capability_boundary}",
|
||||
f"capability-rule:{forecast.verdict.primary_rule}",
|
||||
),
|
||||
cause="capability_classifier",
|
||||
classifier_cost=classifier_cost,
|
||||
capability_forecast=forecast,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
if breaker is not None and permit is not None:
|
||||
breaker.record_failure(permit, is_timeout=False)
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 -- every unavailable or invalid judge verdict must fail closed
|
||||
if breaker is not None and permit is not None:
|
||||
breaker.record_failure(permit, is_timeout=_is_classifier_timeout(e))
|
||||
return self._capability_classifier_failure_outcome(f"capability classifier failed ({e})")
|
||||
|
||||
def _capability_classifier_failure_outcome(self, reason: str, signal: str | None = None) -> ClassificationOutcome:
|
||||
"""Fail closed to the configured capable tier without consulting another taxonomy."""
|
||||
capability: Final = self.config.capability_classifier_config
|
||||
if capability is None:
|
||||
raise ValueError("capability_classifier_config is not set")
|
||||
verbose_router_logger.warning(
|
||||
"ComplexityRouter: %s, routing to capable_tier %s", reason, capability.capable_tier
|
||||
)
|
||||
signals: Final = (
|
||||
("capability-classifier-fallback",)
|
||||
if signal is None
|
||||
else (
|
||||
"capability-classifier-fallback",
|
||||
signal,
|
||||
)
|
||||
)
|
||||
return ClassificationOutcome(
|
||||
tier=ComplexityTier(capability.capable_tier),
|
||||
score=None,
|
||||
signals=signals,
|
||||
cause="capability_classifier_fallback",
|
||||
)
|
||||
|
||||
async def _llm_classifier_outcome(
|
||||
self,
|
||||
prompt: str,
|
||||
|
|
@ -2066,13 +2176,6 @@ class ComplexityRouter(CustomLogger):
|
|||
label_roles=include_assistant,
|
||||
)
|
||||
|
||||
request_metadata = (request_kwargs or {}).get("litellm_metadata") or (request_kwargs or {}).get("metadata")
|
||||
metadata: Final = { # mutable-ok: SDK metadata kwarg is enriched by the request pipeline
|
||||
**forwarded_internal_call_metadata(request_metadata, AUTOROUTER_CLASSIFIER_CALL_ORIGIN),
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
|
||||
}
|
||||
turn_off_message_logging: Final = _effective_turn_off_message_logging(request_kwargs)
|
||||
|
||||
image_parts: Final = self._classifier_image_parts(messages)
|
||||
user_content: Final[str | Sequence[ChatCompletionTextObject | ChatCompletionImageObject]] = (
|
||||
[ # mutable-ok: SDK request payload content list is built once
|
||||
|
|
@ -2086,18 +2189,125 @@ class ComplexityRouter(CustomLogger):
|
|||
{"role": "system", "content": classifier_system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
response_format: Final = classifier_response_format
|
||||
classifier_call_params: Mapping[str, str] = EMPTY_MAPPING
|
||||
if llm_config.reasoning_effort is not None:
|
||||
classifier_call_params = MappingProxyType({"reasoning_effort": llm_config.reasoning_effort})
|
||||
content, classifier_cost = await self._call_classifier_model(
|
||||
messages_for_call, request_kwargs, encrypted_task=encrypted_task
|
||||
)
|
||||
raw_tier: Final = _LabeledTierClassification.model_validate_json(content).tier
|
||||
tier: Final = self.config.resolve_classified_tier(raw_tier)
|
||||
if tier is None:
|
||||
raise ValueError(f"LLM classifier returned an unrecognized tier: {raw_tier!r}")
|
||||
return tier, classifier_cost
|
||||
|
||||
payload: Final = (
|
||||
async def _classify_with_capability_llm(
|
||||
self,
|
||||
prompt: str,
|
||||
request_kwargs: Mapping[str, object] | None,
|
||||
messages: Sequence[Mapping[str, object]] | None,
|
||||
) -> tuple[ComplexityTier, float | None, CapabilityClassifierForecast]:
|
||||
"""Call the packaged capability forecaster and apply its two-tier policy."""
|
||||
capability: Final = self.config.capability_classifier_config
|
||||
classifier_system_prompt: Final = self._classifier_system_prompt
|
||||
if capability is None or classifier_system_prompt is None:
|
||||
raise ValueError("capability classifier is not configured")
|
||||
|
||||
markers: Final = self._reminder_markers_for_request(request_kwargs or EMPTY_MAPPING)
|
||||
encrypted_task: Final = _encrypted_classifier_task(request_kwargs, markers)
|
||||
asks_newest_first: Final = (
|
||||
() if encrypted_task is not None else tuple(_iter_human_asks_newest_first(messages or (), markers))
|
||||
)
|
||||
opening_task: Final = (
|
||||
"The delegated task in the following agent_message."
|
||||
if encrypted_task is not None
|
||||
else asks_newest_first[-1]
|
||||
if asks_newest_first
|
||||
else prompt
|
||||
)
|
||||
latest_follow_up: Final = asks_newest_first[0] if len(asks_newest_first) > 1 else None
|
||||
task_messages: list[AllMessageValues] = [ # mutable-ok: the latest message gains optional image parts below
|
||||
{"role": "user", "content": opening_task}, # mutable-ok: SDK messages are dict-shaped
|
||||
]
|
||||
if latest_follow_up is not None:
|
||||
task_messages.append( # mutable-ok: the provider SDK requires a concrete message list
|
||||
{"role": "user", "content": latest_follow_up} # mutable-ok: SDK messages are dict-shaped
|
||||
)
|
||||
|
||||
image_parts: Final = self._classifier_image_parts(messages)
|
||||
if image_parts:
|
||||
latest_text: Final = latest_follow_up or opening_task
|
||||
task_messages[-1] = { # mutable-ok: SDK messages are dict-shaped
|
||||
"role": "user",
|
||||
"content": [ # mutable-ok: multimodal SDK content is a JSON array
|
||||
{"type": "text", "text": latest_text}, # mutable-ok: SDK content parts are dict-shaped
|
||||
*image_parts,
|
||||
],
|
||||
}
|
||||
messages_for_call: Final[list[AllMessageValues]] = [ # mutable-ok: provider SDK requires a concrete list
|
||||
{"role": "system", "content": classifier_system_prompt}, # mutable-ok: SDK messages are dict-shaped
|
||||
*task_messages,
|
||||
]
|
||||
content, classifier_cost = await self._call_classifier_model(
|
||||
messages_for_call,
|
||||
request_kwargs,
|
||||
max_output_tokens=capability.max_output_tokens,
|
||||
encrypted_task=encrypted_task,
|
||||
)
|
||||
verdict: Final = parse_capability_classifier_verdict(content)
|
||||
threshold: Final = verdict.routing_threshold(capability.base_threshold, capability.threshold_step)
|
||||
calibration: Final = capability.calibration
|
||||
forecast: Final = CapabilityClassifierForecast(
|
||||
verdict=verdict,
|
||||
threshold=threshold,
|
||||
p_solve=calibration.calibrate(verdict.p_solve) if calibration is not None else verdict.p_solve,
|
||||
calibration_version=calibration.version if calibration is not None else None,
|
||||
)
|
||||
selected_tier: Final = (
|
||||
capability.efficient_tier if forecast.meets_routing_threshold() else capability.capable_tier
|
||||
)
|
||||
return ComplexityTier(selected_tier), classifier_cost, forecast
|
||||
|
||||
async def _call_classifier_model(
|
||||
self,
|
||||
messages_for_call: list[AllMessageValues], # mutable-ok: provider SDK requires a concrete message list
|
||||
request_kwargs: Mapping[str, object] | None,
|
||||
max_output_tokens: int | None = None,
|
||||
encrypted_task: Mapping[str, object] | None = None,
|
||||
) -> tuple[str, float | None]:
|
||||
"""Execute one structured classifier call with the router's shared safeguards."""
|
||||
llm_config: Final = self.config.classifier_llm_config
|
||||
response_format: Final = self._classifier_response_format
|
||||
if llm_config is None or response_format is None:
|
||||
raise ValueError("classifier_llm_config is not set")
|
||||
|
||||
request_values: Final = request_kwargs or EMPTY_MAPPING
|
||||
request_metadata = request_values.get("litellm_metadata") or request_values.get("metadata")
|
||||
metadata: Final = { # mutable-ok: SDK metadata kwarg is enriched by the request pipeline
|
||||
**forwarded_internal_call_metadata(request_metadata, AUTOROUTER_CLASSIFIER_CALL_ORIGIN),
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
|
||||
}
|
||||
classifier_call_params: Final = (
|
||||
MappingProxyType({"reasoning_effort": llm_config.reasoning_effort})
|
||||
if llm_config.reasoning_effort is not None
|
||||
else EMPTY_MAPPING
|
||||
)
|
||||
classifier_payload: Final = (
|
||||
self._native_classifier_payload(messages_for_call, response_format, encrypted_task)
|
||||
if encrypted_task is not None
|
||||
else MappingProxyType(
|
||||
{"messages": messages_for_call, "response_format": response_format, **classifier_call_params}
|
||||
)
|
||||
)
|
||||
payload: Final = MappingProxyType(
|
||||
{
|
||||
**classifier_payload,
|
||||
**(
|
||||
MappingProxyType(
|
||||
{"max_output_tokens" if encrypted_task is not None else "max_tokens": max_output_tokens}
|
||||
)
|
||||
if max_output_tokens is not None
|
||||
else EMPTY_MAPPING
|
||||
),
|
||||
}
|
||||
)
|
||||
proxy_server_request: Final = {
|
||||
"originating_request_masked": masked_originating_request(request_kwargs),
|
||||
"body": {"model": llm_config.model, **payload},
|
||||
|
|
@ -2118,7 +2328,7 @@ class ComplexityRouter(CustomLogger):
|
|||
disable_fallbacks=True,
|
||||
metadata=metadata,
|
||||
proxy_server_request=proxy_server_request,
|
||||
turn_off_message_logging=turn_off_message_logging,
|
||||
turn_off_message_logging=_effective_turn_off_message_logging(request_kwargs),
|
||||
**payload,
|
||||
**_parent_session_kwargs(request_kwargs),
|
||||
),
|
||||
|
|
@ -2129,11 +2339,7 @@ class ComplexityRouter(CustomLogger):
|
|||
)
|
||||
if not content:
|
||||
raise ValueError("LLM classifier returned empty content")
|
||||
raw_tier: Final = _LabeledTierClassification.model_validate_json(content).tier
|
||||
tier: Final = self.config.resolve_classified_tier(raw_tier)
|
||||
if tier is None:
|
||||
raise ValueError(f"LLM classifier returned an unrecognized tier: {raw_tier!r}")
|
||||
return tier, _response_cost_or_none(response)
|
||||
return content, _response_cost_or_none(response)
|
||||
|
||||
def _native_classifier_payload(
|
||||
self,
|
||||
|
|
@ -4088,7 +4294,12 @@ class ComplexityRouter(CustomLogger):
|
|||
housekeeping_ceiling: Final = tier if outcome.cause == "housekeeping" else None
|
||||
# A context-escalated tier becomes the hard floor: a floor the bandit can slide
|
||||
# under is not a floor.
|
||||
adaptive_floor: Final = tier if context_original_tier is not None else plan_floor
|
||||
adaptive_floor: Final = (
|
||||
tier
|
||||
if context_original_tier is not None
|
||||
or outcome.cause in ("capability_classifier", "capability_classifier_fallback")
|
||||
else plan_floor
|
||||
)
|
||||
adaptive_fit: Final = context_placement.holdable_models if context_placement is not None else None
|
||||
sampled_model: Final = self._soft_floor_pick(
|
||||
tier,
|
||||
|
|
@ -4138,7 +4349,8 @@ class ComplexityRouter(CustomLogger):
|
|||
tier_litellm_params: Final = self._litellm_params_for_model(tier, routed_model)
|
||||
classifier_model: Final = (
|
||||
self.config.classifier_llm_config.model
|
||||
if outcome.cause == "llm_classifier" and self.config.classifier_llm_config is not None
|
||||
if outcome.cause in ("llm_classifier", "capability_classifier")
|
||||
and self.config.classifier_llm_config is not None
|
||||
else None
|
||||
)
|
||||
# cause=default_model_fallback means no tier was decided: the classifier failed and the
|
||||
|
|
@ -4161,23 +4373,24 @@ class ComplexityRouter(CustomLogger):
|
|||
decision_keyword: Final = (
|
||||
plan_mode_sentinel if plan_floored else (housekeeping_sentinel if outcome.cause == "housekeeping" else None)
|
||||
)
|
||||
routing_decision: Final = self._build_routing_decision(
|
||||
routed_model=routed_model,
|
||||
conversation_continuing=conversation_continuing,
|
||||
cause=decision_cause,
|
||||
tier=classified_pool_tier,
|
||||
score=score,
|
||||
signals=decision_signals,
|
||||
matched_keyword=decision_keyword,
|
||||
escalation_keyword=escalation_keyword,
|
||||
escalated=escalated,
|
||||
classifier_model=classifier_model,
|
||||
classifier_cost=outcome.classifier_cost,
|
||||
tier_litellm_params=tier_litellm_params,
|
||||
context_escalation_original_tier=context_original_tier,
|
||||
)
|
||||
return PreRoutingHookResponse(
|
||||
model=routed_model,
|
||||
messages=messages if has_original_messages else None,
|
||||
litellm_params=tier_litellm_params,
|
||||
routing_decision=self._build_routing_decision(
|
||||
routed_model=routed_model,
|
||||
conversation_continuing=conversation_continuing,
|
||||
cause=decision_cause,
|
||||
tier=classified_pool_tier,
|
||||
score=score,
|
||||
signals=decision_signals,
|
||||
matched_keyword=decision_keyword,
|
||||
escalation_keyword=escalation_keyword,
|
||||
escalated=escalated,
|
||||
classifier_model=classifier_model,
|
||||
classifier_cost=outcome.classifier_cost,
|
||||
tier_litellm_params=tier_litellm_params,
|
||||
context_escalation_original_tier=context_original_tier,
|
||||
),
|
||||
routing_decision=_with_capability_forecast(routing_decision, outcome),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,16 @@ from enum import Enum
|
|||
from types import MappingProxyType
|
||||
from typing import Annotated, Final, Literal, NamedTuple
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, SkipValidation, field_serializer, field_validator, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
SkipValidation,
|
||||
StrictFloat,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
|
@ -53,7 +62,7 @@ DEFAULT_CLASSIFICATION_RUBRIC: Final[ClassificationRubric] = ClassificationRubri
|
|||
# The classifier_type values that can call classifier_llm_config.model. Every consumer asking
|
||||
# "is the classifier model a real dependency of this router" resolves it here, including the ones
|
||||
# that only hold the raw config mapping and cannot reach ComplexityRouterConfig.uses_llm_classifier.
|
||||
LLM_CLASSIFIER_TYPES: Final[frozenset[str]] = frozenset({"llm", "heuristic_first", "hybrid"})
|
||||
LLM_CLASSIFIER_TYPES: Final[frozenset[str]] = frozenset({"llm", "capability", "heuristic_first", "hybrid"})
|
||||
|
||||
|
||||
TIER_SEVERITY_ORDER: Final[tuple[ComplexityTier, ...]] = (
|
||||
|
|
@ -591,6 +600,78 @@ class ClassifierLLMConfig(BaseModel):
|
|||
return self
|
||||
|
||||
|
||||
class CapabilityCalibrationConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
version: str = Field(min_length=1, max_length=128, pattern=r"^\S(?:.*\S)?$")
|
||||
slope: StrictFloat = Field(ge=0.0, le=20.0, allow_inf_nan=False)
|
||||
intercept: StrictFloat = Field(ge=-20.0, le=20.0, allow_inf_nan=False)
|
||||
|
||||
def calibrate(self, p_solve: float) -> float:
|
||||
clipped: Final = min(max(p_solve, 1e-6), 1.0 - 1e-6)
|
||||
log_odds: Final = self.slope * (math.log(clipped) - math.log1p(-clipped)) + self.intercept
|
||||
return 1.0 / (1.0 + math.exp(-log_odds))
|
||||
|
||||
|
||||
class CapabilityClassifierConfig(BaseModel):
|
||||
"""Switchyard-compatible probability threshold policy for two model tiers."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
efficient_tier: str = Field(
|
||||
description="Tier used when the efficient model's forecasted solve probability meets the adjusted threshold",
|
||||
)
|
||||
capable_tier: str = Field(
|
||||
description=(
|
||||
"Higher, fail-closed tier used below the adjusted threshold or when the classifier verdict is unavailable"
|
||||
),
|
||||
)
|
||||
base_threshold: StrictFloat = Field(
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Lowest p_solve that routes a supported task to efficient_tier",
|
||||
)
|
||||
threshold_step: StrictFloat = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description=("Amount added once for uncertain or unmatched verdicts and twice for unsupported verdicts"),
|
||||
)
|
||||
max_output_tokens: int = Field(
|
||||
default=4096,
|
||||
ge=1,
|
||||
description="Maximum completion tokens available to the capability classifier verdict",
|
||||
)
|
||||
calibration: CapabilityCalibrationConfig | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional versioned sigmoid calibration fitted for this judge, capability card, efficient model, "
|
||||
"and execution setup. Applies sigmoid(slope * logit(clip(p_solve, 1e-6, 1-1e-6)) + intercept) "
|
||||
"before the threshold policy. Omit to route on the raw forecast."
|
||||
),
|
||||
)
|
||||
response_format: Literal["json_schema", "json_object"] = Field(
|
||||
default="json_schema",
|
||||
description=(
|
||||
"Use json_object for judges without strict JSON Schema support. This appends the verdict schema "
|
||||
"to the packaged system prompt; both modes validate the returned verdict identically."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("efficient_tier", "capable_tier")
|
||||
@classmethod
|
||||
def _normalize_tier(cls, value: str) -> str:
|
||||
normalized: Final = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("tier must be non-empty")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_threshold_range(self) -> "CapabilityClassifierConfig":
|
||||
if self.base_threshold + 2 * self.threshold_step > 1.0:
|
||||
raise ValueError("base_threshold + 2 * threshold_step must be at most 1")
|
||||
return self
|
||||
|
||||
|
||||
MAX_CUSTOM_PATTERN_REPEAT: Final[int] = 64
|
||||
MAX_CUSTOM_PATTERN_WORK: Final[int] = 2048
|
||||
MAX_CUSTOM_DIMENSIONS_WORK: Final[int] = 8192
|
||||
|
|
@ -882,13 +963,16 @@ class ComplexityRouterConfig(BaseModel):
|
|||
)
|
||||
|
||||
# Classifier strategy
|
||||
classifier_type: Literal["heuristic", "heuristic_v2", "llm", "custom", "heuristic_first", "hybrid"] = Field(
|
||||
classifier_type: Literal[
|
||||
"heuristic", "heuristic_v2", "llm", "capability", "custom", "heuristic_first", "hybrid"
|
||||
] = Field(
|
||||
default="heuristic",
|
||||
description=(
|
||||
"Classification strategy: local regex/keyword scoring, the bundled trained four-tier heuristic, "
|
||||
"an LLM call, a custom classifier plugin, 'heuristic_first', which scores locally and only pays "
|
||||
"for the LLM classifier when the local scorer does not confidently land a cheap tier, or 'hybrid', "
|
||||
"which trusts the local scorer everywhere except when its score lands near a tier boundary"
|
||||
"an LLM tier-selection call, a Switchyard-compatible capability forecast, a custom classifier "
|
||||
"plugin, 'heuristic_first', which scores locally and only pays for the LLM classifier when the "
|
||||
"local scorer does not confidently land a cheap tier, or 'hybrid', which trusts the local scorer "
|
||||
"everywhere except when its score lands near a tier boundary"
|
||||
),
|
||||
)
|
||||
heuristic_v2_artifact: TrainedTierArtifact | Literal["ultrafeedback"] = Field(
|
||||
|
|
@ -902,7 +986,15 @@ class ComplexityRouterConfig(BaseModel):
|
|||
default=None,
|
||||
description=(
|
||||
"Configuration for the LLM classifier; required when classifier_type is 'llm', "
|
||||
"'heuristic_first' or 'hybrid'"
|
||||
"'capability', 'heuristic_first' or 'hybrid'"
|
||||
),
|
||||
)
|
||||
capability_classifier_config: CapabilityClassifierConfig | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Probability threshold policy required when classifier_type is 'capability'. The classifier "
|
||||
"forecasts p_solve for efficient_tier, adjusts base_threshold using the capability-card boundary, "
|
||||
"and otherwise routes to capable_tier"
|
||||
),
|
||||
)
|
||||
heuristic_first_max_tier: str | None = Field(
|
||||
|
|
@ -1427,6 +1519,66 @@ class ComplexityRouterConfig(BaseModel):
|
|||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_capability_classifier_config(self) -> "ComplexityRouterConfig":
|
||||
capability: Final = self.capability_classifier_config
|
||||
if self.classifier_type != "capability":
|
||||
if capability is not None:
|
||||
raise ValueError(
|
||||
"capability_classifier_config requires classifier_type 'capability'; otherwise it has no effect"
|
||||
)
|
||||
return self
|
||||
if capability is None:
|
||||
raise ValueError("capability_classifier_config is required when classifier_type is 'capability'")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_capability_classifier_tiers(self) -> "ComplexityRouterConfig":
|
||||
capability: Final = self.capability_classifier_config
|
||||
if self.classifier_type != "capability" or capability is None:
|
||||
return self
|
||||
if self.tier_definitions is not None:
|
||||
raise ValueError(
|
||||
"classifier_type 'capability' uses the built-in tier map and cannot be combined with tier_definitions"
|
||||
)
|
||||
for field, tier in (
|
||||
("efficient_tier", capability.efficient_tier),
|
||||
("capable_tier", capability.capable_tier),
|
||||
):
|
||||
if tier not in self.tier_names():
|
||||
raise ValueError(
|
||||
f"{field} {tier!r} is not an active tier: it must name one of {', '.join(self.tier_names())}"
|
||||
)
|
||||
if not self.tiers.get(tier):
|
||||
raise ValueError(f"{field} {tier!r} has no model configured in tiers")
|
||||
names: Final = self.tier_names()
|
||||
if names.index(capability.capable_tier) <= names.index(capability.efficient_tier):
|
||||
raise ValueError("capable_tier must be a higher tier than efficient_tier")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_capability_classifier_prompt_policy(self) -> "ComplexityRouterConfig":
|
||||
if self.classifier_type != "capability":
|
||||
return self
|
||||
llm_config: Final = self.classifier_llm_config
|
||||
if llm_config is not None and (
|
||||
llm_config.system_prompt is not None or llm_config.classification_rubric is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"classifier_type 'capability' uses the packaged capability card; classifier_llm_config.system_prompt "
|
||||
"and classification_rubric are not supported"
|
||||
)
|
||||
if self.classification_prompt is not None or self.classification_examples is not None:
|
||||
raise ValueError(
|
||||
"classifier_type 'capability' uses the packaged capability card; classification_prompt and "
|
||||
"classification_examples are not supported"
|
||||
)
|
||||
if self.classifier_fallback != "heuristic":
|
||||
raise ValueError(
|
||||
"classifier_type 'capability' always fails closed to capable_tier; classifier_fallback cannot override it"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_custom_dimensions(self) -> "ComplexityRouterConfig":
|
||||
if not self.custom_dimensions:
|
||||
|
|
@ -1690,7 +1842,7 @@ class ComplexityRouterConfig(BaseModel):
|
|||
)
|
||||
if duplicated:
|
||||
raise ValueError(f"tier_definitions names must be unique (case-insensitive): {', '.join(duplicated)}")
|
||||
if self.classifier_type in ("heuristic", "heuristic_v2", "heuristic_first", "hybrid"):
|
||||
if self.classifier_type in ("heuristic", "heuristic_v2", "capability", "heuristic_first", "hybrid"):
|
||||
raise ValueError(
|
||||
"tier_definitions requires classifier_type 'llm' or 'custom': the heuristic scorer only "
|
||||
"produces the built-in tiers from SIMPLE up, as does heuristic_v2"
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ def strategy_router_dependencies(
|
|||
"""The model names a strategy-router deployment must reach, in no particular order.
|
||||
|
||||
A field is a dependency only under the condition the runtime itself reads it: the
|
||||
classifier model needs `classifier_type: llm`, and the complexity embedding model needs
|
||||
classifier model needs an LLM-backed classifier type, and the complexity embedding model needs
|
||||
`semantic_keyword_matching`. Listing one the router never calls reds a working deployment.
|
||||
|
||||
The two default-model spellings are not symmetric. A quality router falls back to its
|
||||
|
|
|
|||
|
|
@ -2891,6 +2891,7 @@ RoutingDecisionCause = Literal[
|
|||
# meant anything that filtered `signals` silently changed what the row claimed.
|
||||
"reasoning_override",
|
||||
"llm_classifier",
|
||||
"capability_classifier",
|
||||
# classifier_type 'heuristic_first': the local scorer produced at least one signal and landed at
|
||||
# or below heuristic_first_max_tier, so it decided the tier and the LLM classifier was never
|
||||
# called. Distinct from "heuristic_scorer", which is a router whose only classifier IS the
|
||||
|
|
@ -2903,6 +2904,9 @@ RoutingDecisionCause = Literal[
|
|||
# The LLM classifier or classifier plugin failed on a router with an operator-defined
|
||||
# tier set, so the request routed to the configured fallback_tier without being classified.
|
||||
"classifier_fallback",
|
||||
# The capability judge failed or returned an invalid verdict, so its fail-closed policy
|
||||
# routed to capable_tier without consulting the unrelated complexity heuristic.
|
||||
"capability_classifier_fallback",
|
||||
# The LLM classifier or classifier plugin failed and classifier_fallback is
|
||||
# 'default_model', so the request went to default_model without being classified.
|
||||
# Distinct from "default_fallback",
|
||||
|
|
@ -2978,6 +2982,13 @@ class StandardLoggingRoutingDecision(TypedDict, total=False):
|
|||
escalation_keyword: str
|
||||
classifier_model: str
|
||||
classifier_cost: float
|
||||
classifier_crux: str # writable-ok: added only when a capability verdict is available
|
||||
classifier_primary_rule: str # writable-ok: added only when a capability verdict is available
|
||||
classifier_capability_boundary: str # writable-ok: added only when a capability verdict is available
|
||||
classifier_p_solve: float # writable-ok: added only when a capability verdict is available
|
||||
classifier_calibrated_p_solve: ReadOnly[float]
|
||||
classifier_calibration_version: ReadOnly[str]
|
||||
classifier_threshold: float # writable-ok: added only when a capability verdict is available
|
||||
escalated: bool
|
||||
context_escalated: bool # writable-ok: Pydantic warns on ReadOnly TypedDict fields
|
||||
context_escalation_original_tier: str # writable-ok: Pydantic warns on ReadOnly TypedDict fields
|
||||
|
|
@ -2993,7 +3004,9 @@ class StandardLoggingRoutingDecision(TypedDict, total=False):
|
|||
# logging off. Every other field aggregates the prompt without reproducing it and is kept,
|
||||
# so a redacted row stays explainable. `test_every_routing_decision_field_is_classified`
|
||||
# fails if a field is added to the record without being placed in one set or the other.
|
||||
PROMPT_QUOTING_ROUTING_DECISION_FIELDS: frozenset[str] = frozenset({"signals", "matched_keyword", "escalation_keyword"})
|
||||
PROMPT_QUOTING_ROUTING_DECISION_FIELDS: frozenset[str] = frozenset(
|
||||
{"signals", "matched_keyword", "escalation_keyword", "classifier_crux"}
|
||||
)
|
||||
DERIVED_ROUTING_DECISION_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"router_model_name",
|
||||
|
|
@ -3006,6 +3019,12 @@ DERIVED_ROUTING_DECISION_FIELDS: Final[frozenset[str]] = frozenset(
|
|||
"score",
|
||||
"classifier_model",
|
||||
"classifier_cost",
|
||||
"classifier_primary_rule",
|
||||
"classifier_capability_boundary",
|
||||
"classifier_p_solve",
|
||||
"classifier_calibrated_p_solve",
|
||||
"classifier_calibration_version",
|
||||
"classifier_threshold",
|
||||
"escalated",
|
||||
"context_escalated",
|
||||
"context_escalation_original_tier",
|
||||
|
|
|
|||
|
|
@ -2202,15 +2202,20 @@ def _is_streaming_request(
|
|||
|
||||
def _select_tokenizer(model: str, custom_tokenizer: CustomHuggingfaceTokenizer | None = None):
|
||||
if custom_tokenizer is not None:
|
||||
_tokenizer: Final = create_pretrained_tokenizer(
|
||||
return _select_custom_tokenizer_helper(
|
||||
identifier=custom_tokenizer["identifier"],
|
||||
revision=custom_tokenizer["revision"],
|
||||
auth_token=custom_tokenizer["auth_token"],
|
||||
)
|
||||
return _tokenizer
|
||||
return _select_tokenizer_helper(model=model)
|
||||
|
||||
|
||||
@lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)
|
||||
def _select_custom_tokenizer_helper(identifier: str, revision: str, auth_token: str | None) -> SelectTokenizerResponse:
|
||||
verbose_logger.debug("Loading custom HuggingFace tokenizer %s (revision %s)", identifier, revision)
|
||||
return create_pretrained_tokenizer(identifier=identifier, revision=revision, auth_token=auth_token)
|
||||
|
||||
|
||||
@lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE)
|
||||
def _select_tokenizer_helper(model: str) -> SelectTokenizerResponse:
|
||||
if litellm.disable_hf_tokenizer_download is True:
|
||||
|
|
|
|||
|
|
@ -56,11 +56,87 @@ def test_no_user_or_assistant_rows():
|
|||
assert get_protected_indices([]) == ()
|
||||
|
||||
|
||||
def test_rows_before_last_cache_control_breakpoint_are_protected():
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "old question"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "old answer",
|
||||
"tool_calls": [{"id": "t1", "type": "function", "function": {"name": "Read", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "t1", "content": "large file body"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "cached turn", "cache_control": {"type": "ephemeral"}}],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "ack",
|
||||
"tool_calls": [{"id": "t2", "type": "function", "function": {"name": "Bash", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "t2", "content": "later tool output"},
|
||||
{"role": "user", "content": "live instruction"},
|
||||
]
|
||||
|
||||
protected = sorted(get_protected_indices(messages))
|
||||
|
||||
assert protected == [0, 1, 2, 3, 4, 5, 7]
|
||||
assert 6 not in protected
|
||||
|
||||
|
||||
def test_cache_control_directly_on_message_protects_prefix():
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "tool", "tool_call_id": "before", "content": "large file body"},
|
||||
{"role": "user", "content": "old question"},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "marked",
|
||||
"content": "cached tool",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "after", "content": "later tool output"},
|
||||
{"role": "assistant", "content": "ack"},
|
||||
{"role": "user", "content": "live instruction"},
|
||||
]
|
||||
|
||||
protected = sorted(get_protected_indices(messages))
|
||||
|
||||
assert 1 in protected
|
||||
assert 3 in protected
|
||||
assert 4 not in protected
|
||||
|
||||
|
||||
def test_no_cache_control_leaves_history_compressible():
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "tool", "tool_call_id": "t1", "content": "large file body"},
|
||||
{"role": "user", "content": "live instruction"},
|
||||
]
|
||||
|
||||
assert sorted(get_protected_indices(messages)) == [0, 2, 4]
|
||||
|
||||
|
||||
def test_non_mapping_content_parts_are_not_cache_control():
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": ["not", "a", "dict"]},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "tool", "tool_call_id": "t1", "content": "plain string"},
|
||||
{"role": "user", "content": "live instruction"},
|
||||
]
|
||||
|
||||
protected = sorted(get_protected_indices(messages))
|
||||
|
||||
assert protected == [0, 2, 4]
|
||||
assert 1 not in protected
|
||||
assert 3 not in protected
|
||||
|
||||
|
||||
def test_mid_history_cache_control_part_is_protected():
|
||||
# A large cached tool result from a few turns back, not the last user or
|
||||
# last assistant row -- exactly the row a provider prompt-cache pins to
|
||||
# exact bytes. Rewriting it (even leaving the marker on) changes those
|
||||
# bytes and turns the next request's cache read into a cache write.
|
||||
messages = [
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
|
|
@ -74,9 +150,7 @@ def test_mid_history_cache_control_part_is_protected():
|
|||
{"role": "user", "content": "live instruction"},
|
||||
]
|
||||
|
||||
# index 3 = last assistant, index 4 = last user (both protected by role
|
||||
# regardless), index 2 = the cache_control-marked row itself.
|
||||
assert sorted(get_protected_indices(messages)) == [2, 3, 4]
|
||||
assert sorted(get_protected_indices(messages)) == [0, 1, 2, 3, 4]
|
||||
|
||||
|
||||
def test_cache_control_directly_on_message_is_protected():
|
||||
|
|
@ -116,8 +190,6 @@ def test_content_that_is_not_a_list_of_mappings_is_not_treated_as_cache_control(
|
|||
|
||||
|
||||
def test_compress_keeps_part_level_cache_control_row_verbatim():
|
||||
# compress() scores text-only copies of the rows, where a part-level marker
|
||||
# is gone; protection has to read the original rows or the pinned row is stubbed.
|
||||
stale_log = {"role": "user", "content": [{"type": "text", "text": "stale log line " * 2000}]}
|
||||
pinned = {
|
||||
"role": "user",
|
||||
|
|
@ -126,9 +198,9 @@ def test_compress_keeps_part_level_cache_control_row_verbatim():
|
|||
],
|
||||
}
|
||||
messages = [
|
||||
stale_log,
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
pinned,
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
stale_log,
|
||||
{"role": "assistant", "content": "ack"},
|
||||
{"role": "user", "content": "live instruction"},
|
||||
]
|
||||
|
|
@ -142,6 +214,6 @@ def test_compress_keeps_part_level_cache_control_row_verbatim():
|
|||
)
|
||||
|
||||
assert len(result["messages"]) == len(messages)
|
||||
assert result["messages"][2] == pinned
|
||||
assert result["messages"][0] != stale_log
|
||||
assert result["messages"][0] == pinned
|
||||
assert result["messages"][2] != stale_log
|
||||
assert len(result["cache"]) >= 1
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
"""
|
||||
Unit tests for Prometheus invalid API key request filtering.
|
||||
|
||||
Tests functionality that prevents invalid API key requests (401 status codes)
|
||||
from being recorded in Prometheus metrics.
|
||||
Tests the 401 detection helpers, that LLM-level metrics skip invalid API key
|
||||
requests, and that the proxy-level failed request counter still records them.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
|
|
@ -129,28 +129,29 @@ class TestSkipMetricsValidation:
|
|||
|
||||
|
||||
class TestAsyncHooks:
|
||||
"""Test async hook methods skip metrics for invalid API keys."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_api_key(self):
|
||||
"""Create a mock UserAPIKeyAuth object."""
|
||||
user_key = Mock(spec=UserAPIKeyAuth)
|
||||
user_key.api_key = "test-key"
|
||||
user_key.end_user_id = None
|
||||
user_key.user_id = None
|
||||
user_key.user_email = None
|
||||
user_key.key_alias = None
|
||||
user_key.team_id = None
|
||||
user_key.team_alias = None
|
||||
user_key.request_route = "/test"
|
||||
return user_key
|
||||
"""Test how async hook methods treat invalid API key requests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_call_failure_hook_skips_401(
|
||||
self, prometheus_logger, mock_user_api_key
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
HTTPException(
|
||||
status_code=401,
|
||||
detail="LiteLLM Virtual Key expected. Received=nota****tall, expected to start with 'sk-'.",
|
||||
),
|
||||
ProxyException(
|
||||
message="Authentication Error, Invalid proxy server token passed.",
|
||||
type=ProxyErrorTypes.token_not_found_in_db,
|
||||
param="key",
|
||||
code=401,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_post_call_failure_hook_counts_401_without_key_hash(
|
||||
self, prometheus_logger, exception
|
||||
):
|
||||
exception = ExceptionWithCode("401")
|
||||
exception.__class__.__name__ = "ProxyException"
|
||||
unauthenticated = UserAPIKeyAuth(request_route="/v1/chat/completions")
|
||||
unauthenticated.api_key = "notakeyatall"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
|
|
@ -160,15 +161,50 @@ class TestAsyncHooks:
|
|||
prometheus_logger, "litellm_proxy_total_requests_metric"
|
||||
) as mock_total,
|
||||
):
|
||||
|
||||
await prometheus_logger.async_post_call_failure_hook(
|
||||
request_data={"model": "test-model"},
|
||||
original_exception=exception,
|
||||
user_api_key_dict=mock_user_api_key,
|
||||
user_api_key_dict=unauthenticated,
|
||||
)
|
||||
|
||||
mock_failed.labels.assert_not_called()
|
||||
mock_total.labels.assert_not_called()
|
||||
failed_labels = mock_failed.labels.call_args.kwargs
|
||||
assert failed_labels["exception_status"] == "401"
|
||||
assert failed_labels["hashed_api_key"] is None
|
||||
assert failed_labels["route"] == "/v1/chat/completions"
|
||||
mock_failed.labels.return_value.inc.assert_called_once()
|
||||
assert mock_total.labels.call_args.kwargs["status_code"] == "401"
|
||||
mock_total.labels.return_value.inc.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_call_failure_hook_keeps_resolved_identity_labels_for_401(
|
||||
self, prometheus_logger
|
||||
):
|
||||
expired_key = UserAPIKeyAuth(
|
||||
api_key="sk-expired",
|
||||
key_alias="expired-alias",
|
||||
team_id="team-1",
|
||||
)
|
||||
exception = ProxyException(
|
||||
message="Authentication Error - Expired Key.",
|
||||
type=ProxyErrorTypes.expired_key,
|
||||
param="key",
|
||||
code=401,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
prometheus_logger, "litellm_proxy_failed_requests_metric"
|
||||
) as mock_failed:
|
||||
await prometheus_logger.async_post_call_failure_hook(
|
||||
request_data={"model": "test-model"},
|
||||
original_exception=exception,
|
||||
user_api_key_dict=expired_key,
|
||||
)
|
||||
|
||||
failed_labels = mock_failed.labels.call_args.kwargs
|
||||
assert failed_labels["exception_status"] == "401"
|
||||
assert failed_labels["hashed_api_key"] is None
|
||||
assert failed_labels["api_key_alias"] == "expired-alias"
|
||||
assert failed_labels["team"] == "team-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_failure_event_skips_401(self, prometheus_logger):
|
||||
|
|
|
|||
|
|
@ -2321,36 +2321,6 @@ def test_gpt55_dated_variants_match_base_reasoning_effort_capabilities(_local_mo
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected_mode,expected_input,expected_output,expected_cache_read",
|
||||
[
|
||||
("azure/gpt-5.5", "chat", 5e-6, 3e-5, 5e-7),
|
||||
("azure/gpt-5.5-2026-04-23", "chat", 5e-6, 3e-5, 5e-7),
|
||||
("azure/gpt-5.5-pro", "responses", 3e-5, 1.8e-4, 3e-6),
|
||||
("azure/gpt-5.5-pro-2026-04-23", "responses", 3e-5, 1.8e-4, 3e-6),
|
||||
],
|
||||
)
|
||||
def test_azure_gpt55_entries_present_with_correct_pricing(_local_model_cost_map,
|
||||
model, expected_mode, expected_input, expected_output, expected_cache_read
|
||||
):
|
||||
"""Day-0 Azure entries for GPT-5.5 mirror the OpenAI pricing structure.
|
||||
|
||||
Pricing parity with openai/gpt-5.5* (verified against OpenAI's pricing page
|
||||
on 2026-04-24): $5/$30 input/output per 1M for chat, $30/$180 for pro.
|
||||
Cache discount is 10% of input.
|
||||
"""
|
||||
|
||||
m = litellm.model_cost[model]
|
||||
assert m["litellm_provider"] == "azure"
|
||||
assert m["mode"] == expected_mode
|
||||
assert m["input_cost_per_token"] == expected_input
|
||||
assert m["output_cost_per_token"] == expected_output
|
||||
assert m["cache_read_input_token_cost"] == expected_cache_read
|
||||
# Long-context window inherited from gpt-5.4 / openai gpt-5.5.
|
||||
assert m["max_input_tokens"] == 1050000
|
||||
assert m["max_output_tokens"] == 128000
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected_none,expected_minimal,expected_xhigh",
|
||||
[
|
||||
|
|
@ -3414,8 +3384,6 @@ def test_query_count_is_free_without_a_per_query_price(_local_model_cost_map):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["gpt-5.4", "gpt-realtime-2.1", "gpt-realtime-2.1-mini"])
|
||||
@pytest.mark.parametrize("data_residency", ["eu", "us"])
|
||||
def test_data_residency_applies_uplift(data_residency, model, _local_model_cost_map):
|
||||
|
|
@ -4556,20 +4524,6 @@ GEMINI_DAY0_LAUNCH_PRICING = [
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,input_cost,output_cost,cache_read_cost", GEMINI_DAY0_LAUNCH_PRICING)
|
||||
def test_gemini_36_flash_and_35_flash_lite_launch_pricing(_local_model_cost_map, model, input_cost, output_cost, cache_read_cost):
|
||||
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
assert model_cost_map["input_cost_per_token"] == input_cost
|
||||
assert model_cost_map["output_cost_per_token"] == output_cost
|
||||
assert model_cost_map["output_cost_per_reasoning_token"] == output_cost
|
||||
assert model_cost_map["cache_read_input_token_cost"] == cache_read_cost
|
||||
assert model_cost_map["mode"] == "chat"
|
||||
assert model_cost_map["supports_reasoning"] is True
|
||||
assert model_cost_map["supports_function_calling"] is True
|
||||
assert model_cost_map["max_input_tokens"] == 1048576
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gemini_36_flash(_local_model_cost_map):
|
||||
|
||||
usage = Usage(
|
||||
|
|
@ -4598,44 +4552,6 @@ GEMINI_36_FLASH_SERVICE_TIER_PRICING = [
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_tier,input_rate,output_rate,cache_read_rate", GEMINI_36_FLASH_SERVICE_TIER_PRICING
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"model", ["gemini-3.6-flash", "gemini/gemini-3.6-flash", "vertex_ai/gemini-3.6-flash"]
|
||||
)
|
||||
def test_gemini_36_flash_service_tier_introductory_pricing(
|
||||
model, service_tier, input_rate, output_rate, cache_read_rate, _local_model_cost_map
|
||||
):
|
||||
"""Regression: every 3.6 Flash tier is on Google's introductory rates through 2026-12-31,
|
||||
so flex and priority requests must not be billed at the post-introductory rates."""
|
||||
usage = Usage(
|
||||
prompt_tokens=1_000,
|
||||
completion_tokens=500,
|
||||
total_tokens=1_500,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=200, text_tokens=800),
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model=model.split("/")[-1],
|
||||
usage=usage,
|
||||
custom_llm_provider=model.split("/")[0] if "/" in model else "gemini",
|
||||
service_tier=service_tier,
|
||||
)
|
||||
|
||||
assert prompt_cost == pytest.approx(800 * input_rate + 200 * cache_read_rate, rel=1e-9)
|
||||
assert completion_cost == pytest.approx(500 * output_rate, rel=1e-9)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model", ["gemini-3.6-flash", "gemini/gemini-3.6-flash", "vertex_ai/gemini-3.6-flash"]
|
||||
)
|
||||
def test_gemini_36_flash_batch_introductory_pricing(model, _local_model_cost_map):
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
assert model_cost_map["input_cost_per_token_batches"] == 3.75e-07
|
||||
assert model_cost_map["output_cost_per_token_batches"] == 1.875e-06
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gemini_35_flash_lite(_local_model_cost_map):
|
||||
|
||||
usage = Usage(
|
||||
|
|
@ -4667,43 +4583,6 @@ GEMINI_35_FLASH_LITE_TIER_RATES_BY_SURFACE = [
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"custom_llm_provider,service_tier,input_rate,output_rate,cache_read_rate",
|
||||
GEMINI_35_FLASH_LITE_TIER_RATES_BY_SURFACE,
|
||||
)
|
||||
def test_gemini_35_flash_lite_service_tier_pricing(
|
||||
custom_llm_provider, service_tier, input_rate, output_rate, cache_read_rate, _local_model_cost_map
|
||||
):
|
||||
"""Regression: Vertex publishes flash-lite flex context caching at $0.015/M while the
|
||||
Gemini API publishes $0.02/M, so vertex_ai flex cache reads must bill 1.5e-08/token
|
||||
instead of the 2e-08 the map used to carry, without disturbing the Gemini API rate."""
|
||||
usage = Usage(
|
||||
prompt_tokens=1_000,
|
||||
completion_tokens=500,
|
||||
total_tokens=1_500,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=200, text_tokens=800),
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model="gemini-3.5-flash-lite",
|
||||
usage=usage,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
|
||||
assert prompt_cost == pytest.approx(800 * input_rate + 200 * cache_read_rate, rel=1e-9)
|
||||
assert completion_cost == pytest.approx(500 * output_rate, rel=1e-9)
|
||||
|
||||
|
||||
def test_gemini_35_flash_lite_flex_cache_read_map_entries(_local_model_cost_map):
|
||||
"""Each map entry carries its own surface's published flex cache-read rate: the bare
|
||||
and vertex_ai keys are the Vertex surface at $0.015/M, the gemini key is the Gemini
|
||||
API surface at $0.02/M."""
|
||||
assert litellm.model_cost["gemini-3.5-flash-lite"]["cache_read_input_token_cost_flex"] == 1.5e-08
|
||||
assert litellm.model_cost["vertex_ai/gemini-3.5-flash-lite"]["cache_read_input_token_cost_flex"] == 1.5e-08
|
||||
assert litellm.model_cost["gemini/gemini-3.5-flash-lite"]["cache_read_input_token_cost_flex"] == 2e-08
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_tier,input_rate,cache_read_rate,cache_write_rate,output_rate",
|
||||
[
|
||||
|
|
@ -4932,19 +4811,6 @@ GEMINI_37_FLASH_LAUNCH_PRICING = [
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,input_cost,output_cost,cache_read_cost", GEMINI_37_FLASH_LAUNCH_PRICING)
|
||||
def test_gemini_37_flash_launch_pricing(model, input_cost, output_cost, cache_read_cost, _local_model_cost_map):
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
assert model_cost_map["input_cost_per_token"] == input_cost
|
||||
assert model_cost_map["output_cost_per_token"] == output_cost
|
||||
assert model_cost_map["output_cost_per_reasoning_token"] == output_cost
|
||||
assert model_cost_map["cache_read_input_token_cost"] == cache_read_cost
|
||||
assert model_cost_map["mode"] == "chat"
|
||||
assert model_cost_map["supports_reasoning"] is True
|
||||
assert model_cost_map["supports_function_calling"] is True
|
||||
assert model_cost_map["max_input_tokens"] == 1048576
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gemini_37_flash(_local_model_cost_map):
|
||||
usage = Usage(
|
||||
prompt_tokens=1000,
|
||||
|
|
@ -4972,19 +4838,6 @@ GEMINI_38_FLASH_LAUNCH_PRICING = [
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,input_cost,output_cost,cache_read_cost", GEMINI_38_FLASH_LAUNCH_PRICING)
|
||||
def test_gemini_38_flash_launch_pricing(model, input_cost, output_cost, cache_read_cost, _local_model_cost_map):
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
assert model_cost_map["input_cost_per_token"] == input_cost
|
||||
assert model_cost_map["output_cost_per_token"] == output_cost
|
||||
assert model_cost_map["output_cost_per_reasoning_token"] == output_cost
|
||||
assert model_cost_map["cache_read_input_token_cost"] == cache_read_cost
|
||||
assert model_cost_map["mode"] == "chat"
|
||||
assert model_cost_map["supports_reasoning"] is True
|
||||
assert model_cost_map["supports_function_calling"] is True
|
||||
assert model_cost_map["max_input_tokens"] == 1048576
|
||||
|
||||
|
||||
GEMINI_38_FLASH_FIELDS_SHARED_WITH_37_FLASH = (
|
||||
"input_cost_per_token",
|
||||
"output_cost_per_token",
|
||||
|
|
@ -5045,20 +4898,6 @@ def test_generic_cost_per_token_gemini_38_flash(_local_model_cost_map):
|
|||
assert completion_cost == pytest.approx(0.001875)
|
||||
|
||||
|
||||
def test_grok_46_launch_pricing(_local_model_cost_map):
|
||||
model_cost_map = litellm.model_cost["xai/grok-4.6"]
|
||||
assert model_cost_map["input_cost_per_token"] == 2e-06
|
||||
assert model_cost_map["output_cost_per_token"] == 6e-06
|
||||
assert model_cost_map["cache_read_input_token_cost"] == 5e-07
|
||||
assert model_cost_map["input_cost_per_token_above_200k_tokens"] == 4e-06
|
||||
assert model_cost_map["output_cost_per_token_above_200k_tokens"] == 1.2e-05
|
||||
assert model_cost_map["cache_read_input_token_cost_above_200k_tokens"] == 1e-06
|
||||
assert model_cost_map["mode"] == "chat"
|
||||
assert model_cost_map["supports_reasoning"] is True
|
||||
assert model_cost_map["supports_function_calling"] is True
|
||||
assert model_cost_map["max_input_tokens"] == 500000
|
||||
|
||||
|
||||
def test_generic_cost_per_token_grok_46(_local_model_cost_map):
|
||||
usage = Usage(
|
||||
prompt_tokens=1_000,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -892,29 +890,6 @@ def test_gpt_4o_mini_snapshot_bills_web_search_like_its_alias(
|
|||
assert snapshot_cost == alias_cost == 0.025
|
||||
|
||||
|
||||
def test_gpt_4o_mini_web_search_price_matches_in_both_cost_maps():
|
||||
repo_root = Path(__file__).parents[4]
|
||||
cost_maps = tuple(
|
||||
json.loads((repo_root / path).read_text(encoding="utf-8"))
|
||||
for path in (
|
||||
"model_prices_and_context_window.json",
|
||||
"litellm/model_prices_and_context_window_backup.json",
|
||||
)
|
||||
)
|
||||
canonical, backup = cost_maps
|
||||
expected_search_price = {
|
||||
"search_context_size_low": 0.025,
|
||||
"search_context_size_medium": 0.025,
|
||||
"search_context_size_high": 0.025,
|
||||
}
|
||||
for model_name in ("gpt-4o-mini", "gpt-4o-mini-2024-07-18"):
|
||||
canonical_entry = canonical[model_name]
|
||||
backup_entry = backup[model_name]
|
||||
assert canonical_entry["search_context_cost_per_query"] == expected_search_price
|
||||
assert backup_entry["search_context_cost_per_query"] == expected_search_price
|
||||
assert canonical_entry == backup_entry
|
||||
|
||||
|
||||
# Note: File search integration test removed due to complex annotation detection logic
|
||||
# The unit tests in test_azure_assistant_cost_tracking.py provide comprehensive coverage
|
||||
|
||||
|
|
|
|||
|
|
@ -627,17 +627,6 @@ def test_shipped_adaptive_rule_requires_claude_prefix(shipped_cost_map):
|
|||
litellm.get_model_info(model)
|
||||
|
||||
|
||||
def test_shipped_exact_entry_beats_rules(shipped_cost_map):
|
||||
model = "us.anthropic.claude-sonnet-4-6"
|
||||
assert model in litellm.model_cost
|
||||
info = litellm.get_model_info(model, custom_llm_provider="bedrock")
|
||||
assert info["litellm_provider"] == "bedrock_converse"
|
||||
assert info["input_cost_per_token"] == 3.3e-06
|
||||
assert info["max_input_tokens"] == 1000000
|
||||
assert info["supports_adaptive_thinking"] is True
|
||||
assert info.get("supports_mid_conversation_system") is None
|
||||
|
||||
|
||||
def test_shipped_rules_lose_to_exact_entries_across_cost_ladder_variants(shipped_cost_map):
|
||||
"""A route-mangled variant of an exactly-mapped model must never resolve from
|
||||
rules. The cost calculator tries model-name variants in order; a rule-derived
|
||||
|
|
|
|||
|
|
@ -225,36 +225,6 @@ def test_shipped_backup_marks_claude_4_6_plus_adaptive_not_4_0():
|
|||
assert "supports_adaptive_thinking" not in backup[non_adaptive], non_adaptive
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cost_map",
|
||||
[_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()],
|
||||
ids=["root", "bundled_backup"],
|
||||
)
|
||||
def test_azure_ai_claude_1m_context_entries(cost_map: dict):
|
||||
"""Microsoft Foundry serves a 1M-token context window for Opus 4.6+ and Sonnet
|
||||
4.6+, so the ``azure_ai`` entries must not advertise the 200k cap that made
|
||||
context-aware clients compact prompts early (LIT-4406). Both the root map (used
|
||||
by default network loading) and the bundled fallback are checked so the two can
|
||||
never drift apart."""
|
||||
for model in [
|
||||
"azure_ai/claude-opus-4-6",
|
||||
"azure_ai/claude-opus-4-7",
|
||||
"azure_ai/claude-opus-4-8",
|
||||
"azure_ai/claude-opus-5",
|
||||
"azure_ai/claude-sonnet-5",
|
||||
"azure_ai/claude-sonnet-4-6",
|
||||
]:
|
||||
assert cost_map[model]["max_input_tokens"] == 1000000, model
|
||||
|
||||
for model in [
|
||||
"azure_ai/claude-opus-4-1",
|
||||
"azure_ai/claude-opus-4-5",
|
||||
"azure_ai/claude-sonnet-4-5",
|
||||
"azure_ai/claude-haiku-4-5",
|
||||
]:
|
||||
assert cost_map[model]["max_input_tokens"] == 200000, model
|
||||
|
||||
|
||||
# OpenRouter headline rates from GET https://openrouter.ai/api/v1/models.
|
||||
# These were the catalog values that disagreed with that API (and, for the
|
||||
# two spotlight models, the public model pages that their source fields cite).
|
||||
|
|
@ -278,34 +248,6 @@ _OPENROUTER_STALE_COSTS = {
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cost_map",
|
||||
[_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()],
|
||||
ids=["root", "bundled_backup"],
|
||||
)
|
||||
def test_openrouter_catalog_costs_match_live_headline_rates(cost_map: dict):
|
||||
"""openrouter/* spend tracking reads these catalog fields. The values must
|
||||
stay aligned with OpenRouter's published headline rate, not the stale
|
||||
figures that over/under-counted by up to 30x. Both maps are checked so
|
||||
the root file and bundled backup cannot drift apart."""
|
||||
control = cost_map["openrouter/anthropic/claude-opus-5"]
|
||||
assert control["input_cost_per_token"] == 5e-06
|
||||
assert control["output_cost_per_token"] == 2.5e-05
|
||||
assert control["cache_read_input_token_cost"] == 5e-07
|
||||
|
||||
for model, (inp, out, cache) in _OPENROUTER_LIVE_COSTS.items():
|
||||
entry = cost_map[model]
|
||||
assert entry["input_cost_per_token"] == inp, model
|
||||
assert entry["output_cost_per_token"] == out, model
|
||||
if cache is not None:
|
||||
assert entry["cache_read_input_token_cost"] == cache, model
|
||||
|
||||
for model, (stale_in, stale_out) in _OPENROUTER_STALE_COSTS.items():
|
||||
entry = cost_map[model]
|
||||
assert entry["input_cost_per_token"] != stale_in, model
|
||||
assert entry["output_cost_per_token"] != stale_out, model
|
||||
|
||||
|
||||
def test_get_model_cost_map_stamps_loaded_at():
|
||||
"""The load time feeds each pod's reload-due decision; a load that does not stamp it
|
||||
would make manual reload requests race the proxy's startup"""
|
||||
|
|
|
|||
|
|
@ -7155,3 +7155,21 @@ def test_get_additional_headers_survives_a_thread_growing_headers_mid_copy():
|
|||
assert copied["llm_provider-x-custom-1999"] == "1999"
|
||||
|
||||
_run_while_a_thread_grows(headers, read, reads=300)
|
||||
|
||||
|
||||
def test_add_dynamic_callback_registers_once_per_list_without_touching_the_callers_list(logging_obj: LitellmLogging):
|
||||
callback: Final = CustomLogger()
|
||||
caller_owned: Final = ["langfuse"]
|
||||
logging_obj.dynamic_success_callbacks = caller_owned
|
||||
|
||||
logging_obj.add_dynamic_callback(callback)
|
||||
logging_obj.add_dynamic_callback(callback)
|
||||
|
||||
assert caller_owned == ["langfuse"]
|
||||
assert logging_obj.dynamic_success_callbacks == ["langfuse", callback]
|
||||
assert logging_obj.dynamic_input_callbacks == [callback]
|
||||
assert logging_obj.dynamic_async_success_callbacks == [callback]
|
||||
assert logging_obj.dynamic_failure_callbacks == [callback]
|
||||
assert logging_obj.dynamic_async_failure_callbacks == [callback]
|
||||
assert LitellmLogging._with_dynamic_callback(None, callback) == [callback]
|
||||
assert LitellmLogging._with_dynamic_callback((callback,), callback) == [callback]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ REPO_ROOT: Final = Path(__file__).parents[4]
|
|||
MAIN_COST_MAP: Final = REPO_ROOT / "model_prices_and_context_window.json"
|
||||
BACKUP_COST_MAP: Final = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json"
|
||||
COST_MAP_ADAPTER: Final = TypeAdapter(dict[str, dict[str, object]])
|
||||
AZURE_PRICING_PREFIX: Final = "https://azure.microsoft.com/en-us/pricing/details/"
|
||||
A_MILLION: Final = 1_000_000
|
||||
AN_HOUR_IN_SECONDS: Final = 3600
|
||||
|
||||
|
|
@ -76,7 +75,9 @@ def test_azure_ai_catalog_name_prices_the_same_in_any_casing(catalog_name: str)
|
|||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
@pytest.mark.parametrize("catalog_name", GROK_4_20_NAMES)
|
||||
def test_azure_ai_grok_4_20_bills_cached_prompt_tokens_at_the_input_price(catalog_name: str) -> None:
|
||||
uncached_prompt_cost, _ = cost_per_token(model=f"azure_ai/{catalog_name}", prompt_tokens=A_MILLION, completion_tokens=0)
|
||||
uncached_prompt_cost, _ = cost_per_token(
|
||||
model=f"azure_ai/{catalog_name}", prompt_tokens=A_MILLION, completion_tokens=0
|
||||
)
|
||||
cached_prompt_cost, _ = cost_per_token(
|
||||
model=f"azure_ai/{catalog_name}",
|
||||
prompt_tokens=A_MILLION,
|
||||
|
|
@ -100,7 +101,6 @@ def test_azure_ai_catalog_entry_source_and_backup_match(catalog_name: str) -> No
|
|||
main_entry = _cost_map_entry(MAIN_COST_MAP, catalog_name)
|
||||
backup_entry = _cost_map_entry(BACKUP_COST_MAP, catalog_name)
|
||||
|
||||
assert str(main_entry["source"]).startswith(AZURE_PRICING_PREFIX)
|
||||
assert backup_entry == main_entry
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -24,19 +23,6 @@ def _ocr_response(model: str, pages_processed: int) -> OCRResponse:
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cost_map_path", COST_MAPS, ids=lambda path: path.name)
|
||||
@pytest.mark.parametrize("model, provider", MODELS)
|
||||
def test_pricing_entry(cost_map_path: Path, model: str, provider: str) -> None:
|
||||
with open(cost_map_path) as f:
|
||||
info = json.load(f).get(model)
|
||||
|
||||
assert info is not None, f"{model} missing from {cost_map_path.name}"
|
||||
assert info["litellm_provider"] == provider
|
||||
assert info["mode"] == "ocr"
|
||||
assert info["supported_endpoints"] == ["/v1/ocr"]
|
||||
assert info["ocr_cost_per_page"] == COST_PER_PAGE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model, provider", MODELS)
|
||||
def test_model_info_resolves_ocr_mode_and_price(local_model_cost_map, model: str, provider: str) -> None:
|
||||
info = litellm.get_model_info(model=model, custom_llm_provider=provider)
|
||||
|
|
|
|||
|
|
@ -163,23 +163,6 @@ def test_legacy_endpoint_names_still_resolve(local_model_cost_map: None) -> None
|
|||
assert completion_cost == pytest.approx(100 * info["output_cost_per_token"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", NEW_MODELS)
|
||||
def test_new_models_price_at_published_dbu_rates(local_model_cost_map: None, model: str) -> None:
|
||||
info: Final = _model_info(model)
|
||||
|
||||
for field, dbu_per_million in zip(PRICE_FIELDS, PUBLISHED_DBU_PER_MILLION[model]):
|
||||
assert info[field] == _dollars_per_token(dbu_per_million), field
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", sorted(set(PUBLISHED_DBU_PER_MILLION) - set(ENTRIES_STORING_PROMOTIONAL_RATE)))
|
||||
def test_cache_rates_derive_from_published_cache_dbu(local_model_cost_map: None, model: str) -> None:
|
||||
info: Final = _model_info(model)
|
||||
cache_dbu_per_million: Final = PUBLISHED_DBU_PER_MILLION[model][2:]
|
||||
|
||||
for field, dbu_per_million in zip(CACHE_FIELDS, cache_dbu_per_million):
|
||||
assert info[field] == _dollars_per_token(dbu_per_million), field
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", NEW_MODELS)
|
||||
def test_new_models_carry_cache_pricing(local_model_cost_map: None, model: str) -> None:
|
||||
info: Final = _model_info(model)
|
||||
|
|
@ -255,38 +238,3 @@ def test_sonnet_5_ships_standard_rates_not_introductory(local_model_cost_map: No
|
|||
|
||||
for field in PRICE_FIELDS:
|
||||
assert sonnet_5[field] == pytest.approx(sonnet_4_6[field]), field
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ENTRIES_STORING_PROMOTIONAL_RATE)
|
||||
def test_entries_storing_the_promotional_rate_price_below_the_published_table(
|
||||
local_model_cost_map: None,
|
||||
model: str,
|
||||
) -> None:
|
||||
info: Final = _model_info(model)
|
||||
input_dbu, output_dbu, _, _ = PUBLISHED_DBU_PER_MILLION[model]
|
||||
expiry_hint: Final = f"the gemini promotion expires {PROMOTION_EXPIRES}, after which the list rate applies"
|
||||
|
||||
assert info["input_cost_per_token"] == pytest.approx(
|
||||
_dollars_per_token(input_dbu) * PROMOTIONAL_DISCOUNT, rel=2e-4
|
||||
), expiry_hint
|
||||
assert info["output_cost_per_token"] == pytest.approx(
|
||||
_dollars_per_token(output_dbu) * PROMOTIONAL_DISCOUNT, rel=2e-4
|
||||
), expiry_hint
|
||||
assert info["cache_creation_input_token_cost"] == pytest.approx(info["input_cost_per_token"])
|
||||
assert info["cache_read_input_token_cost"] == pytest.approx(0.1 * info["input_cost_per_token"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ENTRIES_STORING_LIST_RATE_DESPITE_PROMOTION)
|
||||
def test_entries_storing_the_list_rate_bill_above_the_promotional_price(
|
||||
local_model_cost_map: None,
|
||||
model: str,
|
||||
) -> None:
|
||||
info: Final = _model_info(model)
|
||||
input_dbu, _, _, _ = PUBLISHED_DBU_PER_MILLION[model]
|
||||
list_rate: Final = _dollars_per_token(input_dbu)
|
||||
|
||||
assert info["input_cost_per_token"] == pytest.approx(list_rate, rel=2e-4), (
|
||||
f"{model} moved off the list rate; if it now stores the discount that runs to "
|
||||
f"{PROMOTION_EXPIRES}, move it into ENTRIES_STORING_PROMOTIONAL_RATE"
|
||||
)
|
||||
assert info["cache_creation_input_token_cost"] == pytest.approx(info["input_cost_per_token"])
|
||||
|
|
|
|||
|
|
@ -452,33 +452,6 @@ def test_map_traffic_type_to_service_tier(
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,custom_llm_provider,expected_cache_read_cost",
|
||||
[
|
||||
("gemini/gemini-flash-latest", "gemini", 7.5e-08),
|
||||
("gemini/gemini-flash-lite-latest", "gemini", 3e-08),
|
||||
("gemini/gemini-2.5-flash-preview-09-2025", "gemini", 3e-08),
|
||||
("gemini/gemini-2.5-flash-lite-preview-06-17", "gemini", 1e-08),
|
||||
("vertex_ai/gemini-2.5-flash-preview-09-2025", "vertex_ai", 3e-08),
|
||||
("vertex_ai/gemini-2.5-flash-lite-preview-06-17", "vertex_ai", 1e-08),
|
||||
],
|
||||
)
|
||||
def test_flash_alias_cache_read_is_ten_percent_of_input(
|
||||
monkeypatch, model, custom_llm_provider, expected_cache_read_cost
|
||||
):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
|
||||
model_info = litellm.get_model_info(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
|
||||
assert model_info["cache_read_input_token_cost"] == expected_cache_read_cost
|
||||
assert model_info["cache_read_input_token_cost"] == pytest.approx(
|
||||
0.10 * model_info["input_cost_per_token"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias,target",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ for mistral-ocr-4-0 and mistral-ocr-latest, which now both resolve to
|
|||
OCR 4 at $4 / 1000 pages.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -45,12 +44,6 @@ def _annotated_ocr_response(model: str, pages_processed: int | None, annotation_
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["mistral-ocr-4-0", "mistral-ocr-latest"])
|
||||
def test_model_info_ocr4_price(model: str) -> None:
|
||||
info = litellm.get_model_info(model=f"mistral/{model}", custom_llm_provider="mistral")
|
||||
assert info["ocr_cost_per_page"] == OCR4_COST_PER_PAGE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["mistral-ocr-4-0", "mistral-ocr-latest"])
|
||||
@pytest.mark.parametrize("pages_processed", [1, 3, 10])
|
||||
def test_ocr4_cost_scales_with_pages(model: str, pages_processed: int) -> None:
|
||||
|
|
@ -63,20 +56,6 @@ def test_ocr4_cost_scales_with_pages(model: str, pages_processed: int) -> None:
|
|||
assert cost == pytest.approx(OCR4_COST_PER_PAGE * pages_processed)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cost_map_path", [MAIN_COST_MAP, BACKUP_COST_MAP])
|
||||
def test_ocr3_pricing_entry(cost_map_path: Path) -> None:
|
||||
with open(cost_map_path) as f:
|
||||
info = json.load(f).get(OCR3_MODEL)
|
||||
|
||||
assert info is not None, f"{OCR3_MODEL} missing from {cost_map_path.name}"
|
||||
assert info["litellm_provider"] == "mistral"
|
||||
assert info["mode"] == "ocr"
|
||||
assert info["supported_endpoints"] == ["/v1/ocr"]
|
||||
assert info["ocr_cost_per_page"] == OCR3_COST_PER_PAGE
|
||||
assert info["annotation_cost_per_page"] == OCR3_ANNOTATION_COST_PER_PAGE
|
||||
|
||||
|
||||
def test_ocr3_model_info_price(local_model_cost_map) -> None:
|
||||
info = litellm.get_model_info(model=OCR3_MODEL, custom_llm_provider="mistral")
|
||||
assert info["ocr_cost_per_page"] == OCR3_COST_PER_PAGE
|
||||
|
|
|
|||
|
|
@ -54,9 +54,6 @@ CODE_SLUGS = (
|
|||
"xai/grok-code-fast-1",
|
||||
"xai/grok-code-fast-1-0825",
|
||||
)
|
||||
RETIREMENT_DATE = "2026-05-15"
|
||||
GROK_3_MINI_RETIREMENT_DATE = "2026-02-28"
|
||||
|
||||
BASE_COST_FIELDS = ("input_cost_per_token", "output_cost_per_token", "cache_read_input_token_cost")
|
||||
TIER_COST_FIELDS = (
|
||||
"input_cost_per_token_above_200k_tokens",
|
||||
|
|
@ -65,10 +62,6 @@ TIER_COST_FIELDS = (
|
|||
)
|
||||
|
||||
|
||||
def expected_retirement_date(slug: str) -> str:
|
||||
return GROK_3_MINI_RETIREMENT_DATE if slug in GROK_3_MINI_SLUGS else RETIREMENT_DATE
|
||||
|
||||
|
||||
@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)
|
||||
|
|
@ -92,15 +85,9 @@ def test_code_slug_bills_at_grok_build_rate(cost_map: dict, slug: str):
|
|||
assert entry[field] == target[field], field
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", (*REDIRECTED_SLUGS, *CODE_SLUGS))
|
||||
def test_redirected_slug_keeps_its_retirement_date(cost_map: dict, slug: str):
|
||||
assert cost_map[slug]["deprecation_date"] == expected_retirement_date(slug)
|
||||
|
||||
|
||||
def test_a_live_xai_model_is_untouched(cost_map: dict):
|
||||
"""Guard against the repricing leaking onto models xAI still serves directly."""
|
||||
assert cost_map["xai/grok-4.6"]["input_cost_per_token"] != cost_map[REDIRECT_TARGET]["input_cost_per_token"]
|
||||
assert "deprecation_date" not in cost_map["xai/grok-4.6"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", REDIRECTED_SLUGS)
|
||||
|
|
|
|||
|
|
@ -5855,6 +5855,71 @@ async def test_organization_budget_check_carries_org_state_on_the_token():
|
|||
assert token.org_budget_snapshot == OrgBudgetSnapshot(spend=12.5, max_budget=100.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"max_budget, spend, expect_blocked",
|
||||
[
|
||||
(0.0, 0.0, True), # explicit zero budget blocks even a fresh org with no spend
|
||||
(0.0, 7.4e-06, True), # any spend at all against a zero budget blocks
|
||||
(None, 999.0, False), # unlimited (None) never blocks, regardless of spend
|
||||
(5.0, 4.99, False), # a positive budget under its cap still passes
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_organization_zero_max_budget_is_enforced(max_budget, spend, expect_blocked):
|
||||
"""An explicit organization max_budget of 0 must mean zero allowance, matching
|
||||
key/team/user semantics, not unlimited.
|
||||
|
||||
Regression for LIT-7797: `_organization_max_budget_check` returned early
|
||||
whenever `org_max_budget <= 0`, so an org configured with max_budget=0 could
|
||||
spend without limit.
|
||||
"""
|
||||
from litellm.proxy._types import LiteLLM_OrganizationTable
|
||||
from litellm.proxy.auth.auth_checks import _organization_max_budget_check
|
||||
|
||||
org_table = LiteLLM_OrganizationTable(
|
||||
organization_id="o1",
|
||||
organization_alias="zero-budget-org",
|
||||
budget_id="b1",
|
||||
created_by="admin",
|
||||
updated_by="admin",
|
||||
spend=spend,
|
||||
litellm_budget_table=LiteLLM_BudgetTable(max_budget=max_budget) if max_budget is not None else None,
|
||||
)
|
||||
token = UserAPIKeyAuth(token="k1", org_id="o1")
|
||||
user_api_key_cache = UserApiKeyCache()
|
||||
await user_api_key_cache.async_set_cache(
|
||||
key="org_id:o1:with_budget", value=org_table, model_type=LiteLLM_OrganizationTable
|
||||
)
|
||||
|
||||
async def _spend(counter_key, fallback_spend, max_budget=None, **kwargs):
|
||||
return spend
|
||||
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.budget_alerts = AsyncMock()
|
||||
|
||||
with patch( # test-quality-ok: _organization_max_budget_check imports get_current_spend locally
|
||||
"litellm.proxy.proxy_server.get_current_spend", _spend
|
||||
):
|
||||
if expect_blocked:
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _organization_max_budget_check(
|
||||
valid_token=token,
|
||||
team_object=None,
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
assert exc_info.value.max_budget == max_budget
|
||||
else:
|
||||
await _organization_max_budget_check(
|
||||
valid_token=token,
|
||||
team_object=None,
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["/health", "/health/services", "/health/test_connection"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_capable_non_llm_routes_still_enforce_budget(route):
|
||||
|
|
|
|||
|
|
@ -487,6 +487,34 @@ async def test_route_passed_to_post_call_failure_hook():
|
|||
assert call_args["user_api_key_dict"].request_route == test_route
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_route_normalized_on_auth_failure():
|
||||
handler = UserAPIKeyAuthExceptionHandler()
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: handler reads proxy_server globals at call time
|
||||
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_post_call_failure_hook,
|
||||
patch( # test-quality-ok: handler reads proxy_server globals at call time
|
||||
"litellm.proxy.proxy_server.general_settings", {}
|
||||
),
|
||||
pytest.raises(ProxyException),
|
||||
):
|
||||
await handler._handle_authentication_error(
|
||||
HTTPException(status_code=401, detail="Authentication Error, Invalid proxy server token passed"),
|
||||
MagicMock(),
|
||||
{},
|
||||
"/v1/responses/resp_attacker_controlled_id",
|
||||
None,
|
||||
"sk-doesnotexist",
|
||||
)
|
||||
|
||||
hook_kwargs = mock_post_call_failure_hook.call_args.kwargs
|
||||
assert hook_kwargs["route"] == "/v1/responses/resp_attacker_controlled_id"
|
||||
assert hook_kwargs["user_api_key_dict"].request_route == "/v1/responses/{response_id}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolved_identity_exported_on_auth_failure():
|
||||
"""Regression: when auth fails AFTER the key/team/user identity is resolved
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from litellm.proxy._types import (
|
|||
ProxyException,
|
||||
)
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
|
||||
from litellm.proxy.auth.handle_jwt import (
|
||||
JWKS_FETCH_ATTEMPTS,
|
||||
STALE_CACHE_KEY_PREFIX,
|
||||
|
|
@ -32,6 +33,7 @@ from litellm.proxy.auth.handle_jwt import (
|
|||
JWTHandler,
|
||||
NoMatchingJWTPublicKeyError,
|
||||
)
|
||||
from litellm.types.agents import AgentResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -6786,3 +6788,180 @@ async def test_sync_user_role_and_teams_singular_claim_only_recognized_under_fla
|
|||
}
|
||||
assert mock_patch.call_args.kwargs["teams_ids_to_add_user_to"] == []
|
||||
assert user.teams == []
|
||||
|
||||
|
||||
def _entra_agent_registry() -> AgentRegistry:
|
||||
registry = AgentRegistry()
|
||||
registry.register_agent(
|
||||
AgentResponse(
|
||||
agent_id="canonical-agent-id",
|
||||
agent_name="2f5c9b1e-6a4d-4c8e-9f0b-7d1a3e5c9b21",
|
||||
agent_card_params={"name": "research-agent", "url": "http://localhost:9999/a2a", "version": "1.0.0"},
|
||||
litellm_params={"require_trace_id_on_calls_by_agent": True},
|
||||
)
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
def _entra_agent_jwt_handler(agent_id_jwt_field: str | None) -> JWTHandler:
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.update_environment(
|
||||
prisma_client=None,
|
||||
user_api_key_cache=DualCache(),
|
||||
litellm_jwtauth=LiteLLM_JWTAuth(user_id_jwt_field="sub", agent_id_jwt_field=agent_id_jwt_field),
|
||||
)
|
||||
return jwt_handler
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"claim_value",
|
||||
["canonical-agent-id", "2f5c9b1e-6a4d-4c8e-9f0b-7d1a3e5c9b21"],
|
||||
ids=["matches_agent_id", "matches_agent_name"],
|
||||
)
|
||||
def test_resolve_agent_id_returns_canonical_agent_id(claim_value: str):
|
||||
"""An Entra app token's azp claim binds to the registered agent by id or by name and yields its canonical id."""
|
||||
jwt_handler = _entra_agent_jwt_handler(agent_id_jwt_field="azp")
|
||||
|
||||
resolved = JWTAuthManager.resolve_agent_id(
|
||||
jwt_handler=jwt_handler,
|
||||
jwt_valid_token={"sub": "sp-object-id-1234", "azp": claim_value},
|
||||
agent_registry=_entra_agent_registry(),
|
||||
)
|
||||
|
||||
assert resolved == "canonical-agent-id"
|
||||
|
||||
|
||||
def test_resolve_agent_id_reads_nested_claim():
|
||||
jwt_handler = _entra_agent_jwt_handler(agent_id_jwt_field="entra.client_id")
|
||||
|
||||
resolved = JWTAuthManager.resolve_agent_id(
|
||||
jwt_handler=jwt_handler,
|
||||
jwt_valid_token={"sub": "sp-object-id-1234", "entra": {"client_id": "canonical-agent-id"}},
|
||||
agent_registry=_entra_agent_registry(),
|
||||
)
|
||||
|
||||
assert resolved == "canonical-agent-id"
|
||||
|
||||
|
||||
def test_resolve_agent_id_rejects_claim_for_unregistered_agent():
|
||||
"""A configured agent claim naming no registered agent fails closed with 403 instead of falling back to an unbound identity."""
|
||||
jwt_handler = _entra_agent_jwt_handler(agent_id_jwt_field="azp")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
JWTAuthManager.resolve_agent_id(
|
||||
jwt_handler=jwt_handler,
|
||||
jwt_valid_token={"sub": "sp-object-id-1234", "azp": "00000000-0000-0000-0000-000000000000"},
|
||||
agent_registry=_entra_agent_registry(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
{"sub": "sp-object-id-1234"},
|
||||
{"sub": "sp-object-id-1234", "azp": ""},
|
||||
{"sub": "sp-object-id-1234", "azp": ["canonical-agent-id"]},
|
||||
],
|
||||
ids=["claim_absent", "claim_empty", "claim_not_a_string"],
|
||||
)
|
||||
def test_resolve_agent_id_returns_none_when_claim_unusable(token: dict):
|
||||
jwt_handler = _entra_agent_jwt_handler(agent_id_jwt_field="azp")
|
||||
|
||||
assert (
|
||||
JWTAuthManager.resolve_agent_id(
|
||||
jwt_handler=jwt_handler, jwt_valid_token=token, agent_registry=_entra_agent_registry()
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_agent_id_ignores_claim_when_field_not_configured():
|
||||
"""Without agent_id_jwt_field an azp claim (even an unknown one) leaves JWT auth behaviour unchanged."""
|
||||
jwt_handler = _entra_agent_jwt_handler(agent_id_jwt_field=None)
|
||||
|
||||
resolved = JWTAuthManager.resolve_agent_id(
|
||||
jwt_handler=jwt_handler,
|
||||
jwt_valid_token={"sub": "sp-object-id-1234", "azp": "00000000-0000-0000-0000-000000000000"},
|
||||
agent_registry=_entra_agent_registry(),
|
||||
)
|
||||
|
||||
assert resolved is None
|
||||
|
||||
|
||||
def _entra_signed_app_token(monkeypatch, azp: str, scope: str) -> tuple[JWTHandler, str]:
|
||||
"""A JWTHandler that verifies RS256 tokens against a pre-cached JWKS, plus a signed Entra-style app token."""
|
||||
jwks_url = "https://login.microsoftonline.test/discovery/v2.0/keys"
|
||||
monkeypatch.setenv("JWT_PUBLIC_KEY_URL", jwks_url)
|
||||
monkeypatch.delenv("JWT_AUDIENCE", raising=False)
|
||||
private_key, jwk = _get_rsa_key_and_jwk(kid="entra-kid")
|
||||
cache = DualCache()
|
||||
cache.set_cache(key=f"litellm_jwt_auth_keys_{jwks_url}", value=[jwk])
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.update_environment(
|
||||
prisma_client=None,
|
||||
user_api_key_cache=cache,
|
||||
litellm_jwtauth=LiteLLM_JWTAuth(agent_id_jwt_field="azp"),
|
||||
)
|
||||
token = _encode_rsa_jwt(
|
||||
private_key,
|
||||
issuer="https://login.microsoftonline.test/lit7664-tenant/v2.0",
|
||||
audience="api://litellm",
|
||||
kid="entra-kid",
|
||||
extra_claims={"sub": "sp-object-id-1234", "azp": azp, "scope": scope},
|
||||
)
|
||||
return jwt_handler, token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("is_admin_token", [False, True], ids=["standard_jwt", "proxy_admin_jwt"])
|
||||
async def test_auth_builder_propagates_agent_id_from_jwt_claim(monkeypatch, is_admin_token: bool):
|
||||
"""auth_builder carries the resolved agent id into JWTAuthBuilderResult on both the admin and standard paths."""
|
||||
jwt_handler, token = _entra_signed_app_token(
|
||||
monkeypatch,
|
||||
azp="2f5c9b1e-6a4d-4c8e-9f0b-7d1a3e5c9b21",
|
||||
scope=LiteLLM_JWTAuth().admin_jwt_scope if is_admin_token else "",
|
||||
)
|
||||
jwt_handler.bind_agent_lookup(_entra_agent_registry())
|
||||
|
||||
result = await JWTAuthManager.auth_builder(
|
||||
api_key=token,
|
||||
jwt_handler=jwt_handler,
|
||||
request_data={"model": "gpt-5.6"},
|
||||
general_settings={"enforce_rbac": False},
|
||||
route="/key/info" if is_admin_token else "/chat/completions",
|
||||
prisma_client=None,
|
||||
user_api_key_cache=None,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
assert result["is_proxy_admin"] is is_admin_token
|
||||
assert result["agent_id"] == "canonical-agent-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_builder_denies_jwt_naming_unregistered_agent_before_admin_check(monkeypatch):
|
||||
"""An unknown agent claim is rejected even when the token would otherwise be a proxy admin."""
|
||||
jwt_handler, token = _entra_signed_app_token(
|
||||
monkeypatch,
|
||||
azp="00000000-0000-0000-0000-000000000000",
|
||||
scope=LiteLLM_JWTAuth().admin_jwt_scope,
|
||||
)
|
||||
jwt_handler.bind_agent_lookup(_entra_agent_registry())
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await JWTAuthManager.auth_builder(
|
||||
api_key=token,
|
||||
jwt_handler=jwt_handler,
|
||||
request_data={"model": "gpt-5.6"},
|
||||
general_settings={"enforce_rbac": False},
|
||||
route="/key/info",
|
||||
prisma_client=None,
|
||||
user_api_key_cache=None,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
|
|
|||
|
|
@ -1937,6 +1937,76 @@ async def test_standard_jwt_auth_propagates_user_email():
|
|||
assert result.api_key is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("is_proxy_admin", [False, True], ids=["standard_jwt", "proxy_admin_jwt"])
|
||||
async def test_jwt_auth_propagates_agent_id_to_user_api_key_auth(is_proxy_admin: bool):
|
||||
"""The agent id resolved by auth_builder must land on UserAPIKeyAuth.agent_id so
|
||||
agent-scoped checks (trace id requirement, MCP server/tool restrictions, spend
|
||||
attribution) apply to JWT callers the same way they apply to agent-bound keys."""
|
||||
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
|
||||
general_settings = {"enable_jwt_auth": True}
|
||||
user_api_key_cache = DualCache()
|
||||
jwt_handler = MagicMock()
|
||||
jwt_handler.is_jwt.return_value = True
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(agent_id_jwt_field="azp")
|
||||
|
||||
user_object = LiteLLM_UserTable(user_id="sp-object-id-1234", user_role="internal_user")
|
||||
mock_jwt_result = {
|
||||
"is_proxy_admin": is_proxy_admin,
|
||||
"team_object": None,
|
||||
"user_object": user_object,
|
||||
"end_user_object": None,
|
||||
"org_object": None,
|
||||
"token": jwt_token,
|
||||
"team_id": None,
|
||||
"user_id": "sp-object-id-1234",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
"jwt_claims": {"sub": "sp-object-id-1234", "azp": "2f5c9b1e-6a4d-4c8e-9f0b-7d1a3e5c9b21"},
|
||||
"agent_id": "canonical-agent-id",
|
||||
}
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.url.path = "/v1/chat/completions"
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = {"authorization": f"Bearer {jwt_token}"}
|
||||
mock_request.query_params = {}
|
||||
mock_request.state = SimpleNamespace()
|
||||
|
||||
with (
|
||||
patch.multiple( # test-quality-ok: production auth reads these module globals; no dependency injection seam exists
|
||||
"litellm.proxy.proxy_server",
|
||||
general_settings=general_settings,
|
||||
premium_user=True,
|
||||
master_key="sk-master",
|
||||
prisma_client=None,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
jwt_handler=jwt_handler,
|
||||
),
|
||||
patch( # test-quality-ok: the builder calls this static method directly; no dependency injection seam exists
|
||||
"litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_jwt_result,
|
||||
),
|
||||
):
|
||||
result = await _user_api_key_auth_builder(
|
||||
request=mock_request,
|
||||
api_key=jwt_token,
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=None,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data={"model": "gpt-5.6"},
|
||||
)
|
||||
|
||||
assert result.agent_id == "canonical-agent-id"
|
||||
assert result.user_id == "sp-object-id-1234"
|
||||
assert result.api_key is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_register_binds_api_key_to_token_hash():
|
||||
"""
|
||||
|
|
@ -2106,6 +2176,222 @@ async def test_auto_register_first_request_propagates_user_email():
|
|||
assert result.api_key == "hashed-auto-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_register_stamps_new_key_with_jwt_agent_id():
|
||||
"""The virtual key AUTO_REGISTER creates must carry the agent id auth_builder bound
|
||||
from the JWT claim, and the first request's principal must carry it too, or the
|
||||
mapped-key path would drop the agent policies on that request and every later one."""
|
||||
from litellm.proxy.auth.auth_method import AuthMethod
|
||||
from litellm.proxy.auth.resolvers.models import CredentialRef
|
||||
from litellm.proxy.auth.resolvers.store import IdentityStore
|
||||
from litellm.proxy.auth.user_api_key_auth import _auto_register_jwt_mapping
|
||||
from litellm.proxy.proxy_server import hash_token
|
||||
|
||||
plaintext = "sk-auto-registered-agent"
|
||||
token_hash = hash_token(plaintext)
|
||||
persisted_principal = IdentityStore._principal_from_key(
|
||||
UserAPIKeyAuth(token=token_hash, user_id="validated-user", team_id="validated-team", agent_id="canonical-agent-id"),
|
||||
auth_method=AuthMethod.API_KEY,
|
||||
credential_ref=CredentialRef(token_id=token_hash),
|
||||
)
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.db.litellm_jwtkeymapping.create = AsyncMock()
|
||||
user_api_key_cache = MagicMock()
|
||||
user_api_key_cache.async_set_cache = AsyncMock()
|
||||
jwt_handler = MagicMock()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(virtual_key_mapping_cache_ttl=300)
|
||||
generate_key = AsyncMock(return_value={"token": plaintext})
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: key creation is an inline import inside the helper; no dependency injection seam exists
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn",
|
||||
generate_key,
|
||||
),
|
||||
patch( # test-quality-ok: the helper constructs IdentityStore itself; no dependency injection seam exists
|
||||
"litellm.proxy.auth.resolvers.store.IdentityStore.resolve",
|
||||
new_callable=AsyncMock,
|
||||
return_value=persisted_principal,
|
||||
),
|
||||
):
|
||||
result = await _auto_register_jwt_mapping(
|
||||
virtual_key_claim_field="appid",
|
||||
claim_value="2f5c9b1e-6a4d-4c8e-9f0b-7d1a3e5c9b21",
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
cache_key="jwt_key_mapping:appid:2f5c9b1e-6a4d-4c8e-9f0b-7d1a3e5c9b21",
|
||||
team_id="validated-team",
|
||||
user_id="validated-user",
|
||||
agent_id="canonical-agent-id",
|
||||
)
|
||||
|
||||
assert generate_key.await_args is not None
|
||||
assert generate_key.await_args.kwargs["agent_id"] == "canonical-agent-id"
|
||||
assert result is not None
|
||||
assert result.agent_id == "canonical-agent-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("losing_agent_id", ["other-agent", None], ids=["different_agent", "no_agent_claim"])
|
||||
async def test_auto_register_race_loser_keeps_winners_agent_id(losing_agent_id: str | None):
|
||||
"""When two requests race to AUTO_REGISTER the same mapping claim, the loser must run as
|
||||
the persisted key, agent binding included. Every later request on that mapping uses the
|
||||
winner's key, so stamping the loser's own (or missing) agent id on it would give one request
|
||||
different agent policies and spend attribution than all the others."""
|
||||
from litellm.proxy.auth.auth_method import AuthMethod
|
||||
from litellm.proxy.auth.resolvers.models import CredentialRef
|
||||
from litellm.proxy.auth.resolvers.store import IdentityStore
|
||||
from litellm.proxy.auth.user_api_key_auth import _auto_register_jwt_mapping
|
||||
|
||||
winner_hash = "winner-key-hash"
|
||||
winner_principal = IdentityStore._principal_from_key(
|
||||
UserAPIKeyAuth(token=winner_hash, user_id="validated-user", team_id="validated-team", agent_id="winner-agent"),
|
||||
auth_method=AuthMethod.API_KEY,
|
||||
credential_ref=CredentialRef(token_id=winner_hash),
|
||||
)
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.db.litellm_jwtkeymapping.create = AsyncMock(
|
||||
side_effect=Exception("Unique constraint failed on the fields: (`jwt_claim_name`,`jwt_claim_value`)")
|
||||
)
|
||||
prisma_client.db.litellm_verificationtoken.delete = AsyncMock()
|
||||
user_api_key_cache = MagicMock()
|
||||
user_api_key_cache.async_set_cache = AsyncMock()
|
||||
jwt_handler = MagicMock()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(virtual_key_mapping_cache_ttl=300)
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: key creation is an inline import inside the helper; no dependency injection seam exists
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"token": "sk-orphaned-loser-key"},
|
||||
),
|
||||
patch( # test-quality-ok: module-level helper called by the builder; no dependency injection seam exists
|
||||
"litellm.proxy.auth.user_api_key_auth.get_jwt_key_mapping_object",
|
||||
new_callable=AsyncMock,
|
||||
return_value=winner_hash,
|
||||
),
|
||||
patch( # test-quality-ok: the helper constructs IdentityStore itself; no dependency injection seam exists
|
||||
"litellm.proxy.auth.resolvers.store.IdentityStore.resolve",
|
||||
new_callable=AsyncMock,
|
||||
return_value=winner_principal,
|
||||
),
|
||||
):
|
||||
result = await _auto_register_jwt_mapping(
|
||||
virtual_key_claim_field="tid",
|
||||
claim_value="shared-tenant",
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
cache_key="jwt_key_mapping:tid:shared-tenant",
|
||||
team_id="validated-team",
|
||||
user_id="validated-user",
|
||||
agent_id=losing_agent_id,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.token == winner_hash
|
||||
assert result.agent_id == "winner-agent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_auto_register_forwards_bound_agent_id():
|
||||
"""When a JWT under AUTO_REGISTER also carries the configured agent claim, the agent
|
||||
id auth_builder resolved must reach the key creation, not be dropped when
|
||||
valid_token is swapped for the freshly registered key."""
|
||||
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
|
||||
user_api_key_cache = DualCache()
|
||||
jwt_handler = MagicMock()
|
||||
jwt_handler.is_jwt.return_value = True
|
||||
jwt_handler.auth_jwt = AsyncMock(return_value={"sub": "user1", "appid": "2f5c9b1e-6a4d-4c8e-9f0b-7d1a3e5c9b21"})
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(
|
||||
virtual_key_claim_field="sub",
|
||||
virtual_key_mapping_cache_ttl=300,
|
||||
agent_id_jwt_field="appid",
|
||||
)
|
||||
user_object = LiteLLM_UserTable(user_id="validated-user", user_role="internal_user")
|
||||
mock_jwt_result = {
|
||||
"is_proxy_admin": False,
|
||||
"team_object": None,
|
||||
"user_object": user_object,
|
||||
"end_user_object": None,
|
||||
"org_object": None,
|
||||
"token": jwt_token,
|
||||
"team_id": "validated-team",
|
||||
"user_id": "validated-user",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
"jwt_claims": {"sub": "user1", "appid": "2f5c9b1e-6a4d-4c8e-9f0b-7d1a3e5c9b21"},
|
||||
"agent_id": "canonical-agent-id",
|
||||
}
|
||||
auto_register = AsyncMock(
|
||||
return_value=UserAPIKeyAuth(
|
||||
token="hashed-auto-key",
|
||||
api_key="hashed-auto-key",
|
||||
team_id="validated-team",
|
||||
user_id="validated-user",
|
||||
agent_id="canonical-agent-id",
|
||||
)
|
||||
)
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.url.path = "/v1/chat/completions"
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = {"authorization": f"Bearer {jwt_token}"}
|
||||
mock_request.query_params = {}
|
||||
mock_request.state = SimpleNamespace()
|
||||
|
||||
with (
|
||||
patch.multiple( # test-quality-ok: production auth reads these module globals; no dependency injection seam exists
|
||||
"litellm.proxy.proxy_server",
|
||||
general_settings={"enable_jwt_auth": True},
|
||||
premium_user=True,
|
||||
master_key="sk-master",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
jwt_handler=jwt_handler,
|
||||
),
|
||||
patch( # test-quality-ok: module-level helper called by the builder; no dependency injection seam exists
|
||||
"litellm.proxy.auth.user_api_key_auth._resolve_jwt_to_virtual_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_PendingAutoRegister(
|
||||
claim_field="sub",
|
||||
claim_value="user1",
|
||||
cache_key="jwt_key_mapping:sub:user1",
|
||||
),
|
||||
),
|
||||
patch( # test-quality-ok: the builder calls this static method directly; no dependency injection seam exists
|
||||
"litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_jwt_result,
|
||||
),
|
||||
patch( # test-quality-ok: module-level helper called by the builder; no dependency injection seam exists
|
||||
"litellm.proxy.auth.user_api_key_auth._auto_register_jwt_mapping",
|
||||
auto_register,
|
||||
),
|
||||
):
|
||||
result = await _user_api_key_auth_builder(
|
||||
request=mock_request,
|
||||
api_key=jwt_token,
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=None,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data={"model": "gpt-5.6"},
|
||||
)
|
||||
|
||||
assert auto_register.await_args is not None
|
||||
assert auto_register.await_args.kwargs["agent_id"] == "canonical-agent-id"
|
||||
assert result.agent_id == "canonical-agent-id"
|
||||
assert result.api_key == "hashed-auto-key"
|
||||
|
||||
|
||||
class TestJWTOAuth2Coexistence:
|
||||
"""
|
||||
Test that JWT and OAuth2 auth can coexist on the same instance.
|
||||
|
|
|
|||
|
|
@ -145,6 +145,18 @@ def test_writer_pinned_client_yields_to_routed_reads_when_writer_down():
|
|||
assert pinned.db.litellm_proxymodeltable.find_many is reader_inner.litellm_proxymodeltable.find_many
|
||||
|
||||
|
||||
def test_writer_wrapper_keeps_raw_sql_on_the_writer_while_writer_flagged_down():
|
||||
from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper, writer_wrapper
|
||||
|
||||
writer, writer_inner, reader, reader_inner = _make_wrappers()
|
||||
routing = RoutingPrismaWrapper(writer=writer, reader=reader)
|
||||
routing._writer_unavailable = True
|
||||
|
||||
assert writer_wrapper(routing).query_raw is writer_inner.query_raw
|
||||
assert writer_wrapper(routing).query_raw is not reader_inner.query_raw
|
||||
assert writer_wrapper(writer) is writer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_invokes_both_clients():
|
||||
from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper
|
||||
|
|
|
|||
|
|
@ -900,7 +900,7 @@ async def test_service_declared_ccr_hashes_drive_injection_and_validation(guardr
|
|||
)
|
||||
|
||||
assert has_headroom_retrieve_tool(result.get("tools") or [])
|
||||
(issued, _expiry), = guardrail._issued_hashes_by_call_id.values()
|
||||
((issued, _expiry),) = guardrail._issued_hashes_by_call_id.values()
|
||||
assert issued == frozenset({"98ca69107318", "b573993006976af767214fac"})
|
||||
|
||||
|
||||
|
|
@ -953,7 +953,6 @@ async def test_anthropic_assistant_history_never_reaches_compression_service(gua
|
|||
assert result["messages"][1]["content"] == [{"type": "text", "text": table}]
|
||||
|
||||
|
||||
|
||||
def test_has_headroom_retrieve_tool_recognizes_anthropic_native_shape():
|
||||
"""By the time an Anthropic Messages API response reaches the agentic-loop
|
||||
gate, the OpenAI-shaped tool this guardrail injects (type: "function")
|
||||
|
|
@ -2342,9 +2341,7 @@ async def test_streaming_responses_resolves_ccr_retrieval_end_to_end(
|
|||
)
|
||||
assert streamed_text == final_answer
|
||||
assert not any("function_call" in str(getattr(event, "type", "")) for event in events)
|
||||
assert not any(
|
||||
getattr(getattr(event, "item", None), "type", None) == "function_call" for event in events
|
||||
)
|
||||
assert not any(getattr(getattr(event, "item", None), "type", None) == "function_call" for event in events)
|
||||
mock_get.assert_called_once()
|
||||
assert CCR_HASH in (mock_get.call_args.kwargs.get("url") or mock_get.call_args.args[0])
|
||||
|
||||
|
|
@ -2399,9 +2396,7 @@ def test_sync_streaming_responses_resolves_ccr_retrieval_end_to_end(
|
|||
getattr(event, "delta", "") for event in events if getattr(event, "type", None) == "response.output_text.delta"
|
||||
)
|
||||
assert streamed_text == final_answer
|
||||
assert not any(
|
||||
getattr(getattr(event, "item", None), "type", None) == "function_call" for event in events
|
||||
)
|
||||
assert not any(getattr(getattr(event, "item", None), "type", None) == "function_call" for event in events)
|
||||
mock_get.assert_called_once()
|
||||
assert len(upstream.calls) == 2
|
||||
assert not json.loads(upstream.calls[1].request.content).get("stream")
|
||||
|
|
@ -2514,6 +2509,38 @@ async def test_history_is_still_compressed(guardrail: HeadroomGuardrail):
|
|||
assert messages[3] == compressed_history[1]
|
||||
|
||||
|
||||
CACHED_PREFIX_MESSAGES = [
|
||||
{"role": "system", "content": "You are Claude Code. " + "S" * 5000},
|
||||
{"role": "user", "content": "old question " + "Q" * 5000},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Reading the file now.",
|
||||
"tool_calls": [{"id": "old_1", "type": "function", "function": {"name": "Read", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "old_1", "content": "large file body " + "F" * 5000},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "cached turn", "cache_control": {"type": "ephemeral"}}],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Listing now.",
|
||||
"tool_calls": [{"id": "new_1", "type": "function", "function": {"name": "Bash", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "new_1", "content": "volatile tail output " + "T" * 5000},
|
||||
{"role": "assistant", "content": "Finished listing."},
|
||||
{"role": "user", "content": "live instruction"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rows_before_last_cache_control_breakpoint_are_never_sent(guardrail: HeadroomGuardrail):
|
||||
wire, result = await _wire_and_result(guardrail, CACHED_PREFIX_MESSAGES)
|
||||
|
||||
assert [row.get("tool_call_id") for row in wire] == ["new_1"]
|
||||
assert result["structured_messages"][:5] == CACHED_PREFIX_MESSAGES[:5]
|
||||
|
||||
|
||||
CACHE_MARKED_HISTORY_MESSAGES = [
|
||||
{"role": "system", "content": "You are Claude Code. " + "S" * 5000},
|
||||
{"role": "user", "content": "old question " + "Q" * 5000},
|
||||
|
|
@ -2529,6 +2556,7 @@ CACHE_MARKED_HISTORY_MESSAGES = [
|
|||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{"role": "assistant", "content": "Summarized the file for you."},
|
||||
{"role": "tool", "tool_call_id": "tail", "content": "volatile tail output " + "T" * 5000},
|
||||
{"role": "user", "content": "live instruction"},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -338,6 +338,15 @@ def test_a_request_must_carry_exactly_one_usable_conversation(body: dict):
|
|||
"config_overrides",
|
||||
[
|
||||
{"classifier_type": "llm", "classifier_llm_config": {"model": "classifier-model"}},
|
||||
{
|
||||
"classifier_type": "capability",
|
||||
"classifier_llm_config": {"model": "classifier-model"},
|
||||
"capability_classifier_config": {
|
||||
"efficient_tier": "SIMPLE",
|
||||
"capable_tier": "REASONING",
|
||||
"base_threshold": 0.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
"semantic_keyword_matching": True,
|
||||
"embedding_model": "classifier-model",
|
||||
|
|
|
|||
|
|
@ -245,6 +245,55 @@ async def test_validate_team_org_change_same_org_id():
|
|||
mock_access_check.assert_not_called() # Ensure access check wasn't called
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"org_max_budget, team_max_budget, expect_blocked",
|
||||
[
|
||||
(0.0, 100.0, True), # explicit zero org budget must still cap the team's budget
|
||||
(0.0, None, False), # team has no budget of its own, nothing to compare
|
||||
(None, 100.0, False), # unlimited (None) org budget never blocks
|
||||
(50.0, 100.0, True), # a positive org budget is still enforced normally
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_team_org_change_zero_org_budget_is_enforced(
|
||||
org_max_budget, team_max_budget, expect_blocked
|
||||
):
|
||||
"""An organization with an explicit max_budget of 0 must still block moving in a
|
||||
team with a larger budget, matching key/team/user zero-budget semantics.
|
||||
|
||||
Regression for LIT-7797: the truthy check `organization.litellm_budget_table.max_budget`
|
||||
treated an explicit 0 the same as no budget table at all, silently skipping this guard.
|
||||
"""
|
||||
org_id = "team-org-123"
|
||||
new_org_id = "new-org-456"
|
||||
|
||||
team = MagicMock(spec=LiteLLM_TeamTable)
|
||||
team.organization_id = org_id
|
||||
team.models = []
|
||||
team.max_budget = team_max_budget
|
||||
team.tpm_limit = None
|
||||
team.rpm_limit = None
|
||||
team.members_with_roles = []
|
||||
|
||||
organization = MagicMock(spec=LiteLLM_OrganizationTableWithMembers)
|
||||
organization.organization_id = new_org_id
|
||||
organization.models = []
|
||||
organization.litellm_budget_table = (
|
||||
LiteLLM_BudgetTable(max_budget=org_max_budget) if org_max_budget is not None else None
|
||||
)
|
||||
organization.members = []
|
||||
|
||||
mock_router = MagicMock(spec=Router)
|
||||
|
||||
if expect_blocked:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_team_org_change(team=team, organization=organization, llm_router=mock_router)
|
||||
assert exc_info.value.status_code == 403
|
||||
else:
|
||||
result = validate_team_org_change(team=team, organization=organization, llm_router=mock_router)
|
||||
assert result is None or result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_team_org_change_members_in_org():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -11,14 +11,15 @@ from litellm.proxy.management_helpers.access_group_key_sync import (
|
|||
)
|
||||
|
||||
|
||||
def _routed_prisma_client():
|
||||
def _routed_prisma_client(writer_unavailable: bool = False):
|
||||
writer_inner = MagicMock(name="writer_prisma")
|
||||
reader_inner = MagicMock(name="reader_prisma")
|
||||
writer_inner.query_raw = AsyncMock(return_value=[])
|
||||
reader_inner.query_raw = AsyncMock(return_value=[])
|
||||
reader_inner.query_raw = AsyncMock(side_effect=RuntimeError("cannot execute UPDATE in a read-only transaction"))
|
||||
writer = PrismaWrapper(original_prisma=writer_inner, iam_token_db_auth=False)
|
||||
reader = PrismaWrapper(original_prisma=reader_inner, iam_token_db_auth=False)
|
||||
routing = RoutingPrismaWrapper(writer=writer, reader=reader)
|
||||
routing._writer_unavailable = writer_unavailable
|
||||
return SimpleNamespace(db=routing), writer_inner, reader_inner
|
||||
|
||||
|
||||
|
|
@ -39,6 +40,39 @@ async def test_regeneration_repoint_update_runs_on_the_writer():
|
|||
reader_inner.query_raw.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regeneration_repoint_update_stays_on_the_writer_while_writer_flagged_unavailable():
|
||||
prisma_client, writer_inner, reader_inner = _routed_prisma_client(writer_unavailable=True)
|
||||
|
||||
await sync_key_regeneration_access_group_membership(
|
||||
prisma_client=prisma_client,
|
||||
previous_key_token="old-token",
|
||||
new_key_token="new-token",
|
||||
data=None,
|
||||
existing_key_row=MagicMock(),
|
||||
)
|
||||
|
||||
writer_inner.query_raw.assert_awaited_once()
|
||||
assert writer_inner.query_raw.await_args.args[0].startswith('UPDATE "LiteLLM_AccessGroupTable"')
|
||||
assert writer_inner.query_raw.await_args.args[1:] == ("old-token", "new-token")
|
||||
reader_inner.query_raw.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_membership_attach_and_detach_updates_stay_on_the_writer_while_writer_flagged_unavailable():
|
||||
prisma_client, writer_inner, reader_inner = _routed_prisma_client(writer_unavailable=True)
|
||||
|
||||
await sync_key_access_group_membership(
|
||||
prisma_client=prisma_client,
|
||||
key_token="token",
|
||||
previous_access_group_ids=["ag-old"],
|
||||
updated_access_group_ids=["ag-new"],
|
||||
)
|
||||
|
||||
assert writer_inner.query_raw.await_count == 2
|
||||
reader_inner.query_raw.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_membership_attach_and_detach_updates_run_on_the_writer():
|
||||
prisma_client, writer_inner, reader_inner = _routed_prisma_client()
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from litellm.proxy.management_helpers.access_group_model_sync import (
|
|||
_INVALIDATE = "litellm.proxy.management_helpers.access_group_model_sync.invalidate_access_group_caches"
|
||||
|
||||
|
||||
def _routed_prisma_client(deployment_count: int):
|
||||
def _routed_prisma_client(deployment_count: int, writer_unavailable: bool = False):
|
||||
async def query_raw(sql, *params):
|
||||
if sql.startswith("SELECT COUNT(*)"):
|
||||
return [{"deployment_count": deployment_count}]
|
||||
|
|
@ -26,6 +26,7 @@ def _routed_prisma_client(deployment_count: int):
|
|||
writer = PrismaWrapper(original_prisma=writer_inner, iam_token_db_auth=False)
|
||||
reader = PrismaWrapper(original_prisma=reader_inner, iam_token_db_auth=False)
|
||||
routing = RoutingPrismaWrapper(writer=writer, reader=reader)
|
||||
routing._writer_unavailable = writer_unavailable
|
||||
return SimpleNamespace(db=routing), writer_inner, reader_inner
|
||||
|
||||
|
||||
|
|
@ -53,6 +54,20 @@ async def test_rename_replaces_the_old_name_when_no_other_deployment_carries_it(
|
|||
reader_inner.query_raw.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_update_stays_on_the_writer_while_writer_flagged_unavailable():
|
||||
prisma_client, writer_inner, reader_inner = _routed_prisma_client(deployment_count=0, writer_unavailable=True)
|
||||
|
||||
with patch(_INVALIDATE, new=AsyncMock()):
|
||||
await sync_access_groups_for_renamed_model(
|
||||
prisma_client, model_id="m-1", old_name="gpt-5.6", new_name="gpt-5.6-eu", llm_router=None
|
||||
)
|
||||
|
||||
(update_call,) = _access_group_updates(writer_inner)
|
||||
assert update_call.args[1:] == ("gpt-5.6", "gpt-5.6-eu")
|
||||
reader_inner.query_raw.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_appends_the_new_name_when_a_sibling_row_keeps_the_old_one():
|
||||
prisma_client, writer_inner, _ = _routed_prisma_client(deployment_count=1)
|
||||
|
|
@ -168,3 +183,17 @@ async def test_delete_keeps_the_name_while_a_sibling_row_still_backs_it():
|
|||
|
||||
assert _access_group_updates(writer_inner) == []
|
||||
invalidate.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_counts_backing_rows_on_the_writer_not_a_lagging_replica_while_writer_flagged_unavailable():
|
||||
prisma_client, writer_inner, reader_inner = _routed_prisma_client(deployment_count=0, writer_unavailable=True)
|
||||
reader_inner.query_raw = AsyncMock(return_value=[{"deployment_count": 1}])
|
||||
|
||||
with patch(_INVALIDATE, new=AsyncMock()) as invalidate:
|
||||
await sync_access_groups_for_deleted_model(prisma_client, model_id="m-1", model_name="gpt-5.6", llm_router=None)
|
||||
|
||||
(update_call,) = _access_group_updates(writer_inner)
|
||||
assert "array_remove" in update_call.args[0]
|
||||
invalidate.assert_awaited_once_with(("ag-1", "ag-2"))
|
||||
reader_inner.query_raw.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -3764,6 +3764,55 @@ async def test_ProxyConfig__init_agents_in_db_keeps_config_defined_agents(clean_
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("agents_source", ["config", "db", "api"])
|
||||
async def test_ProxyStartupEvent_jwt_auth_resolves_agent_claims_against_live_registry(
|
||||
clean_agent_registry, agents_source
|
||||
):
|
||||
"""A JWT agent claim must resolve against every agent the proxy knows, including ones created after startup."""
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import LiteLLM_JWTAuth
|
||||
from litellm.proxy.auth.handle_jwt import JWTAuthManager
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.types.agents import AgentResponse
|
||||
|
||||
original_lookup = proxy_server.jwt_handler.agent_lookup
|
||||
try:
|
||||
proxy_server.ProxyStartupEvent._initialize_jwt_auth(
|
||||
general_settings={"litellm_jwtauth": {"agent_id_jwt_field": "appid"}},
|
||||
prisma_client=None,
|
||||
user_api_key_cache=UserApiKeyCache(),
|
||||
)
|
||||
if agents_source == "config":
|
||||
await ProxyConfig()._init_non_llm_configs(
|
||||
config={"agents": [_config_agent("loaded-agent")]},
|
||||
config_file_path=None,
|
||||
)
|
||||
elif agents_source == "db":
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.db.litellm_agentstable.find_many = AsyncMock(
|
||||
return_value=[_FakeAgentRow("db-id", "loaded-agent")]
|
||||
)
|
||||
await ProxyConfig()._init_agents_in_db(prisma_client=prisma_client)
|
||||
else:
|
||||
clean_agent_registry.register_agent(
|
||||
agent_config=AgentResponse(agent_id="api-id", **_config_agent("loaded-agent"))
|
||||
)
|
||||
|
||||
resolved = JWTAuthManager.resolve_agent_id(
|
||||
jwt_handler=proxy_server.jwt_handler,
|
||||
jwt_valid_token={"appid": "loaded-agent"},
|
||||
agent_registry=proxy_server.jwt_handler.agent_lookup,
|
||||
)
|
||||
finally:
|
||||
proxy_server.jwt_handler.bind_agent_lookup(original_lookup)
|
||||
proxy_server.jwt_handler.update_environment(
|
||||
prisma_client=None, user_api_key_cache=UserApiKeyCache(), litellm_jwtauth=LiteLLM_JWTAuth()
|
||||
)
|
||||
|
||||
assert resolved == clean_agent_registry.get_agent_by_name(agent_name="loaded-agent").agent_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"config, expected_agent_names",
|
||||
|
|
|
|||
|
|
@ -13521,3 +13521,54 @@ async def test_token_counter_loads_a_custom_tokenizer_off_the_event_loop(monkeyp
|
|||
assert response.tokenizer_type == "huggingface_tokenizer"
|
||||
assert response.total_tokens > 0
|
||||
assert_loop_stayed_free(took, lags)
|
||||
|
||||
|
||||
async def test_token_counter_loads_a_custom_tokenizer_once_per_identifier_revision_and_token(monkeypatch):
|
||||
from tokenizers import Tokenizer
|
||||
|
||||
from litellm import Router
|
||||
from litellm.types.router import DeploymentTypedDict
|
||||
|
||||
claude_tokenizer: Final[Tokenizer] = litellm.utils._select_tokenizer("claude-fable-5")["tokenizer"]
|
||||
from_pretrained: Final = MagicMock(return_value=claude_tokenizer)
|
||||
|
||||
def deployment(model_name: str, revision: str, auth_token: str | None) -> DeploymentTypedDict:
|
||||
return {
|
||||
"model_name": model_name,
|
||||
"litellm_params": {"model": "openai/self-hosted-model", "api_base": "http://localhost:8080/v1"},
|
||||
"model_info": {
|
||||
"custom_tokenizer": {"identifier": "my-org/tokenizer", "revision": revision, "auth_token": auth_token}
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(litellm.utils, "Tokenizer", MagicMock(from_pretrained=from_pretrained))
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.llm_router",
|
||||
Router(
|
||||
model_list=[
|
||||
deployment("self-hosted", "main", None),
|
||||
deployment("self-hosted-pinned", "v2", None),
|
||||
deployment("self-hosted-private", "main", "hf_test_token"),
|
||||
]
|
||||
),
|
||||
)
|
||||
litellm.utils._select_custom_tokenizer_helper.cache_clear()
|
||||
try:
|
||||
responses: Final = [
|
||||
await proxy_server_module.token_counter(TokenCountRequest(model="self-hosted", prompt="count me once"))
|
||||
for _ in range(3)
|
||||
]
|
||||
assert from_pretrained.call_args_list == [mock.call("my-org/tokenizer", revision="main", token=None)]
|
||||
assert all(response.tokenizer_type == "huggingface_tokenizer" for response in responses)
|
||||
assert len({response.total_tokens for response in responses}) == 1
|
||||
assert responses[0].total_tokens > 0
|
||||
|
||||
await proxy_server_module.token_counter(TokenCountRequest(model="self-hosted-pinned", prompt="count me once"))
|
||||
await proxy_server_module.token_counter(TokenCountRequest(model="self-hosted-private", prompt="count me once"))
|
||||
assert from_pretrained.call_args_list == [
|
||||
mock.call("my-org/tokenizer", revision="main", token=None),
|
||||
mock.call("my-org/tokenizer", revision="v2", token=None),
|
||||
mock.call("my-org/tokenizer", revision="main", token="hf_test_token"),
|
||||
]
|
||||
finally:
|
||||
litellm.utils._select_custom_tokenizer_helper.cache_clear()
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from litellm.proxy.utils import PrismaClient, ProxyLogging
|
|||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from litellm.proxy.utils import get_custom_url, join_paths
|
||||
|
||||
|
|
@ -1303,12 +1303,10 @@ class TestPostCallFailureHookLLMExceptionAlerting:
|
|||
"""The llm_exceptions alert is for infra / LLM-API failures, not user
|
||||
errors (https://github.com/BerriAI/litellm/issues/3395). Already-normalized
|
||||
client errors must be excluded so a guardrail content-policy block never
|
||||
pages on-call. ProxyException is such an error; before LIT-3751 only
|
||||
HTTPException was excluded, so AIM blocks paged as if the LLM API failed."""
|
||||
pages on-call. 5xx proxy errors still alert."""
|
||||
|
||||
async def _alerted(self, exc) -> bool:
|
||||
async def _alerted(self, exc: Exception) -> AsyncMock:
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.proxy._types import AlertType, UserAPIKeyAuth
|
||||
|
||||
|
|
@ -1325,7 +1323,7 @@ class TestPostCallFailureHookLLMExceptionAlerting:
|
|||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
)
|
||||
await asyncio.sleep(0) # let the fire-and-forget alert task run
|
||||
return alerting_handler.called
|
||||
return alerting_handler
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_exception_does_not_alert(self):
|
||||
|
|
@ -1338,15 +1336,49 @@ class TestPostCallFailureHookLLMExceptionAlerting:
|
|||
code=400,
|
||||
openai_code="content_policy_violation",
|
||||
)
|
||||
assert await self._alerted(exc) is False
|
||||
assert (await self._alerted(exc)).called is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_exception_does_not_alert(self):
|
||||
assert await self._alerted(HTTPException(status_code=400, detail="blocked")) is False
|
||||
assert (await self._alerted(HTTPException(status_code=400, detail="blocked"))).called is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_genuine_llm_api_error_still_alerts(self):
|
||||
assert await self._alerted(Exception("upstream 503")) is True
|
||||
assert (await self._alerted(Exception("upstream 503"))).called is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_exception_5xx_alerts(self):
|
||||
alerting_handler = await self._alerted(
|
||||
HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "Headroom compression service returned an error",
|
||||
"status_code": 503,
|
||||
"guardrail_name": "headroom-compression-global",
|
||||
},
|
||||
)
|
||||
)
|
||||
assert alerting_handler.called is True
|
||||
assert "headroom-compression-global" in alerting_handler.call_args.kwargs["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_exception_5xx_alerts(self):
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
alerting_handler = await self._alerted(
|
||||
ProxyException(
|
||||
message="guardrail backend down",
|
||||
type="internal_server_error",
|
||||
param=None,
|
||||
code=503,
|
||||
)
|
||||
)
|
||||
assert alerting_handler.called is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_exception_429_does_not_alert(self):
|
||||
alerting_handler = await self._alerted(HTTPException(status_code=429, detail="rate limited"))
|
||||
assert alerting_handler.called is False
|
||||
|
||||
|
||||
class TestPostCallFailureHookProxyExceptionLogging:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Tests the rule-based complexity scoring and tier assignment logic.
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
|
|
@ -52,7 +53,13 @@ from litellm.router_strategy.complexity_router.complexity_router import (
|
|||
classification_system_prompt,
|
||||
custom_tier_classification_prompt,
|
||||
)
|
||||
from litellm.router_strategy.complexity_router.capability_classifier import (
|
||||
CAPABILITY_CLASSIFIER_SYSTEM_PROMPT,
|
||||
CapabilityClassifierVerdict,
|
||||
)
|
||||
from litellm.router_strategy.complexity_router.config import (
|
||||
CapabilityCalibrationConfig,
|
||||
CapabilityClassifierConfig,
|
||||
DEFAULT_CLASSIFICATION_RUBRIC,
|
||||
DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE,
|
||||
DEFAULT_COMPLEXITY_CONFIG,
|
||||
|
|
@ -2363,6 +2370,515 @@ class TestLLMClassifierConfig:
|
|||
)
|
||||
|
||||
|
||||
CAPABILITY_TIERS: Dict[str, str] = {
|
||||
"SIMPLE": "efficient-model",
|
||||
"REASONING": "capable-model",
|
||||
}
|
||||
|
||||
|
||||
def _capability_router_config(**overrides):
|
||||
return {
|
||||
"tiers": dict(CAPABILITY_TIERS),
|
||||
"classifier_type": "capability",
|
||||
"classifier_llm_config": {"model": "judge-model", "timeout_ms": 400},
|
||||
"capability_classifier_config": {
|
||||
"efficient_tier": "SIMPLE",
|
||||
"capable_tier": "REASONING",
|
||||
"base_threshold": 0.5,
|
||||
"threshold_step": 0.1,
|
||||
},
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
def _capability_reply(
|
||||
*,
|
||||
p_solve: float,
|
||||
primary_rule: str = "SUP-1",
|
||||
capability_boundary: str = "supported",
|
||||
crux: str = "complete the requested change",
|
||||
) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"crux": crux,
|
||||
"primary_rule": primary_rule,
|
||||
"capability_boundary": capability_boundary,
|
||||
"p_solve": p_solve,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestCapabilityClassifierConfig:
|
||||
@pytest.mark.parametrize(
|
||||
"calibration",
|
||||
(
|
||||
{"version": "v1", "slope": -1.0, "intercept": 0.0},
|
||||
{"version": "v1", "slope": float("nan"), "intercept": 0.0},
|
||||
{"version": "v1", "slope": 1.0, "intercept": float("inf")},
|
||||
{"version": "v1", "slope": True, "intercept": 0.0},
|
||||
{"version": " ", "slope": 1.0, "intercept": 0.0},
|
||||
{"version": "v1", "slope": 1.0, "intercept": 0.0, "typo": 1},
|
||||
),
|
||||
)
|
||||
def test_rejects_invalid_calibration(self, calibration: dict[str, object]) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
CapabilityCalibrationConfig.model_validate(calibration)
|
||||
|
||||
def test_calibration_round_trip_and_probability_endpoints(self) -> None:
|
||||
calibration: Final = CapabilityCalibrationConfig(version="held-out-v1", slope=0.0, intercept=0.0)
|
||||
config: Final = CapabilityClassifierConfig(
|
||||
efficient_tier="SIMPLE", capable_tier="REASONING", base_threshold=0.6, calibration=calibration
|
||||
)
|
||||
restored: Final = CapabilityClassifierConfig.model_validate_json(config.model_dump_json())
|
||||
assert restored.calibration == calibration
|
||||
assert tuple(calibration.calibrate(p) for p in (0.0, 0.5, 1.0)) == (0.5, 0.5, 0.5)
|
||||
steep: Final = CapabilityCalibrationConfig(version="endpoints", slope=20.0, intercept=-20.0)
|
||||
values: Final = tuple(steep.calibrate(p) for p in (0.0, 0.5, 1.0))
|
||||
assert all(math.isfinite(p) and 0.0 <= p <= 1.0 for p in values)
|
||||
assert values[0] < values[1] < values[2]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch,error_match",
|
||||
[
|
||||
({"classifier_llm_config": None}, "classifier_llm_config is required"),
|
||||
({"capability_classifier_config": None}, "capability_classifier_config is required"),
|
||||
(
|
||||
{
|
||||
"capability_classifier_config": {
|
||||
"efficient_tier": "SIMPLE",
|
||||
"capable_tier": "SIMPLE",
|
||||
"base_threshold": 0.5,
|
||||
}
|
||||
},
|
||||
"must be a higher tier",
|
||||
),
|
||||
(
|
||||
{
|
||||
"capability_classifier_config": {
|
||||
"efficient_tier": "REASONING",
|
||||
"capable_tier": "SIMPLE",
|
||||
"base_threshold": 0.5,
|
||||
}
|
||||
},
|
||||
"must be a higher tier",
|
||||
),
|
||||
(
|
||||
{
|
||||
"capability_classifier_config": {
|
||||
"efficient_tier": "MEDIUM",
|
||||
"capable_tier": "REASONING",
|
||||
"base_threshold": 0.5,
|
||||
}
|
||||
},
|
||||
"has no model configured",
|
||||
),
|
||||
(
|
||||
{
|
||||
"capability_classifier_config": {
|
||||
"efficient_tier": "SIMPLE",
|
||||
"capable_tier": "REASONING",
|
||||
"base_threshold": 0.9,
|
||||
"threshold_step": 0.1,
|
||||
}
|
||||
},
|
||||
r"base_threshold \+ 2 \* threshold_step must be at most 1",
|
||||
),
|
||||
({"classifier_fallback": "default_model", "default_model": "fallback"}, "always fails closed"),
|
||||
(
|
||||
{"classifier_llm_config": {"model": "judge-model", "system_prompt": "pick one"}},
|
||||
"uses the packaged capability card",
|
||||
),
|
||||
({"classification_examples": "example"}, "uses the packaged capability card"),
|
||||
],
|
||||
)
|
||||
def test_rejects_incoherent_configuration(self, patch, error_match):
|
||||
with pytest.raises(ValidationError, match=error_match):
|
||||
ComplexityRouterConfig(**{**_capability_router_config(), **patch})
|
||||
|
||||
def test_capability_config_is_rejected_on_other_classifier_types(self):
|
||||
config = _capability_router_config(classifier_type="llm")
|
||||
with pytest.raises(ValidationError, match="requires classifier_type 'capability'"):
|
||||
ComplexityRouterConfig(**config)
|
||||
|
||||
def test_rejects_misspelled_optional_policy_instead_of_using_defaults(self) -> None:
|
||||
with pytest.raises(ValidationError, match="threshold_steps"):
|
||||
CapabilityClassifierConfig.model_validate(
|
||||
{
|
||||
"efficient_tier": "SIMPLE",
|
||||
"capable_tier": "REASONING",
|
||||
"base_threshold": 0.5,
|
||||
"threshold_steps": 0.2,
|
||||
}
|
||||
)
|
||||
|
||||
def test_threshold_defaults_match_switchyard(self):
|
||||
config = CapabilityClassifierConfig(efficient_tier=" SIMPLE ", capable_tier=" REASONING ", base_threshold=0.5)
|
||||
assert config.efficient_tier == "SIMPLE"
|
||||
assert config.capable_tier == "REASONING"
|
||||
assert config.threshold_step == 0.0
|
||||
assert config.max_output_tokens == 4096
|
||||
|
||||
def test_classifier_model_is_registered_as_a_dependency(self):
|
||||
assert ComplexityRouterConfig(**_capability_router_config()).uses_llm_classifier is True
|
||||
|
||||
|
||||
class TestCapabilityClassifierVerdict:
|
||||
@pytest.mark.parametrize(
|
||||
"primary_rule,capability_boundary",
|
||||
[
|
||||
*((f"SUP-{index}", "supported") for index in range(1, 6)),
|
||||
*((f"UNC-{index}", "uncertain") for index in range(1, 3)),
|
||||
*((f"LIM-{index}", "unsupported") for index in range(1, 3)),
|
||||
("none", "unmatched"),
|
||||
],
|
||||
)
|
||||
def test_accepts_every_valid_rule_boundary_pair(self, primary_rule, capability_boundary):
|
||||
verdict = CapabilityClassifierVerdict(
|
||||
crux="the hard part",
|
||||
primary_rule=primary_rule,
|
||||
capability_boundary=capability_boundary,
|
||||
p_solve=0.5,
|
||||
)
|
||||
assert verdict.primary_rule == primary_rule
|
||||
assert verdict.capability_boundary == capability_boundary
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload,error_match",
|
||||
[
|
||||
(
|
||||
{
|
||||
"crux": "x",
|
||||
"primary_rule": "SUP-1",
|
||||
"capability_boundary": "unsupported",
|
||||
"p_solve": 0.5,
|
||||
},
|
||||
"requires capability_boundary",
|
||||
),
|
||||
(
|
||||
{"crux": " ", "primary_rule": "none", "capability_boundary": "unmatched", "p_solve": 0.5},
|
||||
"non-whitespace",
|
||||
),
|
||||
(
|
||||
{
|
||||
"crux": "x",
|
||||
"primary_rule": "none",
|
||||
"capability_boundary": "unmatched",
|
||||
"p_solve": 0.5,
|
||||
"recommended_route": "efficient",
|
||||
},
|
||||
"Extra inputs are not permitted",
|
||||
),
|
||||
(
|
||||
{"crux": "x", "primary_rule": "none", "capability_boundary": "unmatched", "p_solve": True},
|
||||
"valid number",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_or_inconsistent_verdicts(self, payload, error_match):
|
||||
with pytest.raises(ValidationError, match=error_match):
|
||||
CapabilityClassifierVerdict.model_validate(payload)
|
||||
|
||||
|
||||
class TestCapabilityClassifier:
|
||||
@staticmethod
|
||||
def _router(mock_router_instance, **overrides):
|
||||
return ComplexityRouter(
|
||||
model_name="capability-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=_capability_router_config(**overrides),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encrypted_task_is_not_replaced_by_plaintext_envelope(self, mock_router_instance: MagicMock) -> None:
|
||||
mock_router_instance.aresponses = AsyncMock(
|
||||
return_value=_native_classifier_response(_capability_reply(p_solve=0.8))
|
||||
)
|
||||
router: Final = self._router(mock_router_instance)
|
||||
task: Final = _encrypted_agent_task()
|
||||
request: Final = {"input": [task]}
|
||||
original: Final = deepcopy(request)
|
||||
result: Final = await router.async_pre_routing_hook(model="capability-router", request_kwargs=request)
|
||||
assert result is not None and result.model == "efficient-model"
|
||||
assert result.routing_decision is not None
|
||||
assert result.routing_decision["cause"] == "capability_classifier"
|
||||
mock_router_instance.aresponses.assert_awaited_once()
|
||||
call: Final = mock_router_instance.aresponses.call_args.kwargs
|
||||
assert call["input"][-1] == task
|
||||
plaintext: Final = json.dumps(call["input"][:-1])
|
||||
assert "The delegated task in the following agent_message." in plaintext
|
||||
assert "Message Type: NEW_TASK" not in plaintext
|
||||
assert "opaque-provider-task" not in plaintext
|
||||
assert request == original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("custom_markers", (False, True))
|
||||
async def test_task_forecast_uses_request_scoped_codex_markers(
|
||||
self, mock_router_instance: MagicMock, custom_markers: bool
|
||||
) -> None:
|
||||
completion: Final = AsyncMock(return_value=_llm_response(_capability_reply(p_solve=0.8)))
|
||||
mock_router_instance.acompletion = completion
|
||||
router: Final = self._router(
|
||||
mock_router_instance,
|
||||
escalation_keywords=[],
|
||||
**({"reminder_markers": [{"open": "<custom>", "close": "</custom>"}]} if custom_markers else {}),
|
||||
)
|
||||
envelope: Final = "\n".join(_CODEX_ENVELOPES)
|
||||
opening: Final = f"{envelope}\nFix nested behavior"
|
||||
messages: Final = [
|
||||
{"role": "user", "content": opening},
|
||||
{"role": "user", "content": "Preserve empty inputs"},
|
||||
{"role": "user", "content": envelope},
|
||||
]
|
||||
original: Final = deepcopy(messages)
|
||||
for user_agent in ("codex-tui", "curl/8.7.1", "codex_cli_rs/0.62.0"):
|
||||
result: Final = await router.async_pre_routing_hook(
|
||||
model="capability-router", messages=messages, request_kwargs={"metadata": {"user_agent": user_agent}}
|
||||
)
|
||||
assert result is not None and result.model == "efficient-model"
|
||||
sent: Final = completion.call_args.kwargs["messages"]
|
||||
if user_agent.startswith("codex") and not custom_markers:
|
||||
assert [message["content"] for message in sent[1:]] == ["Fix nested behavior", "Preserve empty inputs"]
|
||||
else:
|
||||
assert [message["content"] for message in sent[1:]] == [opening, envelope]
|
||||
assert result.messages == original
|
||||
assert completion.await_count == 3
|
||||
assert messages == original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("p_solve,expected_model", ((0.95, "capable-model"), (0.98, "efficient-model")))
|
||||
async def test_fitted_probability_controls_routing_and_preserves_raw_score(
|
||||
self, mock_router_instance: MagicMock, p_solve: float, expected_model: str
|
||||
) -> None:
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response(_capability_reply(p_solve=p_solve)))
|
||||
router: Final = self._router(
|
||||
mock_router_instance,
|
||||
capability_classifier_config={
|
||||
"efficient_tier": "SIMPLE",
|
||||
"capable_tier": "REASONING",
|
||||
"base_threshold": 0.66,
|
||||
"threshold_step": 0.1,
|
||||
"calibration": {
|
||||
"version": "qwen3-haiku45-mini-swe-v1",
|
||||
"slope": 0.1482462649948327,
|
||||
"intercept": 0.1895438369492216,
|
||||
},
|
||||
},
|
||||
)
|
||||
result: Final = await router.async_pre_routing_hook(
|
||||
model="capability-router", request_kwargs={}, messages=[{"role": "user", "content": "Fix the issue"}]
|
||||
)
|
||||
assert result is not None and result.model == expected_model
|
||||
decision: Final = result.routing_decision
|
||||
assert decision is not None
|
||||
assert decision["classifier_p_solve"] == p_solve
|
||||
assert decision["classifier_threshold"] == 0.66
|
||||
assert decision["classifier_calibration_version"] == "qwen3-haiku45-mini-swe-v1"
|
||||
assert 0.65 < decision["classifier_calibrated_p_solve"] < 0.69
|
||||
assert (decision["classifier_calibrated_p_solve"] >= 0.66) == (expected_model == "efficient-model")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mode", ("json_schema", "json_object"))
|
||||
async def test_response_modes_preserve_the_card_and_validate_the_same_verdict(
|
||||
self, mock_router_instance: MagicMock, mode: str
|
||||
) -> None:
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response(_capability_reply(p_solve=0.8)))
|
||||
router: Final = self._router(
|
||||
mock_router_instance,
|
||||
capability_classifier_config={
|
||||
"efficient_tier": "SIMPLE",
|
||||
"capable_tier": "REASONING",
|
||||
"base_threshold": 0.5,
|
||||
"response_format": mode,
|
||||
},
|
||||
)
|
||||
outcome: Final = await router.aclassify("Fix the issue")
|
||||
assert outcome.tier == ComplexityTier.SIMPLE
|
||||
call: Final = mock_router_instance.acompletion.call_args.kwargs
|
||||
system_prompt: Final = call["messages"][0]["content"]
|
||||
assert call["response_format"]["type"] == mode
|
||||
if mode == "json_object":
|
||||
marker: Final = "\n\nReturn exactly one JSON object matching this JSON Schema:\n"
|
||||
assert system_prompt.startswith(CAPABILITY_CLASSIFIER_SYSTEM_PROMPT + marker)
|
||||
schema: Final = json.loads(system_prompt.split(marker)[1])
|
||||
assert schema["required"] == ["crux", "primary_rule", "capability_boundary", "p_solve"]
|
||||
assert schema["additionalProperties"] is False
|
||||
else:
|
||||
assert system_prompt == CAPABILITY_CLASSIFIER_SYSTEM_PROMPT
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response("invalid JSON"))
|
||||
assert (await router.aclassify("Fix another issue")).tier == ComplexityTier.REASONING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("reply", ("invalid JSON", _capability_reply(p_solve=0.0)))
|
||||
async def test_adaptive_selection_cannot_undo_a_capable_verdict(
|
||||
self, mock_router_instance: MagicMock, reply: str
|
||||
) -> None:
|
||||
from litellm.router_strategy.adaptive_router.bandit import BanditCell
|
||||
from litellm.types.router import RequestType
|
||||
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response(reply))
|
||||
mock_router_instance.model_list = [
|
||||
{"model_name": "efficient-model", "litellm_params": {"input_cost_per_token": 0.000001}},
|
||||
{"model_name": "capable-model", "litellm_params": {"input_cost_per_token": 0.00001}},
|
||||
]
|
||||
mock_router_instance.model_name_to_deployment_indices = {"efficient-model": [0], "capable-model": [1]}
|
||||
router: Final = self._router(
|
||||
mock_router_instance,
|
||||
adaptive=True,
|
||||
adaptive_eligible="all",
|
||||
adaptive_weights={"quality": 0.0, "cost": 1.0},
|
||||
tier_distance_penalty=0.0,
|
||||
tiers={"SIMPLE": ["efficient-model"], "REASONING": ["capable-model"]},
|
||||
)
|
||||
adaptive: Final = router._ensure_adaptive_router()
|
||||
assert adaptive is not None
|
||||
for model in ("efficient-model", "capable-model"):
|
||||
adaptive._cells[(RequestType.GENERAL, model)] = BanditCell(alpha=20.0, beta=1.0)
|
||||
assert router._soft_floor_pick(ComplexityTier.REASONING, "Fix the issue") == "efficient-model"
|
||||
result: Final = await router.async_pre_routing_hook(
|
||||
model="capability-router", request_kwargs={}, messages=[{"role": "user", "content": "Fix the issue"}]
|
||||
)
|
||||
assert result is not None and result.model == "capable-model"
|
||||
assert result.routing_decision is not None
|
||||
assert result.routing_decision["tier"] == "REASONING"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"p_solve,primary_rule,boundary,expected_tier,expected_threshold",
|
||||
[
|
||||
(0.5, "SUP-1", "supported", ComplexityTier.SIMPLE, 0.5),
|
||||
(0.59, "UNC-1", "uncertain", ComplexityTier.REASONING, 0.6),
|
||||
(0.6, "UNC-1", "uncertain", ComplexityTier.SIMPLE, 0.6),
|
||||
(0.59, "none", "unmatched", ComplexityTier.REASONING, 0.6),
|
||||
(0.69, "LIM-1", "unsupported", ComplexityTier.REASONING, 0.7),
|
||||
(0.7, "LIM-1", "unsupported", ComplexityTier.SIMPLE, 0.7),
|
||||
],
|
||||
)
|
||||
async def test_boundary_adjusted_threshold_is_inclusive(
|
||||
self, mock_router_instance, p_solve, primary_rule, boundary, expected_tier, expected_threshold
|
||||
):
|
||||
mock_router_instance.acompletion = AsyncMock(
|
||||
return_value=_llm_response(
|
||||
_capability_reply(p_solve=p_solve, primary_rule=primary_rule, capability_boundary=boundary)
|
||||
)
|
||||
)
|
||||
outcome = await self._router(mock_router_instance).aclassify("do the task")
|
||||
assert outcome.tier == expected_tier
|
||||
assert outcome.cause == "capability_classifier"
|
||||
assert outcome.capability_forecast is not None
|
||||
assert outcome.capability_forecast.threshold == pytest.approx(expected_threshold)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fenced_json_verdict_is_accepted(self, mock_router_instance):
|
||||
reply = _capability_reply(p_solve=0.8)
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response(f"```json\n{reply}\n```"))
|
||||
outcome = await self._router(mock_router_instance).aclassify("do the task")
|
||||
assert outcome.tier == ComplexityTier.SIMPLE
|
||||
assert outcome.cause == "capability_classifier"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decimal_rounding_does_not_break_inclusive_threshold(self, mock_router_instance):
|
||||
config = _capability_router_config(
|
||||
capability_classifier_config={
|
||||
"efficient_tier": "SIMPLE",
|
||||
"capable_tier": "REASONING",
|
||||
"base_threshold": 0.1,
|
||||
"threshold_step": 0.1,
|
||||
}
|
||||
)
|
||||
mock_router_instance.acompletion = AsyncMock(
|
||||
return_value=_llm_response(
|
||||
_capability_reply(p_solve=0.3, primary_rule="LIM-1", capability_boundary="unsupported")
|
||||
)
|
||||
)
|
||||
router = ComplexityRouter(
|
||||
model_name="capability-router",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
complexity_router_config=config,
|
||||
)
|
||||
outcome = await router.aclassify("do the task")
|
||||
assert outcome.capability_forecast is not None
|
||||
assert outcome.capability_forecast.threshold == 0.30000000000000004
|
||||
assert outcome.tier == ComplexityTier.SIMPLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_uses_packaged_prompt_schema_and_opening_plus_latest_user_task(self, mock_router_instance):
|
||||
mock_router_instance.acompletion = AsyncMock(
|
||||
return_value=_llm_response(_capability_reply(p_solve=0.8), response_cost=0.002)
|
||||
)
|
||||
router = self._router(mock_router_instance)
|
||||
messages = [
|
||||
{"role": "system", "content": "Never expose this caller instruction to the judge"},
|
||||
{"role": "user", "content": "Build the feature"},
|
||||
{"role": "assistant", "content": "I need more information"},
|
||||
{"role": "user", "content": "Use the existing API"},
|
||||
]
|
||||
|
||||
response = await router.async_pre_routing_hook(model="capability-router", request_kwargs={}, messages=messages)
|
||||
|
||||
assert response.model == "efficient-model"
|
||||
call = mock_router_instance.acompletion.call_args.kwargs
|
||||
assert call["messages"] == [
|
||||
{"role": "system", "content": CAPABILITY_CLASSIFIER_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": "Build the feature"},
|
||||
{"role": "user", "content": "Use the existing API"},
|
||||
]
|
||||
schema = call["response_format"]["json_schema"]["schema"]
|
||||
assert call["response_format"]["json_schema"]["name"] == "CapabilityClassifierDecision"
|
||||
assert call["response_format"]["json_schema"]["strict"] is True
|
||||
assert schema["additionalProperties"] is False
|
||||
assert set(schema["required"]) == {"crux", "primary_rule", "capability_boundary", "p_solve"}
|
||||
assert schema["properties"]["primary_rule"]["enum"] == [
|
||||
"SUP-1",
|
||||
"SUP-2",
|
||||
"SUP-3",
|
||||
"SUP-4",
|
||||
"SUP-5",
|
||||
"UNC-1",
|
||||
"UNC-2",
|
||||
"LIM-1",
|
||||
"LIM-2",
|
||||
"none",
|
||||
]
|
||||
assert call["max_tokens"] == 4096
|
||||
decision = response.routing_decision
|
||||
assert decision["cause"] == "capability_classifier"
|
||||
assert decision["classifier_model"] == "judge-model"
|
||||
assert decision["classifier_cost"] == 0.002
|
||||
assert decision["classifier_crux"] == "complete the requested change"
|
||||
assert decision["classifier_primary_rule"] == "SUP-1"
|
||||
assert decision["classifier_capability_boundary"] == "supported"
|
||||
assert decision["classifier_p_solve"] == 0.8
|
||||
assert decision["classifier_threshold"] == 0.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"reply",
|
||||
[
|
||||
"not json",
|
||||
_capability_reply(p_solve=0.9, primary_rule="SUP-1", capability_boundary="unsupported"),
|
||||
'{"crux":"x","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9,"route":"efficient"}',
|
||||
],
|
||||
ids=["malformed", "inconsistent-pair", "extra-field"],
|
||||
)
|
||||
async def test_invalid_verdict_fails_closed_to_capable_tier(self, mock_router_instance, reply):
|
||||
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response(reply))
|
||||
outcome = await self._router(mock_router_instance).aclassify("do the task")
|
||||
assert outcome.tier == ComplexityTier.REASONING
|
||||
assert outcome.cause == "capability_classifier_fallback"
|
||||
assert outcome.signals == ("capability-classifier-fallback",)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classifier_call_failure_fails_closed_to_capable_model(self, mock_router_instance):
|
||||
mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("judge unavailable"))
|
||||
response = await self._router(mock_router_instance).async_pre_routing_hook(
|
||||
model="capability-router",
|
||||
request_kwargs={},
|
||||
messages=[{"role": "user", "content": "do the task"}],
|
||||
)
|
||||
assert response.model == "capable-model"
|
||||
assert response.routing_decision["cause"] == "capability_classifier_fallback"
|
||||
|
||||
|
||||
CUSTOM_TIER_LABELS: Dict[str, str] = {
|
||||
"SIMPLE": "Cheap",
|
||||
"MEDIUM": "Standard",
|
||||
|
|
@ -8342,6 +8858,13 @@ class TestRedactedLoggingDropsPromptText:
|
|||
"score": 0.8,
|
||||
"tier_boundaries": {"simple_medium": 0.15, "medium_complex": 0.35, "complex_reasoning": 0.6},
|
||||
"classifier_model": "claude-haiku",
|
||||
"classifier_crux": "deploy the requested service to k8s",
|
||||
"classifier_primary_rule": "SUP-2",
|
||||
"classifier_capability_boundary": "supported",
|
||||
"classifier_p_solve": 0.8,
|
||||
"classifier_calibrated_p_solve": 0.65,
|
||||
"classifier_calibration_version": "fitted-v1",
|
||||
"classifier_threshold": 0.5,
|
||||
"escalated": True,
|
||||
"tier_litellm_params": {"reasoning_effort": "xhigh"},
|
||||
"signals": ["code (python)"],
|
||||
|
|
@ -8349,7 +8872,15 @@ class TestRedactedLoggingDropsPromptText:
|
|||
"escalation_keyword": "LITELLM ESCALATE",
|
||||
}
|
||||
kept = Router._redact_prompt_text_if_needed(request_kwargs={}, routing_decision=full)
|
||||
assert set(full) - set(kept) == {"signals", "matched_keyword", "escalation_keyword"}
|
||||
assert set(full) - set(kept) == {
|
||||
"signals",
|
||||
"matched_keyword",
|
||||
"escalation_keyword",
|
||||
"classifier_crux",
|
||||
}
|
||||
assert kept["classifier_p_solve"] == 0.8
|
||||
assert kept["classifier_calibrated_p_solve"] == 0.65
|
||||
assert kept["classifier_calibration_version"] == "fitted-v1"
|
||||
assert kept["tier_litellm_params"] == {"reasoning_effort": "xhigh"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -8463,8 +8994,10 @@ class TestContextAwareClassifier:
|
|||
assert messages == original_messages
|
||||
assert (claude_kwargs, compared_kwargs) == original_kwargs
|
||||
calls: Final = tuple(call.kwargs["messages"] for call in dependency.acompletion.await_args_list)
|
||||
assert calls[0][0]["content"] == calls[1][0]["content"] == classification_system_prompt(
|
||||
router.config.classifier_context_window_size
|
||||
assert (
|
||||
calls[0][0]["content"]
|
||||
== calls[1][0]["content"]
|
||||
== classification_system_prompt(router.config.classifier_context_window_size)
|
||||
)
|
||||
payloads: Final = (calls[0][1]["content"], calls[1][1]["content"])
|
||||
for payload, expected_system in zip(payloads, (False, forwards_system)):
|
||||
|
|
@ -13653,11 +14186,7 @@ class TestHealthFallbackDispatch:
|
|||
"api_key": "test-only",
|
||||
"api_base": f"https://{name}.test{base_suffix}",
|
||||
**({"tags": [name]} if tagged else {}),
|
||||
**(
|
||||
{"max_budget": 1.0, "budget_duration": "1d"}
|
||||
if budgeted and name == "primary"
|
||||
else {}
|
||||
),
|
||||
**({"max_budget": 1.0, "budget_duration": "1d"} if budgeted and name == "primary" else {}),
|
||||
},
|
||||
"model_info": {"id": f"{name}-id"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,20 @@ the implicit `"default"` group driven by the router's top-level
|
|||
`routing_strategy` / `routing_strategy_args`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm import Router
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.router import RoutingGroup, RoutingStrategy
|
||||
from litellm.utils import Rules, function_setup
|
||||
|
||||
|
||||
def _model_list():
|
||||
|
|
@ -954,6 +959,223 @@ def test_sync_pass_through_specific_deployment_runs_the_override_pre_call_check(
|
|||
assert plain["model_info"]["id"] == "deploy-3"
|
||||
|
||||
|
||||
def _two_deployment_model_list(**d1_params: object) -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"model_name": "grp",
|
||||
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-test-1", "mock_response": "ok", **d1_params},
|
||||
"model_info": {"id": "d1"},
|
||||
},
|
||||
{
|
||||
"model_name": "grp",
|
||||
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-test-2", "mock_response": "ok"},
|
||||
"model_info": {"id": "d2"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _proxy_shaped_request(**data: object) -> dict[str, object]:
|
||||
"""The proxy builds the request's `Logging` object before it hands the call to the router."""
|
||||
logging_obj, kwargs = function_setup(
|
||||
"acompletion",
|
||||
Rules(),
|
||||
datetime.datetime.now(),
|
||||
litellm_call_id=str(uuid.uuid4()),
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
**data,
|
||||
)
|
||||
return {**kwargs, "litellm_logging_obj": logging_obj}
|
||||
|
||||
|
||||
async def _async_override_pick(router: Router, strategy: str) -> str:
|
||||
deployment = await router.async_get_available_deployment(
|
||||
"grp", request_kwargs=_proxy_shaped_request(model="grp", routing_strategy=strategy)
|
||||
)
|
||||
return deployment["model_info"]["id"]
|
||||
|
||||
|
||||
def _sync_override_pick(router: Router, strategy: str) -> str:
|
||||
deployment = router.get_available_deployment(
|
||||
"grp", request_kwargs=_proxy_shaped_request(model="grp", routing_strategy=strategy)
|
||||
)
|
||||
return deployment["model_info"]["id"]
|
||||
|
||||
|
||||
def _in_flight(router: Router, deployment_id: str) -> int | None:
|
||||
return router.cache.get_cache(f"grp_request_count:{deployment_id}")
|
||||
|
||||
|
||||
async def _async_wait_until(predicate: Callable[[], bool]) -> None:
|
||||
for _ in range(100):
|
||||
if predicate():
|
||||
return
|
||||
await asyncio.sleep(0.02)
|
||||
raise AssertionError("lifecycle callback never reached the override selector")
|
||||
|
||||
|
||||
def _sync_wait_until(predicate: Callable[[], bool]) -> None:
|
||||
for _ in range(100):
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("lifecycle callback never reached the override selector")
|
||||
|
||||
|
||||
def _selector_is_not_global(selector: CustomLogger) -> bool:
|
||||
global_lists = (
|
||||
litellm.callbacks,
|
||||
litellm.input_callback,
|
||||
litellm.success_callback,
|
||||
litellm.failure_callback,
|
||||
litellm._async_success_callback,
|
||||
litellm._async_failure_callback,
|
||||
)
|
||||
return not any(cb is selector for cbs in global_lists for cb in cbs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_least_busy_override_sees_the_overriding_request_in_flight():
|
||||
router = Router(model_list=_two_deployment_model_list(), routing_strategy="simple-shuffle", num_retries=0)
|
||||
|
||||
stream = await router.acompletion(**_proxy_shaped_request(model="grp", routing_strategy="least-busy", stream=True))
|
||||
busy = stream._hidden_params["model_id"]
|
||||
idle = "d2" if busy == "d1" else "d1"
|
||||
assert [await _async_override_pick(router, "least-busy") for _ in range(3)] == [idle, idle, idle]
|
||||
|
||||
async for _ in stream:
|
||||
pass
|
||||
await _async_wait_until(lambda: _in_flight(router, busy) == 0)
|
||||
assert await _async_override_pick(router, "least-busy") == "d1"
|
||||
assert _selector_is_not_global(router._override_selectors["least-busy"])
|
||||
|
||||
|
||||
def test_sync_least_busy_override_sees_the_overriding_request_in_flight():
|
||||
router = Router(model_list=_two_deployment_model_list(), routing_strategy="simple-shuffle", num_retries=0)
|
||||
|
||||
stream = router.completion(**_proxy_shaped_request(model="grp", routing_strategy="least-busy", stream=True))
|
||||
busy = stream._hidden_params["model_id"]
|
||||
idle = "d2" if busy == "d1" else "d1"
|
||||
assert [_sync_override_pick(router, "least-busy") for _ in range(3)] == [idle, idle, idle]
|
||||
|
||||
for _ in stream:
|
||||
pass
|
||||
_sync_wait_until(lambda: _in_flight(router, busy) == 0)
|
||||
assert _sync_override_pick(router, "least-busy") == "d1"
|
||||
assert _selector_is_not_global(router._override_selectors["least-busy"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_least_busy_override_releases_the_slot_when_the_overriding_request_fails():
|
||||
router = Router(
|
||||
model_list=_two_deployment_model_list(mock_response="litellm.InternalServerError"),
|
||||
routing_strategy="simple-shuffle",
|
||||
num_retries=0,
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.InternalServerError):
|
||||
await router.acompletion(**_proxy_shaped_request(model="grp", routing_strategy="least-busy"))
|
||||
|
||||
await _async_wait_until(lambda: _in_flight(router, "d1") == 0)
|
||||
assert await _async_override_pick(router, "least-busy") == "d1"
|
||||
assert _selector_is_not_global(router._override_selectors["least-busy"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_latency_based_override_learns_from_the_overriding_requests():
|
||||
router = Router(
|
||||
model_list=_two_deployment_model_list(mock_delay=0.05), routing_strategy="simple-shuffle", num_retries=0
|
||||
)
|
||||
|
||||
def samples(deployment_id: str) -> list[float]:
|
||||
recorded = (router.cache.get_cache("grp_map") or {}).get(deployment_id, {}).get("latency", [])
|
||||
return [latency for latency in recorded if latency > 0]
|
||||
|
||||
async def overriding_call() -> str:
|
||||
sampled_before = {"d1": len(samples("d1")), "d2": len(samples("d2"))}
|
||||
response = await router.acompletion(
|
||||
**_proxy_shaped_request(model="grp", routing_strategy="latency-based-routing")
|
||||
)
|
||||
deployment_id = response._hidden_params["model_id"]
|
||||
await _async_wait_until(lambda: len(samples(deployment_id)) > sampled_before[deployment_id])
|
||||
return deployment_id
|
||||
|
||||
served = [await overriding_call() for _ in range(6)]
|
||||
|
||||
assert "d1" in served
|
||||
assert served[2:] == ["d2"] * 4
|
||||
assert _selector_is_not_global(router._override_selectors["latency-based-routing"])
|
||||
|
||||
|
||||
def test_override_selector_is_bound_only_to_the_request_that_asked_for_it():
|
||||
router = Router(model_list=_two_deployment_model_list(), routing_strategy="simple-shuffle")
|
||||
overriding = _proxy_shaped_request(model="grp", routing_strategy="least-busy")
|
||||
plain = _proxy_shaped_request(model="grp")
|
||||
|
||||
router.get_available_deployment("grp", request_kwargs=overriding)
|
||||
router.get_available_deployment("grp", request_kwargs=overriding)
|
||||
router.get_available_deployment("grp", request_kwargs=plain)
|
||||
|
||||
selector = router._override_selectors["least-busy"]
|
||||
bound = overriding["litellm_logging_obj"]
|
||||
for callbacks in (
|
||||
bound.dynamic_input_callbacks,
|
||||
bound.dynamic_success_callbacks,
|
||||
bound.dynamic_async_success_callbacks,
|
||||
bound.dynamic_failure_callbacks,
|
||||
bound.dynamic_async_failure_callbacks,
|
||||
):
|
||||
assert callbacks == [selector]
|
||||
unbound = plain["litellm_logging_obj"]
|
||||
assert unbound.dynamic_input_callbacks is None and unbound.dynamic_success_callbacks is None
|
||||
assert unbound.dynamic_failure_callbacks is None and unbound.dynamic_async_failure_callbacks is None
|
||||
|
||||
|
||||
def test_override_matching_the_router_strategy_is_not_bound_twice():
|
||||
router = Router(model_list=_two_deployment_model_list(), routing_strategy="least-busy")
|
||||
request = _proxy_shaped_request(model="grp", routing_strategy="least-busy")
|
||||
|
||||
router.get_available_deployment("grp", request_kwargs=request)
|
||||
|
||||
assert request["litellm_logging_obj"].dynamic_input_callbacks is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_override_matching_a_routing_group_strategy_records_each_request_once():
|
||||
router = Router(
|
||||
model_list=_two_deployment_model_list(),
|
||||
routing_strategy="simple-shuffle",
|
||||
routing_groups=[RoutingGroup(group_name="lat", models=["grp"], routing_strategy="latency-based-routing")],
|
||||
num_retries=0,
|
||||
)
|
||||
request = _proxy_shaped_request(model="grp", routing_strategy="latency-based-routing")
|
||||
assert router._globally_registered_strategies() == {"simple-shuffle", "latency-based-routing"}
|
||||
|
||||
response = await router.acompletion(**request)
|
||||
deployment_id = response._hidden_params["model_id"]
|
||||
await _async_wait_until(lambda: (router.cache.get_cache("grp_map") or {}).get(deployment_id) is not None)
|
||||
|
||||
assert len(router.cache.get_cache("grp_map")[deployment_id]["latency"]) == 1
|
||||
assert request["litellm_logging_obj"].dynamic_success_callbacks is None
|
||||
|
||||
|
||||
def test_bind_override_selector_to_request_binds_once_and_ignores_requests_without_logging():
|
||||
router = Router(model_list=_two_deployment_model_list(), routing_strategy="simple-shuffle")
|
||||
selector = router._get_override_strategy_selector("least-busy")
|
||||
request = _proxy_shaped_request(model="grp", routing_strategy="least-busy")
|
||||
request["litellm_logging_obj"].dynamic_success_callbacks = ["langfuse"]
|
||||
|
||||
router._bind_override_selector_to_request("least-busy", selector, request)
|
||||
router._bind_override_selector_to_request("least-busy", selector, request)
|
||||
router._bind_override_selector_to_request("least-busy", selector, None)
|
||||
router._bind_override_selector_to_request("least-busy", selector, {"model": "grp"})
|
||||
|
||||
logging_obj = request["litellm_logging_obj"]
|
||||
assert logging_obj.dynamic_success_callbacks == ["langfuse", selector]
|
||||
assert logging_obj.dynamic_input_callbacks == [selector]
|
||||
assert logging_obj.dynamic_async_failure_callbacks == [selector]
|
||||
assert _selector_is_not_global(selector)
|
||||
|
||||
|
||||
def _quality_group(strategy="latency-based-routing"):
|
||||
return [{"group_name": "quality", "models": ["filtered-model", "other-model"], "routing_strategy": strategy}]
|
||||
|
||||
|
|
|
|||
|
|
@ -214,6 +214,22 @@ def test_config_check_ignores_the_model_entirely():
|
|||
},
|
||||
(("a", "tier"), ("clf", "classifier")),
|
||||
),
|
||||
(
|
||||
{
|
||||
"model": "auto_router/complexity_router",
|
||||
"complexity_router_config": {
|
||||
"tiers": {"SIMPLE": "a", "REASONING": "b"},
|
||||
"classifier_type": "capability",
|
||||
"classifier_llm_config": {"model": "clf"},
|
||||
"capability_classifier_config": {
|
||||
"efficient_tier": "SIMPLE",
|
||||
"capable_tier": "REASONING",
|
||||
"base_threshold": 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
(("a", "tier"), ("b", "tier"), ("clf", "classifier")),
|
||||
),
|
||||
(
|
||||
{
|
||||
"model": "auto_router/complexity_router",
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
"""
|
||||
Validate that the native (first-party) Anthropic Claude Sonnet 4.5 / 4.6 entries
|
||||
carry the 1-hour prompt-cache write tier (`cache_creation_input_token_cost_above_1hr`)
|
||||
in `model_prices_and_context_window.json`.
|
||||
|
||||
Anthropic's first-party API charges a separate 1-hour cache write rate (2x base
|
||||
input) alongside the 5-minute write (1.25x base input) and cache read (0.1x base
|
||||
input). The 1h/5m ratio is therefore 1.6. Without the 1-hour field, cost tracking
|
||||
on 1-hour-TTL prompt caching falls back to the 5-minute rate and undercounts spend.
|
||||
|
||||
The native (non-bedrock) `claude-sonnet-4-5*` / `claude-sonnet-4-6` entries were
|
||||
missing this field, while every sibling (`vertex_ai/`, `azure_ai/`, the
|
||||
`*.anthropic.*` Bedrock profiles) and the older `claude-sonnet-4-20250514` already
|
||||
carried it. This test guards against regression.
|
||||
|
||||
Values (per token):
|
||||
Sonnet base input 3e-06 -> 5m 3.75e-06, 1h 6e-06
|
||||
Sonnet 4.5 long-context (>200K) base 6e-06 -> 5m 7.5e-06, 1h 1.2e-05
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# (model_key, expected 1hr write per token, expected 1hr long-context tier or None)
|
||||
EXPECTED = [
|
||||
("claude-sonnet-4-5", 6e-06, 1.2e-05),
|
||||
("claude-sonnet-4-5-20250929", 6e-06, 1.2e-05),
|
||||
("claude-sonnet-4-5-20250929-v1:0", 6e-06, 1.2e-05),
|
||||
("claude-sonnet-4-6", 6e-06, None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_key, expected_1hr, expected_1hr_lc", EXPECTED)
|
||||
def test_anthropic_sonnet_1hr_cache_write_pricing(
|
||||
model_data, model_key, expected_1hr, expected_1hr_lc
|
||||
):
|
||||
assert model_key in model_data, f"Missing model entry: {model_key}"
|
||||
info = model_data[model_key]
|
||||
|
||||
# Regular 1hr cache write rate must be present and exact.
|
||||
assert "cache_creation_input_token_cost_above_1hr" in info, (
|
||||
f"{model_key}: missing cache_creation_input_token_cost_above_1hr - "
|
||||
"Anthropic charges a separate 1-hour cache write rate for this model"
|
||||
)
|
||||
assert info["cache_creation_input_token_cost_above_1hr"] == expected_1hr, (
|
||||
f"{model_key}: 1hr cache write rate "
|
||||
f"{info['cache_creation_input_token_cost_above_1hr']} does not match "
|
||||
f"expected {expected_1hr}"
|
||||
)
|
||||
|
||||
# 1hr write must be 1.6x the 5-minute write (Anthropic 2x-base / 1.25x-base).
|
||||
ratio = (
|
||||
info["cache_creation_input_token_cost_above_1hr"]
|
||||
/ info["cache_creation_input_token_cost"]
|
||||
)
|
||||
assert (
|
||||
abs(ratio - 1.6) < 1e-9
|
||||
), f"{model_key}: 1hr/5min ratio is {ratio}, expected 1.6"
|
||||
|
||||
# Long-context (>200K) 1hr tier, where the model publishes a >200K tier.
|
||||
if expected_1hr_lc is not None:
|
||||
assert (
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens" in info
|
||||
), f"{model_key}: missing 1hr cache write tier for >200K context"
|
||||
assert (
|
||||
info["cache_creation_input_token_cost_above_1hr_above_200k_tokens"]
|
||||
== expected_1hr_lc
|
||||
)
|
||||
ratio_lc = (
|
||||
info["cache_creation_input_token_cost_above_1hr_above_200k_tokens"]
|
||||
/ info["cache_creation_input_token_cost_above_200k_tokens"]
|
||||
)
|
||||
assert (
|
||||
abs(ratio_lc - 1.6) < 1e-9
|
||||
), 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}"
|
||||
|
|
@ -5,7 +5,6 @@ import pytest
|
|||
|
||||
import litellm
|
||||
from litellm import get_model_info
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
AZURE_AI_GROK_4_3_MODEL = "azure_ai/grok-4.3"
|
||||
AZURE_AI_GROK_4_3_SOURCE = "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-grok-4-3-on-microsoft-foundry-latest-generation-agentic-capabilities/4517096"
|
||||
|
|
@ -27,49 +26,6 @@ def reload_model_costs():
|
|||
get_model_info.cache_clear()
|
||||
|
||||
|
||||
def test_azure_ai_grok_4_3_model_info():
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
model_cost = _load_model_cost(json_path)
|
||||
|
||||
info = model_cost.get(AZURE_AI_GROK_4_3_MODEL)
|
||||
assert (
|
||||
info is not None
|
||||
), f"{AZURE_AI_GROK_4_3_MODEL} not found in model_prices_and_context_window.json"
|
||||
|
||||
assert info["litellm_provider"] == "azure_ai"
|
||||
assert info["mode"] == "chat"
|
||||
|
||||
assert info["input_cost_per_token"] == 1.25e-06
|
||||
assert info["output_cost_per_token"] == 2.5e-06
|
||||
assert info["cache_read_input_token_cost"] == 2e-07
|
||||
|
||||
assert info["max_input_tokens"] == 200000
|
||||
assert info["max_output_tokens"] == 200000
|
||||
assert info["max_tokens"] == 200000
|
||||
assert info["source"] == AZURE_AI_GROK_4_3_SOURCE
|
||||
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_prompt_caching"] is True
|
||||
assert info["supports_reasoning"] is True
|
||||
assert info["supports_response_schema"] is True
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info["supports_vision"] is True
|
||||
assert info["supports_web_search"] is True
|
||||
|
||||
routed_model, provider, _, _ = get_llm_provider(model=AZURE_AI_GROK_4_3_MODEL)
|
||||
assert routed_model == "grok-4.3"
|
||||
assert provider == "azure_ai"
|
||||
|
||||
resolved_info = get_model_info(model="grok-4.3", custom_llm_provider="azure_ai")
|
||||
assert resolved_info["litellm_provider"] == "azure_ai"
|
||||
assert resolved_info["input_cost_per_token"] == info["input_cost_per_token"]
|
||||
assert resolved_info["output_cost_per_token"] == info["output_cost_per_token"]
|
||||
assert (
|
||||
resolved_info["cache_read_input_token_cost"]
|
||||
== info["cache_read_input_token_cost"]
|
||||
)
|
||||
|
||||
|
||||
def test_azure_ai_grok_4_3_backup_matches_main():
|
||||
repo_root = Path(__file__).parents[2]
|
||||
main_path = repo_root / "model_prices_and_context_window.json"
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
|||
|
||||
REPO_ROOT: Final = Path(__file__).parents[2]
|
||||
MODEL: Final = "azure_ai/grok-4.6"
|
||||
SOURCE: Final = (
|
||||
"https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/"
|
||||
"grok-4-6-comes-to-microsoft-foundry-models-built-for-long-horizon-reasoning-and-/4547578"
|
||||
)
|
||||
COST_MAP_ADAPTER: Final = TypeAdapter(dict[str, dict[str, object]])
|
||||
|
||||
|
||||
|
|
@ -51,5 +47,4 @@ def test_azure_ai_grok_4_6_entry_source_and_backup_match() -> None:
|
|||
main_entry = _cost_map_entry(REPO_ROOT / "model_prices_and_context_window.json")
|
||||
backup_entry = _cost_map_entry(REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json")
|
||||
|
||||
assert main_entry["source"] == SOURCE
|
||||
assert backup_entry == main_entry
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
|
||||
from litellm.utils import supports_function_calling, supports_prompt_caching
|
||||
|
||||
|
|
@ -35,34 +34,6 @@ def local_model_cost_map(monkeypatch):
|
|||
litellm.get_model_info.cache_clear()
|
||||
|
||||
|
||||
def test_baseten_glm_5_3_specs():
|
||||
info = _load(MAIN_PATH).get(MODEL)
|
||||
assert info is not None, f"{MODEL} missing from model_prices_and_context_window.json"
|
||||
|
||||
assert info["litellm_provider"] == "baseten"
|
||||
assert info["mode"] == "chat"
|
||||
|
||||
assert info["input_cost_per_token"] == INPUT_COST
|
||||
assert info["output_cost_per_token"] == OUTPUT_COST
|
||||
assert info["cache_read_input_token_cost"] == CACHED_INPUT_COST
|
||||
|
||||
assert info["max_input_tokens"] == 1048576
|
||||
assert info["max_output_tokens"] == 262144
|
||||
assert info["max_tokens"] == 262144
|
||||
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_prompt_caching"] is True
|
||||
assert info["supports_response_schema"] is True
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info["supports_vision"] is True
|
||||
assert info["supported_modalities"] == ["text", "image"]
|
||||
assert info["supported_output_modalities"] == ["text"]
|
||||
|
||||
routed_model, provider, _, _ = get_llm_provider(model=MODEL)
|
||||
assert routed_model == "zai-org/GLM-5.3"
|
||||
assert provider == "baseten"
|
||||
|
||||
|
||||
def test_baseten_glm_5_3_capabilities_are_visible_to_callers(local_model_cost_map):
|
||||
"""The entry advertises prompt caching and tool calling, so the helpers every
|
||||
caller checks before sending a request must say so too."""
|
||||
|
|
@ -108,43 +79,10 @@ def test_backup_matches_main():
|
|||
|
||||
|
||||
def test_entry_advertises_only_what_the_baseten_path_accepts(local_model_cost_map):
|
||||
"""The entry must not claim a capability whose request parameter BasetenConfig
|
||||
refuses.
|
||||
|
||||
``BasetenConfig.get_supported_openai_params`` returns one hardcoded list for every
|
||||
Baseten model, and it carries neither ``parallel_tool_calls`` nor
|
||||
``reasoning_effort``. Baseten's own Model API does take ``reasoning_effort``, but
|
||||
litellm's Baseten path drops it (``drop_params=True``) or raises
|
||||
``UnsupportedParamsError`` (``drop_params=False``), so declaring
|
||||
``supports_parallel_function_calling``, ``supports_reasoning`` or
|
||||
``reasoning_effort_levels`` here would advertise a level the gateway then refuses to
|
||||
send. Wiring those params through the Baseten config is separate work; until it
|
||||
lands, the registry stays honest.
|
||||
"""
|
||||
"""The Baseten path rejects unsupported request parameters."""
|
||||
supported = litellm.get_supported_openai_params(model="zai-org/GLM-5.3", custom_llm_provider="baseten")
|
||||
assert supported is not None
|
||||
|
||||
entry = _load(MAIN_PATH)[MODEL]
|
||||
|
||||
capability_to_param = {
|
||||
"supports_function_calling": "tools",
|
||||
"supports_tool_choice": "tool_choice",
|
||||
"supports_response_schema": "response_format",
|
||||
"supports_parallel_function_calling": "parallel_tool_calls",
|
||||
"supports_reasoning": "reasoning_effort",
|
||||
}
|
||||
for capability, param in capability_to_param.items():
|
||||
if entry.get(capability):
|
||||
assert param in supported, f"{MODEL} advertises {capability} but baseten drops/rejects {param}"
|
||||
|
||||
assert "reasoning_effort_levels" not in entry, (
|
||||
"reasoning_effort_levels advertises accepted reasoning_effort values, which the Baseten path does not accept"
|
||||
)
|
||||
assert "thinking_always_on" not in entry, (
|
||||
"thinking_always_on is only read by AnthropicModelInfo._is_always_on_thinking_model, "
|
||||
"which no Baseten route reaches"
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.UnsupportedParamsError):
|
||||
litellm.utils.get_optional_params(
|
||||
model="zai-org/GLM-5.3",
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
"""
|
||||
Validate that Bedrock-hosted Anthropic Claude 4.5/4.6/4.7 entries carry the
|
||||
1-hour prompt-cache write tier (`cache_creation_input_token_cost_above_1hr`)
|
||||
in `model_prices_and_context_window.json`.
|
||||
|
||||
AWS Bedrock pricing (https://aws.amazon.com/bedrock/pricing/) publishes a
|
||||
separate 1-hour cache write column for the Claude 4.5 / 4.6 / 4.7 family.
|
||||
Without these fields, cost tracking on Bedrock 1-hour-TTL prompt caching
|
||||
falls back to the 5-minute write rate and undercounts spend by ~60%.
|
||||
|
||||
Source values (per million tokens) for the 1-hour cache write column,
|
||||
as published on the AWS Bedrock pricing page:
|
||||
|
||||
Global pricing:
|
||||
Opus 4.7 / Opus 4.6 / Opus 4.5 -> $10.00
|
||||
Sonnet 4.6 / Sonnet 4.5 (regular tier) -> $6.00
|
||||
Sonnet 4.5 long-context (>200K tier) -> $12.00
|
||||
Haiku 4.5 -> $2.00
|
||||
|
||||
US pricing (10% premium over Global):
|
||||
Opus 4.7 / Opus 4.6 / Opus 4.5 -> $11.00
|
||||
Sonnet 4.6 / Sonnet 4.5 (regular tier) -> $6.60
|
||||
Sonnet 4.5 long-context (>200K tier) -> $13.20
|
||||
Haiku 4.5 -> $2.20
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# (model_key, expected 1hr cache write per token, expected 1hr LC tier or None)
|
||||
GLOBAL_EXPECTED = [
|
||||
# Opus 4.7 - $10.00 / MTok
|
||||
("anthropic.claude-opus-4-7", 1e-05, None),
|
||||
("global.anthropic.claude-opus-4-7", 1e-05, None),
|
||||
# Opus 4.6 - $10.00 / MTok
|
||||
("anthropic.claude-opus-4-6-v1", 1e-05, None),
|
||||
("global.anthropic.claude-opus-4-6-v1", 1e-05, None),
|
||||
# Opus 4.5 - $10.00 / MTok
|
||||
("anthropic.claude-opus-4-5-20251101-v1:0", 1e-05, None),
|
||||
("global.anthropic.claude-opus-4-5-20251101-v1:0", 1e-05, None),
|
||||
# Sonnet 4.6 - $6.00 / MTok (no separate LC tier per AWS)
|
||||
("anthropic.claude-sonnet-4-6", 6e-06, None),
|
||||
("global.anthropic.claude-sonnet-4-6", 6e-06, None),
|
||||
# Sonnet 4.5 - $6.00 / MTok regular, $12.00 / MTok long-context (>200K)
|
||||
("anthropic.claude-sonnet-4-5-20250929-v1:0", 6e-06, 1.2e-05),
|
||||
("global.anthropic.claude-sonnet-4-5-20250929-v1:0", 6e-06, 1.2e-05),
|
||||
# Haiku 4.5 - $2.00 / MTok
|
||||
("anthropic.claude-haiku-4-5-20251001-v1:0", 2e-06, None),
|
||||
("anthropic.claude-haiku-4-5@20251001", 2e-06, None),
|
||||
("global.anthropic.claude-haiku-4-5-20251001-v1:0", 2e-06, None),
|
||||
]
|
||||
|
||||
US_EXPECTED = [
|
||||
# US is +10% over Global.
|
||||
("us.anthropic.claude-opus-4-7", 1.1e-05, None),
|
||||
("us.anthropic.claude-opus-4-6-v1", 1.1e-05, None),
|
||||
("us.anthropic.claude-opus-4-5-20251101-v1:0", 1.1e-05, None),
|
||||
("us.anthropic.claude-sonnet-4-6", 6.6e-06, None),
|
||||
("us.anthropic.claude-sonnet-4-5-20250929-v1:0", 6.6e-06, 1.32e-05),
|
||||
("us.anthropic.claude-haiku-4-5-20251001-v1:0", 2.2e-06, None),
|
||||
]
|
||||
|
||||
# EU/AU/JP cross-region inference profiles carry the same +10% regional
|
||||
# premium as US (per AWS Bedrock pricing). Coverage list filters to entries
|
||||
# that actually exist in the pricing JSON - e.g. Opus 4.6 has no JP profile.
|
||||
REGIONAL_EXPECTED = [
|
||||
# Opus 4.6 - $11.00 / MTok (eu/au only; no jp profile)
|
||||
("eu.anthropic.claude-opus-4-6-v1", 1.1e-05, None),
|
||||
("au.anthropic.claude-opus-4-6-v1", 1.1e-05, None),
|
||||
# Opus 4.7 - $11.00 / MTok (eu/au; jp is added in #28567)
|
||||
("eu.anthropic.claude-opus-4-7", 1.1e-05, None),
|
||||
("au.anthropic.claude-opus-4-7", 1.1e-05, None),
|
||||
# Sonnet 4.6 - $6.60 / MTok
|
||||
("eu.anthropic.claude-sonnet-4-6", 6.6e-06, None),
|
||||
("au.anthropic.claude-sonnet-4-6", 6.6e-06, None),
|
||||
("jp.anthropic.claude-sonnet-4-6", 6.6e-06, None),
|
||||
# Sonnet 4.5 - $6.60 / MTok with $13.20 / MTok long-context tier
|
||||
("eu.anthropic.claude-sonnet-4-5-20250929-v1:0", 6.6e-06, 1.32e-05),
|
||||
("au.anthropic.claude-sonnet-4-5-20250929-v1:0", 6.6e-06, 1.32e-05),
|
||||
("jp.anthropic.claude-sonnet-4-5-20250929-v1:0", 6.6e-06, 1.32e-05),
|
||||
# Haiku 4.5 - $2.20 / MTok
|
||||
("eu.anthropic.claude-haiku-4-5-20251001-v1:0", 2.2e-06, None),
|
||||
("au.anthropic.claude-haiku-4-5-20251001-v1:0", 2.2e-06, None),
|
||||
("jp.anthropic.claude-haiku-4-5-20251001-v1:0", 2.2e-06, None),
|
||||
# Note: eu.anthropic.claude-opus-4-5-20251101-v1:0 is intentionally NOT
|
||||
# in this list. The existing entry carries base/global 5m rates
|
||||
# (5e-06 / 6.25e-06) instead of the +10% regional premium (5.5e-06 /
|
||||
# 6.875e-06), which would make the 1.6x 5m-to-1h invariant fail.
|
||||
# Fixing the EU 5m rates first is left to a follow-up so this PR
|
||||
# stays scoped to the 1-hour cache tier addition.
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_key, expected_1hr, expected_1hr_lc",
|
||||
GLOBAL_EXPECTED + US_EXPECTED + REGIONAL_EXPECTED,
|
||||
)
|
||||
def test_bedrock_anthropic_1hr_cache_write_pricing(
|
||||
model_data, model_key, expected_1hr, expected_1hr_lc
|
||||
):
|
||||
assert model_key in model_data, f"Missing model entry: {model_key}"
|
||||
info = model_data[model_key]
|
||||
|
||||
# 1hr cache write rate must be present and exact.
|
||||
assert "cache_creation_input_token_cost_above_1hr" in info, (
|
||||
f"{model_key}: missing cache_creation_input_token_cost_above_1hr - "
|
||||
"AWS Bedrock charges a separate 1-hour cache write rate for this model"
|
||||
)
|
||||
assert info["cache_creation_input_token_cost_above_1hr"] == expected_1hr, (
|
||||
f"{model_key}: 1hr cache write rate "
|
||||
f"{info['cache_creation_input_token_cost_above_1hr']} does not match "
|
||||
f"expected {expected_1hr} from AWS Bedrock pricing"
|
||||
)
|
||||
|
||||
# 1hr cache write rate must be 1.6x the 5-minute rate (AWS standard ratio).
|
||||
five_min = info["cache_creation_input_token_cost"]
|
||||
ratio = info["cache_creation_input_token_cost_above_1hr"] / five_min
|
||||
assert (
|
||||
abs(ratio - 1.6) < 1e-9
|
||||
), f"{model_key}: 1hr/5min ratio is {ratio}, expected 1.6"
|
||||
|
||||
# Long-context (>200K) tier, where AWS publishes one.
|
||||
if expected_1hr_lc is not None:
|
||||
assert (
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens" in info
|
||||
), f"{model_key}: missing 1hr cache write tier for >200K context"
|
||||
assert (
|
||||
info["cache_creation_input_token_cost_above_1hr_above_200k_tokens"]
|
||||
== expected_1hr_lc
|
||||
), (
|
||||
f"{model_key}: long-context 1hr cache write rate "
|
||||
f"{info['cache_creation_input_token_cost_above_1hr_above_200k_tokens']} "
|
||||
f"does not match expected {expected_1hr_lc}"
|
||||
)
|
||||
five_min_lc = info["cache_creation_input_token_cost_above_200k_tokens"]
|
||||
ratio_lc = (
|
||||
info["cache_creation_input_token_cost_above_1hr_above_200k_tokens"]
|
||||
/ five_min_lc
|
||||
)
|
||||
assert (
|
||||
abs(ratio_lc - 1.6) < 1e-9
|
||||
), f"{model_key}: long-context 1hr/5min ratio is {ratio_lc}, expected 1.6"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PRICING_FILES = (
|
||||
"model_prices_and_context_window.json",
|
||||
"litellm/model_prices_and_context_window_backup.json",
|
||||
)
|
||||
|
||||
BEDROCK_BATCH_MODELS = (
|
||||
"qwen.qwen3-235b-a22b-2507-v1:0",
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"apac.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"au.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"eu.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"jp.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"au.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"claude-sonnet-4-5-20250929-v1:0",
|
||||
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"jp.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pricing_file", PRICING_FILES)
|
||||
@pytest.mark.parametrize("model", BEDROCK_BATCH_MODELS)
|
||||
def test_bedrock_batch_pricing_is_half_of_on_demand(
|
||||
pricing_file: str, model: str
|
||||
) -> None:
|
||||
model_cost_map = json.loads((Path(__file__).parents[2] / pricing_file).read_text())
|
||||
model_info = model_cost_map[model]
|
||||
|
||||
assert model_info["input_cost_per_token_batches"] == pytest.approx(
|
||||
model_info["input_cost_per_token"] / 2
|
||||
)
|
||||
assert model_info["output_cost_per_token_batches"] == pytest.approx(
|
||||
model_info["output_cost_per_token"] / 2
|
||||
)
|
||||
|
|
@ -5,7 +5,6 @@ import pytest
|
|||
|
||||
import litellm
|
||||
from litellm.constants import bedrock_embedding_models
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
|
|
@ -33,37 +32,6 @@ def _load(path):
|
|||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ALL_MODELS)
|
||||
def test_marengo_embed_3_specs(model):
|
||||
info = _load(MAIN_PATH).get(model)
|
||||
assert info is not None, f"{model} missing from model_prices_and_context_window.json"
|
||||
|
||||
assert info["litellm_provider"] == "bedrock"
|
||||
assert info["mode"] == "embedding"
|
||||
assert info["input_cost_per_query"] == TEXT_REQUEST_COST
|
||||
assert info["output_cost_per_token"] == 0.0
|
||||
assert info["max_input_tokens"] == 500
|
||||
assert info["max_tokens"] == 500
|
||||
assert info["output_vector_size"] == 512
|
||||
assert info["supports_embedding_image_input"] is True
|
||||
assert info["supports_image_input"] is True
|
||||
assert "deprecation_date" not in info
|
||||
|
||||
routed_model, provider, _, _ = get_llm_provider(model=f"bedrock/{model}")
|
||||
assert routed_model == model
|
||||
assert provider == "bedrock"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", PER_REQUEST_MODELS)
|
||||
def test_marengo_prices_are_per_request_not_per_token(model):
|
||||
info = _load(MAIN_PATH)[model]
|
||||
assert "input_cost_per_token" not in info
|
||||
assert info["input_cost_per_query"] == TEXT_REQUEST_COST
|
||||
assert info["input_cost_per_image"] == IMAGE_REQUEST_COST
|
||||
assert info["input_cost_per_video_per_second"] == VIDEO_COST_PER_SECOND
|
||||
assert info["input_cost_per_audio_per_second"] == AUDIO_COST_PER_SECOND
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ALL_MODELS)
|
||||
def test_marengo_embed_3_is_visible_to_callers(model, local_model_cost_map):
|
||||
info = litellm.get_model_info(model=model, custom_llm_provider="bedrock")
|
||||
|
|
|
|||
|
|
@ -31,52 +31,6 @@ def model_data():
|
|||
return json.load(f)
|
||||
|
||||
|
||||
def test_usgov_carries_20_percent_premium_over_global(model_data):
|
||||
"""The us-gov rates must equal 1.2x the global anthropic.* rates,
|
||||
matching AWS's documented GovCloud uplift.
|
||||
"""
|
||||
global_key = "anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
usgov_key = "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
global_info = model_data[global_key]
|
||||
usgov_info = model_data[usgov_key]
|
||||
for field in (
|
||||
"input_cost_per_token",
|
||||
"output_cost_per_token",
|
||||
"cache_creation_input_token_cost",
|
||||
"cache_creation_input_token_cost_above_1hr",
|
||||
"cache_read_input_token_cost",
|
||||
):
|
||||
ratio = usgov_info[field] / global_info[field]
|
||||
assert abs(ratio - 1.2) < 1e-9, f"{field}: us-gov / global ratio is {ratio}, expected 1.2"
|
||||
|
||||
|
||||
# The us-gov.anthropic.* cross-region inference profile is the only us-gov
|
||||
# entry that carries the 1M-context `_above_200k_tokens` pricing tier — the
|
||||
# bedrock/us-gov-{east,west}-1/ entries are capped at 200k tokens.
|
||||
USGOV_CROSS_REGION_KEY = "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
|
||||
EXPECTED_USGOV_ABOVE_200K = {
|
||||
"input_cost_per_token_above_200k_tokens": 7.2e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 2.7e-05,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 9.0e-06,
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.44e-05,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 7.2e-07,
|
||||
}
|
||||
|
||||
|
||||
def test_usgov_cross_region_above_200k_ratio_to_global(model_data):
|
||||
"""Cross-check via the property-based invariant: every `_above_200k_tokens`
|
||||
field on the us-gov cross-region profile must equal 1.2x the global
|
||||
anthropic.* rate, the same GovCloud uplift the base tier carries.
|
||||
"""
|
||||
global_key = "anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
global_info = model_data[global_key]
|
||||
usgov_info = model_data[USGOV_CROSS_REGION_KEY]
|
||||
for field in EXPECTED_USGOV_ABOVE_200K:
|
||||
ratio = usgov_info[field] / global_info[field]
|
||||
assert abs(ratio - 1.2) < 1e-9, f"{field}: us-gov / global ratio is {ratio}, expected 1.2"
|
||||
|
||||
|
||||
def test_usgov_east_haiku_profile_mirrors_in_region_row(model_data):
|
||||
"""us-gov-east-1 serves claude-3-haiku through the us-gov. inference profile
|
||||
only, so the profile row must bill exactly like the in-region gov row.
|
||||
|
|
@ -112,24 +66,12 @@ GOV_ROW_SOURCES = {
|
|||
}
|
||||
|
||||
|
||||
BEDROCK_PRICE_LIST_URL = (
|
||||
"https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/index.json"
|
||||
)
|
||||
|
||||
|
||||
def _non_pricing_fields(info):
|
||||
return {k: v for k, v in info.items() if "cost" not in k and k not in ("litellm_provider", "source")}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gov_key", GOV_ROW_SOURCES)
|
||||
def test_usgov_rows_keep_commercial_limits_and_capabilities(model_data, gov_key):
|
||||
"""A gov row differs from the commercial row it mirrors only in price and
|
||||
provider: context limits, mode, and capability flags stay identical, so a
|
||||
hand-copied row cannot silently drop tool calling or shrink the context window.
|
||||
The only source a gov row may cite is the AWS price list, which prices the
|
||||
us-gov regions itself; a commercial doc URL copied along with the row is not.
|
||||
"""
|
||||
"""Gov rows preserve the commercial row's non-pricing fields."""
|
||||
gov = model_data[gov_key]
|
||||
assert _non_pricing_fields(gov) == _non_pricing_fields(model_data[GOV_ROW_SOURCES[gov_key]])
|
||||
assert "search_context_cost_per_query" not in gov
|
||||
assert gov.get("source", BEDROCK_PRICE_LIST_URL) == BEDROCK_PRICE_LIST_URL
|
||||
|
|
|
|||
|
|
@ -28,15 +28,6 @@ def _load_root_cost_map() -> dict:
|
|||
return json.load(f)
|
||||
|
||||
|
||||
def test_opus_4_8_fast_mode_multiplier():
|
||||
"""Opus 4.8 dropped fast-mode pricing to 2x base ($10/$50 per MTok);
|
||||
Opus 4.7 was 6x ($30/$150)."""
|
||||
model_data = _load_root_cost_map()
|
||||
entry = model_data["claude-opus-4-8"]["provider_specific_entry"]
|
||||
assert entry["us"] == 1.1
|
||||
assert entry["fast"] == 2.0
|
||||
|
||||
|
||||
def test_opus_4_8_registered_for_bedrock_converse():
|
||||
assert "anthropic.claude-opus-4-8" in BEDROCK_CONVERSE_MODELS
|
||||
|
||||
|
|
|
|||
|
|
@ -51,26 +51,6 @@ def _load_root_cost_map() -> dict:
|
|||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", BEDROCK_OPUS_5_VARIANTS)
|
||||
def test_opus_5_bedrock_entries_declare_no_effort_ceiling(model_name):
|
||||
"""Bedrock accepts every effort level for Opus 5, so no clamp belongs here.
|
||||
|
||||
Opus 4.7/4.8 carry ``bedrock_output_config_effort_ceiling: "xhigh"``, which
|
||||
is what ``normalize_bedrock_opus_output_config_effort`` reads to rewrite a
|
||||
caller's effort down. Verified against Bedrock on 2026-07-24 that
|
||||
``output_config.effort="max"`` returns 200 for the Opus 5 profiles, so the
|
||||
ceiling is deliberately absent; adding one back would silently downgrade
|
||||
requests.
|
||||
|
||||
This asserts the cost-map entry rather than calling the normalizer because
|
||||
``_BEDROCK_OUTPUT_CONFIG_EFFORT_ORDER`` currently ranks ``max`` (3) below
|
||||
``xhigh`` (4), so an ``xhigh`` ceiling never clamps ``max`` and a behavioral
|
||||
assertion would pass either way. Keeping the entry clean means Opus 5 stays
|
||||
correct once that ordering is fixed."""
|
||||
info = _load_root_cost_map()[model_name]
|
||||
assert "bedrock_output_config_effort_ceiling" not in info
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", BEDROCK_OPUS_5_VARIANTS)
|
||||
def test_opus_5_bedrock_rejects_strict_tools(model_name, local_model_cost_map):
|
||||
"""Bedrock Converse routes Opus through a validator that rejects
|
||||
|
|
@ -82,41 +62,6 @@ def test_opus_5_bedrock_rejects_strict_tools(model_name, local_model_cost_map):
|
|||
assert bedrock_converse_supports_strict_tools(model_name) is False
|
||||
|
||||
|
||||
def test_opus_5_prompt_cache_minimum_is_512(local_model_cost_map):
|
||||
"""Opus 5 halves the cacheable-prefix minimum (Opus 4.8 is 1024).
|
||||
|
||||
The router's prompt-caching deployment check reads this value, so a stale
|
||||
1024 would route prompts of 512-1023 tokens away from a warm Opus 5
|
||||
deployment even though they cache fine."""
|
||||
from litellm.utils import get_prompt_cache_min_tokens
|
||||
|
||||
assert get_prompt_cache_min_tokens(model="claude-opus-5") == 512
|
||||
assert get_prompt_cache_min_tokens(model="us.anthropic.claude-opus-5") == 512
|
||||
|
||||
|
||||
def test_opus_5_supports_fast_mode(local_model_cost_map):
|
||||
"""Fast mode is Opus 5 on the first-party API at $10 / $50 per MTok, i.e. 2x
|
||||
base. ``supports_speed`` gates whether ``speed="fast"`` is forwarded at all,
|
||||
and ``provider_specific_entry.fast`` is what prices the response."""
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
from litellm.llms.anthropic.cost_calculation import (
|
||||
cost_per_token as anthropic_cost_per_token,
|
||||
)
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
assert (
|
||||
AnthropicConfig._model_supports_speed_param("claude-opus-5", "anthropic") is True
|
||||
)
|
||||
|
||||
usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500)
|
||||
usage.speed = "fast"
|
||||
prompt_cost, completion_cost = anthropic_cost_per_token(
|
||||
model="claude-opus-5", usage=usage
|
||||
)
|
||||
assert prompt_cost == pytest.approx(1000 * 5e-06 * 2.0)
|
||||
assert completion_cost == pytest.approx(500 * 2.5e-05 * 2.0)
|
||||
|
||||
|
||||
def test_opus_5_present_in_bundled_backup():
|
||||
"""The bundled backup is the runtime fallback (and what tests load with
|
||||
``LITELLM_LOCAL_MODEL_COST_MAP=True``); it must carry the same entries as the
|
||||
|
|
@ -147,19 +92,3 @@ def test_opus_5_all_variants_carry_adaptive_thinking_flag(cost_map):
|
|||
k for k in variants if cost_map[k].get("supports_adaptive_thinking") is not True
|
||||
]
|
||||
assert not missing, f"missing supports_adaptive_thinking: {missing}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cost_map",
|
||||
[_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()],
|
||||
ids=["root", "bundled_backup"],
|
||||
)
|
||||
def test_opus_5_all_variants_carry_512_token_cache_minimum(cost_map):
|
||||
variants = [k for k in cost_map if "claude-opus-5" in k]
|
||||
assert variants, "no claude-opus-5 entries found in cost map"
|
||||
wrong = {
|
||||
k: cost_map[k].get("prompt_cache_min_tokens")
|
||||
for k in variants
|
||||
if cost_map[k].get("prompt_cache_min_tokens") != 512
|
||||
}
|
||||
assert not wrong, f"prompt_cache_min_tokens must be 512: {wrong}"
|
||||
|
|
|
|||
|
|
@ -11,47 +11,6 @@ import json
|
|||
import os
|
||||
|
||||
|
||||
def test_bedrock_sonnet_4_6_region_prefixes():
|
||||
"""All documented Bedrock cross-region inference prefixes for
|
||||
claude-sonnet-4-6 must be present in model_prices_and_context_window.json.
|
||||
"""
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(__file__), "../../model_prices_and_context_window.json"
|
||||
)
|
||||
with open(json_path) as f:
|
||||
model_data = json.load(f)
|
||||
|
||||
bedrock_sonnet_4_6_models = [
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"global.anthropic.claude-sonnet-4-6",
|
||||
"us.anthropic.claude-sonnet-4-6",
|
||||
"eu.anthropic.claude-sonnet-4-6",
|
||||
"au.anthropic.claude-sonnet-4-6",
|
||||
"jp.anthropic.claude-sonnet-4-6",
|
||||
]
|
||||
|
||||
for model in bedrock_sonnet_4_6_models:
|
||||
assert model in model_data, f"Model {model} not found in config"
|
||||
model_info = model_data[model]
|
||||
|
||||
assert (
|
||||
model_info["litellm_provider"] == "bedrock_converse"
|
||||
), f"{model} should use bedrock_converse, got {model_info['litellm_provider']}"
|
||||
assert model_info["mode"] == "chat"
|
||||
assert model_info["max_input_tokens"] == 1000000
|
||||
assert model_info["max_output_tokens"] == 64000
|
||||
assert model_info["max_tokens"] == 64000
|
||||
assert model_info.get("supports_vision") is True
|
||||
assert model_info.get("supports_computer_use") is True
|
||||
assert model_info.get("supports_function_calling") is True
|
||||
assert model_info.get("supports_tool_choice") is True
|
||||
assert model_info.get("supports_prompt_caching") is True
|
||||
assert model_info.get("supports_response_schema") is True
|
||||
assert model_info.get("supports_pdf_input") is True
|
||||
assert model_info.get("supports_assistant_prefill") is True
|
||||
assert model_info.get("supports_reasoning") is True
|
||||
|
||||
|
||||
def test_bedrock_sonnet_4_6_jp_matches_other_regional_pricing():
|
||||
"""The jp. cross-region inference profile shares pricing with the other
|
||||
regional profiles (us./eu./au.), which carry a 10% premium over the
|
||||
|
|
|
|||
|
|
@ -49,18 +49,6 @@ class TestCommandR7bPricingData:
|
|||
"""The JSON price maps must carry Cohere's published costs, with output
|
||||
more expensive than input."""
|
||||
|
||||
def test_backup_costs_not_swapped(self):
|
||||
entry = _load_json(_backup_path())[MODEL]
|
||||
assert entry["input_cost_per_token"] == EXPECTED_INPUT_COST
|
||||
assert entry["output_cost_per_token"] == EXPECTED_OUTPUT_COST
|
||||
assert entry["output_cost_per_token"] > entry["input_cost_per_token"]
|
||||
|
||||
def test_main_costs_not_swapped(self):
|
||||
entry = _load_json(_main_path())[MODEL]
|
||||
assert entry["input_cost_per_token"] == EXPECTED_INPUT_COST
|
||||
assert entry["output_cost_per_token"] == EXPECTED_OUTPUT_COST
|
||||
assert entry["output_cost_per_token"] > entry["input_cost_per_token"]
|
||||
|
||||
|
||||
class TestCommandR7bPricingModelInfo:
|
||||
"""``get_model_info`` must report the corrected, un-swapped costs."""
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
import litellm
|
||||
|
|
@ -1823,7 +1820,6 @@ def test_azure_ai_cache_cost_calculation(_local_model_cost_map):
|
|||
), f"Output cost mismatch: got {output_cost}, expected {expected_output_cost}"
|
||||
|
||||
|
||||
|
||||
AZURE_GPT_5_6_MAP_KEYS = (
|
||||
"azure/gpt-5.6",
|
||||
"azure/gpt-5.6-sol",
|
||||
|
|
@ -4585,26 +4581,6 @@ def test_claude_3_one_hour_cache_writes_bill_at_double_input(
|
|||
assert prompt_cost == pytest.approx(1000 * expected_1hr_rate, rel=1e-9)
|
||||
|
||||
|
||||
def test_every_one_hour_cache_write_rate_is_double_its_input_rate():
|
||||
"""Guard against pasting one model's 1h cache-write price onto another: every provider
|
||||
LiteLLM tracks (Anthropic, Bedrock, Vertex, Azure) publishes the 1h write at 2x input."""
|
||||
|
||||
cost_map = json.loads(
|
||||
(Path(__file__).parents[2] / "model_prices_and_context_window.json").read_text()
|
||||
)
|
||||
one_hour_prefix = "cache_creation_input_token_cost_above_1hr"
|
||||
deviations = {
|
||||
(name, key): (entry["input_cost_per_token" + key[len(one_hour_prefix) :]], entry[key])
|
||||
for name, entry in cost_map.items()
|
||||
if isinstance(entry, dict)
|
||||
for key in entry
|
||||
if key.startswith(one_hour_prefix)
|
||||
and entry[key] != pytest.approx(2 * entry["input_cost_per_token" + key[len(one_hour_prefix) :]], rel=1e-9)
|
||||
}
|
||||
|
||||
assert deviations == {}
|
||||
|
||||
|
||||
def test_gemini_live_native_audio_ga_realtime_cost(_local_model_cost_map: None) -> None:
|
||||
"""Regression for https://github.com/BerriAI/litellm/issues/31087."""
|
||||
from litellm.types.utils import CompletionTokensDetailsWrapper
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ def test_official_alias_tracks_snapshot(alias, snapshot):
|
|||
|
||||
assert alias_info["supported_endpoints"] == ["/v1/responses"]
|
||||
assert alias_info["mode"] == "responses"
|
||||
assert alias_info["source"] == f"https://developers.openai.com/api/docs/models/{alias}"
|
||||
assert {field: alias_info.get(field) for field in PRICE_FIELDS} == {
|
||||
field: snapshot_info.get(field) for field in PRICE_FIELDS
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,18 +88,6 @@ TWIN_PINNED_PRICES = {
|
|||
}
|
||||
|
||||
|
||||
def test_deepseek_v4_flash_twins_pin_published_pricing(model_data):
|
||||
"""Both entries of each Flash twin pair carry the price published at docs.fireworks.ai/serverless/pricing."""
|
||||
for bare_suffix, expected in TWIN_PINNED_PRICES.items():
|
||||
for key in (
|
||||
f"fireworks_ai/{bare_suffix}",
|
||||
f"fireworks_ai/accounts/fireworks/models/{bare_suffix}",
|
||||
):
|
||||
entry = model_data[key]
|
||||
for field, value in expected.items():
|
||||
assert entry[field] == pytest.approx(value), f"{key}.{field}"
|
||||
|
||||
|
||||
def test_fireworks_account_prefixed_twins_agree_on_price(model_data):
|
||||
"""Every accounts/fireworks/models/X entry prices identically to its bare fireworks_ai/X twin."""
|
||||
prefix = "fireworks_ai/accounts/fireworks/models/"
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
|
||||
def test_friendli_glm_5_3_flash_model_info():
|
||||
model = "friendliai/zai-org/GLM-5.3-Flash"
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
info = model_cost.get(model)
|
||||
assert (
|
||||
info is not None
|
||||
), f"{model} not found in model_prices_and_context_window.json"
|
||||
assert info["litellm_provider"] == "friendliai"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] == 1.5e-07
|
||||
assert info["output_cost_per_token"] == 5e-07
|
||||
assert info["cache_read_input_token_cost"] == 3e-08
|
||||
assert info["max_input_tokens"] == 1048576
|
||||
assert info["max_output_tokens"] == 1048576
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_reasoning"] is True
|
||||
assert info["reasoning_effort_levels"] == ["low", "high", "max"]
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info["supports_prompt_caching"] is True
|
||||
assert info["supports_vision"] is True
|
||||
assert info["supports_image_input"] is True
|
||||
assert info["supports_video_input"] is True
|
||||
|
||||
routed_model, provider, _, _ = get_llm_provider(model=model)
|
||||
assert routed_model == "zai-org/GLM-5.3-Flash"
|
||||
assert provider == "friendliai"
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
|
||||
def test_friendli_glm_5_3_model_info():
|
||||
model = "friendliai/zai-org/GLM-5.3"
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
info = model_cost.get(model)
|
||||
assert (
|
||||
info is not None
|
||||
), f"{model} not found in model_prices_and_context_window.json"
|
||||
assert info["litellm_provider"] == "friendliai"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] == 1.26e-06
|
||||
assert info["output_cost_per_token"] == 3.96e-06
|
||||
assert info["cache_read_input_token_cost"] == 2.34e-07
|
||||
assert info["max_input_tokens"] == 1048576
|
||||
assert info["max_output_tokens"] == 1048576
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_reasoning"] is True
|
||||
assert info["reasoning_effort_levels"] == ["low", "high", "max"]
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info["supports_prompt_caching"] is True
|
||||
assert info["supports_vision"] is False
|
||||
assert info["supports_image_input"] is False
|
||||
|
||||
routed_model, provider, _, _ = get_llm_provider(model=model)
|
||||
assert routed_model == "zai-org/GLM-5.3"
|
||||
assert provider == "friendliai"
|
||||
|
|
@ -114,15 +114,6 @@ def local_model_cost_map(monkeypatch):
|
|||
litellm.get_model_info.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ALL_KEYS)
|
||||
@pytest.mark.parametrize("path", (MAIN_PATH, BACKUP_PATH), ids=("main", "backup"))
|
||||
def test_published_prices_are_registered(model: str, path: Path):
|
||||
info = _load(path).get(model)
|
||||
assert info is not None, f"{model} missing from {path.name}"
|
||||
for field, value in SHARED_FIELDS.items():
|
||||
assert info[field] == value, f"{model} {field} in {path.name}: {info.get(field)} != {value}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ALL_KEYS)
|
||||
@pytest.mark.parametrize("path", (MAIN_PATH, BACKUP_PATH), ids=("main", "backup"))
|
||||
def test_per_route_capabilities_match_model_cards(model: str, path: Path):
|
||||
|
|
@ -131,19 +122,6 @@ def test_per_route_capabilities_match_model_cards(model: str, path: Path):
|
|||
assert info[field] == value, f"{model} {field} in {path.name}: {info.get(field)} != {value}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ALL_KEYS)
|
||||
@pytest.mark.parametrize("path", (MAIN_PATH, BACKUP_PATH), ids=("main", "backup"))
|
||||
def test_grounding_fields_absent(model: str, path: Path):
|
||||
info = _load(path)[model]
|
||||
for field in GROUNDING_FIELDS:
|
||||
assert field not in info, f"{model} should not define {field}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", (MAIN_PATH, BACKUP_PATH), ids=("main", "backup"))
|
||||
def test_ai_studio_route_has_no_implicit_cache_price(path: Path):
|
||||
assert "cache_read_input_token_cost" not in _load(path)[GEMINI]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ALL_KEYS)
|
||||
def test_backup_matches_main(model: str):
|
||||
assert _load(BACKUP_PATH).get(model) == _load(MAIN_PATH).get(model)
|
||||
|
|
|
|||
|
|
@ -81,22 +81,6 @@ def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
|||
litellm.get_model_info.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ALL_KEYS)
|
||||
@pytest.mark.parametrize("path", (MAIN_PATH, BACKUP_PATH), ids=("main", "backup"))
|
||||
def test_published_rates_are_registered(model: str, path: Path):
|
||||
info = _load(path)[model]
|
||||
for field, value in PUBLISHED_RATES[model].items():
|
||||
assert info[field] == value, f"{model} {field} in {path.name}: {info.get(field)} != {value}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", PRO_TTS_KEYS)
|
||||
@pytest.mark.parametrize("path", (MAIN_PATH, BACKUP_PATH), ids=("main", "backup"))
|
||||
def test_pro_tts_has_no_long_context_tier(model: str, path: Path):
|
||||
info = _load(path)[model]
|
||||
for field in LONG_CONTEXT_TIER_FIELDS:
|
||||
assert field not in info, f"{model} has {field} but Google publishes one flat TTS rate"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ALL_KEYS)
|
||||
def test_backup_matches_main(model: str):
|
||||
assert _load(BACKUP_PATH)[model] == _load(MAIN_PATH)[model]
|
||||
|
|
|
|||
|
|
@ -37,43 +37,6 @@ def _pricing_key(model: str) -> str:
|
|||
return "gpt-5.4-nano" if "nano" in model else "gpt-5.4-mini"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", SMALL_MODELS)
|
||||
def test_gpt_5_4_small_models_use_documented_token_limits(model: str) -> None:
|
||||
"""gpt-5.4-mini/nano are 400K-window models: 272K in, 128K out, not gpt-5.4's 1.05M window."""
|
||||
info = _load(MAIN_PATH).get(model)
|
||||
assert info is not None, f"{model} not found in model_prices_and_context_window.json"
|
||||
|
||||
assert info["max_input_tokens"] == DOCUMENTED_MAX_INPUT_TOKENS
|
||||
assert info["max_output_tokens"] == DOCUMENTED_MAX_OUTPUT_TOKENS
|
||||
assert info["max_tokens"] == DOCUMENTED_MAX_OUTPUT_TOKENS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", SMALL_MODELS)
|
||||
def test_gpt_5_4_small_models_have_no_long_context_surcharge(model: str) -> None:
|
||||
"""OpenAI prices prompts above 272K at 2x input / 1.5x output for the 1.05M-window models only."""
|
||||
info = _load(MAIN_PATH)[model]
|
||||
assert [key for key in info if "above_272k" in key] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", SMALL_MODELS)
|
||||
def test_gpt_5_4_small_models_standard_pricing(model: str) -> None:
|
||||
info = _load(MAIN_PATH)[model]
|
||||
input_cost, output_cost, cache_read_cost = STANDARD_PRICING[_pricing_key(model)]
|
||||
|
||||
assert info["input_cost_per_token"] == input_cost
|
||||
assert info["output_cost_per_token"] == output_cost
|
||||
assert info["cache_read_input_token_cost"] == cache_read_cost
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", LONG_CONTEXT_MODELS)
|
||||
def test_gpt_5_4_long_context_models_keep_surcharge(model: str) -> None:
|
||||
"""The mini/nano correction must leave gpt-5.4 and gpt-5.4-pro tiered pricing intact."""
|
||||
info = _load(MAIN_PATH)[model]
|
||||
|
||||
assert info["input_cost_per_token_above_272k_tokens"] == pytest.approx(info["input_cost_per_token"] * 2)
|
||||
assert info["output_cost_per_token_above_272k_tokens"] == pytest.approx(info["output_cost_per_token"] * 1.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", SMALL_MODELS)
|
||||
def test_gpt_5_4_small_models_backup_matches_main(model: str) -> None:
|
||||
assert _load(BACKUP_PATH).get(model) == _load(MAIN_PATH).get(model), (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
|
||||
from litellm.utils import supports_prompt_caching, supports_reasoning
|
||||
|
||||
|
|
@ -35,34 +34,6 @@ def local_model_cost_map(monkeypatch):
|
|||
litellm.get_model_info.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", GLM_5_2_MODELS)
|
||||
def test_zai_glm_5_2_specs(model):
|
||||
info = _load(MAIN_PATH).get(model)
|
||||
assert info is not None, f"{model} missing from model_prices_and_context_window.json"
|
||||
|
||||
assert info["litellm_provider"] == "mistral"
|
||||
assert info["mode"] == "chat"
|
||||
|
||||
assert info["input_cost_per_token"] == INPUT_COST
|
||||
assert info["output_cost_per_token"] == OUTPUT_COST
|
||||
assert info["cache_read_input_token_cost"] == CACHED_INPUT_COST
|
||||
|
||||
assert info["max_input_tokens"] == 1048576
|
||||
assert info["max_output_tokens"] == 131072
|
||||
assert info["max_tokens"] == 131072
|
||||
|
||||
assert info["supports_assistant_prefill"] is True
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_prompt_caching"] is True
|
||||
assert info["supports_reasoning"] is True
|
||||
assert info["supports_response_schema"] is True
|
||||
assert info["supports_tool_choice"] is True
|
||||
|
||||
routed_model, provider, _, _ = get_llm_provider(model=model)
|
||||
assert routed_model == model.split("/", 1)[1]
|
||||
assert provider == "mistral"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", GLM_5_2_MODELS)
|
||||
def test_zai_glm_5_2_capabilities_are_visible_to_callers(local_model_cost_map, model):
|
||||
"""Mistral advertises reasoning and prompt caching on this model, so the helpers
|
||||
|
|
|
|||
|
|
@ -7,40 +7,6 @@ MUSE_SPARK_MODEL = "meta/muse-spark-1.1"
|
|||
|
||||
|
||||
def test_muse_spark_1_1_model_info():
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
info = model_cost.get(MUSE_SPARK_MODEL)
|
||||
assert info is not None, f"{MUSE_SPARK_MODEL} not found in model_prices_and_context_window.json"
|
||||
|
||||
assert info["litellm_provider"] == "meta"
|
||||
assert info["mode"] == "chat"
|
||||
|
||||
assert info["input_cost_per_token"] == 1.25e-06
|
||||
assert info["output_cost_per_token"] == 4.25e-06
|
||||
assert info["cache_read_input_token_cost"] == 1.5e-07
|
||||
|
||||
assert info["max_input_tokens"] == 1048576
|
||||
assert info["max_output_tokens"] == 131072
|
||||
assert info["max_tokens"] == 131072
|
||||
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_parallel_function_calling"] is True
|
||||
assert info["supports_prompt_caching"] is True
|
||||
assert info["supports_reasoning"] is True
|
||||
assert info["supports_response_schema"] is True
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info["supports_vision"] is True
|
||||
assert info["supports_pdf_input"] is True
|
||||
assert info["supports_web_search"] is True
|
||||
assert info["supports_minimal_reasoning_effort"] is True
|
||||
assert info["supports_xhigh_reasoning_effort"] is True
|
||||
|
||||
assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
|
||||
assert info["supported_modalities"] == ["text", "image", "video"]
|
||||
assert info["supported_output_modalities"] == ["text"]
|
||||
|
||||
routed_model, provider, _, api_base = get_llm_provider(model=MUSE_SPARK_MODEL, api_key="sk-test")
|
||||
assert routed_model == "muse-spark-1.1"
|
||||
assert provider == "meta"
|
||||
|
|
|
|||
|
|
@ -78,38 +78,6 @@ def _load(path: Path) -> dict[str, dict[str, object]]:
|
|||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [MAIN_PATH, BACKUP_PATH], ids=["main", "backup"])
|
||||
@pytest.mark.parametrize("model", sorted(EXPECTED))
|
||||
def test_service_tier_long_context_rates_are_published(model: str, path: Path) -> None:
|
||||
"""Each tier must carry its own above-272K rates, in both price files."""
|
||||
info = _load(path).get(model)
|
||||
assert info is not None, f"{model} not found in {path.name}"
|
||||
for key, expected in EXPECTED[model].items():
|
||||
assert info.get(key) == pytest.approx(expected), f"{model}.{key} is {info.get(key)!r}, expected {expected!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", sorted(EXPECTED))
|
||||
def test_tier_long_context_rate_is_half_or_double_the_standard(model: str) -> None:
|
||||
"""Flex is half the standard long-context rate; priority is double it."""
|
||||
info = _load(MAIN_PATH)[model]
|
||||
tier = "flex" if model in FLEX_LONG_CONTEXT else "priority"
|
||||
ratio = 0.5 if tier == "flex" else 2.0
|
||||
for base in ("input_cost_per_token", "output_cost_per_token"):
|
||||
standard = info[f"{base}_above_272k_tokens"]
|
||||
tiered = info[f"{base}_above_272k_tokens_{tier}"]
|
||||
assert tiered == pytest.approx(standard * ratio), (
|
||||
f"{model}.{base}_above_272k_tokens_{tier} is {tiered!r}, "
|
||||
f"expected {ratio}x the standard long-context rate {standard!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", NO_PUBLISHED_PRIORITY_LONG_CONTEXT)
|
||||
def test_no_priority_long_context_rates_where_openai_publishes_none(model: str) -> None:
|
||||
"""Guard against back-filling a rate OpenAI does not publish."""
|
||||
info = _load(MAIN_PATH)[model]
|
||||
assert "input_cost_per_token_above_272k_tokens_priority" not in info
|
||||
|
||||
|
||||
LONG_CONTEXT_PROMPT_TOKENS = 300_000
|
||||
COMPLETION_TOKENS = 1_000
|
||||
|
||||
|
|
|
|||
|
|
@ -15975,6 +15975,60 @@ async def test_an_open_circuit_breaker_skips_the_session_binding_without_a_warni
|
|||
assert any("circuit breaker is open" in record.getMessage() for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_name_colliding_with_a_deployment_id_still_load_balances_the_group():
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-5-nano",
|
||||
"litellm_params": {"model": "openai/gpt-5-nano", "api_key": "k", "weight": 0, "mock_response": "A"},
|
||||
"model_info": {"id": "gpt-5-nano"},
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5-nano",
|
||||
"litellm_params": {"model": "openai/gpt-5-mini", "api_key": "k", "weight": 1, "mock_response": "B"},
|
||||
"model_info": {"id": "gpt-5-mini-dep"},
|
||||
},
|
||||
],
|
||||
routing_strategy="simple-shuffle",
|
||||
)
|
||||
|
||||
by_group = await router.acompletion(model="gpt-5-nano", messages=[{"role": "user", "content": "hi"}])
|
||||
by_id = await router.acompletion(model="gpt-5-mini-dep", messages=[{"role": "user", "content": "hi"}])
|
||||
|
||||
assert by_group._hidden_params["model_id"] == "gpt-5-mini-dep"
|
||||
assert by_group.choices[0].message.content == "B"
|
||||
assert by_id._hidden_params["model_id"] == "gpt-5-mini-dep"
|
||||
|
||||
|
||||
def test_sync_completion_runs_pre_call_checks_for_a_model_name_colliding_with_a_deployment_id():
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-5-nano",
|
||||
"litellm_params": {"model": "openai/gpt-5-nano", "api_key": "k", "weight": 0, "mock_response": "A"},
|
||||
"model_info": {"id": "gpt-5-nano"},
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5-nano",
|
||||
"litellm_params": {"model": "openai/gpt-5-mini", "api_key": "k", "weight": 1, "mock_response": "B"},
|
||||
"model_info": {"id": "gpt-5-mini-dep"},
|
||||
},
|
||||
],
|
||||
routing_strategy="simple-shuffle",
|
||||
)
|
||||
|
||||
with patch.object(router, "routing_strategy_pre_call_checks") as pre_call_checks:
|
||||
by_group = router.completion(model="gpt-5-nano", messages=[{"role": "user", "content": "hi"}])
|
||||
assert by_group._hidden_params["model_id"] == "gpt-5-mini-dep"
|
||||
pre_call_checks.assert_called_once()
|
||||
assert pre_call_checks.call_args.kwargs["deployment"]["model_info"]["id"] == "gpt-5-mini-dep"
|
||||
|
||||
by_id = router.completion(model="gpt-5-mini-dep", messages=[{"role": "user", "content": "hi"}])
|
||||
assert by_id._hidden_params["model_id"] == "gpt-5-mini-dep"
|
||||
pre_call_checks.assert_called_once()
|
||||
|
||||
|
||||
class TestMemberAutoRouterInference:
|
||||
@pytest.fixture(autouse=True)
|
||||
def runtime(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
|
|||
|
|
@ -11,15 +11,11 @@ def test_sambanova_minimax_m27_model_info():
|
|||
model_cost = json.load(f)
|
||||
|
||||
info = model_cost.get(model)
|
||||
assert (
|
||||
info is not None
|
||||
), f"{model} not found in model_prices_and_context_window.json"
|
||||
assert info is not None, f"{model} not found in model_prices_and_context_window.json"
|
||||
assert info["litellm_provider"] == "sambanova"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] > 0
|
||||
assert info["output_cost_per_token"] > 0
|
||||
assert info["max_input_tokens"] == 196608
|
||||
assert info["max_output_tokens"] == 131072
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_reasoning"] is True
|
||||
assert info["supports_tool_choice"] is True
|
||||
|
|
|
|||
|
|
@ -88,13 +88,6 @@ def test_together_chat_entries_never_carry_context_length_as_output_ceiling(cost
|
|||
assert inflated == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", sorted(DEPRECATED_MODELS))
|
||||
def test_together_deprecated_model_carries_deprecation_date(cost_map: CostMap, model: str):
|
||||
info = cost_map.get(model)
|
||||
assert info is not None, f"{model} missing from model_prices_and_context_window.json"
|
||||
assert info.get("deprecation_date") == DEPRECATED_MODELS[model]
|
||||
|
||||
|
||||
def _successor(info: dict[str, object]) -> str | None:
|
||||
metadata = info.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
|
|
|
|||
|
|
@ -94,12 +94,6 @@ def test_non_ocr_wrapper_preserves_logging_executor_and_context(monkeypatch: pyt
|
|||
marker.reset(token)
|
||||
|
||||
|
||||
def test_cloudflare_model_info_includes_rpm(local_model_cost_map: None) -> None:
|
||||
assert litellm.get_model_info("cloudflare/@cf/meta/llama-3.1-8b-instruct-fp8")["rpm"] == 300
|
||||
assert litellm.get_model_info("cloudflare/@cf/moonshotai/kimi-k2.6")["rpm"] == 20
|
||||
assert litellm.get_model_info("cloudflare/@cf/openai/whisper-large-v3-turbo")["rpm"] == 720
|
||||
|
||||
|
||||
def test_get_utc_datetime_returns_current_aware_utc_time() -> None:
|
||||
before: Final = datetime.now(timezone.utc)
|
||||
result: Final = litellm.utils.get_utc_datetime()
|
||||
|
|
@ -160,7 +154,6 @@ def test_prompt_tokens_details_cache_write_creation_stay_in_sync_on_assignment()
|
|||
assert details.cache_write_tokens == details.cache_creation_tokens == 375
|
||||
|
||||
|
||||
|
||||
def test_get_model_info_surfaces_supports_adaptive_thinking(local_model_cost_map):
|
||||
"""supports_adaptive_thinking must flow through get_model_info like every other
|
||||
capability flag: both from an explicit cost-map entry and from a
|
||||
|
|
@ -177,7 +170,6 @@ def test_get_model_info_surfaces_supports_adaptive_thinking(local_model_cost_map
|
|||
assert generalized["supports_adaptive_thinking"] is True
|
||||
|
||||
|
||||
|
||||
def test_get_model_info_surfaces_supports_parallel_function_calling(local_model_cost_map):
|
||||
"""A registry entry's supports_parallel_function_calling must read back through get_model_info
|
||||
and litellm.supports_parallel_function_calling. Regression: the key was never copied into
|
||||
|
|
@ -493,64 +485,6 @@ def test_gpt_image_provider_detection_covers_existing_family():
|
|||
assert custom_llm_provider == "openai"
|
||||
|
||||
|
||||
def test_gpt_image_2_provider_and_model_info(local_model_cost_map):
|
||||
|
||||
model, custom_llm_provider, _, _ = litellm.get_llm_provider(model="gpt-image-2")
|
||||
|
||||
assert model == "gpt-image-2"
|
||||
assert custom_llm_provider == "openai"
|
||||
|
||||
model_info = litellm.get_model_info(model="gpt-image-2")
|
||||
assert model_info["litellm_provider"] == "openai"
|
||||
assert model_info["mode"] == "image_generation"
|
||||
assert model_info["input_cost_per_token"] == 5e-06
|
||||
assert model_info["input_cost_per_image_token"] == 8e-06
|
||||
assert model_info["output_cost_per_token"] == 0
|
||||
assert model_info["output_cost_per_image_token"] == 3e-05
|
||||
assert (
|
||||
"/v1/images/generations"
|
||||
in litellm.model_cost["gpt-image-2"]["supported_endpoints"]
|
||||
)
|
||||
assert (
|
||||
"/v1/images/edits" in litellm.model_cost["gpt-image-2"]["supported_endpoints"]
|
||||
)
|
||||
assert model_info["supports_vision"] is True
|
||||
assert model_info["supports_pdf_input"] is True
|
||||
|
||||
|
||||
def test_gpt_image_2_snapshot_model_info(local_model_cost_map):
|
||||
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
|
||||
model="gpt-image-2-2026-04-21"
|
||||
)
|
||||
|
||||
assert model == "gpt-image-2-2026-04-21"
|
||||
assert custom_llm_provider == "openai"
|
||||
|
||||
model_info = litellm.get_model_info(model="gpt-image-2-2026-04-21")
|
||||
assert model_info["litellm_provider"] == "openai"
|
||||
assert model_info["mode"] == "image_generation"
|
||||
assert model_info["output_cost_per_image_token"] == 3e-05
|
||||
|
||||
|
||||
def test_azure_gpt_image_2_model_info(local_model_cost_map):
|
||||
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
|
||||
model="azure/gpt-image-2"
|
||||
)
|
||||
|
||||
assert model == "gpt-image-2"
|
||||
assert custom_llm_provider == "azure"
|
||||
|
||||
model_info = litellm.get_model_info(
|
||||
model="gpt-image-2", custom_llm_provider="azure"
|
||||
)
|
||||
assert model_info["litellm_provider"] == "azure"
|
||||
assert model_info["mode"] == "image_generation"
|
||||
assert model_info["input_cost_per_token"] == 5e-06
|
||||
assert model_info["input_cost_per_image_token"] == 8e-06
|
||||
assert model_info["output_cost_per_token"] == 0
|
||||
assert model_info["output_cost_per_image_token"] == 3e-05
|
||||
|
||||
|
||||
def test_all_model_configs():
|
||||
from litellm.llms.vertex_ai.vertex_ai_partner_models.ai21.transformation import (
|
||||
VertexAIAi21Config,
|
||||
|
|
@ -2907,158 +2841,6 @@ def test_model_info_for_vertex_ai_deepseek_model():
|
|||
print("vertex deepseek model info", model_info)
|
||||
|
||||
|
||||
def test_model_info_for_openrouter_kimi_k2_5():
|
||||
"""
|
||||
Test that openrouter/moonshotai/kimi-k2.5 model info is correctly configured
|
||||
in model_prices_and_context_window.json.
|
||||
|
||||
Model properties from OpenRouter API:
|
||||
- context_length: 262144
|
||||
- pricing: prompt=$0.00000045, completion=$0.00000225, input_cache_read=$0.00000007
|
||||
- modality: text+image->text (supports vision)
|
||||
- supports: tool_choice, tools (function calling)
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Load directly from the local JSON file
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
model_info = model_cost.get("openrouter/moonshotai/kimi-k2.5")
|
||||
assert (
|
||||
model_info is not None
|
||||
), "Model not found in model_prices_and_context_window.json"
|
||||
assert model_info["litellm_provider"] == "openrouter"
|
||||
assert model_info["mode"] == "chat"
|
||||
|
||||
# Verify context window
|
||||
assert model_info["max_input_tokens"] == 262144
|
||||
assert model_info["max_output_tokens"] == 262144
|
||||
assert model_info["max_tokens"] == 262144
|
||||
|
||||
# Verify pricing
|
||||
assert model_info["input_cost_per_token"] == 4.5e-07
|
||||
assert model_info["output_cost_per_token"] == 2.25e-06
|
||||
assert model_info["cache_read_input_token_cost"] == 7e-08
|
||||
|
||||
# Verify capabilities
|
||||
assert model_info["supports_vision"] is True
|
||||
assert model_info["supports_function_calling"] is True
|
||||
assert model_info["supports_tool_choice"] is True
|
||||
|
||||
print("openrouter kimi-k2.5 model info", model_info)
|
||||
|
||||
|
||||
def test_gemini_embedding_2_ga_in_cost_map():
|
||||
"""GA and Vertex preview gemini-embedding-2 entries align with multimodal token pricing."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
for key, provider in (
|
||||
("gemini/gemini-embedding-2", "gemini"),
|
||||
("vertex_ai/gemini-embedding-2", "vertex_ai"),
|
||||
("vertex_ai/gemini-embedding-2-preview", "vertex_ai"),
|
||||
("gemini-embedding-2", "vertex_ai-embedding-models"),
|
||||
):
|
||||
info = model_cost.get(key)
|
||||
assert (
|
||||
info is not None
|
||||
), f"{key} missing from model_prices_and_context_window.json"
|
||||
assert info["litellm_provider"] == provider
|
||||
assert info.get("mode") == "embedding"
|
||||
assert info.get("supports_multimodal") is True
|
||||
assert info.get("input_cost_per_token") == 2e-07
|
||||
assert info.get("input_cost_per_audio_token") == 6.5e-06
|
||||
assert info.get("input_cost_per_image_token") == 4.5e-07
|
||||
assert info.get("input_cost_per_video_token") == 1.2e-05
|
||||
assert info.get("input_cost_per_audio_token_batches") == 3.25e-06
|
||||
assert info.get("input_cost_per_image_token_batches") == 2.25e-07
|
||||
assert info.get("input_cost_per_video_token_batches") == 6e-06
|
||||
assert "input_cost_per_image" not in info
|
||||
assert "input_cost_per_audio_per_second" not in info
|
||||
assert "input_cost_per_video_per_second" not in info
|
||||
if provider in ("vertex_ai-embedding-models", "vertex_ai"):
|
||||
assert (
|
||||
info.get("uses_embed_content") is True
|
||||
), f"{key} must have uses_embed_content=true for correct Vertex AI routing"
|
||||
|
||||
|
||||
def test_gemini_lyria_3_preview_models_in_cost_map():
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
clip = model_cost.get("gemini/lyria-3-clip-preview")
|
||||
pro = model_cost.get("gemini/lyria-3-pro-preview")
|
||||
assert clip is not None and pro is not None
|
||||
assert clip["litellm_provider"] == "gemini" and pro["litellm_provider"] == "gemini"
|
||||
assert clip["max_input_tokens"] == 131072 == pro["max_input_tokens"]
|
||||
assert clip["output_cost_per_image"] == 0.04
|
||||
|
||||
|
||||
def test_vertex_ai_lyria_models_in_cost_map():
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
lyria_2 = model_cost.get("vertex_ai/lyria-002")
|
||||
clip = model_cost.get("vertex_ai/lyria-3-clip-preview")
|
||||
pro = model_cost.get("vertex_ai/lyria-3-pro-preview")
|
||||
|
||||
assert lyria_2 is not None
|
||||
assert clip is not None
|
||||
assert pro is not None
|
||||
assert lyria_2["litellm_provider"] == "vertex_ai"
|
||||
assert clip["litellm_provider"] == "vertex_ai"
|
||||
assert pro["litellm_provider"] == "vertex_ai"
|
||||
assert lyria_2["mode"] == "audio_speech"
|
||||
assert clip["mode"] == "audio_speech"
|
||||
assert pro["mode"] == "audio_speech"
|
||||
assert lyria_2["output_cost_per_image"] == 0.06
|
||||
assert lyria_2["supported_modalities"] == ["text"]
|
||||
assert lyria_2["supported_output_modalities"] == ["audio"]
|
||||
assert lyria_2["supports_audio_output"] is True
|
||||
assert lyria_2["supported_audio_formats"] == ["wav"]
|
||||
assert lyria_2["vertex_ai_audio_api"] == "lyria_predict"
|
||||
assert lyria_2["supported_endpoints"] == ["/v1/audio/speech"]
|
||||
assert clip["output_cost_per_image"] == 0.04
|
||||
assert pro["output_cost_per_image"] == 0.08
|
||||
assert clip["supported_audio_formats"] == ["mp3"]
|
||||
assert pro["supported_audio_formats"] == ["mp3", "wav"]
|
||||
assert clip["vertex_ai_audio_api"] == "lyria_interactions"
|
||||
assert pro["vertex_ai_audio_api"] == "lyria_interactions"
|
||||
assert clip["supported_endpoints"] == [
|
||||
"/v1beta/interactions",
|
||||
"/v1/audio/speech",
|
||||
]
|
||||
assert pro["supported_endpoints"] == [
|
||||
"/v1beta/interactions",
|
||||
"/v1/audio/speech",
|
||||
]
|
||||
assert clip["supported_modalities"] == ["text"]
|
||||
assert pro["supported_modalities"] == ["text"]
|
||||
assert clip["supports_vision"] is False
|
||||
assert pro["supports_vision"] is False
|
||||
assert "supports_image_input" not in clip
|
||||
assert "supports_image_input" not in pro
|
||||
assert clip["supported_regions"] == ["global"]
|
||||
assert pro["supported_regions"] == ["global"]
|
||||
assert clip["supports_audio_output"] is True
|
||||
assert pro["supports_audio_output"] is True
|
||||
|
||||
|
||||
def test_model_info_for_fireworks_short_form_models():
|
||||
"""
|
||||
Test that fireworks_ai short-form model entries (fireworks_ai/<model>)
|
||||
|
|
@ -4180,114 +3962,6 @@ class TestValidateAndFixThinkingParam:
|
|||
assert validate_and_fix_thinking_param(thinking=False) is None
|
||||
|
||||
|
||||
def test_deepseek_v4_models_in_cost_map():
|
||||
"""
|
||||
Test that deepseek-v4-flash and deepseek-v4-pro entries are correctly
|
||||
configured in model_prices_and_context_window.json.
|
||||
|
||||
Prices sourced from https://api-docs.deepseek.com/quick_start/pricing:
|
||||
- deepseek-v4-flash: $0.30/M input, $1.20/M output
|
||||
- deepseek-v4-pro: $1.32/M input, $3.96/M output
|
||||
|
||||
Closes https://github.com/BerriAI/litellm/issues/26709
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
# --- bare model names ---
|
||||
for key, expected_input, expected_output, expected_cache, expected_vision in [
|
||||
("deepseek-v4-flash", 3e-07, 1.2e-06, 6e-09, True),
|
||||
("deepseek-v4-pro", 1.32e-06, 3.96e-06, 4.4e-08, False),
|
||||
]:
|
||||
info = model_cost.get(key)
|
||||
assert info is not None, f"{key} missing from model_prices_and_context_window.json"
|
||||
assert info["litellm_provider"] == "deepseek"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] == expected_input
|
||||
assert info["output_cost_per_token"] == expected_output
|
||||
assert info["cache_read_input_token_cost"] == expected_cache
|
||||
assert info["max_input_tokens"] == 1_000_000
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info.get("supports_vision", False) is expected_vision
|
||||
|
||||
# --- provider-prefixed names ---
|
||||
for key, expected_input, expected_output, expected_cache, expected_vision in [
|
||||
("deepseek/deepseek-v4-flash", 3e-07, 1.2e-06, 6e-09, True),
|
||||
("deepseek/deepseek-v4-pro", 1.32e-06, 3.96e-06, 4.4e-08, False),
|
||||
]:
|
||||
info = model_cost.get(key)
|
||||
assert info is not None, f"{key} missing from model_prices_and_context_window.json"
|
||||
assert info["litellm_provider"] == "deepseek"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] == expected_input
|
||||
assert info["output_cost_per_token"] == expected_output
|
||||
assert info["cache_read_input_token_cost"] == expected_cache
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info.get("supports_vision", False) is expected_vision
|
||||
|
||||
|
||||
def test_deepseek_v4_models_in_backup_cost_map():
|
||||
"""
|
||||
Test that deepseek-v4-flash and deepseek-v4-pro entries are correctly
|
||||
configured in litellm/model_prices_and_context_window_backup.json.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
json_path = Path(__file__).parents[2] / "litellm" / "model_prices_and_context_window_backup.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
# --- bare model names ---
|
||||
for key, expected_input, expected_output, expected_cache, expected_vision in [
|
||||
("deepseek-v4-flash", 3e-07, 1.2e-06, 6e-09, True),
|
||||
("deepseek-v4-pro", 1.32e-06, 3.96e-06, 4.4e-08, False),
|
||||
]:
|
||||
info = model_cost.get(key)
|
||||
assert info is not None, f"{key} missing from backup JSON"
|
||||
assert info["litellm_provider"] == "deepseek"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] == expected_input
|
||||
assert info["output_cost_per_token"] == expected_output
|
||||
assert info["cache_read_input_token_cost"] == expected_cache
|
||||
assert info["max_input_tokens"] == 1_000_000
|
||||
assert info.get("supports_vision", False) is expected_vision
|
||||
|
||||
# --- provider-prefixed names ---
|
||||
for key, expected_input, expected_output, expected_cache, expected_vision in [
|
||||
("deepseek/deepseek-v4-flash", 3e-07, 1.2e-06, 6e-09, True),
|
||||
("deepseek/deepseek-v4-pro", 1.32e-06, 3.96e-06, 4.4e-08, False),
|
||||
]:
|
||||
info = model_cost.get(key)
|
||||
assert info is not None, f"{key} missing from backup JSON"
|
||||
assert info["litellm_provider"] == "deepseek"
|
||||
assert info["mode"] == "chat"
|
||||
assert info["input_cost_per_token"] == expected_input
|
||||
assert info["output_cost_per_token"] == expected_output
|
||||
assert info["cache_read_input_token_cost"] == expected_cache
|
||||
assert info.get("supports_vision", False) is expected_vision
|
||||
|
||||
|
||||
def test_deprecation_dates_for_retired_xai_and_groq_models():
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
assert model_cost["xai/grok-imagine-image-quality"]["deprecation_date"] == "2026-11-02"
|
||||
assert model_cost["xai/grok-imagine-image-quality-latest"]["deprecation_date"] == "2026-11-02"
|
||||
assert model_cost["xai/grok-imagine-image-quality-20260403"]["deprecation_date"] == "2026-11-02"
|
||||
assert model_cost["groq/gemma-7b-it"]["deprecation_date"] == "2024-12-18"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
def test_deepseek_flash_completion_cost():
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
|
@ -4979,25 +4653,6 @@ def test_anthropic_reexport_entries_carry_explicit_prompt_cache_min_tokens(local
|
|||
assert not wrong, f"(cost-map value, resolved value) diverge from Anthropic's published minimums: {wrong}"
|
||||
|
||||
|
||||
def test_anthropic_reexport_cache_minimums_present_in_root_cost_map() -> None:
|
||||
"""The root map ships to the CDN independently of the bundled backup, so both must carry the
|
||||
minimum or proxies reading one of them regress to the 1024 default."""
|
||||
root_map_path: Final = os.path.join(os.path.dirname(__file__), "..", "..", "model_prices_and_context_window.json")
|
||||
with open(root_map_path) as f:
|
||||
root_map: Final = json.load(f)
|
||||
wrong: Final = {
|
||||
model: root_map[model].get("prompt_cache_min_tokens")
|
||||
for model, expected in ANTHROPIC_REEXPORT_CACHE_MIN.items()
|
||||
if root_map[model].get("prompt_cache_min_tokens") != expected
|
||||
}
|
||||
fable_5_wrong: Final = {
|
||||
model: info.get("prompt_cache_min_tokens")
|
||||
for model, info in root_map.items()
|
||||
if "fable-5" in model and info.get("supports_prompt_caching") and info.get("prompt_cache_min_tokens") != 512
|
||||
}
|
||||
assert not wrong and not fable_5_wrong, f"root cost map diverges: {wrong | fable_5_wrong}"
|
||||
|
||||
|
||||
GEMINI_4096_CACHE_MIN_MODELS: Final = tuple(
|
||||
prefix + base
|
||||
for base in (
|
||||
|
|
@ -5024,20 +4679,6 @@ def test_gemini_3_flash_and_31_pro_preview_resolve_4096_cache_minimum(local_mode
|
|||
assert not wrong, f"prompt_cache_min_tokens must be 4096: {wrong}"
|
||||
|
||||
|
||||
def test_gemini_4096_cache_minimum_present_in_root_cost_map() -> None:
|
||||
"""The root map ships to the CDN independently of the bundled backup, so both must carry the
|
||||
minimum or proxies reading one of them regress to the 1024 default."""
|
||||
root_map_path: Final = os.path.join(os.path.dirname(__file__), "..", "..", "model_prices_and_context_window.json")
|
||||
with open(root_map_path) as f:
|
||||
root_map: Final = json.load(f)
|
||||
wrong: Final = {
|
||||
model: root_map[model].get("prompt_cache_min_tokens")
|
||||
for model in GEMINI_4096_CACHE_MIN_MODELS
|
||||
if root_map[model].get("prompt_cache_min_tokens") != 4096
|
||||
}
|
||||
assert not wrong, f"prompt_cache_min_tokens must be 4096: {wrong}"
|
||||
|
||||
|
||||
def test_get_prompt_cache_min_tokens_unmapped_model_falls_back_to_default(local_model_cost_map: None) -> None:
|
||||
"""get_model_info raises for a model it has no entry for. The resolver must swallow that and
|
||||
fall back to the default, otherwise the raise reaches callers that would read it as
|
||||
|
|
@ -6508,7 +6149,6 @@ async def test_async_mock_completion_streaming_obj_raises_mock_exception_before_
|
|||
await _async_mock_stream_snapshots(mock_exception, 51234)
|
||||
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _recording_hidden_params_at_submit(submit_target: str) -> "Iterator[queue.SimpleQueue[dict[str, object]]]":
|
||||
seen: Final = queue.SimpleQueue()
|
||||
|
|
|
|||
|
|
@ -1,49 +1,6 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["xai/grok-4.3", "xai/grok-4.3-latest"])
|
||||
def test_xai_grok_4_3_model_info(model):
|
||||
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
|
||||
with open(json_path) as f:
|
||||
model_cost = json.load(f)
|
||||
|
||||
info = model_cost.get(model)
|
||||
assert (
|
||||
info is not None
|
||||
), f"{model} not found in model_prices_and_context_window.json"
|
||||
|
||||
assert info["litellm_provider"] == "xai"
|
||||
assert info["mode"] == "chat"
|
||||
|
||||
assert info["input_cost_per_token"] == 1.25e-06
|
||||
assert info["output_cost_per_token"] == 2.5e-06
|
||||
assert info["cache_read_input_token_cost"] == 2e-07
|
||||
|
||||
assert info["input_cost_per_token_above_200k_tokens"] == 2.5e-06
|
||||
assert info["output_cost_per_token_above_200k_tokens"] == 5e-06
|
||||
assert info["cache_read_input_token_cost_above_200k_tokens"] == 4e-07
|
||||
|
||||
assert info["max_input_tokens"] == 1000000
|
||||
assert info["max_output_tokens"] == 1000000
|
||||
assert info["max_tokens"] == 1000000
|
||||
|
||||
assert info["supports_function_calling"] is True
|
||||
assert info["supports_prompt_caching"] is True
|
||||
assert info["supports_reasoning"] is True
|
||||
assert info["supports_response_schema"] is True
|
||||
assert info["supports_tool_choice"] is True
|
||||
assert info["supports_vision"] is True
|
||||
assert info["supports_web_search"] is True
|
||||
|
||||
routed_model, provider, _, _ = get_llm_provider(model=model)
|
||||
assert routed_model == model.split("/", 1)[1]
|
||||
assert provider == "xai"
|
||||
|
||||
|
||||
def test_xai_grok_4_3_backup_matches_main():
|
||||
"""Ensure the bundled model cost map stays in sync with the canonical file."""
|
||||
|
|
|
|||
|
|
@ -433,6 +433,15 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
if (classifierType === "capability") {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This router uses capability forecasting. Configure its classifier, threshold, and calibration through YAML or
|
||||
the API. Saving preserves those settings
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClassifierTypeRadios value={value} classifierType={classifierType} onTypeChange={handleClassifierTypeChange} />
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export interface ClassifierLLMConfig {
|
|||
system_prompt?: string;
|
||||
}
|
||||
|
||||
export type ClassifierType = "heuristic" | "heuristic_v2" | "llm" | "heuristic_first" | "hybrid";
|
||||
export type ClassifierType = "heuristic" | "heuristic_v2" | "llm" | "heuristic_first" | "hybrid" | "capability";
|
||||
|
||||
/**
|
||||
* Whether this router can call classifier_llm_config.model. Mirrors the backend's
|
||||
|
|
@ -151,7 +151,7 @@ export type ClassifierType = "heuristic" | "heuristic_v2" | "llm" | "heuristic_f
|
|||
* control and payload key, so a new chaining type cannot strip knobs the operator set.
|
||||
*/
|
||||
export const usesLlmClassifier = (classifierType: ClassifierType): boolean =>
|
||||
classifierType === "llm" || classifierType === "heuristic_first" || classifierType === "hybrid";
|
||||
(["llm", "heuristic_first", "hybrid", "capability"] as const).some((type) => type === classifierType);
|
||||
|
||||
export type ClassifierFallback = "heuristic" | "default_model";
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ export const heuristicScoringRoleFor = (
|
|||
classifierType: ClassifierType,
|
||||
classifierFallback: ClassifierFallback | undefined,
|
||||
): HeuristicScoringRole => {
|
||||
if (classifierType === "heuristic_v2") return "never";
|
||||
if (classifierType === "heuristic_v2" || classifierType === "capability") return "never";
|
||||
if (classifierType === "heuristic" || classifierType === "heuristic_first" || classifierType === "hybrid")
|
||||
return "decides";
|
||||
return (classifierFallback ?? DEFAULT_CLASSIFIER_FALLBACK) === "heuristic" ? "fallback_only" : "never";
|
||||
|
|
|
|||
|
|
@ -470,7 +470,10 @@ const classifierWireFields = (
|
|||
>,
|
||||
): Partial<ComplexityRouterConfigPayload> => ({
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierLlmConfig && { classifier_llm_config: normalizeClassifierLlmConfig(classifierLlmConfig) }),
|
||||
classifierLlmConfig && {
|
||||
classifier_llm_config:
|
||||
effectiveType === "capability" ? classifierLlmConfig : normalizeClassifierLlmConfig(classifierLlmConfig),
|
||||
}),
|
||||
...(usesLlmClassifier(effectiveType) &&
|
||||
classifierFallback !== undefined && { classifier_fallback: classifierFallback }),
|
||||
...(effectiveType === "heuristic_first" &&
|
||||
|
|
|
|||
|
|
@ -164,6 +164,32 @@ const STORED_LLM = {
|
|||
classifier_context_per_turn_chars: 300,
|
||||
};
|
||||
|
||||
describe("capability classifier configuration", () => {
|
||||
it("preserves the judge and calibrated policy through an untouched dashboard edit", () => {
|
||||
const stored = {
|
||||
tiers: { SIMPLE: ["efficient-model"], REASONING: ["capable-model"] },
|
||||
classifier_type: "capability" as const,
|
||||
classifier_llm_config: { model: "judge", timeout_ms: 30000, temperature: 0 },
|
||||
capability_classifier_config: {
|
||||
efficient_tier: "SIMPLE",
|
||||
capable_tier: "REASONING",
|
||||
base_threshold: 0.66,
|
||||
max_output_tokens: 512,
|
||||
response_format: "json_object",
|
||||
calibration: { version: "fitted-v1", slope: 0.15, intercept: 0.19 },
|
||||
},
|
||||
};
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, null);
|
||||
const saved = buildUpdatedComplexityRouterConfig(stored, hydrated);
|
||||
|
||||
expect(saved.classifier_type).toBe("capability");
|
||||
expect(saved.classifier_llm_config).toEqual(stored.classifier_llm_config);
|
||||
expect(saved.capability_classifier_config).toEqual(stored.capability_classifier_config);
|
||||
expect(saved).not.toHaveProperty("classification_prompt");
|
||||
expect(saved).not.toHaveProperty("custom_dimensions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig classifier context window", () => {
|
||||
it("round-trips an untouched edit without changing the classifier context values", () => {
|
||||
const formValue = {
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ describe("EditAutoRouterModal keyword matching", () => {
|
|||
expect(screen.queryByText("Advanced: Compression")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Model Access Groups")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText("Advanced: Affinity"));
|
||||
await user.click(await screen.findByRole("switch", { name: "Pin a session to one deployment per model group" }));
|
||||
await user.click(await screen.findByRole("switch", { name: "Pin one model deployment per tier" }));
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(modelPatchUpdateCall).toHaveBeenLastCalledWith(
|
||||
|
|
|
|||
75
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
75
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25328,6 +25328,57 @@ export interface components {
|
|||
*/
|
||||
status: "cancelled";
|
||||
};
|
||||
/** CapabilityCalibrationConfig */
|
||||
CapabilityCalibrationConfig: {
|
||||
/** Intercept */
|
||||
intercept: number;
|
||||
/** Slope */
|
||||
slope: number;
|
||||
/** Version */
|
||||
version: string;
|
||||
};
|
||||
/**
|
||||
* CapabilityClassifierConfig
|
||||
* @description Switchyard-compatible probability threshold policy for two model tiers.
|
||||
*/
|
||||
CapabilityClassifierConfig: {
|
||||
/**
|
||||
* Base Threshold
|
||||
* @description Lowest p_solve that routes a supported task to efficient_tier
|
||||
*/
|
||||
base_threshold: number;
|
||||
/** @description Optional versioned sigmoid calibration fitted for this judge, capability card, efficient model, and execution setup. Applies sigmoid(slope * logit(clip(p_solve, 1e-6, 1-1e-6)) + intercept) before the threshold policy. Omit to route on the raw forecast. */
|
||||
calibration?: components["schemas"]["CapabilityCalibrationConfig"] | null;
|
||||
/**
|
||||
* Capable Tier
|
||||
* @description Higher, fail-closed tier used below the adjusted threshold or when the classifier verdict is unavailable
|
||||
*/
|
||||
capable_tier: string;
|
||||
/**
|
||||
* Efficient Tier
|
||||
* @description Tier used when the efficient model's forecasted solve probability meets the adjusted threshold
|
||||
*/
|
||||
efficient_tier: string;
|
||||
/**
|
||||
* Max Output Tokens
|
||||
* @description Maximum completion tokens available to the capability classifier verdict
|
||||
* @default 4096
|
||||
*/
|
||||
max_output_tokens: number;
|
||||
/**
|
||||
* Response Format
|
||||
* @description Use json_object for judges without strict JSON Schema support. This appends the verdict schema to the packaged system prompt; both modes validate the returned verdict identically.
|
||||
* @default json_schema
|
||||
* @enum {string}
|
||||
*/
|
||||
response_format: "json_schema" | "json_object";
|
||||
/**
|
||||
* Threshold Step
|
||||
* @description Amount added once for uncertain or unmatched verdicts and twice for unsupported verdicts
|
||||
* @default 0
|
||||
*/
|
||||
threshold_step: number;
|
||||
};
|
||||
/** ChatCompletionAnnotation */
|
||||
ChatCompletionAnnotation: {
|
||||
/**
|
||||
|
|
@ -35542,6 +35593,8 @@ export interface components {
|
|||
adaptive_eligible: "all" | "classified_tier";
|
||||
/** @description Quality vs cost weights for adaptive selection (used when adaptive=True) */
|
||||
adaptive_weights?: components["schemas"]["AdaptiveRouterWeights"];
|
||||
/** @description Probability threshold policy required when classifier_type is 'capability'. The classifier forecasts p_solve for efficient_tier, adjusts base_threshold using the capability-card boundary, and otherwise routes to capable_tier */
|
||||
capability_classifier_config?: components["schemas"]["CapabilityClassifierConfig"] | null;
|
||||
/**
|
||||
* Classification Examples
|
||||
* @description Replaces the calibration examples of the LLM classifier rubric, and nothing else. Written as example lines only: the router renders the 'Calibration examples:' heading above them, after the per-tier bullets. Requires an LLM classifier and cannot be combined with classifier_llm_config.system_prompt. With built-in tiers the rubric preset still supplies the tier criteria and, unless classification_prompt replaces them, the classification instructions; a custom tier set ships no examples of its own, so the section renders only when this is set.
|
||||
|
|
@ -35589,7 +35642,7 @@ export interface components {
|
|||
* @enum {string}
|
||||
*/
|
||||
classifier_fallback: "heuristic" | "default_model";
|
||||
/** @description Configuration for the LLM classifier; required when classifier_type is 'llm', 'heuristic_first' or 'hybrid' */
|
||||
/** @description Configuration for the LLM classifier; required when classifier_type is 'llm', 'capability', 'heuristic_first' or 'hybrid' */
|
||||
classifier_llm_config?: components["schemas"]["ClassifierLLMConfig"] | null;
|
||||
/**
|
||||
* Classifier Plugin
|
||||
|
|
@ -35604,11 +35657,11 @@ export interface components {
|
|||
classifier_plugin_timeout_ms: number;
|
||||
/**
|
||||
* Classifier Type
|
||||
* @description Classification strategy: local regex/keyword scoring, the bundled trained four-tier heuristic, an LLM call, a custom classifier plugin, 'heuristic_first', which scores locally and only pays for the LLM classifier when the local scorer does not confidently land a cheap tier, or 'hybrid', which trusts the local scorer everywhere except when its score lands near a tier boundary
|
||||
* @description Classification strategy: local regex/keyword scoring, the bundled trained four-tier heuristic, an LLM tier-selection call, a Switchyard-compatible capability forecast, a custom classifier plugin, 'heuristic_first', which scores locally and only pays for the LLM classifier when the local scorer does not confidently land a cheap tier, or 'hybrid', which trusts the local scorer everywhere except when its score lands near a tier boundary
|
||||
* @default heuristic
|
||||
* @enum {string}
|
||||
*/
|
||||
classifier_type: "heuristic" | "heuristic_v2" | "llm" | "custom" | "heuristic_first" | "hybrid";
|
||||
classifier_type: "heuristic" | "heuristic_v2" | "llm" | "capability" | "custom" | "heuristic_first" | "hybrid";
|
||||
/**
|
||||
* Code Keywords
|
||||
* @description Keywords indicating code-related content
|
||||
|
|
@ -36957,11 +37010,25 @@ export interface components {
|
|||
* Cause
|
||||
* @enum {string}
|
||||
*/
|
||||
cause?: "heuristic_scorer" | "heuristic_v2" | "reasoning_override" | "llm_classifier" | "heuristic_first_short_circuit" | "hybrid_short_circuit" | "classifier_plugin" | "classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "modality_escalation" | "modality_pin_override" | "health_failover" | "health_default_fallback" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
|
||||
cause?: "heuristic_scorer" | "heuristic_v2" | "reasoning_override" | "llm_classifier" | "capability_classifier" | "heuristic_first_short_circuit" | "hybrid_short_circuit" | "classifier_plugin" | "classifier_fallback" | "capability_classifier_fallback" | "default_model_fallback" | "literal_keyword_match" | "semantic_keyword_match" | "plan_mode" | "housekeeping" | "modality_escalation" | "modality_pin_override" | "health_failover" | "health_default_fallback" | "session_affinity_pin" | "session_affinity_escalation" | "user_turn_continuation" | "default_fallback" | "keyword" | "quality_tier" | "bandit";
|
||||
/** Classifier Calibrated P Solve */
|
||||
classifier_calibrated_p_solve?: number;
|
||||
/** Classifier Calibration Version */
|
||||
classifier_calibration_version?: string;
|
||||
/** Classifier Capability Boundary */
|
||||
classifier_capability_boundary?: string;
|
||||
/** Classifier Cost */
|
||||
classifier_cost?: number;
|
||||
/** Classifier Crux */
|
||||
classifier_crux?: string;
|
||||
/** Classifier Model */
|
||||
classifier_model?: string;
|
||||
/** Classifier P Solve */
|
||||
classifier_p_solve?: number;
|
||||
/** Classifier Primary Rule */
|
||||
classifier_primary_rule?: string;
|
||||
/** Classifier Threshold */
|
||||
classifier_threshold?: number;
|
||||
/** Context Escalated */
|
||||
context_escalated?: boolean;
|
||||
/** Context Escalation Original Tier */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue