Merge pull request #30408 from BerriAI/litellm_backport_1_88_x_0613

chore(release): backport DB-resilience, passthrough, model-info, budget, and deps fixes to stable/1.88.x
This commit is contained in:
yuneng-jiang 2026-06-13 19:29:53 -07:00 committed by GitHub
commit a0d05ba257
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3499 additions and 234 deletions

View file

@ -2397,6 +2397,17 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"`statement_cache_size`). Keys here override any default LiteLLM sets."
),
)
database_disable_prepared_statements: Optional[bool] = Field(
None,
description=(
"Disable server-side prepared statements by setting Prisma's "
"`pgbouncer=true` URL param. Use this for pgbouncer transaction-pooling "
"deployments, or to prevent the 'cached plan must not change result "
"type' error that pooled connections hit during rolling schema "
"migrations. An explicit `pgbouncer` in `database_extra_connection_params` "
"takes precedence."
),
)
database_type: Optional[Literal["dynamo_db"]] = Field(
None, description="to use dynamodb instead of postgres db"
)
@ -2533,6 +2544,24 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
description="List of MCP server fields that must be filled in for a submission to pass standards checks (e.g. ['description', 'source_url', 'alias']).",
)
disable_budget_reservation: Optional[bool] = Field(
None,
description=(
"If True, disables the optimistic per-request budget reservation "
"introduced in v1.84.0. "
"WARNING: This weakens hard budget enforcement. Without the reservation, "
"a burst of concurrent requests from a single key can each pass the "
"read-time spend check before any of them is charged, allowing a "
"configured budget to be exceeded under high concurrency. "
"Budgets are still evaluated on every request at read time, so "
"an already-exhausted budget is still rejected. "
"Enable only if your deployment is experiencing phantom "
"BudgetExceededError responses caused by leaked reservations "
"(see GitHub issue #27639). "
"A proxy-level WARNING is logged on every request while this flag "
"is active as a reminder that hard enforcement is relaxed."
),
)
class ConfigYAML(LiteLLMPydanticObjectBase):

View file

