Merge pull request #30681 from BerriAI/litellm_backport_1_89_x_0617

chore(release): backport #30380, #30503, #30558, #30130, #30588, #30495, #30690 to stable/1.89.x and cut 1.89.2
This commit is contained in:
yuneng-jiang 2026-06-17 19:22:38 -07:00 committed by GitHub
commit 94dae27b0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2099 additions and 75 deletions

View file

@ -94,6 +94,7 @@ from litellm.types.utils import (
LlmProviders,
LlmProvidersSet,
ModelInfo,
ServiceTier,
StandardBuiltInToolsParams,
TranscriptionUsageDurationObject,
TranscriptionUsageTokensObject,
@ -614,7 +615,9 @@ def cost_per_token( # noqa: PLR0915
service_tier=service_tier,
)
elif custom_llm_provider == "anthropic":
return anthropic_cost_per_token(model=model, usage=usage_block)
return anthropic_cost_per_token(
model=model, usage=usage_block, service_tier=service_tier
)
elif custom_llm_provider == "bedrock":
return bedrock_cost_per_token(
model=model, usage=usage_block, service_tier=service_tier
@ -1224,6 +1227,16 @@ def completion_cost( # noqa: PLR0915
if service_tier is None and optional_params is not None:
service_tier = optional_params.get("service_tier")
# A request-level service_tier only prices the request when it is a
# concrete billable tier string. "auto" is a routing preference and any
# non-string value is not a billable tier, so defer to the tier the
# provider reports on the response/usage instead of crashing or mispricing
if (
not isinstance(service_tier, str)
or service_tier.lower() == ServiceTier.AUTO.value
):
service_tier = None
# Extract service_tier from completion_response if not provided
if service_tier is None and completion_response is not None:
if isinstance(completion_response, BaseModel):

View file

@ -17,7 +17,7 @@ from litellm.integrations.otel.model.payloads import (
ServiceSpanData,
)
from litellm.integrations.otel.plumbing.providers import to_otel_span_kind
from litellm.integrations.otel.model.semconv import Error
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent
from litellm.integrations.otel.model.spans import (
SPAN_REGISTRY,
SpanRole,
@ -179,9 +179,17 @@ class SpanEmitter:
else None
)
if error and (error.error_type or error.message):
span.set_attribute(Error.TYPE, error.error_type or "error")
span.set_status(
Status(StatusCode.ERROR, error.message or error.error_type or "error")
error_type = error.error_type or "error"
message = error.message or error.error_type or "error"
span.set_attribute(Error.TYPE, error_type)
span.set_status(Status(StatusCode.ERROR, message))
# Carry the full message on the standard ``exception`` event so backends
# map it as full text under ``exception.message``. Setting it as a bare
# string attribute instead lets backends like Elasticsearch dynamic-map
# it to a ``keyword`` capped at 1024 chars, truncating the message.
span.add_event(
ExceptionEvent.NAME,
{ExceptionEvent.TYPE: error_type, ExceptionEvent.MESSAGE: message},
)
# On success leave the status UNSET (the semconv default) rather than
# forcing OK — that matches the FastAPI server span and avoids implying a

View file

@ -146,6 +146,21 @@ class Error:
TYPE: Final = "error.type"
class ExceptionEvent:
"""OTel exception-event name and attribute keys (semconv ``exception.*``).
The full error message rides ``exception.message`` on a span event rather than
a custom string attribute. Backends recognise these semantic-convention names
and map them as full text; an unrecognised key (e.g. ``error_message``) falls
into the default dynamic template, which truncates strings to a 1024-char
``keyword``.
"""
NAME: Final = "exception"
TYPE: Final = "exception.type"
MESSAGE: Final = "exception.message"
class Server:
ADDRESS: Final = "server.address"
PORT: Final = "server.port"

View file

@ -2205,6 +2205,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
inference_geo: Optional[str] = None
if "inference_geo" in _usage and _usage["inference_geo"] is not None:
inference_geo = _usage["inference_geo"]
service_tier = cast(
str | None,
_usage.get("service_tier"), # any-ok: untyped usage dict
)
if (
"cache_creation_input_tokens" in _usage
@ -2298,6 +2302,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
),
inference_geo=inference_geo,
speed=speed,
service_tier=service_tier,
)
return usage

View file

@ -18,7 +18,9 @@ if TYPE_CHECKING:
import litellm
def _compute_cache_only_cost(model_info: "ModelInfo", usage: "Usage") -> float:
def _compute_cache_only_cost(
model_info: "ModelInfo", usage: "Usage", service_tier: str | None = None
) -> float:
"""
Return only the cache-related portion of the prompt cost (cache read + cache write).
@ -36,7 +38,9 @@ def _compute_cache_only_cost(model_info: "ModelInfo", usage: "Usage") -> float:
cache_creation_cost,
cache_creation_cost_above_1hr,
cache_read_cost,
) = _get_token_base_cost(model_info=model_info, usage=usage)
) = _get_token_base_cost(
model_info=model_info, usage=usage, service_tier=service_tier
)
cache_cost = float(prompt_tokens_details["cache_hit_tokens"]) * cache_read_cost
@ -56,19 +60,26 @@ def _compute_cache_only_cost(model_info: "ModelInfo", usage: "Usage") -> float:
return cache_cost
def cost_per_token(model: str, usage: "Usage") -> Tuple[float, float]:
def cost_per_token(
model: str, usage: "Usage", service_tier: str | None = None
) -> Tuple[float, float]:
"""
Calculates the cost per token for a given model, prompt tokens, and completion tokens.
Input:
- model: str, the model name without provider prefix
- usage: LiteLLM Usage block, containing anthropic caching information
- service_tier: the service tier the request was served at (e.g. "priority"),
read from the Anthropic response usage and used to select tier-specific pricing
Returns:
Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd
"""
prompt_cost, completion_cost = generic_cost_per_token(
model=model, usage=usage, custom_llm_provider="anthropic"
model=model,
usage=usage,
custom_llm_provider="anthropic",
service_tier=service_tier,
)
# Apply provider_specific_entry multipliers for geo/speed routing
@ -89,7 +100,9 @@ def cost_per_token(model: str, usage: "Usage") -> Tuple[float, float]:
multiplier *= provider_specific_entry.get("fast", 1.0)
if multiplier != 1.0:
cache_cost = _compute_cache_only_cost(model_info=model_info, usage=usage)
cache_cost = _compute_cache_only_cost(
model_info=model_info, usage=usage, service_tier=service_tier
)
prompt_cost = (prompt_cost - cache_cost) * multiplier + cache_cost
completion_cost *= multiplier
except Exception:

View file

@ -376,6 +376,8 @@ class LiteLLMRoutes(enum.Enum):
# vector stores
"/vector_stores",
"/v1/vector_stores",
"/vector_stores/{vector_store_id}",
"/v1/vector_stores/{vector_store_id}",
"/vector_stores/{vector_store_id}/search",
"/v1/vector_stores/{vector_store_id}/search",
"/vector_stores/{vector_store_id}/files",

View file

@ -0,0 +1,167 @@
"""Team-scoped (BYOK) model-name translation for the model listing endpoints.
`/v1/models`, `/models`, and `GET /v1/models/{id}` should surface the public
`team_public_model_name` rather than the internal routing key
`model_name_{team_id}_{uuid}`, consistent with `/v1/model/info`. The internal
key still routes regardless; this is a presentation-layer swap only and does not
touch access-group or auth semantics (see issue #28382). Operators can pin the
legacy internal names with `general_settings.use_team_public_model_name: false`.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from litellm.router import Router
class TeamModelNameTranslator:
"""Translates internal team routing keys to their public names for the model
listing/retrieve responses. Stateless; the live router and general_settings
are injected per call so the unit tests can drive it without globals.
"""
@staticmethod
def _internal_public_pair(model: object) -> tuple[str, str] | None:
"""`(internal_routing_key, public_name)` for a team-scoped row, else None."""
if not isinstance(model, dict):
return None
model_dict = cast(dict[str, object], model) # any-ok: checked
model_info_raw: object = model_dict.get("model_info")
if not isinstance(model_info_raw, Mapping):
return None
model_info = cast(Mapping[str, object], model_info_raw) # any-ok: checked
team_id = model_info.get("team_id")
team_public = model_info.get("team_public_model_name")
name = model_dict.get("model_name")
if (
isinstance(team_id, str)
and isinstance(team_public, str)
and isinstance(name, str)
and team_id
and team_public
and name.startswith(f"model_name_{team_id}_")
):
return name, team_public
return None
@staticmethod
def _is_enabled(general_settings: Mapping[str, object]) -> bool:
return general_settings.get("use_team_public_model_name", True) is not False
@staticmethod
def build_internal_to_public_map(
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> dict[str, str]:
"""Internal team routing key -> public `team_public_model_name`.
Empty when disabled via the legacy flag, the router is absent, or the
router model list is malformed.
"""
if llm_router is None or not TeamModelNameTranslator._is_enabled(
general_settings
):
return {}
router_model_list = llm_router.get_model_list()
if not isinstance(router_model_list, list):
return {}
return dict(
pair
for pair in (
TeamModelNameTranslator._internal_public_pair(model)
for model in router_model_list
)
if pair is not None
)
@staticmethod
def _response_to_lookup_map(
model_names: list[str],
internal_to_public: dict[str, str],
) -> dict[str, str]:
"""Map each public response id to the first internal lookup id seen in
`model_names`, preserving first-occurrence order. First-wins keeps list
and retrieve in agreement on which accessible deployment a shared public
id resolves to: a global iterated before a colliding team alias stays
the listed entry, and sibling team rows collapse to their first
occurrence.
"""
result: dict[str, str] = {}
for name in model_names:
result.setdefault(internal_to_public.get(name, name), name)
return result
@staticmethod
def listing_entries(
model_names: list[str],
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> list[tuple[str, str]]:
"""`(response_id, metadata_lookup_id)` for each listed model, de-duplicated
by response_id while preserving order.
For team-scoped rows `response_id` is the public name shown to the client,
while `metadata_lookup_id` stays the internal routing key so downstream
metadata/fallback lookups (keyed by the routing name) still resolve. The
lookup id is always one of `model_names` (the caller's accessible set), so
a public name shared across teams never resolves to another team's
internal key. Both ids are identical for unmapped names (globals,
access-group keys).
"""
internal_to_public = TeamModelNameTranslator.build_internal_to_public_map(
llm_router, general_settings
)
if not internal_to_public:
return [(name, name) for name in model_names]
return list(
TeamModelNameTranslator._response_to_lookup_map(
model_names, internal_to_public
).items()
)
@staticmethod
def translate_listing(
model_names: list[str],
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> list[str]:
"""Public-name view of `model_names` (the `response_id` of each listing
entry). Sibling deployments sharing a public name collapse to one entry
while preserving order; unmapped names pass through.
"""
return [
entry[0]
for entry in TeamModelNameTranslator.listing_entries(
model_names, llm_router, general_settings
)
]
@staticmethod
def resolve_public_name(
model_id: str,
available_models: list[str],
llm_router: "Router | None",
general_settings: Mapping[str, object],
) -> str:
"""Resolve a public team name back to the internal routing key the router
indexes by, so `GET /v1/models/{id}` accepts the name the listing returns.
Resolution is restricted to `available_models` (the caller's accessible
set) so colliding public names across teams never resolve across an access
boundary. Uses the same first-occurrence dedup as `listing_entries` so a
public id advertised by `/v1/models` resolves to the same internal
deployment that the listing's metadata was built from. Returns `model_id`
unchanged when it is not an accessible public team name (already-internal
names and globals pass through).
"""
internal_to_public = TeamModelNameTranslator.build_internal_to_public_map(
llm_router, general_settings
)
if not internal_to_public:
return model_id
return TeamModelNameTranslator._response_to_lookup_map(
available_models, internal_to_public
).get(model_id, model_id)

View file

@ -10,6 +10,8 @@ from litellm.types.utils import SpecialEnums
if TYPE_CHECKING:
from fastapi import Request
from litellm.router import Router
def _is_base64_encoded_unified_file_id(b64_uid: str) -> Union[str, Literal[False]]:
# Ensure b64_uid is a string and not a mock object
@ -296,6 +298,92 @@ def get_credentials_for_model(
return credentials
def get_team_provider_credentials(
llm_router: Optional["Router"],
team_models: List[str],
custom_llm_provider: str,
team_id: Optional[str] = None,
) -> Optional[dict]:
"""
Resolve upstream credentials for a provider-scoped file operation
(e.g. GET /v1/files), which doesn't pin a model.
Priority:
1. The team's own (BYOK) deployment for this provider — a deployment whose
``model_info.team_id`` matches ``team_id``. This keeps team-scoped listings
on the team's own provider account/key instead of a shared global one.
2. Fallback: any deployment the team is granted access to for this provider,
expanding wildcard routes and the all-proxy-models sentinel.
Credential lookup is always scoped to the team's allowlist, so a team can
never resolve a provider key for a deployment it isn't authorized to use.
Returns None when the router is unavailable or no authorized deployment
matches, so the caller can fall back to default credential resolution.
"""
if llm_router is None:
return None
def _provider_credentials(model_id: str) -> Optional[dict]:
credentials = llm_router.get_deployment_credentials_with_provider(
model_id=model_id
)
if (
credentials is not None
and credentials.get("custom_llm_provider") == custom_llm_provider
):
return credentials
return None
# 1. Prefer the team's own BYOK deployment, matched by model_info.team_id.
if team_id is not None:
for deployment in llm_router.model_list or []:
model_info = deployment.get("model_info") or {}
if model_info.get("team_id") != team_id:
continue
deployment_id = model_info.get("id")
if deployment_id is None:
continue
credentials = _provider_credentials(deployment_id)
if credentials is not None:
return credentials
# 2. Fall back to deployments the team is allowed to access. The
# all-proxy-models sentinel isn't expanded by get_complete_model_list, so
# normalize it to an empty allowlist, which defers to the team-scoped
# proxy model list. A team with a restricted allowlist (e.g. anthropic
# only) therefore never resolves another provider's key.
from litellm.proxy._types import SpecialModelNames
from litellm.proxy.auth.model_checks import get_complete_model_list
grants_all_models = SpecialModelNames.all_proxy_models.value in team_models
effective_team_models = [] if grants_all_models else team_models
proxy_model_list = llm_router.get_model_names(team_id=team_id)
model_access_groups = llm_router.get_model_access_groups()
models_to_try = list(
dict.fromkeys(
get_complete_model_list(
key_models=[],
team_models=effective_team_models,
proxy_model_list=proxy_model_list,
user_model=None,
infer_model_from_keys=False,
return_wildcard_routes=True,
llm_router=llm_router,
model_access_groups=model_access_groups,
include_model_access_groups=True,
team_id=team_id,
)
)
)
for model_name in models_to_try:
credentials = _provider_credentials(model_name)
if credentials is not None:
return credentials
return None
def prepare_data_with_credentials(
data: dict,
credentials: dict,

View file

@ -51,6 +51,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
encode_file_id_with_model,
extract_file_creation_params,
get_credentials_for_model,
get_team_provider_credentials,
handle_model_based_routing,
prepare_data_with_credentials,
)
@ -1344,14 +1345,20 @@ async def list_files(
status_code=400,
detail="target_model_names on list files must be a list of one model name. Example: ['gpt-4o']",
)
## Use router to list fine-tuning jobs for that model
if llm_router is None:
raise HTTPException(
status_code=500,
detail="LLM Router not initialized. Ensure models added to proxy.",
)
data["model"] = target_model_names_list[0]
response = await llm_router.afile_list(
credentials = get_credentials_for_model(
llm_router=llm_router,
model_id=target_model_names_list[0],
operation_context="file list",
)
prepare_data_with_credentials(data=data, credentials=credentials)
response = await litellm.afile_list(
custom_llm_provider=credentials["custom_llm_provider"],
purpose=purpose,
**data,
)
else:
@ -1363,6 +1370,18 @@ async def list_files(
or "openai"
)
# No model/target_model_names pinned: resolve upstream credentials from
# the team's deployment for this provider so the call is authenticated
# against the team's own account (e.g. the team's openai deployment).
team_credentials = get_team_provider_credentials(
llm_router=llm_router,
team_models=user_api_key_dict.team_models or [],
custom_llm_provider=custom_llm_provider,
team_id=user_api_key_dict.team_id,
)
if team_credentials is not None:
prepare_data_with_credentials(data=data, credentials=team_credentials)
response = await litellm.afile_list(
custom_llm_provider=custom_llm_provider, purpose=purpose, **data # type: ignore
)

View file

@ -15,6 +15,7 @@ import threading
import time
import traceback
import warnings
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from typing import (
TYPE_CHECKING,
@ -302,6 +303,7 @@ from litellm.proxy.common_utils.load_config_utils import (
get_config_file_contents_from_gcs,
get_file_contents_from_s3,
)
from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator
from litellm.proxy.common_utils.openai_endpoint_utils import (
remove_sensitive_info_from_deployment,
)
@ -8202,6 +8204,7 @@ async def model_list(
include_metadata: Optional[bool] = False,
fallback_type: Optional[str] = None,
scope: Optional[str] = None,
healthy_only: Optional[bool] = False,
):
"""
Use `/model/info` - to get detailed model information, example - pricing, mode, etc.
@ -8215,9 +8218,20 @@ async def model_list(
- scope: Optional scope parameter. Currently only accepts "expand".
When scope=expand is passed, proxy admins, team admins, and org admins
will receive all proxy models as if they are a proxy admin.
- healthy_only: When true, hide models whose backing deployments are all marked
unhealthy by background health checks. Requires
`background_health_checks: true` in general_settings; without
health state the listing is returned unfiltered (fail open).
Models expanded from wildcard routes (e.g. `openai/*`) are not
filtered, and nothing is hidden when `allowed_fails_policy` is
configured (cooldown remains the sole exclusion mechanism).
Hiding is presentation-only: a hidden model can still be
called directly.
"""
global llm_model_list, general_settings, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj
settings = cast(dict[str, object], general_settings) # any-ok: legacy settings
from litellm.proxy.management_endpoints.common_utils import (
_user_has_admin_privileges,
)
@ -8248,6 +8262,19 @@ async def model_list(
llm_router.get_fully_blocked_model_names() if llm_router is not None else set()
)
# Opt-in: also hide models whose deployments are all unhealthy per background
# health checks. Empty when health state is unavailable or stale (fail open).
unhealthy_names: Set[str] = set()
if healthy_only and llm_router is not None:
unhealthy_names = await llm_router.async_get_fully_unhealthy_model_names()
if not unhealthy_names:
verbose_proxy_logger.debug(
"healthy_only=true but no unhealthy deployment state is available "
"(requires background_health_checks); returning unfiltered model list"
)
hidden_names = blocked_names | unhealthy_names
# If scope=expand and user has admin privileges, return all proxy models
if should_expand_scope:
# Get all proxy models as if user is a proxy admin
@ -8280,20 +8307,25 @@ async def model_list(
only_model_access_groups=only_model_access_groups or False,
)
# Hide paused models from the public listing (admins manage them via /model/info)
if blocked_names:
all_models = [m for m in all_models if m not in blocked_names]
# Hide paused/unhealthy models from the public listing
if hidden_names:
all_models = [m for m in all_models if m not in hidden_names]
# Build response data with all proxy models
# Surface the public team name by default; legacy internal keys via flag.
# The internal routing key drives the metadata/fallback lookup, while the
# public name is what the client sees as the model id.
model_data = []
for model in all_models:
for response_id, lookup_id in TeamModelNameTranslator.listing_entries(
all_models, llm_router, settings
):
model_info = create_model_info_response(
model_id=model,
model_id=lookup_id,
provider="openai",
include_metadata=include_metadata or False,
fallback_type=fallback_type,
llm_router=llm_router,
)
model_info["id"] = response_id
model_data.append(model_info)
return dict(
@ -8317,20 +8349,25 @@ async def model_list(
user_api_key_cache=user_api_key_cache,
)
# Hide paused models from the public listing (admins manage them via /model/info)
if blocked_names:
all_models = [m for m in all_models if m not in blocked_names]
# Hide paused/unhealthy models from the public listing
if hidden_names:
all_models = [m for m in all_models if m not in hidden_names]
# Build response data
# Surface the public team name by default; legacy internal keys via flag.
# The internal routing key drives the metadata/fallback lookup, while the
# public name is what the client sees as the model id.
model_data = []
for model in all_models:
for response_id, lookup_id in TeamModelNameTranslator.listing_entries(
all_models, llm_router, settings
):
model_info = create_model_info_response(
model_id=model,
model_id=lookup_id,
provider="openai",
include_metadata=include_metadata or False,
fallback_type=fallback_type,
llm_router=llm_router,
)
model_info["id"] = response_id
model_data.append(model_info)
return dict(
@ -8352,6 +8389,8 @@ async def model_list(
async def model_info(
model_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
team_id: Optional[str] = None,
healthy_only: Optional[bool] = False,
):
"""
Retrieve information about a specific model accessible to your API key.
@ -8361,16 +8400,21 @@ async def model_info(
Follows OpenAI API specification for individual model retrieval.
https://platform.openai.com/docs/api-reference/models/retrieve
Query parameters mirror `/v1/models` so the same caller context (team
scoping, health filtering, paused deployments) drives both endpoints; the
listing's public id must resolve to the same internal deployment here.
"""
global llm_model_list, general_settings, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj
settings = cast(dict[str, object], general_settings) # any-ok: legacy settings
from litellm.proxy.utils import (
create_model_info_response,
get_available_models_for_user,
validate_model_access,
)
# Get available models for the user
all_models = await get_available_models_for_user(
user_api_key_dict=user_api_key_dict,
llm_router=llm_router,
@ -8378,21 +8422,43 @@ async def model_info(
user_model=user_model,
prisma_client=prisma_client,
proxy_logging_obj=proxy_logging_obj,
team_id=None,
team_id=team_id,
include_model_access_groups=False,
only_model_access_groups=False,
return_wildcard_routes=False,
user_api_key_cache=user_api_key_cache,
)
# Mirror /v1/models' visibility filter so first-occurrence resolution
# cannot land on a deployment the listing had hidden.
blocked_names = (
llm_router.get_fully_blocked_model_names() if llm_router is not None else set()
)
unhealthy_names: set[str] = set()
if healthy_only and llm_router is not None:
unhealthy_names = await llm_router.async_get_fully_unhealthy_model_names()
hidden_names = blocked_names | unhealthy_names
if hidden_names:
all_models = [m for m in all_models if m not in hidden_names]
internal_to_public = TeamModelNameTranslator.build_internal_to_public_map(
llm_router, settings
)
resolved_model_id = TeamModelNameTranslator.resolve_public_name(
model_id=model_id,
available_models=all_models,
llm_router=llm_router,
general_settings=settings,
)
# Validate that the requested model is accessible
validate_model_access(model_id=model_id, available_models=all_models)
validate_model_access(model_id=resolved_model_id, available_models=all_models)
# Get provider information from the router deployment
if llm_router is None:
raise HTTPException(status_code=500, detail="Router not initialized")
deployment = llm_router.get_deployment_by_model_group_name(model_id)
deployment = llm_router.get_deployment_by_model_group_name(resolved_model_id)
if deployment is None:
raise HTTPException(
status_code=404,
@ -8402,9 +8468,9 @@ async def model_info(
# Use the actual litellm model from the deployment to get provider info
_, provider, _, _ = litellm.get_llm_provider(model=deployment.litellm_params.model)
# Return the model information in the same format as the list endpoint
response_id = internal_to_public.get(resolved_model_id, model_id)
return create_model_info_response(
model_id=model_id,
model_id=response_id,
provider=provider,
include_metadata=False,
fallback_type=None,

View file

@ -45,6 +45,7 @@ from litellm.proxy._types import (
)
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.proxy.model_listing import ModelInfoResponse
from litellm.types.utils import CallTypes, CallTypesLiteral
try:
@ -6231,56 +6232,39 @@ def create_model_info_response(
include_metadata: bool = False,
fallback_type: Optional[str] = None,
llm_router: Optional["Router"] = None,
) -> dict:
) -> ModelInfoResponse:
"""
Create a standardized model info response.
Create a standardized OpenAI-compatible model object.
Args:
model_id: The model ID
provider: The model provider
include_metadata: Whether to include metadata
fallback_type: Type of fallbacks to include
llm_router: LiteLLM router instance
Returns:
Dictionary containing model information
When include_metadata is true, attaches the model's configured fallbacks
(resolved via the router under fallback_type, defaulting to "general").
Raises HTTPException(400) for an unknown fallback_type.
"""
from litellm.proxy.auth.model_checks import get_all_fallbacks
model_info = {
base: ModelInfoResponse = {
"id": model_id,
"object": "model",
"created": DEFAULT_MODEL_CREATED_AT_TIME,
"owned_by": provider,
}
if not include_metadata:
return base
# Add metadata if requested
if include_metadata:
metadata = {}
# Default fallback_type to "general" if include_metadata is true
effective_fallback_type = (
fallback_type if fallback_type is not None else "general"
effective_fallback_type = fallback_type if fallback_type is not None else "general"
valid_fallback_types = ("general", "context_window", "content_policy")
if effective_fallback_type not in valid_fallback_types:
raise HTTPException(
status_code=400,
detail=f"Invalid fallback_type. Must be one of: {list(valid_fallback_types)}",
)
# Validate fallback_type
valid_fallback_types = ["general", "context_window", "content_policy"]
if effective_fallback_type not in valid_fallback_types:
raise HTTPException(
status_code=400,
detail=f"Invalid fallback_type. Must be one of: {valid_fallback_types}",
)
fallbacks = get_all_fallbacks(
model=model_id,
llm_router=llm_router,
fallback_type=effective_fallback_type,
)
metadata["fallbacks"] = fallbacks
model_info["metadata"] = metadata
return model_info
fallbacks = get_all_fallbacks(
model=model_id,
llm_router=llm_router,
fallback_type=effective_fallback_type,
)
return {**base, "metadata": {"fallbacks": fallbacks}}
def validate_model_access(

View file

@ -9948,6 +9948,63 @@ class Router:
name for name, fully_blocked in blocked_by_name.items() if fully_blocked
}
async def async_get_fully_unhealthy_model_names(self) -> Set[str]:
"""
Returns the set of model names where every backing deployment is currently
marked unhealthy by background health checks (and the health state is not stale).
Used by `/v1/models?healthy_only=true` to hide models that cannot serve any
request. A model with at least one healthy (or unknown-health) deployment
remains visible. Returns an empty set when no health state is available, so
callers fail open to the unfiltered listing.
Notes:
- Mirrors `_async_filter_health_check_unhealthy_deployments`: when
`allowed_fails_policy` is set, cooldown is the sole routing exclusion
mechanism, so nothing is hidden here either.
- Team-specific public model names (`team_public_model_name`) are
aggregated alongside `model_name`, so team aliases of fully-unhealthy
deployments are hidden too (unlike `get_fully_blocked_model_names`,
which matches `model_name` only).
- Wildcard routes (e.g. `openai/*`) are matched by their literal
deployment name only; models expanded from a wildcard route are not
hidden (fail open).
- Intentionally diverges from the routing-time safety net (which
bypasses the health filter when every candidate is unhealthy and
still attempts the request): hiding here is presentation-only
it answers "should this model be advertised?", not "should a
request for it still be attempted?". A hidden model can still be
called directly.
"""
if self.allowed_fails_policy is not None:
return set()
unhealthy_ids = (
await self.health_state_cache.async_get_unhealthy_deployment_ids()
)
if not unhealthy_ids:
return set()
deployments = self.get_model_list() or []
unhealthy_by_name: Dict[str, bool] = {}
for deployment in deployments:
model_info = deployment.get("model_info") or {}
names = [deployment.get("model_name") or ""]
team_public_model_name = model_info.get("team_public_model_name")
if team_public_model_name:
names.append(team_public_model_name)
is_unhealthy = model_info.get("id") in unhealthy_ids
for name in names:
if not name:
continue
if name in unhealthy_by_name:
unhealthy_by_name[name] = unhealthy_by_name[name] and is_unhealthy
else:
unhealthy_by_name[name] = is_unhealthy
return {
name
for name, fully_unhealthy in unhealthy_by_name.items()
if fully_unhealthy
}
def _get_team_specific_model(
self, deployment: DeploymentTypedDict, team_id: Optional[str] = None
) -> Optional[str]:

View file

@ -0,0 +1,21 @@
"""Response types for the model listing/retrieve endpoints (/v1/models, /models)."""
from typing import Literal
from typing_extensions import NotRequired, TypedDict
class ModelInfoMetadata(TypedDict):
fallbacks: list[str]
class ModelInfoResponse(TypedDict):
"""OpenAI-compatible model object. `metadata` is present only when the
endpoint is called with include_metadata=true.
"""
id: str
object: Literal["model"]
created: int
owned_by: str
metadata: NotRequired[ModelInfoMetadata]

View file

@ -3635,6 +3635,7 @@ class SpecialEnums(Enum):
class ServiceTier(Enum):
"""Enum for service tier types used in cost calculations."""
AUTO = "auto"
FLEX = "flex"
PRIORITY = "priority"

View file

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.89.1"
version = "1.89.2"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.14"
@ -264,7 +264,7 @@ source-exclude = [
profile = "black"
[tool.commitizen]
version = "1.89.1"
version = "1.89.2"
version_files = [
"pyproject.toml:^version",
]

View file

@ -906,7 +906,10 @@ class BaseLLMChatTest(ABC):
{
"type": "image_url",
"image_url": {
"url": "https://www.gstatic.com/webp/gallery/1.webp",
# sha-pinned in-repo logo via jsdelivr; gstatic's
# robots.txt blocks server-side fetchers (e.g.
# Anthropic), which 400s the request.
"url": "https://cdn.jsdelivr.net/gh/BerriAI/litellm@d769e81c90d453240c61fc572cdb27fae06a89d0/ui/litellm-dashboard/public/assets/logos/litellm_logo.jpg",
"detail": detail,
},
},

View file

@ -410,6 +410,90 @@ def test_emitter_without_call_id_is_not_deduped():
assert len(exporter.get_finished_spans()) == 2
def _emit_error_span(message, error_type="litellm.APIError"):
from litellm.integrations.otel.emitter import SpanEmitter
cfg = OpenTelemetryV2Config(exporter="in_memory")
provider, exporter = providers.in_memory_provider(cfg)
engine = SpanEmitter(providers.get_tracer(provider, "t"), cfg)
data = LLMCallSpanData(
operation=GenAIOperation.CHAT,
provider="openai",
request_model="gpt-4o",
response_model=None,
response_id=None,
request_params=LLMRequestParams(),
usage=LLMUsage(),
finish_reasons=(),
error=SpanError(error_type=error_type, message=message),
response_cost=None,
server=None,
identity=RequestIdentity(call_id=None),
)
engine.emit(SpanRole.LLM_CALL, data)
(span,) = exporter.get_finished_spans()
return span
def _exception_event(span):
from litellm.integrations.otel.model.semconv import ExceptionEvent
events = [e for e in span.events if e.name == ExceptionEvent.NAME]
assert len(events) == 1, "expected exactly one exception event"
return events[0]
def test_error_message_recorded_as_full_exception_event_untruncated():
"""Regression for the Elasticsearch keyword/ignore_above:1024 truncation.
A long error message must survive intact on the standard ``exception``
event under ``exception.message`` not get dropped onto a bare string
attribute that backends dynamic-map to a 1024-char ``keyword``. The SDK
must not truncate it either, so a 5000-char message stays 5000 chars.
"""
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent
long_message = "boom: " + "x" * 5000
span = _emit_error_span(long_message, error_type="litellm.APIError")
event = _exception_event(span)
assert event.attributes[ExceptionEvent.MESSAGE] == long_message
assert len(event.attributes[ExceptionEvent.MESSAGE]) == len(long_message) > 1024
assert event.attributes[ExceptionEvent.TYPE] == "litellm.APIError"
# error.type stays a low-cardinality attribute; the message does NOT become a
# bare string attribute (which is what got truncated).
assert span.attributes[Error.TYPE] == "litellm.APIError"
assert ExceptionEvent.MESSAGE not in span.attributes
assert span.status.description == long_message
def test_success_span_records_no_exception_event():
from litellm.integrations.otel.emitter import SpanEmitter
from litellm.integrations.otel.model.semconv import ExceptionEvent
cfg = OpenTelemetryV2Config(exporter="in_memory")
provider, exporter = providers.in_memory_provider(cfg)
engine = SpanEmitter(providers.get_tracer(provider, "t"), cfg)
data = LLMCallSpanData(
operation=GenAIOperation.CHAT,
provider="openai",
request_model="gpt-4o",
response_model="gpt-4o",
response_id="resp-1",
request_params=LLMRequestParams(),
usage=LLMUsage(),
finish_reasons=("stop",),
error=None,
response_cost=None,
server=None,
identity=RequestIdentity(call_id=None),
)
engine.emit(SpanRole.LLM_CALL, data)
(span,) = exporter.get_finished_spans()
assert all(e.name != ExceptionEvent.NAME for e in span.events)
# --- service taxonomy: which calls become spans, and of what kind ----------- #

View file

@ -3702,6 +3702,39 @@ def test_fast_mode_with_inference_geo():
assert abs(completion_cost - base_completion * expected_multiplier) < 1e-10
def test_calculate_usage_captures_service_tier():
"""
Anthropic returns the assigned service tier on the response usage object
(e.g. ``"priority"``). It must be surfaced on the Usage object so it is
visible in logs and used to select tier-specific pricing.
"""
config = AnthropicConfig()
usage_object = {
"input_tokens": 410,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 585,
"service_tier": "priority",
}
usage = config.calculate_usage(usage_object=usage_object, reasoning_content=None)
assert usage.service_tier == "priority"
def test_calculate_usage_service_tier_defaults_to_none():
"""A response without a service tier must not invent one."""
config = AnthropicConfig()
usage = config.calculate_usage(
usage_object={"input_tokens": 10, "output_tokens": 5},
reasoning_content=None,
)
assert usage.service_tier is None
def test_fast_mode_parameter_in_supported_params():
"""
Test that 'speed' is in the list of supported OpenAI params.

View file

@ -1400,6 +1400,65 @@ def test_rag_routes_accessible_to_internal_user_viewer():
)
@pytest.mark.parametrize(
"route",
[
"/vector_stores/vs_123",
"/v1/vector_stores/vs_123",
"/vector_stores/vs_123/search",
"/v1/vector_stores/vs_123/search",
"/vector_stores/vs_123/files",
"/v1/vector_stores/vs_123/files",
],
)
def test_vector_store_routes_are_llm_api_routes(route):
"""Retrieve/update/delete on a single vector store must classify as LLM API routes.
Regression for the missing bare `/v1/vector_stores/{vector_store_id}` entry in
`openai_routes` that left retrieve/update/delete blocked for internal roles
while `/search` and `/files` sub-routes worked.
"""
assert RouteChecks.is_llm_api_route(route) is True
@pytest.mark.parametrize(
"user_role",
[
LitellmUserRoles.INTERNAL_USER.value,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
],
)
@pytest.mark.parametrize(
"method, route",
[
("GET", "/v1/vector_stores/vs_123"),
("POST", "/v1/vector_stores/vs_123"),
("DELETE", "/v1/vector_stores/vs_123"),
],
)
def test_vector_store_crud_accessible_to_internal_roles(user_role, method, route):
"""Internal user and internal viewer must reach vector store retrieve/update/delete.
Object-level access is still gated by `assert_user_can_access_vector_store`;
this only verifies the route gate no longer 403s these roles.
"""
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
request = MagicMock(spec=Request)
request.method = method
request.query_params = {}
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=LiteLLM_UserTable(user_id="test_user", user_role=user_role),
_user_role=user_role,
route=route,
request=request,
valid_token=valid_token,
request_data={},
)
def test_videos_route_accessible_to_internal_users():
"""
Test that internal users can access the videos routes.

View file

@ -1873,3 +1873,329 @@ def test_get_file_content_non_openai_provider_skips_streaming_handler(
assert "stream" not in captured_kwargs
mock_streaming_response.assert_not_awaited()
proxy_logging_obj.post_call_failure_hook.assert_not_called()
def test_list_files_resolves_wildcard_deployment_credentials(
mocker: MockerFixture, monkeypatch
):
"""
GET /v1/files?target_model_names=<model> must resolve the upstream api_key
from the matching (wildcard) deployment. Regression for the path routing
through llm_router.afile_list(model=...), which reached OpenAI without an
api_key and failed with "api_key client option must be set".
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
wildcard_router = Router(
model_list=[
{
"model_name": "*",
"litellm_params": {
"model": "openai/*",
"api_key": "wildcard-openai-key",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, wildcard_router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", wildcard_router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="test-user",
)
try:
response = client.get(
"/v1/files?target_model_names=gpt-4o",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") == "wildcard-openai-key"
assert captured_kwargs.get("custom_llm_provider") == "openai"
proxy_logging_obj.post_call_failure_hook.assert_not_called()
def test_list_files_without_target_model_names_uses_team_openai_deployment(
mocker: MockerFixture, monkeypatch
):
"""
Plain GET /v1/files (no target_model_names) must resolve the upstream openai
api_key from the team's openai deployment instead of falling through to a
keyless OpenAI client. Regression for "api_key client option must be set".
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
wildcard_router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "team-openai-key",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, wildcard_router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", wildcard_router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
team_id="test-team",
team_models=["openai/*"],
)
try:
response = client.get(
"/v1/files",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") == "team-openai-key"
assert captured_kwargs.get("custom_llm_provider") == "openai"
proxy_logging_obj.post_call_failure_hook.assert_not_called()
def test_list_files_restricted_team_does_not_leak_global_openai_credentials(
mocker: MockerFixture, monkeypatch
):
"""
A team whose allowlist only grants anthropic must NOT resolve a global
openai deployment's api_key for plain GET /v1/files. Regression for the
last-resort scan that ignored team access control.
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "global-openai-key",
},
},
{
"model_name": "claude-opus-4-6",
"litellm_params": {
"model": "anthropic/claude-opus-4-6",
"api_key": "anthropic-key",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
team_id="anthropic-only-team",
team_models=["claude-opus-4-6"],
)
try:
response = client.get(
"/v1/files",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") != "global-openai-key"
def test_list_files_prefers_team_byok_over_global_openai_deployment(
mocker: MockerFixture, monkeypatch
):
"""
When a team has its own BYOK openai deployment (model_info.team_id set), plain
GET /v1/files must use the team's key, not a shared/global openai deployment.
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "global-openai-key",
},
},
{
"model_name": "team-gpt-4o",
"litellm_params": {
"model": "openai/gpt-4o",
"api_key": "team-byok-openai-key",
},
"model_info": {
"id": "team-byok-deployment-id",
"team_id": "test-team",
"team_public_model_name": "team-gpt-4o",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
team_id="test-team",
team_models=["team-gpt-4o"],
)
try:
response = client.get(
"/v1/files",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") == "team-byok-openai-key"
assert captured_kwargs.get("custom_llm_provider") == "openai"
proxy_logging_obj.post_call_failure_hook.assert_not_called()
def test_list_files_with_all_proxy_models_team_uses_openai_deployment(
mocker: MockerFixture, monkeypatch
):
"""
Teams with all-proxy-models (or empty models) must still resolve openai
credentials for plain GET /v1/files.
"""
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles, SpecialModelNames
wildcard_router = Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {
"model": "openai/*",
"api_key": "team-openai-key",
},
},
{
"model_name": "claude-opus-4-6",
"litellm_params": {
"model": "anthropic/claude-opus-4-6",
"api_key": "anthropic-key",
},
},
]
)
proxy_logging_obj = setup_proxy_logging_object(monkeypatch, wildcard_router)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", wildcard_router)
proxy_logging_obj.update_request_status = mocker.AsyncMock()
proxy_logging_obj.post_call_success_hook = mocker.AsyncMock(return_value=[])
proxy_logging_obj.post_call_failure_hook = mocker.AsyncMock()
captured_kwargs: dict = {}
async def _mock_afile_list(**kwargs):
captured_kwargs.update(kwargs)
return []
monkeypatch.setattr(litellm, "afile_list", _mock_afile_list)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
api_key="test-key",
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="test-user",
team_id="test-team",
team_models=[SpecialModelNames.all_proxy_models.value],
)
try:
response = client.get(
"/v1/files",
headers={"Authorization": "Bearer test-key"},
)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
assert response.status_code == 200, response.text
assert captured_kwargs.get("api_key") == "team-openai-key"
assert captured_kwargs.get("custom_llm_provider") == "openai"
proxy_logging_obj.post_call_failure_hook.assert_not_called()

View file

@ -15,6 +15,7 @@ import pytest
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator
from litellm.proxy.proxy_server import (
_get_proxy_model_info,
_translate_model_name_for_response,
@ -593,3 +594,664 @@ async def test_model_info_v1_litellm_model_id_team_id_applies_team_filter(monkey
team_filter.assert_awaited_once()
assert team_filter.await_args.kwargs["team_id"] == "other-team"
assert team_filter.await_args.kwargs["all_models"] == [team_row]
@pytest.mark.asyncio
async def test_v1_models_translates_team_model_for_access_group_key(monkeypatch):
"""Regression (#28382 sibling leak): a virtual key whose model access group
resolves to a team BYOK deployment must list the PUBLIC name in /v1/models,
not the internal routing key model_name_{team_id}_{uuid}.
The /model/info read-path fix did not cover /v1/models, which builds from
bare model-name strings via access-group expansion.
"""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
# Default behavior: listing surfaces public names.
monkeypatch.setattr(ps, "general_settings", {})
# virtual key granted access via the access group (no team membership)
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key)
ids = [d["id"] for d in resp["data"]]
assert "tushar-gpt-4.1" in ids
assert "model_name_teamX_uuid9" not in ids
@pytest.mark.asyncio
async def test_v1_models_keeps_internal_names_when_public_name_flag_disabled(
monkeypatch,
):
"""Compatibility override: /v1/models can still list the internal routing
name for consumers that scripted against those ids. Translation is enabled
by default and disabled via general_settings['use_team_public_model_name'].
"""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "general_settings", {"use_team_public_model_name": False})
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key)
ids = [d["id"] for d in resp["data"]]
assert "model_name_teamX_uuid9" in ids # internal id preserved (backward-compat)
assert "tushar-gpt-4.1" not in ids
@pytest.mark.asyncio
async def test_v1_models_translates_team_model_with_metadata(monkeypatch):
"""include_metadata=true must build metadata for the public model id."""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "general_settings", {})
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key, include_metadata=True)
assert resp["data"] == [
{
"id": "tushar-gpt-4.1",
"object": "model",
"created": 1677610602,
"owned_by": "openai",
"metadata": {"fallbacks": []},
}
]
@pytest.mark.asyncio
async def test_v1_models_metadata_fallbacks_use_internal_routing_key(monkeypatch):
"""Regression: with include_metadata=true, fallbacks configured for a team
model under its internal routing key must still surface. The metadata lookup
has to run against the internal name, not the translated public name (which
the router's fallback config never keys on) -- otherwise fallbacks silently
drop to []."""
team_dep = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "id1",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_dep]
router.get_model_list.return_value = [team_dep]
# Fallbacks are keyed on the internal routing name, as the router stores them.
router.fallbacks = [{"model_name_teamX_uuid9": ["gpt-4o-backup"]}]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "general_settings", {})
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key, include_metadata=True)
assert resp["data"] == [
{
"id": "tushar-gpt-4.1",
"object": "model",
"created": 1677610602,
"owned_by": "openai",
"metadata": {"fallbacks": ["gpt-4o-backup"]},
}
]
@pytest.mark.asyncio
async def test_v1_models_metadata_does_not_leak_other_team_fallbacks(monkeypatch):
"""Regression: two teams can publish the same team_public_model_name. With
include_metadata=true a caller scoped to teamX must see teamX's fallbacks for
the shared public name, never teamY's. The metadata lookup has to stay within
the caller's accessible models; resolving the public name through a router-wide
reverse map could point it at another team's internal routing key."""
team_x = {
"model_name": "model_name_teamX_uuid9",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "idX",
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
"access_groups": ["grp-a"],
},
}
team_y = {
"model_name": "model_name_teamY_uuidZ",
"litellm_params": {"model": "azure/gpt-4.1"},
"model_info": {
"id": "idY",
"team_id": "teamY",
"team_public_model_name": "tushar-gpt-4.1", # same public name, other team
},
}
router = MagicMock()
router.get_model_names.return_value = ["model_name_teamX_uuid9"]
router.get_model_access_groups.return_value = {"grp-a": ["model_name_teamX_uuid9"]}
router.get_fully_blocked_model_names.return_value = set()
router.model_list = [team_x, team_y]
router.get_model_list.return_value = [team_x, team_y]
router.fallbacks = [
{"model_name_teamX_uuid9": ["teamX-backup"]},
{"model_name_teamY_uuidZ": ["teamY-backup"]},
]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "general_settings", {})
key = UserAPIKeyAuth(
user_id="u", api_key="sk-test", models=["grp-a"], team_models=[]
)
resp = await ps.model_list(user_api_key_dict=key, include_metadata=True)
assert resp["data"] == [
{
"id": "tushar-gpt-4.1",
"object": "model",
"created": 1677610602,
"owned_by": "openai",
"metadata": {"fallbacks": ["teamX-backup"]},
}
]
def test_translate_team_model_names_for_listing_swaps_and_dedupes():
"""Internal team routing keys -> public name; sibling deployments sharing a
public name collapse to one entry (order preserved); globals untouched."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{
"model_name": "model_name_teamX_uuidB", # sibling: same public name
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{"model_name": "gpt-4o", "model_info": {"db_model": False}},
]
out = TeamModelNameTranslator.translate_listing(
["model_name_teamX_uuidA", "model_name_teamX_uuidB", "gpt-4o"],
router,
{},
)
assert out == ["tushar-gpt-4.1", "gpt-4o"]
def test_listing_entries_keep_internal_lookup_id_for_team_rows():
"""`listing_entries` returns (public response id, internal lookup id) so the
response shows the public name while metadata lookups keep the routing key.
Sibling deployments collapse to one entry; globals map to themselves."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{
"model_name": "model_name_teamX_uuidB", # sibling: same public name
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{"model_name": "gpt-4o", "model_info": {"db_model": False}},
]
entries = TeamModelNameTranslator.listing_entries(
["model_name_teamX_uuidA", "model_name_teamX_uuidB", "gpt-4o"],
router,
{},
)
# public id for the client; an internal routing key for the metadata lookup
assert entries[0][0] == "tushar-gpt-4.1"
assert entries[0][1].startswith("model_name_teamX_uuid")
assert entries[1] == ("gpt-4o", "gpt-4o")
assert len(entries) == 2
def test_listing_entries_lookup_id_never_crosses_team_boundary():
"""Regression: when two teams share a team_public_model_name, the lookup id for
the shared public name must stay within the caller's accessible model_names and
never resolve to the other team's internal routing key (which would leak that
team's fallback metadata under include_metadata=true)."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "shared-name",
},
},
{
"model_name": "model_name_teamY_uuidB", # different team, same public name
"model_info": {
"team_id": "teamY",
"team_public_model_name": "shared-name",
},
},
]
# caller can only access teamX's internal key
entries = TeamModelNameTranslator.listing_entries(
["model_name_teamX_uuidA"], router, {}
)
assert entries == [("shared-name", "model_name_teamX_uuidA")]
def test_listing_entries_global_wins_when_team_alias_collides_with_global():
"""Regression: when an accessible global model shares its name with a team
deployment's `team_public_model_name`, the listing must keep the global
entry rather than overwriting its lookup id with the colliding team's
internal routing key (which would surface the team's metadata under the
global id)."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "gpt-4o",
},
},
{"model_name": "gpt-4o", "model_info": {"db_model": False}},
]
entries = TeamModelNameTranslator.listing_entries(
["gpt-4o", "model_name_teamX_uuidA"], router, {}
)
assert entries == [("gpt-4o", "gpt-4o")]
def test_listing_and_resolve_agree_on_sibling_internal_key():
"""Regression: when two team deployments share a public name, listing and
retrieve must pick the same internal routing key, otherwise `/v1/models/{id}`
describes a different deployment than what the listing's metadata was built
from."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
{
"model_name": "model_name_teamX_uuidB",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
},
]
available = ["model_name_teamX_uuidA", "model_name_teamX_uuidB"]
[(_, listing_lookup)] = TeamModelNameTranslator.listing_entries(
available, router, {}
)
resolve_lookup = TeamModelNameTranslator.resolve_public_name(
model_id="tushar-gpt-4.1",
available_models=available,
llm_router=router,
general_settings={},
)
assert listing_lookup == resolve_lookup
def test_listing_entries_skips_empty_team_public_model_name():
"""Regression: a misconfigured row with `team_public_model_name: ""` must not
produce a listing entry with an empty `id`; the internal routing key should
pass through unchanged, matching `/v1/model/info`'s falsy-check behavior."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "",
},
},
]
entries = TeamModelNameTranslator.listing_entries(
["model_name_teamX_uuidA"], router, {}
)
assert entries == [("model_name_teamX_uuidA", "model_name_teamX_uuidA")]
def test_listing_entries_passthrough_when_disabled():
"""Legacy flag / no router -> response id equals lookup id (no translation)."""
assert TeamModelNameTranslator.listing_entries(["a", "b"], None, {}) == [
("a", "a"),
("b", "b"),
]
def test_translate_team_model_names_for_listing_leaves_unmapped_names():
"""Names with no team mapping (globals, access-group keys) pass through."""
router = MagicMock()
router.get_model_list.return_value = [
{"model_name": "gpt-4o", "model_info": {"db_model": False}}
]
assert TeamModelNameTranslator.translate_listing(
["gpt-4o", "beta-group"], router, {}
) == ["gpt-4o", "beta-group"]
def test_translate_team_model_names_for_listing_none_router():
"""No router -> return the input list unchanged."""
assert TeamModelNameTranslator.translate_listing(["a", "b"], None, {}) == ["a", "b"]
def test_translate_team_model_names_for_listing_respects_legacy_flag():
"""Operators can keep returning the legacy internal routing key."""
router = MagicMock()
router.get_model_list.return_value = [
{
"model_name": "model_name_teamX_uuidA",
"model_info": {
"team_id": "teamX",
"team_public_model_name": "tushar-gpt-4.1",
},
}
]
assert TeamModelNameTranslator.translate_listing(
["model_name_teamX_uuidA"], router, {"use_team_public_model_name": False}
) == ["model_name_teamX_uuidA"]
def _public_named_router(*team_rows: dict) -> MagicMock:
router = MagicMock()
router.get_model_list.return_value = list(team_rows)
return router
def test_resolve_public_name_to_internal_routing_key():
"""A public team name resolves back to the internal routing key the router
indexes by, so `GET /v1/models/{public_name}` can find the deployment."""
router = _public_named_router(_team_row())
assert (
TeamModelNameTranslator.resolve_public_name(
model_id="team-claude-sonnet",
available_models=["model_name_team-abc-123_4a6b8"],
llm_router=router,
general_settings={},
)
== "model_name_team-abc-123_4a6b8"
)
def test_resolve_public_name_is_access_scoped_across_teams():
"""Two teams can publish the SAME public name. A caller's query must resolve
to the internal key they can actually access, never another team's."""
# both rows share public name "team-claude-sonnet"
router = _public_named_router(_team_row(), _other_team_row())
# caller only has access to their own team's internal key
resolved = TeamModelNameTranslator.resolve_public_name(
model_id="team-claude-sonnet",
available_models=["model_name_team-abc-123_4a6b8"],
llm_router=router,
general_settings={},
)
assert resolved == "model_name_team-abc-123_4a6b8"
assert resolved != "model_name_team-other_9f2c1"
def test_resolve_public_name_unmapped_passes_through():
"""A public name with no accessible internal mapping is returned unchanged so
the caller hits the normal 404/access path; internal names pass through too."""
router = _public_named_router(_team_row())
# not accessible -> unchanged (downstream validate_model_access will 404)
assert (
TeamModelNameTranslator.resolve_public_name(
model_id="team-claude-sonnet",
available_models=[],
llm_router=router,
general_settings={},
)
== "team-claude-sonnet"
)
# already an internal routing key -> unchanged
assert (
TeamModelNameTranslator.resolve_public_name(
model_id="model_name_team-abc-123_4a6b8",
available_models=["model_name_team-abc-123_4a6b8"],
llm_router=router,
general_settings={},
)
== "model_name_team-abc-123_4a6b8"
)
def test_resolve_public_name_respects_legacy_flag():
"""With the legacy flag set, no public-name resolution happens."""
router = _public_named_router(_team_row())
assert (
TeamModelNameTranslator.resolve_public_name(
model_id="team-claude-sonnet",
available_models=["model_name_team-abc-123_4a6b8"],
llm_router=router,
general_settings={"use_team_public_model_name": False},
)
== "team-claude-sonnet"
)
@pytest.mark.asyncio
async def test_retrieve_model_by_public_name_returns_200(monkeypatch):
"""Regression: `GET /v1/models/{public_name}` must NOT 404. The listing
advertises the public team name, so retrieve must accept the same name,
resolve it to the internal routing key for lookup, and echo the public name
back as the model id."""
import litellm
import litellm.proxy.utils as proxy_utils
team_row = _team_row()
router = _public_named_router(team_row)
deployment = MagicMock()
deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing"
router.get_deployment_by_model_group_name.return_value = deployment
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "general_settings", {})
monkeypatch.setattr(
proxy_utils,
"get_available_models_for_user",
AsyncMock(return_value=["model_name_team-abc-123_4a6b8"]),
)
monkeypatch.setattr(
litellm, "get_llm_provider", lambda model: (model, "openai", None, None)
)
key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[])
resp = await ps.model_info(model_id="team-claude-sonnet", user_api_key_dict=key)
assert resp["id"] == "team-claude-sonnet"
# lookup happened by the internal routing key, not the public name
router.get_deployment_by_model_group_name.assert_called_once_with(
"model_name_team-abc-123_4a6b8"
)
@pytest.mark.asyncio
async def test_retrieve_model_by_internal_name_returns_public_id(monkeypatch):
"""Regression: retrieving by the internal routing key must echo the SAME
public id `/v1/models` advertises for that deployment, not the path. Otherwise
a client iterating the listing's id and then retrieving each one would observe
a different id depending on which alias they queried by."""
import litellm
import litellm.proxy.utils as proxy_utils
router = _public_named_router(_team_row())
deployment = MagicMock()
deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing"
router.get_deployment_by_model_group_name.return_value = deployment
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "general_settings", {})
monkeypatch.setattr(
proxy_utils,
"get_available_models_for_user",
AsyncMock(return_value=["model_name_team-abc-123_4a6b8"]),
)
monkeypatch.setattr(
litellm, "get_llm_provider", lambda model: (model, "openai", None, None)
)
key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[])
resp = await ps.model_info(
model_id="model_name_team-abc-123_4a6b8", user_api_key_dict=key
)
assert resp["id"] == "team-claude-sonnet"
@pytest.mark.asyncio
async def test_retrieve_model_by_internal_name_keeps_internal_id_when_flag_disabled(
monkeypatch,
):
"""With `use_team_public_model_name=false`, retrieve must keep the internal
routing key as the response id, mirroring `/v1/models`' legacy output."""
import litellm
import litellm.proxy.utils as proxy_utils
router = _public_named_router(_team_row())
deployment = MagicMock()
deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing"
router.get_deployment_by_model_group_name.return_value = deployment
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "general_settings", {"use_team_public_model_name": False})
monkeypatch.setattr(
proxy_utils,
"get_available_models_for_user",
AsyncMock(return_value=["model_name_team-abc-123_4a6b8"]),
)
monkeypatch.setattr(
litellm, "get_llm_provider", lambda model: (model, "openai", None, None)
)
key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[])
resp = await ps.model_info(
model_id="model_name_team-abc-123_4a6b8", user_api_key_dict=key
)
assert resp["id"] == "model_name_team-abc-123_4a6b8"
@pytest.mark.asyncio
async def test_retrieve_model_by_inaccessible_public_name_404s(monkeypatch):
"""A caller without access to a team model still gets 404 when retrieving by
its public name; resolution never crosses the access boundary."""
import litellm
import litellm.proxy.utils as proxy_utils
router = _public_named_router(_team_row())
deployment = MagicMock()
deployment.litellm_params.model = "azure/gpt-5.2-low-rpm-testing"
router.get_deployment_by_model_group_name.return_value = deployment
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "general_settings", {})
monkeypatch.setattr(
proxy_utils,
"get_available_models_for_user",
AsyncMock(return_value=[]), # caller has no access
)
monkeypatch.setattr(
litellm, "get_llm_provider", lambda model: (model, "openai", None, None)
)
key = UserAPIKeyAuth(user_id="u", api_key="sk-test", team_models=[])
with pytest.raises(ps.HTTPException) as exc_info:
await ps.model_info(model_id="team-claude-sonnet", user_api_key_dict=key)
assert exc_info.value.status_code == 404
router.get_deployment_by_model_group_name.assert_not_called()