@ -134,6 +134,16 @@ class UserAPIKeyAuthExceptionHandler:
)
elif isinstance(e, ProxyException):
raise e
if PrismaDBExceptionHandler.is_database_service_unavailable_error(e):
raise ProxyException(
message=(
"Service Unavailable, the authentication database is "
"temporarily unreachable. Please retry shortly."
),
type=ProxyErrorTypes.no_db_connection,
param="None",
code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,

View file

@ -2108,6 +2108,7 @@ async def _run_centralized_common_checks( # noqa: PLR0915
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
skip_budget_checks=skip_budget_checks,
general_settings=general_settings,
)
@ -2128,12 +2129,23 @@ async def _reserve_budget_after_common_checks(
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
skip_budget_checks: bool,
general_settings: dict,
end_user_id: Optional[str] = None,
end_user_object: Optional[LiteLLM_EndUserTable] = None,
) -> None:
user_api_key_auth_obj.budget_reservation = None
if skip_budget_checks:
return
if general_settings.get("disable_budget_reservation") is True:
verbose_proxy_logger.warning(
"disable_budget_reservation is enabled: skipping optimistic budget "
"reservation. Budget enforcement is read-time only — concurrent "
"requests can each pass the spend check before their cost is recorded, "
"so a configured budget may be briefly exceeded under high concurrency. "
"Set disable_budget_reservation to False or remove it to restore "
"hard per-request budget enforcement."
)
return
from litellm.proxy.spend_tracking.budget_reservation import (
reserve_budget_for_request,

View file

@ -109,6 +109,92 @@ class PrismaDBExceptionHandler:
return True
return False
@staticmethod
def is_prisma_engine_internal_error(e: Exception) -> bool:
"""True iff ``e`` is a non-``PrismaError`` exception raised from inside
prisma-client-py's query-engine layer.
During the instant a DB connection is torn down, the query engine can
return a malformed error payload (``user_facing_error.meta`` is
``null``). prisma-client-py's ``handle_response_errors`` then crashes
with ``AttributeError: 'NoneType' object has no attribute 'get'``
before it can raise the proper P1001 "can't reach database server"
error. That AttributeError carries no connection keyword, so it can't
be matched by message; identify it by its ``prisma.engine`` origin
instead.
Recognized ``PrismaError`` subclasses are excluded: connectivity ones
are already classified by type/keyword above, and data-layer ones
(the DB IS reachable) must stay 401.
"""
import prisma
if isinstance(e, prisma.errors.PrismaError):
return False
tb = getattr(e, "__traceback__", None)
while tb is not None:
if tb.tb_frame.f_globals.get("__name__", "").startswith("prisma.engine"):
return True
tb = tb.tb_next
return False
@staticmethod
def is_database_service_unavailable_error(e: Exception) -> bool:
"""True iff the exception means the database could not answer at the
infrastructure level (connection refused, socket/interface failure,
timeout) rather than a genuine auth failure (key not found) or a
data-layer error (the DB IS reachable and rejected the data).
Auth must answer 401 only for a key the DB confirms is invalid. When
the DB itself is unreachable, the request has to surface as 503 so
callers retry instead of treating valid keys as invalid during an
outage.
Note: prisma-client-py mislabels the P1001 "can't reach database
server" connectivity failure as a ``DataError`` (a data-layer type),
so a type-only check misses real outages. ``is_database_transport_error``
keyword-matches the connection message and catches that masquerade,
while genuine data errors (no connection keyword) correctly stay 401.
The Postgres "cached plan must not change result type" error is matched
here, not in ``is_database_transport_error``: it is a transient stale-DB-
state condition (not an invalid key), but the connection is healthy so it
must not trigger a reconnect.
A non-``PrismaError`` raised from inside the prisma query engine (e.g.
the ``AttributeError`` from ``handle_response_errors`` when the engine
returns a malformed error payload mid-tear-down) is also treated as
unavailable; see ``is_prisma_engine_internal_error``.
"""
import asyncio
if PrismaDBExceptionHandler.is_database_connection_error(e):
return True
if PrismaDBExceptionHandler.is_database_transport_error(e):
return True
if PrismaDBExceptionHandler.is_prisma_engine_internal_error(e):
return True
if "cached plan must not change result type" in str(e).lower():
return True
# OSError already covers ConnectionError and (Py3.3+) TimeoutError.
# asyncio.TimeoutError is a distinct class before Py3.11.
if isinstance(e, (OSError, asyncio.TimeoutError)):
return True
try:
import asyncpg
except ImportError:
return False
return isinstance(
e,
(
asyncpg.exceptions.PostgresConnectionError,
asyncpg.exceptions.InterfaceError,
),
)
@staticmethod
def handle_db_exception(e: Exception):
"""

View file

@ -490,9 +490,45 @@ def _get_public_model_name(
patch_data: updateDeployment,
db_model: Deployment,
) -> str:
"""Determine the public model name from patch or existing model."""
if patch_data.model_name:
return patch_data.model_name
"""Determine the public model name from patch or existing model.
The top-level ``model_name`` is the rename channel. For team-scoped rows
the DB ``model_name`` column holds an internal routing key
(``model_name_{team_id}_{uuid}``), and ``/model/info`` historically leaked
it into the dashboard edit form, so a non-rename save (e.g. a TPM tweak)
would PATCH the internal name and the update path would treat it as a
rename -- overwriting ``team_public_model_name`` and rewriting the team ACL
(see issue #28382).
Guard against that by ignoring an incoming ``model_name`` that matches the
internal shape, or is a no-op against the current DB column. Anything else
is a genuine rename and wins. We deliberately do NOT read
``patch_data.model_info.team_public_model_name``: the dashboard passes the
existing ``model_info`` blob through untouched on a rename, so honoring it
would return the OLD public name and silently drop the rename.
Precedence (highest first):
1. patch_data.model_name -- a genuine rename: not internal-shape and not a
no-op against db_model.model_name.
2. db_model.model_info.team_public_model_name -- existing public name.
3. db_model.model_name -- last-resort fallback for legacy rows.
"""
team_id = (patch_data.model_info.team_id if patch_data.model_info else None) or (
db_model.model_info.team_id if db_model.model_info else None
)
def _is_internal_shape(name: Optional[str]) -> bool:
if team_id is None or not name:
return False
return name.startswith(f"model_name_{team_id}_")
incoming = patch_data.model_name
if (
incoming
and not _is_internal_shape(incoming)
and incoming != db_model.model_name
):
return incoming
if db_model.model_info and db_model.model_info.team_public_model_name:
return db_model.model_info.team_public_model_name

View file

@ -100,6 +100,42 @@ class AnthropicPassthroughLoggingHandler:
return get_end_user_id_from_request_body(request_body)
return None
@staticmethod
def _resolve_costing_model(model: str, logging_obj: LiteLLMLoggingObj) -> str:
if model and model != "unknown":
return model
litellm_params = (getattr(logging_obj, "model_call_details", {}) or {}).get(
"litellm_params", {}
) or {}
deployment_model = litellm_params.get("model")
if deployment_model and deployment_model != "unknown":
return deployment_model
model_group = (litellm_params.get("metadata", {}) or {}).get("model_group")
if model_group:
return model_group.removeprefix("passthrough/")
return model
@staticmethod
def _extract_model_from_anthropic_chunks(
all_chunks: Sequence[Union[str, bytes]],
) -> Optional[str]:
for raw in all_chunks:
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
for line in text.splitlines():
if not line.startswith("data:"):
continue
try:
data = json.loads(line[len("data:") :].strip())
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(data, dict):
continue
if data.get("type") == "message_start":
model = (data.get("message") or {}).get("model")
if model:
return model
return None
@staticmethod
def _create_anthropic_response_logging_payload(
litellm_model_response: Union[ModelResponse, TextCompletionResponse],
@ -127,6 +163,10 @@ class AnthropicPassthroughLoggingHandler:
"custom_llm_provider"
)
model = AnthropicPassthroughLoggingHandler._resolve_costing_model(
model, logging_obj
)
# Prepend custom_llm_provider to model if not already present
model_for_cost = model
if custom_llm_provider and not model.startswith(f"{custom_llm_provider}/"):
@ -213,6 +253,15 @@ class AnthropicPassthroughLoggingHandler:
):
model = cast(str, litellm_logging_obj.model_call_details.get("model"))
if not model or model == "unknown":
chunk_model = (
AnthropicPassthroughLoggingHandler._extract_model_from_anthropic_chunks(
all_chunks
)
)
if chunk_model:
model = chunk_model
complete_streaming_response = (
AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
all_chunks=all_chunks,
@ -468,6 +517,13 @@ class AnthropicPassthroughLoggingHandler:
# Process each individual event
for event_str in individual_events:
try:
# Skip OpenAI-style [DONE] sentinels some Anthropic-compatible
# providers emit. Match the whole SSE line so a valid chunk whose
# text payload happens to contain "[DONE]" is not dropped.
if any(
line.strip() == "data: [DONE]" for line in event_str.split("\n")
):
continue
transformed_openai_chunk = anthropic_model_response_iterator.convert_str_chunk_to_generic_chunk(
chunk=event_str
)
@ -476,6 +532,14 @@ class AnthropicPassthroughLoggingHandler:
except (StopIteration, StopAsyncIteration):
break
except json.JSONDecodeError:
# Some upstreams emit non-JSON SSE lines; skip them so the
# logging pipeline is not broken by a single bad frame.
verbose_proxy_logger.debug(
"Skipping non-JSON SSE event: %s",
event_str[:200],
)
continue
complete_streaming_response = litellm.stream_chunk_builder(
chunks=all_openai_chunks,

View file

@ -44,15 +44,19 @@ def _build_db_connection_url_params(
pool_timeout: Optional[Union[int, float]],
connect_timeout: Optional[Union[int, float]] = None,
socket_timeout: Optional[Union[int, float]] = None,
disable_prepared_statements: bool = False,
extra_params: Optional[dict] = None,
) -> dict:
"""Build the Prisma DATABASE_URL query params controlling connection pool behavior.
`connect_timeout` / `socket_timeout` map to the Prisma URL params of the same
name (https://www.prisma.io/docs/orm/overview/databases/postgresql) and are
omitted when None so Prisma's defaults apply. `extra_params` is an
untyped passthrough keys it provides win over the named arguments above,
so it can be used to override any default we set here.
omitted when None so Prisma's defaults apply. `disable_prepared_statements`
sets `pgbouncer=true`, which makes Prisma stop using server-side prepared
statements (pgbouncer transaction-pool compatible; also sidesteps the
"cached plan must not change result type" error during rolling migrations).
`extra_params` is an untyped passthrough keys it provides win over the
named arguments above, so it can be used to override any default we set here.
"""
params: dict = {
"connection_limit": connection_limit,
@ -63,6 +67,8 @@ def _build_db_connection_url_params(
params["connect_timeout"] = connect_timeout
if socket_timeout is not None:
params["socket_timeout"] = socket_timeout
if disable_prepared_statements:
params["pgbouncer"] = "true"
if extra_params:
params.update(extra_params)
return params
@ -925,6 +931,7 @@ def run_server( # noqa: PLR0915
db_connection_timeout: Optional[Union[int, float]] = 60
db_connect_timeout: Optional[Union[int, float]] = None
db_socket_timeout: Optional[Union[int, float]] = None
db_disable_prepared_statements: bool = False
db_extra_connection_params: Optional[dict] = None
general_settings = {}
### GET DB TOKEN FOR IAM AUTH ###
@ -1045,6 +1052,17 @@ def run_server( # noqa: PLR0915
)
db_connect_timeout = general_settings.get("database_connect_timeout")
db_socket_timeout = general_settings.get("database_socket_timeout")
_disable_prepared_statements = general_settings.get(
"database_disable_prepared_statements", False
)
if isinstance(_disable_prepared_statements, str):
from litellm.secret_managers.main import str_to_bool
db_disable_prepared_statements = (
str_to_bool(_disable_prepared_statements) is True
)
else:
db_disable_prepared_statements = bool(_disable_prepared_statements)
db_extra_connection_params = general_settings.get(
"database_extra_connection_params"
)
@ -1092,6 +1110,7 @@ def run_server( # noqa: PLR0915
pool_timeout=db_connection_timeout,
connect_timeout=db_connect_timeout,
socket_timeout=db_socket_timeout,
disable_prepared_statements=db_disable_prepared_statements,
extra_params=db_extra_connection_params,
)
if os.getenv("DATABASE_URL", None) is not None:

View file

@ -10907,16 +10907,26 @@ def get_direct_access_models(
return direct_access_models
async def get_all_team_and_direct_access_models(
def _filter_models_to_user_accessible(all_models: List[Dict]) -> List[Dict]:
"""Keep only deployments the caller can use via direct access or team membership."""
return [
_model
for _model in all_models
if _model.get("model_info", {}).get("direct_access", False)
or _model.get("model_info", {}).get("access_via_team_ids", [])
]
async def _populate_team_access_on_models(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
llm_router: Router,
all_models: List[Dict],
) -> List[Dict]:
"""
Get all models across all teams user is in.
Populate `model_info.access_via_team_ids` and `model_info.direct_access`
without filtering the model list.
"""
user_teams: Optional[Union[List[str], Literal["*"]]] = None
direct_access_models: List[str] = []
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
@ -10935,7 +10945,6 @@ async def get_all_team_and_direct_access_models(
user_db_object=user_object,
llm_router=llm_router,
)
## ADD ACCESS_VIA_TEAM_IDS TO ALL MODELS
if user_teams is not None:
team_models = await get_all_team_models(
user_teams=user_teams,
@ -10958,23 +10967,33 @@ async def get_all_team_and_direct_access_models(
model_id, []
)
## ADD DIRECT_ACCESS TO RELEVANT MODELS
direct_access_model_ids = set(direct_access_models)
for _model in all_models:
model_id = _model.get("model_info", {}).get("id", None)
if model_id is not None and model_id in direct_access_models:
_model["model_info"]["direct_access"] = True
if model_id is not None:
_model["model_info"]["direct_access"] = model_id in direct_access_model_ids
## FILTER OUT MODELS THAT ARE NOT IN DIRECT_ACCESS_MODELS OR ACCESS_VIA_TEAM_IDS - only show user models they can call
all_models = [
_model
for _model in all_models
if _model.get("model_info", {}).get("direct_access", False)
or _model.get("model_info", {}).get("access_via_team_ids", [])
]
return all_models
async def get_all_team_and_direct_access_models(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
llm_router: Router,
all_models: List[Dict],
) -> List[Dict]:
"""
Get all models across all teams user is in.
"""
all_models = await _populate_team_access_on_models(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
llm_router=llm_router,
all_models=all_models,
)
return _filter_models_to_user_accessible(all_models)
def _enrich_model_info_with_litellm_data(
model: Dict[str, Any], debug: bool = False, llm_router: Optional[Router] = None
) -> Dict[str, Any]:
@ -11083,6 +11102,22 @@ async def _get_caller_byok_team_scope(
return set(user_row.teams or [])
def _byok_row_outside_caller_teams(
model_info_dict: Dict[str, Any], allowed_team_ids: Optional[Set[str]]
) -> bool:
"""Whether a team BYOK row belongs to a team the caller is not a member of.
`team_id` is only set on team BYOK rows; non-team rows fall through
unaffected. `allowed_team_ids is None` means no scoping (e.g. admins).
"""
if allowed_team_ids is None:
return False
team_id = model_info_dict.get("team_id")
if team_id is None:
return False
return team_id not in allowed_team_ids
# Hard cap on rows the DB-side BYOK search may pull when results need to be
# sorted across the full match set. Without this, an authenticated caller
# can hit `/v2/model/info?search=<broad>&sortBy=<field>` and force the
@ -11204,15 +11239,7 @@ async def _apply_search_filter_to_models(
)
def _is_byok_outside_caller_teams(model_info_dict: Dict[str, Any]) -> bool:
# `team_id` is only set on team BYOK rows. Non-team rows fall
# through unaffected — they are gated by other paths (router
# membership, direct_access, include_team_models).
if allowed_team_ids is None:
return False
team_id = model_info_dict.get("team_id")
if team_id is None:
return False
return team_id not in allowed_team_ids
return _byok_row_outside_caller_teams(model_info_dict, allowed_team_ids)
def _model_matches_search(m: Dict[str, Any]) -> bool:
# Team BYOK models persist an internal `model_name`
@ -11875,6 +11902,9 @@ async def model_info_v2(
# Update total count to include agents
search_total_count = len(all_models)
# Translate `model_name` to the public name for team-scoped rows.
all_models = [_translate_model_name_for_response(m) for m in all_models]
return _paginate_models_response(
all_models=all_models,
page=page,
@ -12309,6 +12339,99 @@ async def model_metrics_exceptions(
return {"data": response, "exception_types": list(exception_types)}
def _deployment_matches_allowed_model_names(
model: Dict[str, Any], allowed_model_names: Set[str]
) -> bool:
"""Match a router deployment against allowed public model names.
Team-scoped rows store an internal routing key in ``model_name``; callers
with key/team restrictions still refer to the public name in
``model_info.team_public_model_name``.
"""
if model.get("model_name") in allowed_model_names:
return True
model_info = model.get("model_info")
if not isinstance(model_info, dict):
return False
team_public_model_name = model_info.get("team_public_model_name")
return (
isinstance(team_public_model_name, str)
and team_public_model_name in allowed_model_names
)
def _get_v1_model_info_allowed_model_names(
user_api_key_dict: UserAPIKeyAuth,
llm_router: Router,
) -> Optional[Set[str]]:
"""Return key/team allowlisted public model names, or None if unrestricted."""
model_access_groups = llm_router.get_model_access_groups()
proxy_model_list = llm_router.get_model_names()
key_models = get_key_models(
user_api_key_dict=user_api_key_dict,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
)
team_models = get_team_models(
team_models=user_api_key_dict.team_models,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
)
if not key_models and not team_models:
return None
return set(
get_complete_model_list(
key_models=key_models,
team_models=team_models,
proxy_model_list=proxy_model_list,
user_model=user_model,
infer_model_from_keys=general_settings.get("infer_model_from_keys", False),
llm_router=llm_router,
return_wildcard_routes=False,
)
)
def _filter_v1_model_info_deployments(
all_models: List[dict],
allowed_model_names: Optional[Set[str]],
) -> List[dict]:
if allowed_model_names is None:
return all_models
return [
model
for model in all_models
if _deployment_matches_allowed_model_names(model, allowed_model_names)
]
def _translate_model_name_for_response(model: dict) -> dict:
"""For team-scoped DB rows, replace `model_name` with the public name
in `model_info.team_public_model_name` before returning. The DB column
and the in-memory router index keep the internal mangled name
(`model_name_{team_id}_{uuid}`) as the routing key -- this swap is a
presentation-layer concern. Returns a shallow copy; never mutates.
Without this swap the internal name leaks into `/v1/model/info` and
`/v2/model/info`, the dashboard binds its edit form to it, and a
non-rename save round-trips the internal name back -- corrupting
`team_public_model_name` and the team ACL (see issue #28382).
"""
if not isinstance(model, dict):
return model
model_info = model.get("model_info") or {}
if not isinstance(model_info, dict):
return model
team_public = model_info.get("team_public_model_name")
team_id = model_info.get("team_id")
if not team_public or not team_id:
return model
current = model.get("model_name") or ""
if not current.startswith(f"model_name_{team_id}_"):
return model
return {**model, "model_name": team_public}
def _get_proxy_model_info(model: dict) -> dict:
# provided model_info in config.yaml
model_info = model.get("model_info", {})
@ -12349,7 +12472,7 @@ def _get_proxy_model_info(model: dict) -> dict:
deployment_dict=model, excluded_keys={"litellm_credential_name"}
)
return model
return _translate_model_name_for_response(model)
@router.get(
@ -12365,6 +12488,14 @@ def _get_proxy_model_info(model: dict) -> dict:
async def model_info_v1( # noqa: PLR0915
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_model_id: Optional[str] = None,
include_team_models: Optional[bool] = fastapi.Query(
False,
description="When true, filter to deployments the caller can use via direct access or team membership.",
),
teamId: Optional[str] = fastapi.Query(
None,
description="Filter models by team ID. Returns models with direct_access=True or teamId in access_via_team_ids",
),
):
"""
Provides more info about each model in /models, including config.yaml descriptions (except api key and api base)
@ -12374,6 +12505,11 @@ async def model_info_v1( # noqa: PLR0915
- When litellm_model_id is passed, it will return the info for that specific model
- When litellm_model_id is not passed, it will return the info for all models
- include_team_models: When true, filter to deployments the caller can use (same as /v2/model/info).
- teamId: Filter to models accessible by the given team.
Each model in the list response includes `model_info.access_via_team_ids` and
`model_info.direct_access` when the proxy database is connected.
Returns:
Returns a dictionary containing information about each model.
@ -12400,6 +12536,12 @@ async def model_info_v1( # noqa: PLR0915
"""
global llm_model_list, general_settings, user_config_file_path, proxy_config, llm_router, user_model
# Unit tests call this handler directly; FastAPI normally resolves Query defaults.
if not isinstance(include_team_models, bool):
include_team_models = False
if not isinstance(teamId, str):
teamId = None
if user_model is not None:
# user is trying to get specific model from litellm router
try:
@ -12436,6 +12578,14 @@ async def model_info_v1( # noqa: PLR0915
},
)
if prisma_client is None and (
include_team_models or (teamId is not None and teamId.strip())
):
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
if litellm_model_id is not None:
# user is trying to get specific model from litellm router
deployment_info = llm_router.get_deployment(model_id=litellm_model_id)
@ -12449,48 +12599,82 @@ async def model_info_v1( # noqa: PLR0915
_deployment_info_dict = _get_proxy_model_info(
model=deployment_info.model_dump(exclude_none=True)
)
return {"data": [_deployment_info_dict]}
single_model_list: List[dict] = [_deployment_info_dict]
if prisma_client is not None:
single_model_list = await _populate_team_access_on_models(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
llm_router=llm_router,
all_models=single_model_list,
)
if include_team_models:
single_model_list = _filter_models_to_user_accessible(single_model_list)
if teamId is not None and teamId.strip():
single_model_list = await _filter_models_by_team_id(
all_models=single_model_list,
team_id=teamId.strip(),
prisma_client=prisma_client,
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
)
return {"data": single_model_list}
all_models: List[dict] = []
model_access_groups: Dict[str, List[str]] = defaultdict(list)
## CHECK IF MODEL RESTRICTIONS ARE SET AT KEY/TEAM LEVEL ##
if llm_router is None:
proxy_model_list = []
else:
proxy_model_list = llm_router.get_model_names()
model_access_groups = llm_router.get_model_access_groups()
key_models = get_key_models(
# Return router deployments (same source as /v2/model/info), not wildcard-
# expanded model names from get_complete_model_list(). Team-scoped rows
# use internal routing keys (model_name_{team_id}_{uuid}) and were omitted
# when v1 resolved models only via public model_name strings.
all_models: List[dict] = copy.deepcopy(llm_router.model_list)
allowed_model_names = _get_v1_model_info_allowed_model_names(
user_api_key_dict=user_api_key_dict,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
)
team_models = get_team_models(
team_models=user_api_key_dict.team_models,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
)
all_models_str = get_complete_model_list(
key_models=key_models,
team_models=team_models,
proxy_model_list=proxy_model_list,
user_model=user_model,
infer_model_from_keys=general_settings.get("infer_model_from_keys", False),
llm_router=llm_router,
)
if len(all_models_str) > 0:
_relevant_models = []
for model in all_models_str:
router_models = llm_router.get_model_list(model_name=model)
if router_models is not None:
_relevant_models.extend(router_models)
if llm_model_list is not None:
all_models = copy.deepcopy(_relevant_models) # type: ignore
else:
all_models = []
all_models = _filter_v1_model_info_deployments(
all_models=all_models,
allowed_model_names=allowed_model_names,
)
for in_place_model in all_models:
in_place_model = _get_proxy_model_info(model=in_place_model)
# Team BYOK deployments carry an internal routing key and other teams'
# public name/team_id/api_base; drop the ones the caller cannot access so
# listing the full router model_list does not leak cross-team metadata.
allowed_team_ids = await _get_caller_byok_team_scope(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
)
all_models = [
model
for model in all_models
if not _byok_row_outside_caller_teams(
model.get("model_info") or {}, allowed_team_ids
)
]
if prisma_client is not None:
all_models = await _populate_team_access_on_models(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
llm_router=llm_router,
all_models=all_models,
)
if include_team_models:
all_models = _filter_models_to_user_accessible(all_models)
all_models = [
_translate_model_name_for_response(
_enrich_model_info_with_litellm_data(model=model, llm_router=llm_router)
)
for model in all_models
]
if teamId is not None and teamId.strip():
all_models = await _filter_models_by_team_id(
all_models=all_models,
team_id=teamId.strip(),
prisma_client=cast(PrismaClient, prisma_client),
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
)
verbose_proxy_logger.debug("all_models: %s", all_models)
return {"data": all_models}

View file

@ -3159,40 +3159,49 @@ class PrismaClient:
self, sql_query: str, *args
) -> Optional[dict]:
"""
Execute a query with automatic fallback for PostgreSQL cached plan errors.
Execute a query, recovering once from PostgreSQL's "cached plan must not
change result type" error.
This handles the "cached plan must not change result type" error that occurs
during rolling deployments when schema changes are applied while old pods
still have cached query plans expecting the old schema.
That error surfaces during rolling deployments when a schema change
invalidates the prepared-statement plans that pooled connections still
hold. Clearing only the server-side plans with DEALLOCATE ALL makes
things worse: Prisma's query engine keeps a per-connection client-side
cache of prepared-statement names, so once the server drops a plan the
engine re-sends a name PostgreSQL no longer recognizes and the
connection breaks with `prepared statement "sN" does not exist`. With a
small pool that connection stays poisoned and every auth lookup fails.
Args:
sql_query: SQL query string to execute
Recreating the Prisma client kills the engine subprocess and drops the
server-side plans and the engine's client-side name cache together, so
the retried query is prepared fresh. We reconnect through
`attempt_db_reconnect`, which is singleflight: when a schema change
poisons every pooled connection at once, the first cached-plan error
recreates the client and the concurrent waiters reuse that single
recreate instead of racing to kill each other's fresh engine. We then
retry the identical query exactly once.
Returns:
Query result or None
The retry reuses the original query byte-for-byte. Mutating the SQL
(e.g. injecting a unique comment) would defeat PostgreSQL's plan cache,
forcing a fresh plan on every request and pegging the database CPU.
Raises:
Original exception if not a cached plan error
If the reconnect is skipped because a recent reconnect is still within
its cooldown, the retry runs against the same connection and may fail
again; the get_data backoff decorator re-runs the lookup and a later
attempt reconnects once the cooldown elapses.
"""
try:
return await self.db.query_first(sql_query, *args)
except Exception as e:
error_str = str(e)
if "cached plan must not change result type" in error_str:
# Force PostgreSQL to re-plan by invalidating the cache
# Add a unique comment to make the query different
sql_query_retry = sql_query.replace(
"SELECT",
f"SELECT /* cache_invalidated_{int(time.time() * 1000)} */",
)
verbose_proxy_logger.warning(
"PostgreSQL cached plan error detected for token lookup, "
"retrying with fresh plan. This may occur during rolling deployments "
"when schema changes are applied."
)
return await self.db.query_first(sql_query_retry, *args)
else:
if "cached plan must not change result type" not in str(e):
raise
verbose_proxy_logger.warning(
"PostgreSQL cached plan error detected for token lookup; "
"recreating the database connection and retrying with the same "
"query. This may occur during rolling deployments when schema "
"changes are applied."
)
await self.attempt_db_reconnect(reason="postgres_cached_plan_error")
return await self.db.query_first(sql_query, *args)
@backoff.on_exception(
backoff.expo,
@ -3548,7 +3557,10 @@ class PrismaClient:
db=self.db, hashed_token=hashed_token
)
if active_token_id:
response = await self.get_data(
# The recursive call returns a finished
# LiteLLM_VerificationTokenView; the dict
# normalization below would crash subscripting it.
deprecated_response = await self.get_data(
token=active_token_id,
table_name="combined_view",
query_type="find_unique",
@ -3556,10 +3568,11 @@ class PrismaClient:
proxy_logging_obj=proxy_logging_obj,
check_deprecated=False,
)
if response is not None:
if deprecated_response is not None:
verbose_proxy_logger.debug(
"Deprecated key used during grace period"
)
return deprecated_response
if response is not None:
if response["team_models"] is None:

View file

@ -125,7 +125,7 @@ proxy-runtime = [
"mangum>=0.17.0,<1.0",
"azure-ai-contentsafety>=1.0.0,<2.0",
"azure-storage-file-datalake>=12.20.0,<13.0",
"pypdf>=6.10.2,<7.0; python_version < '3.14'",
"pypdf>=6.12.0,<7.0; python_version < '3.14'",
"llm-sandbox>=0.3.39,<1.0",
"detect-secrets>=1.5.0,<2.0",
]
@ -231,6 +231,10 @@ requires = ["uv_build==0.11.8"]
build-backend = "uv_build"
[tool.uv]
constraint-dependencies = [
"tornado>=6.5.6",
"aiohttp>=3.13.5,<3.14",
]
default-groups = ["dev"]
required-version = ">=0.10.9"
exclude-newer = "3 days"

View file

@ -112,6 +112,157 @@ async def test_handle_authentication_error_data_layer_errors_do_not_fall_back(
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"db_error",
[
ConnectionError("connection refused"),
TimeoutError("timed out"),
asyncio.TimeoutError(),
OSError("network is unreachable"),
HTTPClientClosedError(),
PrismaError("can't reach database server"),
RawQueryError(
data={
"user_facing_error": {
"message": "cached plan must not change result type",
"meta": {"table": "t"},
}
}
),
],
)
async def test_handle_authentication_error_db_infra_error_returns_503(db_error):
"""Regression for the outage where valid keys got 401 for 4 hours: an
infrastructure-level DB failure during auth must surface as 503 (the DB
could not confirm the key), never as 401 ("Invalid API key")."""
handler = UserAPIKeyAuthExceptionHandler()
with (
patch(
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook",
new_callable=AsyncMock,
return_value=None,
),
patch(
"litellm.proxy.proxy_server.general_settings",
{"allow_requests_on_db_unavailable": False},
),
):
with pytest.raises(ProxyException) as exc_info:
await handler._handle_authentication_error(
db_error,
MagicMock(),
{},
"/v1/chat/completions",
None,
"sk-valid-but-db-down",
)
assert int(exc_info.value.code) == status.HTTP_503_SERVICE_UNAVAILABLE
assert exc_info.value.type == ProxyErrorTypes.no_db_connection
assert "Invalid API key" not in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_handle_authentication_error_prisma_engine_teardown_returns_503():
"""Regression for the first-request-of-an-outage edge case: at the instant
the DB socket drops, the prisma query engine returns a malformed error
payload and prisma-client-py crashes with a bare
``AttributeError: 'NoneType' object has no attribute 'get'`` before it can
raise P1001. That AttributeError reached auth and fell through to 401. It
must surface as 503 like every other infra failure during the outage."""
from prisma.engine import utils as prisma_engine_utils
malformed_payload = [
{
"error": "Can't reach database server",
"user_facing_error": {
"error_code": "P1001",
"message": "Can't reach database server at `localhost`:`5503`",
"meta": None,
},
}
]
try:
prisma_engine_utils.handle_response_errors(None, malformed_payload)
raise AssertionError("expected prisma to raise AttributeError")
except AttributeError as e:
teardown_error = e
handler = UserAPIKeyAuthExceptionHandler()
with (
patch(
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook",
new_callable=AsyncMock,
return_value=None,
),
patch(
"litellm.proxy.proxy_server.general_settings",
{"allow_requests_on_db_unavailable": False},
),
):
with pytest.raises(ProxyException) as exc_info:
await handler._handle_authentication_error(
teardown_error,
MagicMock(),
{},
"/v1/chat/completions",
None,
"sk-valid-but-db-down",
)
assert int(exc_info.value.code) == status.HTTP_503_SERVICE_UNAVAILABLE
assert exc_info.value.type == ProxyErrorTypes.no_db_connection
assert "Invalid API key" not in str(exc_info.value.message)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"auth_error",
[
# DB returned no row -> get_key_object raises this exact 401.
ProxyException(
message="Authentication Error, Invalid proxy server token passed.",
type=ProxyErrorTypes.token_not_found_in_db,
param="key",
code=status.HTTP_401_UNAUTHORIZED,
),
# A bare auth failure raised as a plain Exception (e.g. master-key-only
# route) must keep returning 401, not get reclassified as 503.
Exception("Invalid proxy server token passed"),
],
)
async def test_handle_authentication_error_genuine_auth_failure_stays_401(auth_error):
"""Guard against the 503 conversion being too broad: a genuine auth
failure (missing key / wrong key) must still be 401."""
handler = UserAPIKeyAuthExceptionHandler()
with (
patch(
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook",
new_callable=AsyncMock,
return_value=None,
),
patch(
"litellm.proxy.proxy_server.general_settings",
{"allow_requests_on_db_unavailable": False},
),
):
with pytest.raises(ProxyException) as exc_info:
await handler._handle_authentication_error(
auth_error,
MagicMock(),
{},
"/v1/chat/completions",
None,
"sk-bad-key",
)
assert int(exc_info.value.code) == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_handle_authentication_error_budget_exceeded():
handler = UserAPIKeyAuthExceptionHandler()

View file

@ -110,11 +110,71 @@ async def test_should_clear_stale_budget_reservation_when_budget_checks_skip():
user_api_key_cache=MagicMock(),
proxy_logging_obj=MagicMock(),
skip_budget_checks=True,
general_settings={},
)
assert user_api_key_auth_obj.budget_reservation is None
@pytest.mark.asyncio
async def test_disable_budget_reservation_skips_reservation():
"""#27639: general_settings.disable_budget_reservation turns off the optimistic Redis
reservation so operators hit by phantom BudgetExceededError can opt out of it."""
user_api_key_auth_obj = UserAPIKeyAuth(token="test_token")
with patch(
"litellm.proxy.spend_tracking.budget_reservation.reserve_budget_for_request",
new=AsyncMock(return_value={"reserved_cost": 0.5, "entries": []}),
) as mock_reserve:
await _reserve_budget_after_common_checks(
user_api_key_auth_obj=user_api_key_auth_obj,
request_data={"model": "gpt-4o"},
route="/v1/chat/completions",
llm_router=None,
team_object=None,
user_object=None,
prisma_client=None,
user_api_key_cache=MagicMock(),
proxy_logging_obj=MagicMock(),
skip_budget_checks=False,
general_settings={"disable_budget_reservation": True},
)
mock_reserve.assert_not_called()
assert user_api_key_auth_obj.budget_reservation is None
@pytest.mark.asyncio
async def test_budget_reservation_runs_when_not_disabled():
"""Control for #27639: with the flag absent, the reservation still runs and is stored."""
user_api_key_auth_obj = UserAPIKeyAuth(token="test_token")
reservation = {
"reserved_cost": 0.5,
"entries": [{"counter_key": "spend:key:test_token"}],
}
with patch(
"litellm.proxy.spend_tracking.budget_reservation.reserve_budget_for_request",
new=AsyncMock(return_value=reservation),
) as mock_reserve:
await _reserve_budget_after_common_checks(
user_api_key_auth_obj=user_api_key_auth_obj,
request_data={"model": "gpt-4o"},
route="/v1/chat/completions",
llm_router=None,
team_object=None,
user_object=None,
prisma_client=None,
user_api_key_cache=MagicMock(),
proxy_logging_obj=MagicMock(),
skip_budget_checks=False,
general_settings={},
)
mock_reserve.assert_awaited_once()
assert user_api_key_auth_obj.budget_reservation == reservation
@pytest.mark.asyncio
async def test_should_not_reuse_cached_key_object_for_request_state():
key_cache = DualCache()
@ -3459,3 +3519,114 @@ async def test_user_api_key_auth_does_not_overwrite_end_user_id_set_by_builder()
finally:
for k, v in originals.items():
setattr(_proxy_server_mod, k, v)
def _proxy_attrs_for_db_lookup():
"""Minimal proxy_server attributes for driving the real
``_user_api_key_auth_builder`` down to the DB key lookup."""
proxy_logging_obj = MagicMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
return {
"prisma_client": MagicMock(),
"user_api_key_cache": DualCache(),
"proxy_logging_obj": proxy_logging_obj,
"master_key": "sk-test-master",
"general_settings": {"allow_requests_on_db_unavailable": False},
"llm_model_list": [],
"llm_router": None,
"open_telemetry_logger": None,
"model_max_budget_limiter": MagicMock(),
"user_custom_auth": None,
"jwt_handler": None,
"litellm_proxy_admin_name": "admin",
}
async def _run_builder_with_key_lookup(get_key_object_mock):
"""Drive the real auth builder with ``get_key_object`` replaced by the
given mock. Returns the builder result."""
from fastapi import Request
from starlette.datastructures import URL
import litellm.proxy.proxy_server as _proxy_server_mod
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
attrs = _proxy_attrs_for_db_lookup()
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
try:
for k, v in attrs.items():
setattr(_proxy_server_mod, k, v)
request = Request(scope={"type": "http"})
request._url = URL(url="/chat/completions")
with (
patch(
"litellm.proxy.auth.user_api_key_auth.get_key_object",
get_key_object_mock,
),
):
return await _user_api_key_auth_builder(
request=request,
api_key="Bearer sk-db-lookup-test",
azure_api_key_header="",
anthropic_api_key_header=None,
google_ai_studio_api_key_header=None,
azure_apim_header=None,
request_data={},
)
finally:
for k, v in originals.items():
setattr(_proxy_server_mod, k, v)
@pytest.mark.asyncio
async def test_builder_returns_503_when_db_lookup_raises_infra_error():
"""End-to-end: a DB infrastructure failure during the key lookup must
propagate past the ``except ProxyException`` guard and surface as 503,
not the 401 that masked the 4-hour outage. Killing the new 503 branch
flips this to 401 and fails the test."""
get_key_object = AsyncMock(side_effect=ConnectionError("connection refused"))
with pytest.raises(ProxyException) as exc_info:
await _run_builder_with_key_lookup(get_key_object)
assert int(exc_info.value.code) == status.HTTP_503_SERVICE_UNAVAILABLE
assert exc_info.value.type == ProxyErrorTypes.no_db_connection
assert "Invalid API key" not in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_builder_returns_401_when_db_lookup_reports_missing_key():
"""Regression guard: a genuinely missing key (DB returned no row, which
``get_key_object`` raises as a 401 ProxyException) must still be 401."""
missing_key_error = ProxyException(
message="Authentication Error, Invalid proxy server token passed. key=..., not found in db.",
type=ProxyErrorTypes.token_not_found_in_db,
param="key",
code=status.HTTP_401_UNAUTHORIZED,
)
get_key_object = AsyncMock(side_effect=missing_key_error)
with pytest.raises(ProxyException) as exc_info:
await _run_builder_with_key_lookup(get_key_object)
assert int(exc_info.value.code) == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_builder_succeeds_when_db_lookup_returns_valid_token():
"""Regression guard: a valid key still authenticates. Proves the 503
conversion only fires on the failure path and never intercepts success."""
valid_token = UserAPIKeyAuth(api_key="sk-db-lookup-test", token="hashed-valid")
get_key_object = AsyncMock(return_value=valid_token)
with patch(
"litellm.proxy.auth.user_api_key_auth._return_user_api_key_auth_obj",
new_callable=AsyncMock,
return_value=valid_token,
) as mock_return:
result = await _run_builder_with_key_lookup(get_key_object)
assert isinstance(result, UserAPIKeyAuth)
# Reaching the success-assembly return (never the exception handler)
# proves a valid key is unaffected by the 503 conversion.
mock_return.assert_awaited_once()

View file

@ -107,6 +107,201 @@ def test_is_database_connection_generic_errors():
)
@pytest.mark.parametrize(
"error",
[
ConnectionError("connection refused"),
TimeoutError("timed out"),
OSError("network is unreachable"),
asyncio.TimeoutError(),
HTTPClientClosedError(),
ClientNotConnectedError(),
PrismaError("can't reach database server"),
PrismaError(),
],
)
def test_is_database_service_unavailable_error_infra_failures(error):
"""Infrastructure-level failures (socket/connection/timeout, prisma
transport, unknown PrismaError) mean the DB could not answer, so auth
must surface 503 instead of treating a valid key as invalid."""
assert PrismaDBExceptionHandler.is_database_service_unavailable_error(error) is True
def test_is_database_service_unavailable_error_prisma_p1001_masquerades_as_dataerror():
"""Real-world regression: prisma-client-py raises the P1001 "can't reach
database server" connectivity failure as a DataError (a data-layer type).
A type-only check would miss it and return 401 during a genuine outage;
the message keyword must still classify it as service-unavailable -> 503."""
p1001_as_dataerror = DataError(
data={
"user_facing_error": {
"message": "Can't reach database server at `127.0.0.1`:`5499`",
"meta": {"table": "t"},
}
}
)
assert (
PrismaDBExceptionHandler.is_database_service_unavailable_error(
p1001_as_dataerror
)
is True
)
def test_is_database_service_unavailable_error_cached_plan_escapes_as_503():
"""Composes with the cached-plan retry: when that recovery fails and the
Postgres "cached plan must not change result type" error escapes (raised by
prisma as a data-layer RawQueryError), it is a transient stale-DB-state
condition, not an invalid key, so it must classify as service-unavailable
-> 503 rather than fall through to 401."""
cached_plan_error = RawQueryError(
data={
"user_facing_error": {
"message": "cached plan must not change result type",
"meta": {"table": "t"},
}
}
)
assert (
PrismaDBExceptionHandler.is_database_service_unavailable_error(
cached_plan_error
)
is True
)
def test_is_database_service_unavailable_error_prisma_engine_malformed_payload():
"""Real-world regression: at the instant the DB socket drops, the prisma
query engine returns a malformed error payload (``user_facing_error.meta``
is ``null``). prisma-client-py's ``handle_response_errors`` then crashes
with ``AttributeError: 'NoneType' object has no attribute 'get'`` before it
can raise the proper P1001 error. That bare AttributeError has no
connection keyword, so without the prisma-engine-origin check it falls
through to 401 on the first request of an outage. Reproduce the exact
prisma crash and assert it classifies as service-unavailable -> 503."""
from prisma.engine import utils as prisma_engine_utils
malformed_payload = [
{
"error": "Can't reach database server",
"user_facing_error": {
"error_code": "P1001",
"message": "Can't reach database server at `localhost`:`5503`",
"meta": None,
},
}
]
with pytest.raises(AttributeError) as exc_info:
prisma_engine_utils.handle_response_errors(None, malformed_payload)
assert "no attribute 'get'" in str(exc_info.value)
assert (
PrismaDBExceptionHandler.is_database_service_unavailable_error(exc_info.value)
is True
)
def test_is_prisma_engine_internal_error_excludes_application_attributeerror():
"""The prisma-engine-origin check must stay narrow: a genuine AttributeError
raised by application code (a real bug) must NOT be classified as
service-unavailable, otherwise real bugs would silently become 503s."""
def application_bug():
none_value = None
return none_value.get("oops")
with pytest.raises(AttributeError) as exc_info:
application_bug()
assert (
PrismaDBExceptionHandler.is_prisma_engine_internal_error(exc_info.value)
is False
)
assert (
PrismaDBExceptionHandler.is_database_service_unavailable_error(exc_info.value)
is False
)
def test_is_prisma_engine_internal_error_excludes_data_layer_prisma_error():
"""A data-layer ``PrismaError`` (the DB IS reachable and rejected the data)
must stay 401. These are always raised from prisma internals, so the check
excludes any ``PrismaError`` by type before inspecting the traceback."""
data_layer_error = UniqueViolationError(
data={"user_facing_error": {"meta": {"table": "t"}}}
)
try:
raise data_layer_error
except UniqueViolationError as e:
assert PrismaDBExceptionHandler.is_prisma_engine_internal_error(e) is False
@pytest.mark.parametrize(
"error",
[
DataError(data={"user_facing_error": {"meta": {"table": "t"}}}),
UniqueViolationError(data={"user_facing_error": {"meta": {"table": "t"}}}),
RecordNotFoundError(data={"user_facing_error": {"meta": {"table": "t"}}}),
Exception("some unrelated error"),
ValueError("bad value"),
],
)
def test_is_database_service_unavailable_error_excludes_non_infra(error):
"""Data-layer errors (the DB IS reachable and answered) and generic
non-DB errors must NOT be classified as service-unavailable, otherwise a
genuine 401 would be masked as a transient 503."""
assert (
PrismaDBExceptionHandler.is_database_service_unavailable_error(error) is False
)
def test_is_database_service_unavailable_error_asyncpg(monkeypatch):
"""asyncpg connection/interface errors map to service-unavailable. asyncpg
is not a hard dependency, so inject a stand-in module to exercise the
branch deterministically regardless of the install environment."""
import sys
import types
fake_asyncpg = types.ModuleType("asyncpg")
fake_exceptions = types.ModuleType("asyncpg.exceptions")
class PostgresConnectionError(Exception):
pass
class InterfaceError(Exception):
pass
class UniqueViolationError(Exception): # data-layer, must stay False
pass
fake_exceptions.PostgresConnectionError = PostgresConnectionError
fake_exceptions.InterfaceError = InterfaceError
fake_exceptions.UniqueViolationError = UniqueViolationError
fake_asyncpg.exceptions = fake_exceptions
monkeypatch.setitem(sys.modules, "asyncpg", fake_asyncpg)
monkeypatch.setitem(sys.modules, "asyncpg.exceptions", fake_exceptions)
assert (
PrismaDBExceptionHandler.is_database_service_unavailable_error(
PostgresConnectionError("connection reset")
)
is True
)
assert (
PrismaDBExceptionHandler.is_database_service_unavailable_error(
InterfaceError("connection was closed")
)
is True
)
assert (
PrismaDBExceptionHandler.is_database_service_unavailable_error(
UniqueViolationError("duplicate key")
)
is False
)
# Test should_allow_request_on_db_unavailable method
@patch(
"litellm.proxy.proxy_server.general_settings",

View file

@ -1129,6 +1129,307 @@ class TestTeamModelUpdate:
)
assert "403" in str(exc_info.value)
def test_get_public_model_name_28382_dashboard_echo_preserves_public_name(self):
"""Regression for #28382 - a non-rename dashboard PATCH echoes the
internal generated model_name (model_name_{team}_{uuid}) at the top
level. That internal-shape value must be ignored (not treated as a
rename), so _get_public_model_name falls through to the existing public
name instead of overwriting it with the internal one."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_get_public_model_name,
)
from litellm.types.router import ModelInfo
db_model = Deployment(
model_name="model_name_test-team_abc123",
litellm_params=LiteLLM_Params(model="azure/gpt-5.2-low-rpm-testing"),
model_info=ModelInfo(
team_id="test-team",
team_public_model_name="gpt-5.2-low-rpm-testing",
),
)
patch_data = updateDeployment(
model_name="model_name_test-team_abc123",
model_info=ModelInfo(
team_id="test-team",
team_public_model_name="gpt-5.2-low-rpm-testing",
),
)
assert (
_get_public_model_name(patch_data=patch_data, db_model=db_model)
== "gpt-5.2-low-rpm-testing"
)
def test_get_public_model_name_preserves_db_public_name_when_internal_name_unchanged(
self,
):
"""If patch_data.model_info has no team_public_model_name and
patch_data.model_name equals db_model.model_name (dashboard re-sending
the internal name without touching the public-name field), the
existing db_model.model_info.team_public_model_name must be preserved."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_get_public_model_name,
)
from litellm.types.router import ModelInfo
db_model = Deployment(
model_name="model_name_test-team_abc123",
litellm_params=LiteLLM_Params(model="azure/gpt-5.2-low-rpm-testing"),
model_info=ModelInfo(
team_id="test-team",
team_public_model_name="gpt-5.2-low-rpm-testing",
),
)
patch_data = updateDeployment(
model_name="model_name_test-team_abc123",
model_info=ModelInfo(team_id="test-team"),
)
assert (
_get_public_model_name(patch_data=patch_data, db_model=db_model)
== "gpt-5.2-low-rpm-testing"
)
def test_get_public_model_name_allows_top_level_rename(self):
"""A genuine rename via the top-level model_name field (no
patch_data.model_info.team_public_model_name supplied, and the new
name differs from the existing internal db model_name) must still
return the new name."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_get_public_model_name,
)
from litellm.types.router import ModelInfo
db_model = Deployment(
model_name="model_name_test-team_abc123",
litellm_params=LiteLLM_Params(model="azure/gpt-5.2-low-rpm-testing"),
model_info=ModelInfo(
team_id="test-team",
team_public_model_name="old-public-name",
),
)
patch_data = updateDeployment(
model_name="new-public-name",
model_info=ModelInfo(team_id="test-team"),
)
assert (
_get_public_model_name(patch_data=patch_data, db_model=db_model)
== "new-public-name"
)
def test_get_public_model_name_top_level_rename_wins_over_stale_model_info(self):
"""Regression (codex review): on a dashboard rename the UI sends the new
name in model_name but passes the existing model_info blob through
untouched -- so it still carries the OLD team_public_model_name. The
top-level rename must win; otherwise _update_existing_team_model_assignment
sees no change, never updates the team ACL, and the rename is silently
dropped while the UI optimistically shows the new name."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_get_public_model_name,
)
from litellm.types.router import ModelInfo
db_model = Deployment(
model_name="model_name_team-a_abc123",
litellm_params=LiteLLM_Params(model="azure/gpt-4.1"),
model_info=ModelInfo(
team_id="team-a", team_public_model_name="old-public-name"
),
)
patch_data = updateDeployment(
model_name="new-public-name",
model_info=ModelInfo(
team_id="team-a",
team_public_model_name="old-public-name", # stale, untouched by UI
),
)
assert (
_get_public_model_name(patch_data=patch_data, db_model=db_model)
== "new-public-name"
)
def test_get_public_model_name_falls_back_to_db_public_name(self):
"""When patch_data carries no name hints at all (neither model_name
nor model_info.team_public_model_name), fall back to the existing
db_model.model_info.team_public_model_name."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_get_public_model_name,
)
from litellm.types.router import ModelInfo
db_model = Deployment(
model_name="model_name_test-team_abc123",
litellm_params=LiteLLM_Params(model="azure/gpt-5.2-low-rpm-testing"),
model_info=ModelInfo(
team_id="test-team",
team_public_model_name="gpt-5.2-low-rpm-testing",
),
)
patch_data = updateDeployment(
model_info=ModelInfo(team_id="test-team"),
)
assert (
_get_public_model_name(patch_data=patch_data, db_model=db_model)
== "gpt-5.2-low-rpm-testing"
)
def test_get_public_model_name_last_resort_returns_db_model_name(self):
"""Legacy rows may have no team_public_model_name anywhere; the
function must still return a string (the existing db_model.model_name)
rather than raising."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_get_public_model_name,
)
from litellm.types.router import ModelInfo
db_model = Deployment(
model_name="legacy-model",
litellm_params=LiteLLM_Params(model="azure/legacy"),
model_info=ModelInfo(team_id="test-team"),
)
patch_data = updateDeployment(
model_info=ModelInfo(team_id="test-team"),
)
assert (
_get_public_model_name(patch_data=patch_data, db_model=db_model)
== "legacy-model"
)
def test_get_public_model_name_ignores_different_internal_shape_name(self):
"""A stale client may PATCH an internal-shaped model_name that does not
equal the current DB column (e.g. a different uuid). It must NOT be
treated as a rename -- fall through to the existing public name."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_get_public_model_name,
)
from litellm.types.router import ModelInfo
db_model = Deployment(
model_name="model_name_test-team_realuuid",
litellm_params=LiteLLM_Params(model="azure/gpt-5.2-low-rpm-testing"),
model_info=ModelInfo(
team_id="test-team",
team_public_model_name="gpt-5.2-low-rpm-testing",
),
)
patch_data = updateDeployment(
model_name="model_name_test-team_differentuuid",
model_info=ModelInfo(team_id="test-team"),
)
assert (
_get_public_model_name(patch_data=patch_data, db_model=db_model)
== "gpt-5.2-low-rpm-testing"
)
def test_get_public_model_name_ignores_internal_shape_patch_public(self):
"""If a corrupted row round-trips an internal-shaped value in
model_info.team_public_model_name, it must not be accepted as the
public name -- fall through to the existing db public name."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_get_public_model_name,
)
from litellm.types.router import ModelInfo
db_model = Deployment(
model_name="model_name_test-team_realuuid",
litellm_params=LiteLLM_Params(model="azure/gpt-5.2-low-rpm-testing"),
model_info=ModelInfo(
team_id="test-team",
team_public_model_name="gpt-5.2-low-rpm-testing",
),
)
patch_data = updateDeployment(
model_info=ModelInfo(
team_id="test-team",
team_public_model_name="model_name_test-team_realuuid",
),
)
assert (
_get_public_model_name(patch_data=patch_data, db_model=db_model)
== "gpt-5.2-low-rpm-testing"
)
@pytest.mark.asyncio
async def test_dashboard_edit_preserves_public_name_and_acl(self):
"""End-to-end regression for #28382: PATCH payload shaped like the
dashboard's model-edit form (top-level model_name = internal generated
name, model_info.team_public_model_name = public name) must NOT trigger
a public-name rename, must NOT touch the team ACL, and must serialize
the public name back into model_info."""
from litellm.proxy.management_endpoints.model_management_endpoints import (
_update_team_model_in_db,
)
from litellm.types.router import ModelInfo
db_model = Deployment(
model_name="model_name_test-team_abc123",
litellm_params=LiteLLM_Params(
model="azure/gpt-5.2-low-rpm-testing",
custom_llm_provider="azure",
),
model_info=ModelInfo(
id="model-id-123",
team_id="test-team",
team_public_model_name="gpt-5.2-low-rpm-testing",
),
)
patch_data = updateDeployment(
model_name="model_name_test-team_abc123",
litellm_params=None,
model_info=ModelInfo(
id="model-id-123",
team_id="test-team",
team_public_model_name="gpt-5.2-low-rpm-testing",
),
)
user_api_key_dict = UserAPIKeyAuth(
user_id="test_user",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
prisma_client = MockPrismaClient(team_exists=True)
with (
patch(
"litellm.proxy.proxy_server.premium_user",
True,
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.team_model_add"
) as mock_team_model_add,
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.team_model_delete"
) as mock_team_model_delete,
):
result = await _update_team_model_in_db(
db_model=db_model,
patch_data=patch_data,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client, # type: ignore
)
# team ACL must not be touched on a no-op edit
mock_team_model_add.assert_not_called()
mock_team_model_delete.assert_not_called()
# the merged model_info written to the DB must keep the public name
model_info_json = result.get("model_info", "")
parsed_model_info = json.loads(model_info_json)
assert (
parsed_model_info.get("team_public_model_name") == "gpt-5.2-low-rpm-testing"
)
# the internal model_name must not have been overwritten (caller
# intentionally clears patch_data.model_name so the DB row's name
# column is left alone)
assert result.get("model_name") == "model_name_test-team_abc123"
class TestModelInfoEndpoint:
"""Test the model_info endpoint for retrieving individual model information"""

View file

@ -321,6 +321,270 @@ class TestAzureAnthropicCostCalculation:
assert call_kwargs["model"] == "azure_ai/claude-sonnet-4-5_gb_20250929"
assert call_kwargs["custom_llm_provider"] == "azure_ai"
@patch("litellm.completion_cost")
def test_cost_calculation_resolves_unknown_model_from_litellm_params(
self, mock_completion_cost
):
"""When the body model is the "unknown" sentinel, the deployment model
from litellm_params must be used for costing, not "unknown" (which makes
completion_cost raise and the cost silently fall back to $0)."""
from datetime import datetime
from litellm.types.utils import ModelResponse
mock_completion_cost.return_value = 0.001
logging_obj = self._create_mock_logging_obj(model="unknown")
logging_obj.model_call_details["litellm_params"] = {
"model": "anthropic/claude-3-5-haiku-20241022",
"metadata": {
"model_group": "passthrough/anthropic/claude-3-5-haiku-20241022"
},
}
logging_obj.litellm_params = logging_obj.model_call_details["litellm_params"]
mock_response = MagicMock(spec=ModelResponse)
mock_response.id = "test-id"
mock_response.model = "unknown"
kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
litellm_model_response=mock_response,
model="unknown",
kwargs={},
start_time=datetime.now(),
end_time=datetime.now(),
logging_obj=logging_obj,
)
mock_completion_cost.assert_called_once()
assert (
mock_completion_cost.call_args[1]["model"]
== "anthropic/claude-3-5-haiku-20241022"
)
assert kwargs["response_cost"] == 0.001
assert kwargs["model"] == "anthropic/claude-3-5-haiku-20241022"
@patch("litellm.completion_cost")
def test_cost_calculation_resolves_unknown_model_from_model_group(
self, mock_completion_cost
):
"""With only model_group available (no deployment litellm_params.model),
the leading passthrough/ prefix must be stripped so the cost map can
resolve the model."""
from datetime import datetime
from litellm.types.utils import ModelResponse
mock_completion_cost.return_value = 0.002
logging_obj = self._create_mock_logging_obj(model="unknown")
logging_obj.model_call_details["litellm_params"] = {
"metadata": {
"model_group": "passthrough/anthropic/claude-3-5-haiku-20241022"
}
}
logging_obj.litellm_params = logging_obj.model_call_details["litellm_params"]
mock_response = MagicMock(spec=ModelResponse)
mock_response.id = "test-id"
mock_response.model = "unknown"
kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
litellm_model_response=mock_response,
model="unknown",
kwargs={},
start_time=datetime.now(),
end_time=datetime.now(),
logging_obj=logging_obj,
)
mock_completion_cost.assert_called_once()
assert (
mock_completion_cost.call_args[1]["model"]
== "anthropic/claude-3-5-haiku-20241022"
)
assert kwargs["response_cost"] == 0.002
@patch("litellm.completion_cost")
def test_cost_calculation_skips_unknown_litellm_params_model_for_model_group(
self, mock_completion_cost
):
"""When litellm_params.model is itself the "unknown" sentinel, the
deployment-model branch must not short-circuit; resolution falls through
to model_group so costing still prices the real model instead of "unknown"."""
from datetime import datetime
from litellm.types.utils import ModelResponse
mock_completion_cost.return_value = 0.003
logging_obj = self._create_mock_logging_obj(model="unknown")
logging_obj.model_call_details["litellm_params"] = {
"model": "unknown",
"metadata": {
"model_group": "passthrough/anthropic/claude-3-5-haiku-20241022"
},
}
logging_obj.litellm_params = logging_obj.model_call_details["litellm_params"]
mock_response = MagicMock(spec=ModelResponse)
mock_response.id = "test-id"
mock_response.model = "unknown"
kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
litellm_model_response=mock_response,
model="unknown",
kwargs={},
start_time=datetime.now(),
end_time=datetime.now(),
logging_obj=logging_obj,
)
mock_completion_cost.assert_called_once()
assert (
mock_completion_cost.call_args[1]["model"]
== "anthropic/claude-3-5-haiku-20241022"
)
assert kwargs["response_cost"] == 0.003
assert kwargs["model"] == "anthropic/claude-3-5-haiku-20241022"
@patch("litellm.completion_cost")
def test_streaming_cost_calculation_resolves_model_from_message_start_chunk(
self, mock_completion_cost
):
"""On the bare /anthropic passthrough path litellm_params carries no model
or model_group and the body model is the "unknown" sentinel; the model
must be recovered from the message_start SSE event so completion_cost
prices the real model instead of failing on "unknown" and logging $0."""
from datetime import datetime
from litellm.litellm_core_utils.litellm_logging import (
Logging as RealLoggingObj,
)
from litellm.proxy.pass_through_endpoints.streaming_handler import (
PassThroughStreamingHandler,
)
mock_completion_cost.return_value = 0.001
def _sse(event, data):
return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
frames = [
_sse(
"message_start",
{
"type": "message_start",
"message": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-3-5-haiku-20241022",
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 0},
},
},
),
_sse(
"content_block_start",
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
},
),
_sse(
"content_block_delta",
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "hi"},
},
),
_sse("content_block_stop", {"type": "content_block_stop", "index": 0}),
_sse(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 1},
},
),
_sse("message_stop", {"type": "message_stop"}),
]
all_chunks = list(
PassThroughStreamingHandler._convert_raw_bytes_to_str_lines(frames)
)
logging_obj = RealLoggingObj(
model="unknown",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="pass_through_endpoint",
start_time=datetime.now(),
litellm_call_id="test-call-id",
function_id="1",
)
logging_obj.model_call_details["model"] = "unknown"
logging_obj.model_call_details["stream"] = True
logging_obj.model_call_details["litellm_params"] = {}
logging_obj.litellm_params = {}
result = AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks(
litellm_logging_obj=logging_obj,
passthrough_success_handler_obj=MagicMock(),
url_route="/anthropic/v1/messages",
request_body={"stream": True},
endpoint_type="messages",
start_time=datetime.now(),
all_chunks=all_chunks,
end_time=datetime.now(),
)
assert result["result"] is not None
mock_completion_cost.assert_called_once()
assert mock_completion_cost.call_args[1]["model"] == "claude-3-5-haiku-20241022"
assert result["kwargs"]["response_cost"] == 0.001
assert result["kwargs"]["model"] == "claude-3-5-haiku-20241022"
def test_extract_model_skips_non_dict_data_payload(self):
"""A scalar data: payload (e.g. `data: null`) must be skipped, not crash
the streaming log handler with AttributeError, which would propagate out
and break spend logging for the whole request."""
chunks = [
"event: ping\ndata: null\n\n",
'event: message_start\ndata: {"type": "message_start", "message": '
'{"model": "claude-3-5-haiku-20241022"}}\n\n',
]
assert (
AnthropicPassthroughLoggingHandler._extract_model_from_anthropic_chunks(
chunks
)
== "claude-3-5-haiku-20241022"
)
def test_extract_model_parses_per_line_not_first_data_substring(self):
"""A raw multi-line SSE event whose non-data line contains the substring
"data:" must not derail parsing: matching only lines that start with
"data:" recovers the message_start model, whereas a first-substring slice
would consume the wrong offset, fail to parse JSON, and return None."""
raw_event = (
"event: ping data: not-json\n"
'data: {"type": "message_start", "message": '
'{"model": "claude-3-5-haiku-20241022"}}\n\n'
)
assert (
AnthropicPassthroughLoggingHandler._extract_model_from_anthropic_chunks(
[raw_event]
)
== "claude-3-5-haiku-20241022"
)
class TestAnthropicBatchPassthroughCostTracking:
"""Test cases for Anthropic batch passthrough cost tracking functionality"""
@ -1045,6 +1309,72 @@ class TestPureTextFastPathParity:
)
class TestBuildCompleteStreamingResponseRobustness:
"""_build_complete_streaming_response must tolerate non-standard SSE frames."""
def _build(self, chunks: List[str]):
return AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
all_chunks=chunks,
litellm_logging_obj=MagicMock(),
model="claude-3-sonnet-20240229",
)
def test_done_frame_is_skipped(self):
"""A bare 'data: [DONE]' control frame must not break reconstruction."""
chunks = [
'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude-3-sonnet-20240229","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}}',
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}',
'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}',
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}',
'event: message_stop\ndata: {"type":"message_stop"}',
"data: [DONE]",
]
result = self._build(chunks)
assert result is not None
assert result.choices[0].message.content == "Hi"
def test_non_json_sse_line_is_skipped(self):
"""Non-JSON SSE lines (comments, keep-alive pings) must be skipped."""
chunks = [
": ping",
'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude-3-sonnet-20240229","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}}',
"this is not json at all",
]
# Must not raise; a malformed stream simply yields no usable response.
result = self._build(chunks)
assert result is None or hasattr(result, "choices")
def test_mixed_valid_and_invalid_frames(self):
"""Valid events are still collected when interleaved with invalid ones."""
chunks = [
'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude-3-sonnet-20240229","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}}',
"data: [DONE]",
": keep-alive",
"not-json",
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}',
'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}',
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}',
'event: message_stop\ndata: {"type":"message_stop"}',
]
result = self._build(chunks)
assert result is not None
assert result.choices[0].message.content == "Hello"
def test_done_in_text_payload_is_not_dropped(self):
"""A valid event whose text content contains '[DONE]' must NOT be skipped."""
chunks = [
'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude-3-sonnet-20240229","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}}',
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The stream ends with [DONE]"}}',
'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}',
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":8}}',
'event: message_stop\ndata: {"type":"message_stop"}',
]
result = self._build(chunks)
assert result is not None
assert result.choices[0].message.content == "The stream ends with [DONE]"
class TestStreamFalseDeduplication:
"""
Regression tests for the duplicate-callback bug where a streaming pass-through

View file

@ -0,0 +1,595 @@
"""Coverage for team-scoped model-name translation in /model/info responses.
These live in tests/test_litellm/proxy/proxy_server/ (not the top-level
test_proxy_server.py) because the CI coverage job collects this directory.
They exercise the read-path fix for issue #28382: `/v1`, `/v2`, and
`/model/info` must surface `model_info.team_public_model_name` for team-scoped
rows instead of the internal routing key `model_name_{team_id}_{uuid}`.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.proxy_server import (
_get_proxy_model_info,
_translate_model_name_for_response,
)
def _team_row() -> dict:
return {
"model_name": "model_name_team-abc-123_4a6b8",
"litellm_params": {"model": "azure/gpt-5.2-low-rpm-testing"},
"model_info": {
"id": "byok-id-1",
"team_id": "team-abc-123",
"team_public_model_name": "team-claude-sonnet",
"db_model": True,
},
}
def test_translate_swaps_internal_name_for_public():
"""Team-scoped row: model_name is swapped to the public name."""
result = _translate_model_name_for_response(_team_row())
assert result["model_name"] == "team-claude-sonnet"
def test_translate_leaves_global_row_untouched():
"""No team_id / team_public_model_name -> pass through unchanged."""
model = {
"model_name": "gpt-4o",
"litellm_params": {"model": "gpt-4o"},
"model_info": {"id": "normal-id-1", "db_model": False},
}
assert _translate_model_name_for_response(model)["model_name"] == "gpt-4o"
def test_translate_leaves_non_internal_shape_untouched():
"""Team row whose model_name is not the internal routing key is not rewritten."""
model = _team_row()
model["model_name"] = "already-public-name"
assert (
_translate_model_name_for_response(model)["model_name"] == "already-public-name"
)
def test_translate_handles_missing_or_non_dict_model_info():
"""Missing / None / non-dict model_info, and a non-dict model, must not raise."""
# missing model_info
assert _translate_model_name_for_response({"model_name": "x"})["model_name"] == "x"
# model_info is None -> coerced to {} -> no team fields
assert (
_translate_model_name_for_response({"model_name": "x", "model_info": None})[
"model_name"
]
== "x"
)
# model_info is a truthy non-dict (e.g. a stray string) -> early return
assert (
_translate_model_name_for_response(
{"model_name": "x", "model_info": "garbage"}
)["model_name"]
== "x"
)
# model itself is not a dict
assert _translate_model_name_for_response("not-a-dict") == "not-a-dict" # type: ignore[arg-type]
def test_translate_does_not_mutate_input():
"""Returns a shallow copy; the router's in-memory list keeps the routing key."""
model = _team_row()
result = _translate_model_name_for_response(model)
assert result is not model
assert model["model_name"] == "model_name_team-abc-123_4a6b8"
def test_get_proxy_model_info_returns_public_name_for_team_row():
"""`_get_proxy_model_info` must return the public name for a team-scoped
row. Because _translate_model_name_for_response returns a shallow copy
(it does not mutate), callers MUST use the return value -- the
`/v1/model/info` list path historically discarded it, leaking the internal
routing key (#28382)."""
# Mirror the (fixed) /v1/model/info list path: assign the return back.
all_models = [_get_proxy_model_info(model=m) for m in [_team_row()]]
assert all_models[0]["model_name"] == "team-claude-sonnet"
@pytest.mark.asyncio
async def test_model_info_v2_translates_team_model_name(monkeypatch):
"""/v2/model/info must surface the public name for team-scoped rows.
Covers the translation step in model_info_v2 (the read-path call site)."""
router = MagicMock()
router.model_list = [_team_row()]
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "prisma_client", MagicMock())
monkeypatch.setattr(ps.proxy_config, "get_config", AsyncMock(return_value={}))
monkeypatch.setattr(
ps,
"_apply_search_filter_to_models",
AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))),
)
monkeypatch.setattr(
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
)
import litellm.proxy.agent_endpoints.model_list_helpers as mlh
monkeypatch.setattr(
mlh,
"append_agents_to_model_info",
AsyncMock(side_effect=lambda models, **kw: models),
)
admin = UserAPIKeyAuth(user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN)
# Pass every query param explicitly: called directly (not through FastAPI),
# the fastapi.Query(...) defaults are Query objects, not their values.
resp = await ps.model_info_v2(
user_api_key_dict=admin,
model=None,
user_models_only=False,
include_team_models=False,
debug=False,
page=1,
size=50,
search=None,
modelId=None,
teamId=None,
sortBy=None,
sortOrder="asc",
)
names = [m["model_name"] for m in resp["data"]]
assert "team-claude-sonnet" in names
assert "model_name_team-abc-123_4a6b8" not in names
@pytest.mark.asyncio
async def test_model_info_v1_list_path_translates_team_model_name(monkeypatch):
"""/v1/model/info list path (no litellm_model_id) must include team-scoped
deployments from the router model list and surface the public name (#28382)."""
team_row = _team_row()
global_row = {
"model_name": "gpt-4o",
"litellm_params": {"model": "gpt-4o"},
"model_info": {"id": "normal-id-1", "db_model": False},
}
router = MagicMock()
router.model_list = [team_row, global_row]
router.get_model_names.return_value = ["gpt-4o"]
router.get_model_access_groups.return_value = {}
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
)
admin = UserAPIKeyAuth(
user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN, team_models=[]
)
resp = await ps.model_info_v1(user_api_key_dict=admin, litellm_model_id=None)
names = [m["model_name"] for m in resp["data"]]
assert "team-claude-sonnet" in names
assert "model_name_team-abc-123_4a6b8" not in names
@pytest.mark.asyncio
async def test_model_info_v1_unrestricted_key_returns_all_deployments(monkeypatch):
"""Unrestricted keys must see all router deployments (legacy v1 access logic)."""
deployment = {
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4"},
"model_info": {"id": "global-id-1", "db_model": False},
}
router = MagicMock()
router.model_list = [deployment]
router.get_model_names.return_value = ["gpt-4"]
router.get_model_access_groups.return_value = {}
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
)
caller = UserAPIKeyAuth(
user_id="user-1",
user_role=LitellmUserRoles.INTERNAL_USER,
models=[],
team_models=[],
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
assert [m["model_name"] for m in resp["data"]] == ["gpt-4"]
@pytest.mark.asyncio
async def test_model_info_v1_restricted_key_filters_deployments(monkeypatch):
"""Key-level model allowlists must filter router deployments."""
team_row = _team_row()
global_row = {
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4"},
"model_info": {"id": "global-id-1", "db_model": False},
}
router = MagicMock()
router.model_list = [team_row, global_row]
router.get_model_names.return_value = ["gpt-4", "team-claude-sonnet"]
router.get_model_access_groups.return_value = {}
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
)
caller = UserAPIKeyAuth(
user_id="user-1",
user_role=LitellmUserRoles.INTERNAL_USER,
models=["gpt-4"],
team_models=[],
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
assert [m["model_name"] for m in resp["data"]] == ["gpt-4"]
def _other_team_row() -> dict:
return {
"model_name": "model_name_team-other_9f2c1",
"litellm_params": {
"model": "azure/gpt-5.2-low-rpm-testing",
"api_base": "https://team-other-private.example.com",
},
"model_info": {
"id": "byok-id-other",
"team_id": "team-other",
"team_public_model_name": "team-claude-sonnet",
"db_model": True,
},
}
@pytest.mark.asyncio
async def test_model_info_v1_unrestricted_key_hides_other_team_byok(monkeypatch):
"""Unrestricted non-admin keys must not enumerate other teams' BYOK
deployments, but must still see global models and their own team's."""
team_row = _team_row()
other_team_row = _other_team_row()
global_row = {
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4"},
"model_info": {"id": "global-id-1", "db_model": False},
}
router = MagicMock()
router.model_list = [team_row, other_team_row, global_row]
router.get_model_names.return_value = ["gpt-4"]
router.get_model_access_groups.return_value = {}
prisma_client = MagicMock()
caller_user_row = MagicMock()
caller_user_row.teams = ["team-abc-123"]
caller_user_row.model_dump.return_value = {
"user_id": "user-1",
"teams": ["team-abc-123"],
"models": [],
}
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=caller_user_row
)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "prisma_client", prisma_client)
monkeypatch.setattr(ps, "get_all_team_models", AsyncMock(return_value={}))
monkeypatch.setattr(
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
)
caller = UserAPIKeyAuth(
user_id="user-1",
user_role=LitellmUserRoles.INTERNAL_USER,
models=[],
team_models=[],
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
returned_ids = {m["model_info"]["id"] for m in resp["data"]}
assert returned_ids == {"global-id-1", "byok-id-1"}
assert "byok-id-other" not in returned_ids
names = [m["model_name"] for m in resp["data"]]
assert "team-claude-sonnet" in names
assert "gpt-4" in names
@pytest.mark.asyncio
async def test_model_info_v1_service_key_hides_all_team_byok(monkeypatch):
"""A key without a resolvable user (e.g. CI/service token) sees only
global deployments, never any team-scoped BYOK rows."""
team_row = _team_row()
other_team_row = _other_team_row()
global_row = {
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4"},
"model_info": {"id": "global-id-1", "db_model": False},
}
router = MagicMock()
router.model_list = [team_row, other_team_row, global_row]
router.get_model_names.return_value = ["gpt-4"]
router.get_model_access_groups.return_value = {}
prisma_client = MagicMock()
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "prisma_client", prisma_client)
monkeypatch.setattr(
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
)
caller = UserAPIKeyAuth(
user_id=None,
user_role=LitellmUserRoles.INTERNAL_USER,
team_id="team-abc-123",
models=[],
team_models=[],
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
assert [m["model_info"]["id"] for m in resp["data"]] == ["global-id-1"]
@pytest.mark.asyncio
async def test_model_info_v1_populates_access_via_team_ids(monkeypatch):
"""`/v1/model/info` must populate access_via_team_ids when the DB is connected."""
team_id = "team-abc-123"
team_row = _team_row()
global_row = {
"model_name": "gpt-4o",
"litellm_params": {"model": "gpt-4o"},
"model_info": {"id": "global-id-1", "db_model": False},
}
router = MagicMock()
router.model_list = [team_row, global_row]
router.get_model_names.return_value = ["gpt-4o", "team-claude-sonnet"]
router.get_model_access_groups.return_value = {}
router.get_model_ids.return_value = ["global-id-1"]
prisma_client = MagicMock()
async def _fake_populate(**kwargs):
for model in kwargs["all_models"]:
model_id = model["model_info"]["id"]
if model_id == "byok-id-1":
model["model_info"]["access_via_team_ids"] = [team_id]
model["model_info"]["direct_access"] = False
elif model_id == "global-id-1":
model["model_info"]["direct_access"] = True
return kwargs["all_models"]
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "prisma_client", prisma_client)
monkeypatch.setattr(ps, "_populate_team_access_on_models", _fake_populate)
monkeypatch.setattr(
ps, "_enrich_model_info_with_litellm_data", lambda model, **kw: model
)
admin = UserAPIKeyAuth(
user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN, team_models=[]
)
resp = await ps.model_info_v1(user_api_key_dict=admin, litellm_model_id=None)
by_id = {m["model_info"]["id"]: m for m in resp["data"]}
assert by_id["byok-id-1"]["model_info"]["access_via_team_ids"] == [team_id]
assert by_id["byok-id-1"]["model_info"]["direct_access"] is False
assert by_id["global-id-1"]["model_info"]["direct_access"] is True
@pytest.mark.asyncio
async def test_populate_team_access_sets_direct_access_false_by_default(monkeypatch):
"""Team-accessible models without direct access must return direct_access=false."""
team_row = _team_row()
global_row = {
"model_name": "gpt-4o",
"litellm_params": {"model": "gpt-4o"},
"model_info": {"id": "global-id-1", "db_model": False},
}
router = MagicMock()
router.get_model_ids.return_value = ["global-id-1"]
monkeypatch.setattr(
ps,
"get_all_team_models",
AsyncMock(return_value={"byok-id-1": ["team-abc-123"]}),
)
admin = UserAPIKeyAuth(
user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN, team_models=[]
)
result = await ps._populate_team_access_on_models(
user_api_key_dict=admin,
prisma_client=MagicMock(),
llm_router=router,
all_models=[team_row, global_row],
)
by_id = {m["model_info"]["id"]: m for m in result}
assert by_id["byok-id-1"]["model_info"]["direct_access"] is False
assert by_id["global-id-1"]["model_info"]["direct_access"] is True
@pytest.mark.asyncio
async def test_model_info_v1_team_id_without_db_fails_fast(monkeypatch):
"""`teamId` without a connected DB raises 500 before any enrichment work runs."""
router = MagicMock()
router.model_list = [_team_row()]
enrich_spy = MagicMock(side_effect=lambda model, **kw: model)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "prisma_client", None)
monkeypatch.setattr(ps, "_enrich_model_info_with_litellm_data", enrich_spy)
admin = UserAPIKeyAuth(
user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN, team_models=[]
)
with pytest.raises(ps.HTTPException) as exc_info:
await ps.model_info_v1(
user_api_key_dict=admin, litellm_model_id=None, teamId="team-abc-123"
)
assert exc_info.value.status_code == 500
assert "DB not connected" in exc_info.value.detail["error"]
enrich_spy.assert_not_called()
@pytest.mark.asyncio
async def test_model_info_v1_include_team_models_without_db_fails_fast(monkeypatch):
"""`include_team_models` without a connected DB raises 500 instead of silently
returning an empty list (the access fields can only be populated from the DB)."""
router = MagicMock()
router.model_list = [_team_row()]
enrich_spy = MagicMock(side_effect=lambda model, **kw: model)
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "prisma_client", None)
monkeypatch.setattr(ps, "_enrich_model_info_with_litellm_data", enrich_spy)
admin = UserAPIKeyAuth(
user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN, team_models=[]
)
with pytest.raises(ps.HTTPException) as exc_info:
await ps.model_info_v1(
user_api_key_dict=admin, litellm_model_id=None, include_team_models=True
)
assert exc_info.value.status_code == 500
assert "DB not connected" in exc_info.value.detail["error"]
enrich_spy.assert_not_called()
@pytest.mark.asyncio
async def test_model_info_v1_litellm_model_id_team_id_without_db_fails_fast(
monkeypatch,
):
"""`litellm_model_id` + `teamId` without a connected DB must raise 500 too, not
return 200 with a model dict missing direct_access/access_via_team_ids."""
router = MagicMock()
router.model_list = [_team_row()]
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", router.model_list)
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "prisma_client", None)
admin = UserAPIKeyAuth(
user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN, team_models=[]
)
with pytest.raises(ps.HTTPException) as exc_info:
await ps.model_info_v1(
user_api_key_dict=admin,
litellm_model_id="byok-id-1",
teamId="team-abc-123",
)
assert exc_info.value.status_code == 500
assert "DB not connected" in exc_info.value.detail["error"]
router.get_deployment.assert_not_called()
@pytest.mark.asyncio
async def test_model_info_v1_litellm_model_id_include_team_models_filters_inaccessible(
monkeypatch,
):
"""`litellm_model_id` + `include_team_models` must drop a model the caller cannot
use instead of returning it unconditionally from the single-model lookup."""
team_row = _team_row()
router = MagicMock()
deployment = MagicMock()
deployment.model_dump.return_value = team_row
router.get_deployment.return_value = deployment
async def _fake_populate(**kwargs):
for model in kwargs["all_models"]:
model["model_info"]["direct_access"] = False
model["model_info"]["access_via_team_ids"] = []
return kwargs["all_models"]
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", [team_row])
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "prisma_client", MagicMock())
monkeypatch.setattr(ps, "_get_proxy_model_info", lambda model: team_row)
monkeypatch.setattr(ps, "_populate_team_access_on_models", _fake_populate)
caller = UserAPIKeyAuth(
user_id="u", user_role=LitellmUserRoles.INTERNAL_USER, team_models=[]
)
resp = await ps.model_info_v1(
user_api_key_dict=caller,
litellm_model_id="byok-id-1",
include_team_models=True,
)
assert resp["data"] == []
@pytest.mark.asyncio
async def test_model_info_v1_litellm_model_id_team_id_applies_team_filter(monkeypatch):
"""`litellm_model_id` + `teamId` must run the teamId filter on the single model
rather than returning it regardless of the team's access."""
team_row = _team_row()
router = MagicMock()
deployment = MagicMock()
deployment.model_dump.return_value = team_row
router.get_deployment.return_value = deployment
async def _fake_populate(**kwargs):
return kwargs["all_models"]
team_filter = AsyncMock(return_value=[])
monkeypatch.setattr(ps, "user_model", None)
monkeypatch.setattr(ps, "llm_model_list", [team_row])
monkeypatch.setattr(ps, "llm_router", router)
monkeypatch.setattr(ps, "prisma_client", MagicMock())
monkeypatch.setattr(ps, "_get_proxy_model_info", lambda model: team_row)
monkeypatch.setattr(ps, "_populate_team_access_on_models", _fake_populate)
monkeypatch.setattr(ps, "_filter_models_by_team_id", team_filter)
admin = UserAPIKeyAuth(
user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN, team_models=[]
)
resp = await ps.model_info_v1(
user_api_key_dict=admin,
litellm_model_id="byok-id-1",
teamId="other-team",
)
assert resp["data"] == []
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]

View file

@ -146,9 +146,9 @@ class TestModelInfoEndpointWithRouter:
deployment_dict = deployment.model_dump(exclude_none=True)
mock_router = MagicMock()
mock_router.model_list = [deployment_dict]
mock_router.get_model_names.return_value = ["model1"]
mock_router.get_model_access_groups.return_value = {}
mock_router.get_model_list.return_value = [deployment_dict]
user_api_key_dict = UserAPIKeyAuth(api_key="sk-test")
@ -156,6 +156,7 @@ class TestModelInfoEndpointWithRouter:
patch("litellm.proxy.proxy_server.llm_router", mock_router),
patch("litellm.proxy.proxy_server.llm_model_list", [deployment_dict]),
patch("litellm.proxy.proxy_server.user_model", None),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch("litellm.proxy.proxy_server.get_key_models", return_value=["model1"]),
patch(
"litellm.proxy.proxy_server.get_team_models", return_value=["model1"]

View file

@ -707,6 +707,127 @@ class TestProxyInitializationHelpers:
assert appended_params["pgbouncer"] == "true"
assert appended_params["statement_cache_size"] == 0
def test_build_db_connection_url_params_disable_prepared_statements(self):
from litellm.proxy.proxy_cli import _build_db_connection_url_params
params = _build_db_connection_url_params(
connection_limit=10,
pool_timeout=60,
disable_prepared_statements=True,
)
assert params["pgbouncer"] == "true"
def test_build_db_connection_url_params_no_pgbouncer_by_default(self):
from litellm.proxy.proxy_cli import _build_db_connection_url_params
params = _build_db_connection_url_params(
connection_limit=10,
pool_timeout=60,
)
assert "pgbouncer" not in params
def test_build_db_connection_url_params_extra_pgbouncer_overrides_flag(self):
from litellm.proxy.proxy_cli import _build_db_connection_url_params
params = _build_db_connection_url_params(
connection_limit=10,
pool_timeout=60,
disable_prepared_statements=True,
extra_params={"pgbouncer": "false"},
)
assert params["pgbouncer"] == "false"
@pytest.mark.parametrize(
"config_value, expect_pgbouncer",
[
(True, True),
(False, False),
("true", True),
("false", False),
("not-a-bool", False),
],
)
@patch("subprocess.run")
@patch("atexit.register")
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database")
@patch(
"litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False
)
def test_disable_prepared_statements_forwarded_to_url(
self,
mock_should_update,
mock_setup_db,
mock_atexit_register,
mock_subprocess_run,
config_value,
expect_pgbouncer,
):
from click.testing import CliRunner
from litellm.proxy.proxy_cli import run_server
runner = CliRunner()
mock_subprocess_run.return_value = MagicMock(returncode=0)
mock_proxy_module = MagicMock(
app=MagicMock(),
ProxyConfig=MagicMock(),
KeyManagementSettings=MagicMock(),
save_worker_config=MagicMock(),
)
mock_proxy_module.ProxyConfig.return_value.get_config = AsyncMock(
return_value={
"general_settings": {
"database_url": "postgresql://test:test@localhost:5432/test",
"database_disable_prepared_statements": config_value,
}
}
)
clean_env = {
k: v
for k, v in os.environ.items()
if k not in ("DATABASE_URL", "DIRECT_URL")
}
with (
patch.dict(os.environ, clean_env, clear=True),
patch.dict(
"sys.modules",
{
"proxy_server": mock_proxy_module,
"litellm.proxy.proxy_server": mock_proxy_module,
},
),
patch(
"litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args"
) as mock_get_args,
patch(
"litellm.proxy.proxy_cli.append_query_params",
side_effect=lambda url, params: str(url),
) as mock_append_query_params,
):
mock_get_args.return_value = {
"app": "litellm.proxy.proxy_server:app",
"host": "localhost",
"port": 8000,
}
result = runner.invoke(
run_server,
["--local", "--config", "test-config.yaml", "--skip_server_startup"],
)
assert (
result.exit_code == 0
), f"exit_code={result.exit_code}, output={result.output}"
mock_append_query_params.assert_called()
appended_params = mock_append_query_params.call_args.args[1]
if expect_pgbouncer:
assert appended_params["pgbouncer"] == "true"
else:
assert "pgbouncer" not in appended_params
@patch("uvicorn.run")
@patch("atexit.register")
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database")

View file

@ -3810,14 +3810,15 @@ async def test_model_info_v1_oci_secrets_not_leaked():
# Mock the llm_router to return our test data
mock_router = MagicMock()
mock_router.model_list = [mock_model_data]
mock_router.get_model_names.return_value = ["oci-grok-test"]
mock_router.get_model_access_groups.return_value = {}
mock_router.get_model_list.return_value = [mock_model_data]
# Mock global variables
with (
patch("litellm.proxy.proxy_server.llm_router", mock_router),
patch("litellm.proxy.proxy_server.llm_model_list", [mock_model_data]),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch(
"litellm.proxy.proxy_server.general_settings",
{"infer_model_from_keys": False},

View file

@ -0,0 +1,387 @@
"""Shared fixtures for tests/test_litellm/proxy/utils/prisma_and_spend/.
All fixtures used by PR2 test files live here. Do NOT add fixtures inside
individual test files; if a fixture is missing, add it here and update the
Notion plan.
The PrismaClient is exercised against a fully-mocked Prisma stack: the
``prisma.Prisma`` constructor and the writer/reader wrappers are patched
before PrismaClient.__init__ runs so the init code paths execute without
needing a generated Prisma client or a real database.
"""
from __future__ import annotations
import asyncio
import sys
from dataclasses import dataclass, field
from email.message import EmailMessage
from pathlib import Path
from typing import Any, Callable, Dict, Iterator, List, Optional
from unittest.mock import AsyncMock, MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[5]))
VOLATILE_KEYS = frozenset(
{
"created_at",
"updated_at",
"checked_at",
"started_at",
"request_id",
"id",
"token",
"expires",
"expires_at",
"litellm_call_id",
"created",
"spend",
"last_refreshed_at",
"startTime",
"endTime",
"salt",
}
)
def normalize(data: Any, volatile: frozenset = VOLATILE_KEYS) -> Any:
"""Recursively replace values for volatile keys with '<VOLATILE>'."""
if isinstance(data, dict):
return {
k: ("<VOLATILE>" if k in volatile else normalize(v, volatile))
for k, v in data.items()
}
if isinstance(data, list):
return [normalize(v, volatile) for v in data]
return data
_PRISMA_TABLES: List[str] = [
"litellm_verificationtoken",
"litellm_teamtable",
"litellm_usertable",
"litellm_endusertable",
"litellm_organizationtable",
"litellm_proxymodeltable",
"litellm_modeltable",
"litellm_budgettable",
"litellm_spendlogs",
"litellm_config",
"litellm_usernotifications",
"litellm_healthchecktable",
"litellm_dailyuserspend",
"litellm_dailyteamspend",
"litellm_dailytagspend",
"litellm_managed_object_table",
"litellm_credentialstable",
"litellm_mcpservertable",
"litellm_audit_log",
"litellm_invitationlink",
"litellm_session_token_table",
"litellm_passthrough_endpoint_table",
"litellm_cron_job",
"litellm_passthrough_logs",
"litellm_promptstable",
"litellm_guardrailstable",
"litellm_managed_files",
"litellm_mcpusercredentials",
"litellm_objectpermissiontable",
"litellm_organizationmembership",
]
def _make_table_mock() -> MagicMock:
table = MagicMock()
table.find_unique = AsyncMock(return_value=None)
table.find_many = AsyncMock(return_value=[])
table.find_first = AsyncMock(return_value=None)
table.create = AsyncMock()
table.create_many = AsyncMock()
table.update = AsyncMock()
table.update_many = AsyncMock()
table.upsert = AsyncMock()
table.delete = AsyncMock()
table.delete_many = AsyncMock()
table.count = AsyncMock(return_value=0)
table.group_by = AsyncMock(return_value=[])
table.aggregate = AsyncMock(return_value={})
return table
@pytest.fixture
def mock_prisma_client() -> MagicMock:
"""Bare ``db`` mock with all common LiteLLM_* tables stubbed.
Override individual return values in a test::
mock_prisma_client.db.litellm_usertable.find_unique.return_value = user
"""
client = MagicMock(name="MockPrismaClient")
client.db = MagicMock(name="MockPrismaDB")
client.connect = AsyncMock()
client.disconnect = AsyncMock()
client.health_check = AsyncMock(return_value=[{"?column?": 1}])
client.proxy_logging_obj = MagicMock()
client.proxy_logging_obj.failure_handler = AsyncMock()
client.spend_log_transactions = []
client._spend_log_transactions_lock = asyncio.Lock()
client.jsonify_object = lambda data: dict(data)
client.db.is_connected = MagicMock(return_value=False)
client.db.connect = AsyncMock()
client.db.disconnect = AsyncMock()
client.db.query_raw = AsyncMock(return_value=[{"?column?": 1}])
client.db.execute_raw = AsyncMock()
client.db.tx = MagicMock()
client.db.batch_ = MagicMock()
for table_name in _PRISMA_TABLES:
setattr(client.db, table_name, _make_table_mock())
return client
@pytest.fixture
def mock_dual_cache() -> MagicMock:
"""In-memory DualCache stand-in.
Sync and async get/set wired against a private dict. Override or read
``cache._store`` directly in a test for assertion convenience.
"""
cache = MagicMock(name="MockDualCache")
cache._store: Dict[str, Any] = {}
def _sync_get(key: str, **_: Any) -> Any:
return cache._store.get(key)
def _sync_set(key: str, value: Any, **_: Any) -> None:
cache._store[key] = value
async def _async_get(key: str, **_: Any) -> Any:
return cache._store.get(key)
async def _async_set(key: str, value: Any, **_: Any) -> None:
cache._store[key] = value
async def _async_delete(key: str, **_: Any) -> None:
cache._store.pop(key, None)
cache.get_cache = MagicMock(side_effect=_sync_get)
cache.set_cache = MagicMock(side_effect=_sync_set)
cache.async_get_cache = AsyncMock(side_effect=_async_get)
cache.async_set_cache = AsyncMock(side_effect=_async_set)
cache.async_delete_cache = AsyncMock(side_effect=_async_delete)
return cache
@pytest.fixture
def patched_prisma_import(monkeypatch: pytest.MonkeyPatch) -> Iterator[MagicMock]:
"""Replace ``prisma.Prisma`` and ``PrismaWrapper`` so PrismaClient.__init__
runs without a generated client. Yields the fake Prisma instance.
``prisma`` raises RuntimeError (not AttributeError) for the missing
``Prisma`` attribute, so ``monkeypatch.setattr`` can't probe it; assign
directly and restore in teardown.
"""
import prisma as _prisma_pkg
import litellm.proxy.utils as _utils_mod
fake_prisma = MagicMock(name="FakePrisma")
fake_prisma.is_connected = MagicMock(return_value=False)
fake_prisma.connect = AsyncMock()
fake_prisma.disconnect = AsyncMock()
fake_prisma_factory = MagicMock(name="FakePrismaFactory", return_value=fake_prisma)
had_prisma_attr = "Prisma" in _prisma_pkg.__dict__
previous_prisma_attr = _prisma_pkg.__dict__.get("Prisma")
_prisma_pkg.Prisma = fake_prisma_factory # type: ignore[attr-defined]
fake_wrapper = MagicMock(name="FakePrismaWrapper")
fake_wrapper.is_connected = MagicMock(return_value=False)
fake_wrapper.connect = AsyncMock()
fake_wrapper.disconnect = AsyncMock()
fake_wrapper.query_raw = AsyncMock(return_value=[{"?column?": 1}])
def _fake_wrapper_ctor(*args: Any, **kwargs: Any) -> MagicMock:
return fake_wrapper
monkeypatch.setattr(_utils_mod, "PrismaWrapper", _fake_wrapper_ctor)
fake_prisma.__wrapper__ = fake_wrapper
try:
yield fake_prisma
finally:
if had_prisma_attr:
_prisma_pkg.Prisma = previous_prisma_attr # type: ignore[attr-defined]
else:
try:
del _prisma_pkg.Prisma # type: ignore[attr-defined]
except AttributeError:
pass
@pytest.fixture
def prisma_client(
patched_prisma_import: MagicMock,
mock_prisma_client: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> Any:
"""Wired ``PrismaClient`` whose ``db`` attribute is the table mock.
The init runs through the real code path (testing the constructor's
config-attribute setup) and is then snapped to the easier-to-assert
table mock for downstream behavior pinning.
"""
monkeypatch.delenv("DATABASE_URL_READ_REPLICA", raising=False)
monkeypatch.delenv("IAM_TOKEN_DB_AUTH", raising=False)
from litellm.proxy.utils import PrismaClient
proxy_logging_obj = MagicMock(name="MockProxyLogging")
proxy_logging_obj.failure_handler = AsyncMock()
pc = PrismaClient(
database_url="postgresql://test:test@localhost:5432/test",
proxy_logging_obj=proxy_logging_obj,
)
pc.db = mock_prisma_client.db
return pc
@dataclass
class FakeClock:
"""Monotonic-time controller for the spend monitor loop.
Tests advance time via ``clock.advance(seconds)`` while asyncio.sleep
is replaced with a clock-driven no-op.
"""
now: float = 0.0
sleep_calls: List[float] = field(default_factory=list)
def advance(self, seconds: float) -> None:
self.now += seconds
def time(self) -> float:
return self.now
async def sleep(self, seconds: float) -> None:
self.sleep_calls.append(seconds)
self.now += seconds
@pytest.fixture
def fake_clock(monkeypatch: pytest.MonkeyPatch) -> FakeClock:
"""Install a controllable clock + asyncio.sleep replacement."""
clock = FakeClock()
monkeypatch.setattr("time.time", clock.time)
monkeypatch.setattr("time.monotonic", clock.time)
async def _fast_sleep(seconds: float, *_: Any, **__: Any) -> None:
clock.sleep_calls.append(seconds)
clock.now += seconds
monkeypatch.setattr("asyncio.sleep", _fast_sleep)
return clock
@pytest.fixture
def make_spend_log_row() -> Callable[..., Dict[str, Any]]:
"""Factory for fake LiteLLM_SpendLogs rows."""
def _make(
request_id: str = "req-1",
spend: float = 0.01,
model: str = "gpt-4o-mini",
**overrides: Any,
) -> Dict[str, Any]:
row = {
"request_id": request_id,
"spend": spend,
"model": model,
"user": "user-1",
"team_id": "team-1",
"api_key": "hashed-key",
"startTime": "2026-06-02T00:00:00Z",
"endTime": "2026-06-02T00:00:01Z",
"metadata": {},
}
row.update(overrides)
return row
return _make
@dataclass
class _SentMessage:
from_addr: Optional[str]
to_addrs: Any
subject: Optional[str]
body: Optional[str]
starttls_called: bool
login_args: Optional[tuple]
@dataclass
class InMemorySMTP:
"""Captures outbound SMTP traffic for ``send_email`` tests."""
sent: List[_SentMessage] = field(default_factory=list)
raise_on_send: Optional[Exception] = None
def server_factory(self) -> Callable[..., Any]:
outer = self
class _Conn:
def __init__(self) -> None:
self._starttls_called = False
self._login_args: Optional[tuple] = None
def __enter__(self) -> "_Conn":
return self
def __exit__(self, *exc: Any) -> None:
return None
def starttls(self) -> None:
self._starttls_called = True
def login(self, user: str, password: str) -> None:
self._login_args = (user, password)
def send_message(
self,
msg: EmailMessage,
from_addr: Optional[str] = None,
to_addrs: Any = None,
) -> None:
if outer.raise_on_send is not None:
raise outer.raise_on_send
body = ""
for part in msg.walk():
if part.get_content_type() == "text/html":
body = part.get_payload(decode=False) or ""
break
outer.sent.append(
_SentMessage(
from_addr=from_addr,
to_addrs=to_addrs,
subject=msg["Subject"],
body=body,
starttls_called=self._starttls_called,
login_args=self._login_args,
)
)
def _factory(*args: Any, **kwargs: Any) -> _Conn:
return _Conn()
return _factory
@pytest.fixture
def in_memory_smtp(monkeypatch: pytest.MonkeyPatch) -> InMemorySMTP:
"""Patch ``smtplib.SMTP`` to capture sends in memory.
Override ``smtp.raise_on_send`` to test the SMTP error path.
"""
smtp = InMemorySMTP()
monkeypatch.setattr("smtplib.SMTP", smtp.server_factory())
return smtp

View file

@ -0,0 +1,516 @@
"""Pin ``PrismaClient`` read-side data operations.
Symbols pinned here:
- ``PrismaClient.hash_token``
- ``PrismaClient.jsonify_object``
- ``PrismaClient.jsonify_team_object``
- ``PrismaClient.check_view_exists``
- ``PrismaClient.get_request_status``
- ``PrismaClient.get_generic_data``
- ``PrismaClient._query_first_with_cached_plan_fallback``
- ``PrismaClient.get_data``
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from litellm.proxy._types import LiteLLM_VerificationTokenView
from litellm.proxy.utils import PrismaClient
def test_hash_token_method_returns_sha256(prisma_client: PrismaClient) -> None:
token = "sk-token-xyz"
actual = {
"result": prisma_client.hash_token(token),
"len": len(prisma_client.hash_token(token)),
"expected": hashlib.sha256(token.encode()).hexdigest(),
"deterministic": prisma_client.hash_token(token)
== prisma_client.hash_token(token),
}
assert actual == {
"result": hashlib.sha256(token.encode()).hexdigest(),
"len": 64,
"expected": hashlib.sha256(token.encode()).hexdigest(),
"deterministic": True,
}
def test_hash_token_method_error_on_non_string(prisma_client: PrismaClient) -> None:
with pytest.raises(AttributeError):
prisma_client.hash_token(None) # type: ignore[arg-type]
def test_jsonify_object_serializes_nested_dicts(prisma_client: PrismaClient) -> None:
data = {
"metadata": {"a": 1, "b": [2, 3]},
"models": ["gpt-4o", "gpt-4o-mini"],
"token": "abc",
"spend": 1.23,
}
result = prisma_client.jsonify_object(data)
parsed_meta = json.loads(result["metadata"])
assert result == {
"metadata": json.dumps(data["metadata"]),
"models": ["gpt-4o", "gpt-4o-mini"],
"token": "abc",
"spend": 1.23,
}
assert parsed_meta == {"a": 1, "b": [2, 3]}
def test_jsonify_object_fallback_for_unserializable_dict(
prisma_client: PrismaClient,
) -> None:
class _Bad:
pass
data = {"metadata": {"x": _Bad()}, "label": "ok", "n": 1}
result = prisma_client.jsonify_object(data)
assert result == {
"metadata": "failed-to-serialize-json",
"label": "ok",
"n": 1,
}
def test_jsonify_object_error_on_non_dict(prisma_client: PrismaClient) -> None:
with pytest.raises(AttributeError):
prisma_client.jsonify_object(None) # type: ignore[arg-type]
def test_jsonify_team_object_converts_members_to_json_string(
prisma_client: PrismaClient,
) -> None:
data = {
"team_id": "t1",
"members_with_roles": [{"role": "admin", "user_id": "u1"}],
"metadata": {"foo": "bar"},
"models": ["gpt-4"],
}
result = prisma_client.jsonify_team_object(data)
assert result == {
"team_id": "t1",
"members_with_roles": json.dumps(data["members_with_roles"]),
"metadata": json.dumps(data["metadata"]),
"models": ["gpt-4"],
}
def test_jsonify_team_object_error_on_non_dict(prisma_client: PrismaClient) -> None:
with pytest.raises(AttributeError):
prisma_client.jsonify_team_object(None) # type: ignore[arg-type]
@pytest.mark.parametrize(
"metadata,expected",
[
({"status": "failure"}, "failure"),
({"status": "success"}, "success"),
({}, "success"),
("not-json", "success"),
(json.dumps({"status": "failure"}), "failure"),
],
)
def test_get_request_status_pins_status_resolution(
prisma_client: PrismaClient, metadata: Any, expected: str
) -> None:
assert prisma_client.get_request_status({"metadata": metadata}) == expected
def test_get_request_status_error_returns_success_default(
prisma_client: PrismaClient,
) -> None:
"""``get_request_status`` swallows AttributeError / JSONDecodeError and
defaults to ``success`` to avoid blocking the request pipeline.
"""
class _Broken:
def get(self, *_: Any, **__: Any) -> Any:
raise AttributeError("broken metadata")
actual = prisma_client.get_request_status({"metadata": _Broken()})
assert actual == "success"
@pytest.mark.asyncio
async def test_get_generic_data_dispatches_by_table(
prisma_client: PrismaClient,
) -> None:
row = SimpleNamespace(user_id="u1", spend=0.5, name="Alice")
prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=row)
result = await prisma_client.get_generic_data(
key="user_id", value="u1", table_name="users"
)
actual = {
"result_is_row": result is row,
"find_first_count": prisma_client.db.litellm_usertable.find_first.await_count,
"where_kwarg": prisma_client.db.litellm_usertable.find_first.await_args.kwargs[
"where"
],
"user_attr": result.user_id,
}
assert actual == {
"result_is_row": True,
"find_first_count": 1,
"where_kwarg": {"user_id": "u1"},
"user_attr": "u1",
}
@pytest.mark.asyncio
async def test_get_generic_data_unknown_table_returns_none(
prisma_client: PrismaClient,
) -> None:
result = await prisma_client.get_generic_data(
key="x", value="y", table_name="bogus" # type: ignore[arg-type]
)
assert result is None
@pytest.mark.asyncio
async def test_get_generic_data_logs_failure_handler_and_raises_on_error(
prisma_client: PrismaClient,
) -> None:
prisma_client.db.litellm_usertable.find_first = AsyncMock(
side_effect=RuntimeError("db boom")
)
with pytest.raises(RuntimeError, match="db boom"):
await prisma_client.get_generic_data(
key="user_id", value="x", table_name="users"
)
@pytest.mark.asyncio
async def test_query_first_with_cached_plan_fallback_happy_returns_row(
prisma_client: PrismaClient,
) -> None:
expected = {"token": "abc", "team_spend": 1.0, "team_max_budget": 5.0}
prisma_client.db.query_first = AsyncMock(return_value=expected)
prisma_client.attempt_db_reconnect = AsyncMock(return_value=True)
result = await prisma_client._query_first_with_cached_plan_fallback(
"SELECT * FROM x WHERE token = $1", "abc"
)
actual = {
"result": result,
"call_count": prisma_client.db.query_first.await_count,
"args": prisma_client.db.query_first.await_args.args,
"matches": result == expected,
}
assert actual == {
"result": expected,
"call_count": 1,
"args": ("SELECT * FROM x WHERE token = $1", "abc"),
"matches": True,
}
prisma_client.attempt_db_reconnect.assert_not_awaited()
@pytest.mark.asyncio
async def test_query_first_with_cached_plan_fallback_reconnects_then_retries_identical_query(
prisma_client: PrismaClient,
) -> None:
original_query = 'SELECT * FROM "LiteLLM_VerificationToken" WHERE v.token = $1'
expected = {"token": "abc", "team_spend": 1.0, "team_max_budget": 5.0}
manager = MagicMock()
query_first = AsyncMock(
side_effect=[
RuntimeError("cached plan must not change result type"),
expected,
]
)
reconnect = AsyncMock(return_value=True)
manager.attach_mock(query_first, "query_first")
manager.attach_mock(reconnect, "attempt_db_reconnect")
prisma_client.db.query_first = query_first
prisma_client.attempt_db_reconnect = reconnect
result = await prisma_client._query_first_with_cached_plan_fallback(
original_query, "abc"
)
assert result == expected
assert query_first.await_count == 2
first_call, retry_call = query_first.await_args_list
assert retry_call.args == first_call.args == (original_query, "abc")
reconnect.assert_awaited_once()
assert reconnect.await_args.kwargs.get("force", False) is False
assert [name for name, *_ in manager.mock_calls] == [
"query_first",
"attempt_db_reconnect",
"query_first",
]
@pytest.mark.asyncio
async def test_query_first_with_cached_plan_fallback_never_deallocates(
prisma_client: PrismaClient,
) -> None:
expected = {"token": "abc"}
prisma_client.db.query_first = AsyncMock(
side_effect=[
RuntimeError("cached plan must not change result type"),
expected,
]
)
prisma_client.db.execute_raw = AsyncMock(return_value=0)
prisma_client.attempt_db_reconnect = AsyncMock(return_value=True)
await prisma_client._query_first_with_cached_plan_fallback("SELECT 1")
prisma_client.db.execute_raw.assert_not_awaited()
@pytest.mark.asyncio
async def test_query_first_with_cached_plan_fallback_propagates_when_retry_also_fails(
prisma_client: PrismaClient,
) -> None:
plan_error = RuntimeError("cached plan must not change result type")
prisma_client.db.query_first = AsyncMock(side_effect=[plan_error, plan_error])
prisma_client.attempt_db_reconnect = AsyncMock(return_value=True)
with pytest.raises(RuntimeError, match="cached plan must not change result type"):
await prisma_client._query_first_with_cached_plan_fallback("SELECT 1")
assert prisma_client.db.query_first.await_count == 2
prisma_client.attempt_db_reconnect.assert_awaited_once()
@pytest.mark.asyncio
async def test_query_first_with_cached_plan_fallback_retries_when_reconnect_returns_false(
prisma_client: PrismaClient,
) -> None:
expected = {"token": "abc"}
prisma_client.db.query_first = AsyncMock(
side_effect=[
RuntimeError("cached plan must not change result type"),
expected,
]
)
prisma_client.attempt_db_reconnect = AsyncMock(return_value=False)
result = await prisma_client._query_first_with_cached_plan_fallback("SELECT 1")
assert result == expected
assert prisma_client.db.query_first.await_count == 2
@pytest.mark.asyncio
async def test_query_first_with_cached_plan_fallback_reraises_non_plan_errors(
prisma_client: PrismaClient,
) -> None:
prisma_client.db.query_first = AsyncMock(
side_effect=RuntimeError("totally unrelated")
)
prisma_client.attempt_db_reconnect = AsyncMock(return_value=True)
with pytest.raises(RuntimeError, match="totally unrelated"):
await prisma_client._query_first_with_cached_plan_fallback("SELECT 1")
assert prisma_client.db.query_first.await_count == 1
prisma_client.attempt_db_reconnect.assert_not_awaited()
@pytest.mark.asyncio
async def test_check_view_exists_noop_when_all_views_present(
prisma_client: PrismaClient,
) -> None:
prisma_client.db.query_raw = AsyncMock(
return_value=[
{
"view_count": 8,
"view_names": [
"LiteLLM_VerificationTokenView",
"MonthlyGlobalSpend",
"Last30dKeysBySpend",
"Last30dModelsBySpend",
"MonthlyGlobalSpendPerKey",
"MonthlyGlobalSpendPerUserPerKey",
"Last30dTopEndUsersSpend",
"DailyTagSpend",
],
}
]
)
prisma_client.db.execute_raw = AsyncMock()
result = await prisma_client.check_view_exists()
actual = {
"result": result,
"query_raw_calls": prisma_client.db.query_raw.await_count,
"execute_raw_calls": prisma_client.db.execute_raw.await_count,
"view_query_contains_token_view": "LiteLLM_VerificationTokenView"
in prisma_client.db.query_raw.await_args.args[0],
}
assert actual == {
"result": None,
"query_raw_calls": 1,
"execute_raw_calls": 0,
"view_query_contains_token_view": True,
}
@pytest.mark.asyncio
async def test_check_view_exists_creates_token_view_when_missing(
prisma_client: PrismaClient,
) -> None:
prisma_client.db.query_raw = AsyncMock(
return_value=[
{
"view_count": 1,
"view_names": ["DailyTagSpend"],
}
]
)
prisma_client.db.execute_raw = AsyncMock()
prisma_client.health_check = AsyncMock(return_value=[{"?column?": 1}])
result = await prisma_client.check_view_exists()
actual = {
"result": result,
"create_called": prisma_client.db.execute_raw.await_count,
"create_sql_starts_with_create_view": prisma_client.db.execute_raw.await_args.args[
0
]
.strip()
.startswith('CREATE VIEW "LiteLLM_VerificationTokenView"'),
}
assert actual == {
"result": None,
"create_called": 1,
"create_sql_starts_with_create_view": True,
}
@pytest.mark.asyncio
async def test_check_view_exists_raises_when_query_raw_fails(
prisma_client: PrismaClient,
) -> None:
prisma_client.db.query_raw = AsyncMock(side_effect=RuntimeError("db down"))
with pytest.raises(RuntimeError, match="db down"):
await prisma_client.check_view_exists()
@pytest.mark.asyncio
async def test_get_data_token_find_unique_returns_record(
prisma_client: PrismaClient,
) -> None:
token = "sk-key-1"
hashed = hashlib.sha256(token.encode()).hexdigest()
record = SimpleNamespace(token=hashed, user_id="u1", expires=None, spend=0.5)
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
return_value=record
)
result = await prisma_client.get_data(token=token, table_name="key")
actual = {
"result_is_record": result is record,
"where_arg": prisma_client.db.litellm_verificationtoken.find_unique.await_args.kwargs[
"where"
],
"include_arg": prisma_client.db.litellm_verificationtoken.find_unique.await_args.kwargs[
"include"
],
"token_field_matches": result.token == hashed,
}
assert actual == {
"result_is_record": True,
"where_arg": {"token": hashed},
"include_arg": {"litellm_budget_table": True},
"token_field_matches": True,
}
@pytest.mark.asyncio
async def test_get_data_token_find_unique_missing_token_raises_401(
prisma_client: PrismaClient,
) -> None:
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
return_value=None
)
with pytest.raises(HTTPException) as excinfo:
await prisma_client.get_data(token="sk-missing", table_name="key")
err = excinfo.value
assert "invalid user key" in err.detail
assert err.status_code == 401
@pytest.mark.asyncio
async def test_get_data_user_find_unique_returns_user_row(
prisma_client: PrismaClient,
) -> None:
row = SimpleNamespace(
user_id="u-7",
spend=1.5,
max_budget=10.0,
organization_memberships=[],
)
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=row)
result = await prisma_client.get_data(user_id="u-7", table_name="user")
actual = {
"result_is_row": result is row,
"where_arg": prisma_client.db.litellm_usertable.find_unique.await_args.kwargs[
"where"
],
"include_arg": prisma_client.db.litellm_usertable.find_unique.await_args.kwargs[
"include"
],
"spend": row.spend,
}
assert actual == {
"result_is_row": True,
"where_arg": {"user_id": "u-7"},
"include_arg": {"organization_memberships": True},
"spend": 1.5,
}
@pytest.mark.asyncio
async def test_get_data_logs_and_raises_on_db_error(
prisma_client: PrismaClient,
) -> None:
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
side_effect=RuntimeError("network split")
)
with pytest.raises(RuntimeError, match="network split"):
await prisma_client.get_data(token="sk-broken", table_name="key")
@pytest.mark.asyncio
async def test_get_data_combined_view_returns_view_for_deprecated_key(
prisma_client: PrismaClient,
) -> None:
"""Grace-period rotation, full get_data flow: the old hash misses the
combined view, the deprecated-key table resolves it to the active token,
and get_data must return the recursive lookup's finished view instead of
re-running dict normalization on it (which raised TypeError and turned
every grace-period request into a 401)."""
old_hash = "hashed-old-token-grace-e2e"
active_hash = "hashed-active-token-grace-e2e"
active_row = {
"token": active_hash,
"team_models": None,
"team_blocked": None,
"team_members_with_roles": None,
"user_id": None,
"expires": None,
}
prisma_client.db.query_first = AsyncMock(side_effect=[None, active_row])
prisma_client.db.litellm_deprecatedverificationtoken = MagicMock()
prisma_client.db.litellm_deprecatedverificationtoken.find_first = AsyncMock(
return_value=SimpleNamespace(
active_token_id=active_hash,
revoke_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
)
response = await prisma_client.get_data(
token=old_hash, table_name="combined_view", query_type="find_unique"
)
assert isinstance(response, LiteLLM_VerificationTokenView)
assert response.token == active_hash

View file

@ -51,8 +51,8 @@
"@types/react-dom": "18.3.7",
"@types/react-syntax-highlighter": "15.5.13",
"@types/uuid": "10.0.0",
"@vitest/coverage-v8": "3.2.4",
"@vitest/ui": "3.2.4",
"@vitest/coverage-v8": "3.2.6",
"@vitest/ui": "3.2.6",
"autoprefixer": "10.4.24",
"dotenv": "17.2.3",
"eslint": "9.39.2",
@ -66,7 +66,7 @@
"tailwindcss": "3.4.19",
"typescript": "5.9.3",
"vite": "7.3.2",
"vitest": "3.2.4"
"vitest": "3.2.6"
},
"engines": {
"node": ">=20.9.0",
@ -3998,9 +3998,9 @@
]
},
"node_modules/@vitest/coverage-v8": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
"integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz",
"integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4022,8 +4022,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "3.2.4",
"vitest": "3.2.4"
"@vitest/browser": "3.2.6",
"vitest": "3.2.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -4032,15 +4032,15 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
"integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"@vitest/spy": "3.2.6",
"@vitest/utils": "3.2.6",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@ -4049,13 +4049,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz",
"integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"@vitest/spy": "3.2.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@ -4076,9 +4076,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz",
"integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4089,13 +4089,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz",
"integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"@vitest/utils": "3.2.6",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
@ -4104,13 +4104,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz",
"integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.6",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@ -4119,9 +4119,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz",
"integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4132,13 +4132,13 @@
}
},
"node_modules/@vitest/ui": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz",
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.6.tgz",
"integrity": "sha512-mATfG3zVdhobE9U1rIpvtYD3DGuSSxqZ3Aj/8ityGqKXy8YDJ9BoAjZmAz6dZ1IZ1xI5V+MerkCczvVa+3QK9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"@vitest/utils": "3.2.6",
"fflate": "^0.8.2",
"flatted": "^3.3.3",
"pathe": "^2.0.3",
@ -4150,17 +4150,17 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "3.2.4"
"vitest": "3.2.6"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz",
"integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.6",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
@ -4739,9 +4739,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6512,9 +6512,9 @@
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
@ -13002,20 +13002,20 @@
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
"integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"@vitest/expect": "3.2.6",
"@vitest/mocker": "3.2.6",
"@vitest/pretty-format": "^3.2.6",
"@vitest/runner": "3.2.6",
"@vitest/snapshot": "3.2.6",
"@vitest/spy": "3.2.6",
"@vitest/utils": "3.2.6",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
@ -13045,8 +13045,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"@vitest/browser": "3.2.6",
"@vitest/ui": "3.2.6",
"happy-dom": "*",
"jsdom": "*"
},

View file

@ -63,8 +63,8 @@
"@types/react-dom": "18.3.7",
"@types/react-syntax-highlighter": "15.5.13",
"@types/uuid": "10.0.0",
"@vitest/coverage-v8": "3.2.4",
"@vitest/ui": "3.2.4",
"@vitest/coverage-v8": "3.2.6",
"@vitest/ui": "3.2.6",
"autoprefixer": "10.4.24",
"dotenv": "17.2.3",
"eslint": "9.39.2",
@ -78,7 +78,7 @@
"tailwindcss": "3.4.19",
"typescript": "5.9.3",
"vite": "7.3.2",
"vitest": "3.2.4"
"vitest": "3.2.6"
},
"overrides": {
"prismjs": "1.30.0",
@ -88,6 +88,7 @@
"lodash": "4.18.1",
"ws": "8.20.1",
"braces": "3.0.3",
"brace-expansion": "5.0.6",
"axios": "1.13.6",
"postcss": "8.5.13"
},

View file

@ -1,6 +1,14 @@
"use client";
import { CommentOutlined, DeleteOutlined, ExperimentOutlined, LinkOutlined, PlusOutlined, RobotOutlined, SaveOutlined } from "@ant-design/icons";
import {
CommentOutlined,
DeleteOutlined,
ExperimentOutlined,
LinkOutlined,
PlusOutlined,
RobotOutlined,
SaveOutlined,
} from "@ant-design/icons";
import { Button, Input, Modal, Select, Spin, Tabs } from "antd";
import React, { useCallback, useEffect, useState } from "react";
import CodeBlock from "@/app/(dashboard)/api-reference/components/CodeBlock";
@ -64,9 +72,10 @@ function ConnectTabContent({
onCreateKey,
}: ConnectTabContentProps) {
const baseUrl = proxyBaseUrl ?? getConnectTabBaseUrl(proxySettings, customProxyBaseUrl);
const apiKeyForCurl =
createdKeyValue ?
createdKeyValue.startsWith("Bearer ") ? createdKeyValue : `Bearer ${createdKeyValue}`
const apiKeyForCurl = createdKeyValue
? createdKeyValue.startsWith("Bearer ")
? createdKeyValue
: `Bearer ${createdKeyValue}`
: "Bearer sk-1234";
const curlExample = `curl -L -X POST '${baseUrl}/v1/chat/completions' \\
-H 'x-litellm-api-key: ${apiKeyForCurl}' \\
@ -101,12 +110,7 @@ function ConnectTabContent({
Create a virtual key that can only call this agent. The key will be scoped to you (user_id) and restricted to
the model <span className="font-mono text-gray-800">{agentName}</span>.
</p>
<Button
type="primary"
onClick={onCreateKey}
loading={creatingKey}
disabled={disabledPersonalKeyCreation}
>
<Button type="primary" onClick={onCreateKey} loading={creatingKey} disabled={disabledPersonalKeyCreation}>
Create key for this agent
</Button>
{disabledPersonalKeyCreation && (
@ -127,6 +131,14 @@ function getAgentModelId(agent: AgentModel): string | null {
return info?.id ?? null;
}
// Selection key that always resolves to a non-null string. Prefers the DB
// id (stable across renames and unique across teams) but falls back to
// `model_name` so config-file-defined agents — which have no `model_info.id`
// — remain selectable.
function getAgentSelectionKey(agent: AgentModel): string {
return getAgentModelId(agent) ?? agent.model_name;
}
function parseUnderlyingModel(litellmModel: string | undefined): string | undefined {
if (!litellmModel || !litellmModel.startsWith("litellm_agent/")) return undefined;
return litellmModel.slice("litellm_agent/".length) || undefined;
@ -191,22 +203,25 @@ export default function AgentBuilderView({
const [deleting, setDeleting] = useState(false);
const effectiveApiKey = apiKey || accessToken || "";
const selectedAgent = selectedId === NEW_AGENT_ID ? null : agentModels.find((a) => a.model_name === selectedId) ?? null;
const selectedAgent =
selectedId === NEW_AGENT_ID ? null : agentModels.find((a) => getAgentSelectionKey(a) === selectedId) ?? null;
const isNewAgent = selectedId === NEW_AGENT_ID;
const selectedAgentModelId = selectedAgent ? getAgentModelId(selectedAgent) : null;
const loadAgents = useCallback(async () => {
if (!accessToken || !userID || !userRole) return;
const loadAgents = useCallback(async (): Promise<AgentModel[]> => {
if (!accessToken || !userID || !userRole) return [];
setLoadingAgents(true);
try {
const list = await fetchAvailableAgentModels(accessToken, userID, userRole);
setAgentModels(list);
if (!selectedId || (selectedId !== NEW_AGENT_ID && !list.some((a) => a.model_name === selectedId))) {
setSelectedId(list.length > 0 ? list[0].model_name : null);
if (!selectedId || (selectedId !== NEW_AGENT_ID && !list.some((a) => getAgentSelectionKey(a) === selectedId))) {
setSelectedId(list.length > 0 ? getAgentSelectionKey(list[0]) : null);
}
return list;
} catch (e) {
console.error(e);
NotificationsManager.fromBackend("Failed to load agents");
return [];
} finally {
setLoadingAgents(false);
}
@ -267,7 +282,13 @@ export default function AgentBuilderView({
setDraftMaxTokens(typeof p?.max_tokens === "number" ? p.max_tokens : 4096);
const rawTools = selectedAgent.litellm_params?.tools;
const tools: MCPToolEntry[] = Array.isArray(rawTools)
? rawTools.filter((t): t is MCPToolEntry => t && typeof t === "object" && (t as MCPToolEntry).type === "mcp" && typeof (t as MCPToolEntry).server_url === "string")
? rawTools.filter(
(t): t is MCPToolEntry =>
t &&
typeof t === "object" &&
(t as MCPToolEntry).type === "mcp" &&
typeof (t as MCPToolEntry).server_url === "string",
)
: [];
setDraftTools(tools);
}
@ -297,7 +318,7 @@ export default function AgentBuilderView({
}
setSaving(true);
try {
await modelCreateCall(accessToken, {
const response = await modelCreateCall(accessToken, {
model_name: draftName.trim(),
litellm_params: {
model: `litellm_agent/${draftUnderlyingModel}`,
@ -308,9 +329,15 @@ export default function AgentBuilderView({
},
model_info: {},
});
const newName = draftName.trim();
await loadAgents();
setSelectedId(newName);
// /model/new returns the row with `model_id` at the top level.
// Prefer that id over name-matching so we land on the just-created
// agent even when its public name collides with another team's.
const createdId: string | null = response?.model_id ?? response?.model_info?.id ?? null;
const list = await loadAgents();
const created = createdId
? list.find((a) => getAgentModelId(a) === createdId) ?? list.find((a) => a.model_name === draftName.trim())
: list.find((a) => a.model_name === draftName.trim());
setSelectedId(created ? getAgentSelectionKey(created) : list[0] ? getAgentSelectionKey(list[0]) : null);
setActiveTab("chat");
} catch (e) {
NotificationsManager.fromBackend("Failed to save agent");
@ -342,8 +369,10 @@ export default function AgentBuilderView({
selectedAgentModelId,
);
NotificationsManager.success("Agent updated successfully");
await loadAgents();
setSelectedId(draftName.trim());
const list = await loadAgents();
const stillSelected = list.find((a) => getAgentModelId(a) === selectedAgentModelId);
const target = stillSelected ?? list[0];
setSelectedId(target ? getAgentSelectionKey(target) : null);
} catch (e) {
NotificationsManager.fromBackend("Failed to update agent");
} finally {
@ -387,9 +416,9 @@ export default function AgentBuilderView({
try {
await modelDeleteCall(accessToken, selectedAgentModelId);
NotificationsManager.success("Agent deleted");
await loadAgents();
const remaining = agentModels.filter((a) => a.model_name !== selectedAgent.model_name);
setSelectedId(remaining.length > 0 ? remaining[0].model_name : null);
const list = await loadAgents();
const remaining = list.filter((a) => getAgentModelId(a) !== selectedAgentModelId);
setSelectedId(remaining.length > 0 ? getAgentSelectionKey(remaining[0]) : null);
} catch (e) {
NotificationsManager.fromBackend("Failed to delete agent");
} finally {
@ -401,9 +430,7 @@ export default function AgentBuilderView({
if (!accessToken || !userID || !userRole) {
return (
<div className="flex h-full items-center justify-center p-8 text-gray-500">
Sign in to use Agent Builder.
</div>
<div className="flex h-full items-center justify-center p-8 text-gray-500">Sign in to use Agent Builder.</div>
);
}
@ -412,24 +439,25 @@ export default function AgentBuilderView({
<div className="flex flex-shrink-0 flex-col border-b border-gray-200">
<div className="flex h-12 items-center justify-between px-4">
<span className="text-sm font-medium text-gray-900">Agent Builder</span>
{isNewAgent ? (
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAgent}
loading={saving}
disabled={!draftName?.trim() || !draftUnderlyingModel}
>
Save Agent
</Button>
) : (
<span className="text-xs text-gray-500">Build Agents that pass your compliance requirements.</span>
)}
{isNewAgent ? (
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAgent}
loading={saving}
disabled={!draftName?.trim() || !draftUnderlyingModel}
>
Save Agent
</Button>
) : (
<span className="text-xs text-gray-500">Build Agents that pass your compliance requirements.</span>
)}
</div>
<div className="flex items-center gap-2 border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800">
<ExperimentOutlined className="flex-shrink-0 text-amber-600" />
<span>
Agent Builder is experimental and may change or be removed without notice. Wed love your feedbackemail us at{" "}
Agent Builder is experimental and may change or be removed without notice. Wed love your feedbackemail us
at{" "}
<a href="mailto:product@berri.ai" className="font-medium text-amber-900 underline hover:text-amber-700">
product@berri.ai
</a>
@ -452,21 +480,24 @@ export default function AgentBuilderView({
</div>
) : (
<>
{agentModels.map((agent) => (
<button
key={agent.model_name}
type="button"
onClick={() => setSelectedId(agent.model_name)}
className={`mb-1 w-full rounded-md border-l-2 px-3 py-2 text-left text-sm transition-colors ${
selectedId === agent.model_name
? "border-blue-500 bg-blue-50 text-blue-800"
: "border-transparent hover:bg-gray-50"
}`}
>
<div className="font-medium truncate">{agent.model_name}</div>
<div className="text-[10px] text-gray-500 truncate">litellm_agent</div>
</button>
))}
{agentModels.map((agent) => {
const key = getAgentSelectionKey(agent);
return (
<button
key={key}
type="button"
onClick={() => setSelectedId(key)}
className={`mb-1 w-full rounded-md border-l-2 px-3 py-2 text-left text-sm transition-colors ${
selectedId === key
? "border-blue-500 bg-blue-50 text-blue-800"
: "border-transparent hover:bg-gray-50"
}`}
>
<div className="font-medium truncate">{agent.model_name}</div>
<div className="text-[10px] text-gray-500 truncate">litellm_agent</div>
</button>
);
})}
<button
type="button"
onClick={handleAddAgent}
@ -502,11 +533,12 @@ export default function AgentBuilderView({
),
children: (
<div className="h-full overflow-y-auto p-6">
{(isNewAgent || selectedAgent) ? (
{isNewAgent || selectedAgent ? (
<div className="mx-auto max-w-xl space-y-4">
{!selectedAgentModelId && selectedAgent && (
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
This agent cannot be updated or deleted here (missing model id). Manage it from Models &amp; Endpoints.
This agent cannot be updated or deleted here (missing model id). Manage it from Models
&amp; Endpoints.
</div>
)}
<div>
@ -577,7 +609,9 @@ export default function AgentBuilderView({
/>
{selectedAgent && draftTools.length > 0 && (
<p className="mt-1 text-xs text-gray-500">
{draftTools.length} MCP server{draftTools.length !== 1 ? "s" : ""} saved. Use the same <code className="rounded bg-gray-100 px-1">tools</code> array in chat completions when calling this agent.
{draftTools.length} MCP server{draftTools.length !== 1 ? "s" : ""} saved. Use the same{" "}
<code className="rounded bg-gray-100 px-1">tools</code> array in chat completions when
calling this agent.
</p>
)}
</div>

36
uv.lock generated
View file

@ -9,7 +9,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-06-05T23:54:44.890497Z"
exclude-newer = "2026-06-11T00:01:45.852753Z"
exclude-newer-span = "P3D"
[manifest]
@ -18,6 +18,10 @@ members = [
"litellm-enterprise",
"litellm-proxy-extras",
]
constraints = [
{ name = "aiohttp", specifier = ">=3.13.5,<3.14" },
{ name = "tornado", specifier = ">=6.5.6" },
]
[[package]]
name = "a2a-sdk"
@ -3524,7 +3528,7 @@ requires-dist = [
{ name = "pydantic-settings", marker = "extra == 'proxy'", specifier = ">=2.14.1,<3.0" },
{ name = "pyjwt", marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" },
{ name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" },
{ name = "pypdf", marker = "python_full_version < '3.14' and extra == 'proxy-runtime'", specifier = ">=6.10.2,<7.0" },
{ name = "pypdf", marker = "python_full_version < '3.14' and extra == 'proxy-runtime'", specifier = ">=6.12.0,<7.0" },
{ name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" },
{ name = "python-dotenv", specifier = ">=1.0.0,<2.0" },
{ name = "python-multipart", marker = "extra == 'proxy'", specifier = ">=0.0.27,<1.0" },
@ -6051,14 +6055,14 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.10.2"
version = "6.13.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/3f/9f2167401c2e94833ca3b69535bad89e533b5de75fefe4197a2c224baec2/pypdf-6.10.2.tar.gz", hash = "sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d", size = 5315679, upload-time = "2026-04-15T16:37:36.978Z" }
sdist = { url = "https://files.pythonhosted.org/packages/99/0a/48fe05c6bb3aa4bb4d2a4079a383d33c0dfec1edf613a642f07d8b8b5c2e/pypdf-6.13.2.tar.gz", hash = "sha256:5a96a17dbdfbf9c2ab24c0a13fa0aba182be22ba6f283098712c16fc242f509f", size = 6479250, upload-time = "2026-06-10T16:42:34.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" },
{ url = "https://files.pythonhosted.org/packages/cb/17/378943705992f74e451a06de3401ce68e3213763c81e44d0614559c45599/pypdf-6.13.2-py3-none-any.whl", hash = "sha256:6eeb9e57693f29d41bd01255d02660cbbb41fd7fc818a982677389a35e4f2083", size = 346555, upload-time = "2026-06-10T16:42:32.37Z" },
]
[[package]]
@ -7574,19 +7578,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.5"
version = "6.5.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
{ url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
{ url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
{ url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
{ url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
{ url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
{ url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
]
[[package]]