View file

@ -359,6 +359,7 @@ ignored_keys = [
"metadata.additional_usage_values.cache_read_input_tokens",
"metadata.additional_usage_values.inference_geo",
"metadata.additional_usage_values.speed",
"metadata.additional_usage_values.service_tier",
"metadata.litellm_overhead_time_ms",
"metadata.cost_breakdown",
"metadata.user_api_key",

View file

@ -0,0 +1,92 @@
"""
Tests for the opt-in `healthy_only` filter on GET /v1/models (`model_list`).
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from litellm.proxy import proxy_server
from litellm.proxy._types import UserAPIKeyAuth
@pytest.fixture
def patched_model_list(monkeypatch):
"""Stub router + utility helpers used by `model_list`."""
from litellm.proxy import utils as proxy_utils
router = MagicMock()
router.get_fully_blocked_model_names = MagicMock(return_value=set())
router.async_get_fully_unhealthy_model_names = AsyncMock(
return_value={"claude-sonnet"}
)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "user_model", None)
async def _fake_get_available_models_for_user(**kwargs):
return ["gpt-4", "claude-sonnet"]
monkeypatch.setattr(
proxy_utils,
"get_available_models_for_user",
_fake_get_available_models_for_user,
)
def _fake_create_model_info_response(model_id, provider="openai", **kwargs):
return {"id": model_id, "object": "model", "created": 0, "owned_by": provider}
monkeypatch.setattr(
proxy_utils, "create_model_info_response", _fake_create_model_info_response
)
return router
@pytest.mark.asyncio
async def test_model_list_healthy_only_hides_fully_unhealthy_models(
patched_model_list,
):
response = await proxy_server.model_list(
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
healthy_only=True,
)
assert [m["id"] for m in response["data"]] == ["gpt-4"]
@pytest.mark.asyncio
async def test_model_list_default_keeps_unhealthy_models(patched_model_list):
response = await proxy_server.model_list(
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
)
assert [m["id"] for m in response["data"]] == ["gpt-4", "claude-sonnet"]
patched_model_list.async_get_fully_unhealthy_model_names.assert_not_awaited()
@pytest.mark.asyncio
async def test_model_list_healthy_only_applies_to_scope_expand(
patched_model_list, monkeypatch
):
from litellm.proxy.auth import model_checks
from litellm.proxy.management_endpoints import common_utils
async def _fake_admin(**kwargs):
return True
monkeypatch.setattr(common_utils, "_user_has_admin_privileges", _fake_admin)
monkeypatch.setattr(
model_checks,
"get_complete_model_list",
lambda **kwargs: ["gpt-4", "claude-sonnet"],
)
patched_model_list.get_model_names = MagicMock(
return_value=["gpt-4", "claude-sonnet"]
)
patched_model_list.get_model_access_groups = MagicMock(return_value={})
response = await proxy_server.model_list(
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
scope="expand",
healthy_only=True,
)
assert [m["id"] for m in response["data"]] == ["gpt-4"]

View file

@ -1948,6 +1948,221 @@ def test_completion_cost_service_tier_for_bedrock():
assert priority_cost > default_cost > flex_cost > 0
def test_completion_cost_service_tier_for_anthropic():
"""
Anthropic priority-tier requests must be priced at the priority rate.
Regression for LIT-3771: the Anthropic cost route dropped ``service_tier``,
so priority requests (whose tier is reported on the response usage) were
always billed at the standard rate. The tier is captured by the
transformation and must flow through to ``generic_cost_per_token``.
"""
from litellm import completion_cost
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
model = "claude-test-service-tier-cost-model"
litellm.register_model(
model_cost={
model: {
"input_cost_per_token": 3e-6,
"output_cost_per_token": 15e-6,
"input_cost_per_token_priority": 6e-6,
"output_cost_per_token_priority": 30e-6,
"litellm_provider": "anthropic",
"max_tokens": 8192,
}
}
)
def _cost_for_tier(service_tier):
usage = AnthropicConfig().calculate_usage(
usage_object={
"input_tokens": 1000,
"output_tokens": 500,
"service_tier": service_tier,
},
reasoning_content=None,
)
response = ModelResponse(usage=usage, model=model)
return completion_cost(
completion_response=response,
model=model,
custom_llm_provider="anthropic",
)
standard_cost = _cost_for_tier("standard")
priority_cost = _cost_for_tier("priority")
expected_standard = 1000 * 3e-6 + 500 * 15e-6
assert standard_cost == pytest.approx(expected_standard)
# priority rates are exactly 2x standard for both input and output
assert priority_cost == pytest.approx(2 * standard_cost)
def test_completion_cost_anthropic_auto_tier_uses_served_priority_rate():
"""
Proxy billing path regression for LIT-3771.
Priority is opted into with ``service_tier="auto"``; Anthropic then serves
"priority" and reports it on the response usage. The proxy forwards the
request-level "auto" into ``completion_cost`` (via ``_response_cost_calculator``),
and that preference must not shadow the served tier, otherwise priority
requests are silently billed at the standard rate.
"""
from litellm import completion_cost
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
model = "claude-test-auto-tier-cost-model"
litellm.register_model(
model_cost={
model: {
"input_cost_per_token": 3e-6,
"output_cost_per_token": 15e-6,
"input_cost_per_token_priority": 6e-6,
"output_cost_per_token_priority": 30e-6,
"litellm_provider": "anthropic",
"max_tokens": 8192,
}
}
)
usage = AnthropicConfig().calculate_usage(
usage_object={
"input_tokens": 1000,
"output_tokens": 500,
"service_tier": "priority",
},
reasoning_content=None,
)
response = ModelResponse(usage=usage, model=model)
cost = completion_cost(
completion_response=response,
model=model,
custom_llm_provider="anthropic",
service_tier="auto",
optional_params={"service_tier": "auto"},
)
expected_priority = 1000 * 6e-6 + 500 * 30e-6
assert cost == pytest.approx(expected_priority)
def test_completion_cost_non_string_service_tier_defers_to_served_tier():
"""
Regression: a non-string request-level ``service_tier`` (reachable via
``allowed_openai_params``/``drop_params``) must not crash cost tracking.
Before the fix, ``completion_cost`` called ``service_tier.lower()`` on the
request-level value, so a dict raised ``AttributeError``. ``_response_cost_calculator``
swallowed it and reported ``response_cost=None``, silently dropping the cost.
The non-string preference must be ignored so pricing defers to the tier the
provider actually served on the response usage.
"""
from litellm import completion_cost
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
model = "claude-test-non-string-tier-cost-model"
litellm.register_model(
model_cost={
model: {
"input_cost_per_token": 3e-6,
"output_cost_per_token": 15e-6,
"input_cost_per_token_priority": 6e-6,
"output_cost_per_token_priority": 30e-6,
"litellm_provider": "anthropic",
"max_tokens": 8192,
}
}
)
usage = AnthropicConfig().calculate_usage(
usage_object={
"input_tokens": 1000,
"output_tokens": 500,
"service_tier": "priority",
},
reasoning_content=None,
)
response = ModelResponse(usage=usage, model=model)
cost = completion_cost(
completion_response=response,
model=model,
custom_llm_provider="anthropic",
optional_params={"service_tier": {"name": "auto"}},
)
expected_priority = 1000 * 6e-6 + 500 * 30e-6
assert cost == pytest.approx(expected_priority)
def test_anthropic_cost_per_token_prices_cache_at_served_tier_with_multiplier():
"""
Regression for the cache/tier interaction in the Anthropic geo/speed path.
When a request is served at "priority" and also carries a geo/speed
multiplier (here ``speed="fast"``), the cache portion is held out of the
multiplier so it is not scaled. That held-out cache cost must use the
served tier's cache rate; pricing it at the standard rate while the cache
embedded in ``prompt_cost`` is priced at the priority rate leaves a
``(cache_priority - cache_standard)(multiplier - 1)`` billing error.
"""
from litellm.llms.anthropic.cost_calculation import (
cost_per_token as anthropic_cost_per_token,
)
from litellm.types.utils import PromptTokensDetailsWrapper, Usage
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
model = "claude-test-priority-cache-fast-model"
litellm.register_model(
model_cost={
model: {
"input_cost_per_token": 3e-6,
"output_cost_per_token": 15e-6,
"cache_read_input_token_cost": 0.3e-6,
"input_cost_per_token_priority": 6e-6,
"output_cost_per_token_priority": 30e-6,
"cache_read_input_token_cost_priority": 0.6e-6,
"litellm_provider": "anthropic",
"max_tokens": 8192,
"provider_specific_entry": {"fast": 2.0},
}
}
)
usage = Usage(
prompt_tokens=1000,
completion_tokens=500,
total_tokens=1500,
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=200),
)
usage.speed = "fast"
prompt_cost, completion_cost = anthropic_cost_per_token(
model=model, usage=usage, service_tier="priority"
)
# non-cache input priced at the priority rate and scaled by the fast
# multiplier; the 200 cache-hit tokens priced at the priority cache rate
# and held out of the multiplier
expected_prompt = (1000 - 200) * 6e-6 * 2 + 200 * 0.6e-6
expected_completion = 500 * 30e-6 * 2
assert prompt_cost == pytest.approx(expected_prompt)
assert completion_cost == pytest.approx(expected_completion)
def test_gemini_cache_tokens_details_no_negative_values():
"""
Test for Issue #18750: Negative text_tokens with Gemini caching

View file

@ -4218,6 +4218,82 @@ def test_get_fully_blocked_model_names_treats_missing_key_as_unblocked():
assert router.get_fully_blocked_model_names() == set()
def _seed_unhealthy_states(router, unhealthy_ids, timestamp=None):
import time
ts = timestamp if timestamp is not None else time.time()
router.health_state_cache.set_deployment_health_states(
{
uid: {"is_healthy": False, "timestamp": ts, "reason": "test_unhealthy"}
for uid in unhealthy_ids
}
)
@pytest.mark.asyncio
async def test_async_get_fully_unhealthy_model_names_marks_name_when_all_unhealthy():
router = _router_with_two_deployments([False, False])
_seed_unhealthy_states(router, {"dep-0", "dep-1"})
assert await router.async_get_fully_unhealthy_model_names() == {"gpt-4o"}
@pytest.mark.asyncio
async def test_async_get_fully_unhealthy_model_names_keeps_name_when_partial():
router = _router_with_two_deployments([False, False])
_seed_unhealthy_states(router, {"dep-0"})
assert await router.async_get_fully_unhealthy_model_names() == set()
@pytest.mark.asyncio
async def test_async_get_fully_unhealthy_model_names_empty_without_health_state():
router = _router_with_two_deployments([False, False])
assert await router.async_get_fully_unhealthy_model_names() == set()
@pytest.mark.asyncio
async def test_async_get_fully_unhealthy_model_names_ignores_stale_state():
import time
router = _router_with_two_deployments([False, False])
stale_ts = time.time() - (router.health_state_cache.staleness_threshold + 10)
_seed_unhealthy_states(router, {"dep-0", "dep-1"}, timestamp=stale_ts)
assert await router.async_get_fully_unhealthy_model_names() == set()
@pytest.mark.asyncio
async def test_async_get_fully_unhealthy_model_names_includes_team_alias():
import litellm
router = litellm.Router(
model_list=[
{
"model_name": "gpt-4o",
"litellm_params": {"model": "openai/gpt-4o"},
"model_info": {
"id": "dep-0",
"team_id": "team-1",
"team_public_model_name": "team-gpt",
},
}
]
)
_seed_unhealthy_states(router, {"dep-0"})
assert await router.async_get_fully_unhealthy_model_names() == {
"gpt-4o",
"team-gpt",
}
@pytest.mark.asyncio
async def test_async_get_fully_unhealthy_model_names_noop_with_allowed_fails_policy():
from litellm.types.router import AllowedFailsPolicy
router = _router_with_two_deployments([False, False])
router.allowed_fails_policy = AllowedFailsPolicy(BadRequestErrorAllowedFails=1)
_seed_unhealthy_states(router, {"dep-0", "dep-1"})
assert await router.async_get_fully_unhealthy_model_names() == set()
@pytest.mark.asyncio
async def test_async_get_healthy_deployments_skips_blocked_deployment():
router = _router_with_two_deployments([True, False])

View file

@ -6279,6 +6279,10 @@ export interface paths {
*
* Follows OpenAI API specification for individual model retrieval.
* https://platform.openai.com/docs/api-reference/models/retrieve
*
* Query parameters mirror `/v1/models` so the same caller context (team
* scoping, health filtering, paused deployments) drives both endpoints; the
* listing's public id must resolve to the same internal deployment here.
*/
get: operations["model_info_models__model_id__get"];
put?: never;
@ -14472,6 +14476,10 @@ export interface paths {
*
* Follows OpenAI API specification for individual model retrieval.
* https://platform.openai.com/docs/api-reference/models/retrieve
*
* Query parameters mirror `/v1/models` so the same caller context (team
* scoping, health filtering, paused deployments) drives both endpoints; the
* listing's public id must resolve to the same internal deployment here.
*/
get: operations["model_info_v1_models__model_id__get"];
put?: never;
@ -38145,7 +38153,10 @@ export interface operations {
};
model_info_models__model_id__get: {
parameters: {
query?: never;
query?: {
team_id?: string | null;
healthy_only?: boolean | null;
};
header?: never;
path: {
model_id: string;
@ -48211,7 +48222,10 @@ export interface operations {
};
model_info_v1_models__model_id__get: {
parameters: {
query?: never;
query?: {
team_id?: string | null;
healthy_only?: boolean | null;
};
header?: never;
path: {
model_id: string;

4
uv.lock generated
View file

@ -9,7 +9,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-06-13T01:42:46.429412Z"
exclude-newer = "2026-06-15T02:02:32.823508Z"
exclude-newer-span = "P3D"
[manifest]
@ -3280,7 +3280,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.89.1"
version = "1.89.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },