feat(proxy): declare response models for /model/info, /v1/model/info and /model_group/info

The three routes were served without a response schema, so /openapi.json published an empty schema for their 200 bodies and a client generating types from the document got unknown for each

ModelGroupInfoResponse and ModelInfoV1Response, both named ResourceResponse envelopes, now back the route decorators. /model_group/info returns the model, so FastAPI serializes it. /model/info keeps its orjson Response, so there the model documents the body: a list of deployments, or the single deployment object the proxy serves when started from the CLI with --model, whose litellm_params carry every unset non-secret value as the string None, which is why the params are a wire-specific model rather than the router's input type

The dashboard's generated schema.d.ts is regenerated with npm run gen:api
This commit is contained in:
Vivswan Shah 2026-10-08 04:54:23 -04:00
parent 7c7efd4d55
commit bca69f4439
7 changed files with 315 additions and 15 deletions

View file

@ -933,6 +933,8 @@ from litellm.types.llms.openai import (
from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
ModelGroupInfoProxy,
ModelGroupInfoResponse,
ModelInfoV1Response,
)
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
@ -16371,11 +16373,13 @@ def _model_info_json_response(data: Sequence[Mapping[str, object]] | Mapping[str
"/model/info",
tags=["model management"],
dependencies=[Depends(user_api_key_auth)],
response_model=ModelInfoV1Response,
)
@router.get(
"/v1/model/info",
tags=["model management"],
dependencies=[Depends(user_api_key_auth)],
response_model=ModelInfoV1Response,
)
async def model_info_v1(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
@ -16677,11 +16681,12 @@ _get_model_group_info: Final = get_model_group_info
"/model_group/info",
tags=["model management"],
dependencies=[Depends(user_api_key_auth)],
response_model=ModelGroupInfoResponse,
)
async def model_group_info(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
model_group: str | None = None,
):
) -> ModelGroupInfoResponse:
"""
Get information about all the deployments on litellm proxy, including config.yaml descriptions (except api key and api base)
@ -16836,7 +16841,7 @@ async def model_group_info(
# Return empty data array when no models are configured (graceful handling for fresh installs)
if llm_model_list is None or llm_router is None or not llm_model_list:
return {"data": []}
return ModelGroupInfoResponse(data=())
from litellm.proxy.utils import get_available_models_for_user
@ -16894,13 +16899,13 @@ async def model_group_info(
user_api_key_dict, tuple(dict.fromkeys(public_group_names))
)
return {
"data": [
return ModelGroupInfoResponse(
data=tuple(
group
for group, public_name in zip(model_groups, public_group_names, strict=True)
if public_name not in callback_hidden_names
]
}
)
)
@router.get(

View file

@ -5,7 +5,8 @@ from pydantic import ConfigDict, Field
from litellm.types.llms.base import LiteLLMBaseModel
from ...router import ModelGroupInfo
from ...router import ModelGroupInfo, ModelInfo
from .management_v1 import ResourceResponse
class ModelGroupInfoProxy(ModelGroupInfo):
@ -15,6 +16,61 @@ class ModelGroupInfoProxy(ModelGroupInfo):
health_checked_at: str | None = Field(default=None)
class ModelGroupInfoResponse(ResourceResponse[tuple[ModelGroupInfoProxy, ...]]):
"""`{data: [...]}` of `GET /model_group/info`: one `ModelGroupInfoProxy` per model group the caller may see."""
model_config = ConfigDict(frozen=True)
class ModelInfoV1LiteLLMParams(LiteLLMBaseModel):
"""A deployment's litellm_params as `GET /model/info` serves them.
Every configured key passes through except credentials: api_key, client_secret, vertex credentials and AWS
access keys are removed and every other secret-shaped value is masked, except litellm_credential_name. With the
proxy started from the CLI with `--model`, every unset parameter arrives as the string "None", or as an empty
string when its name is secret-shaped.
"""
model_config = ConfigDict(frozen=True, extra="allow")
model: str
class ModelInfoV1Deployment(LiteLLMBaseModel):
"""One deployment as `GET /model/info` and `GET /v1/model/info` report it.
Any further deployment-level key passes through from the router row unchanged.
"""
model_config = ConfigDict(frozen=True, extra="allow")
model_name: str = Field(
description=(
"The name requests use for this deployment. A team deployment stored under the proxy's internal "
"`model_name_<team_id>_<uuid>` name reports its team_public_model_name instead."
)
)
litellm_params: ModelInfoV1LiteLLMParams
model_info: ModelInfo = Field(
description=(
"The configured model_info merged with the cost map entry for the model (pricing, token limits, "
"mode, supports_* capabilities, supported_openai_params, litellm_provider, key), the proxy's "
"discovered model info and pricing_overrides. With the proxy database connected, direct_access is "
"added, and access_via_team_ids for an admin or for a key that belongs to a user."
)
)
class ModelInfoV1Response(ResourceResponse[tuple[ModelInfoV1Deployment, ...] | ModelInfoV1Deployment]):
"""`{data: ...}` of `GET /model/info` and `GET /v1/model/info`.
`data` is one `ModelInfoV1Deployment` per deployment the caller may see. With the proxy started from the CLI
with `--model` and no config, `data` is that single deployment as one object instead of a list.
"""
model_config = ConfigDict(frozen=True)
class UpdateUsefulLinksRequest(LiteLLMBaseModel):
# Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]])
# New format: { "displayName": { "url": "...", "index": 0 } }

View file

@ -10,7 +10,7 @@ Pins (PR2):
from __future__ import annotations
import copy
from collections.abc import Callable
from collections.abc import Callable, Mapping
from contextlib import AbstractContextManager
from typing import Final
from unittest.mock import AsyncMock, MagicMock
@ -214,6 +214,42 @@ def test_v2_model_info_in_openapi_schema():
assert "get" in schema["paths"]["/v2/model/info"]
_MODEL_INFO_V1_ITEM: Final = {"$ref": "#/components/schemas/ModelInfoV1Deployment"}
@pytest.mark.parametrize(
("path", "response_schema", "data_schema"),
(
(
"/model/info",
"ModelInfoV1Response",
{"anyOf": [{"items": _MODEL_INFO_V1_ITEM, "type": "array"}, _MODEL_INFO_V1_ITEM]},
),
(
"/v1/model/info",
"ModelInfoV1Response",
{"anyOf": [{"items": _MODEL_INFO_V1_ITEM, "type": "array"}, _MODEL_INFO_V1_ITEM]},
),
(
"/model_group/info",
"ModelGroupInfoResponse",
{"items": {"$ref": "#/components/schemas/ModelGroupInfoProxy"}, "type": "array"},
),
),
)
def test_model_info_routes_declare_their_response_schema(
path: str, response_schema: str, data_schema: Mapping[str, object]
) -> None:
"""The OpenAPI document types the 200 body of each model-info route, so a client can generate types."""
from litellm.proxy.proxy_server import get_openapi_schema
schema: Final = get_openapi_schema()
declared: Final = schema["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
assert declared == {"$ref": f"#/components/schemas/{response_schema}"}
data: Final = schema["components"]["schemas"][response_schema]["properties"]["data"]
assert {key: value for key, value in data.items() if key != "title"} == data_schema
# ---------------------------------------------------------------------------
# GET /v1/model/info, GET /model/info
# ---------------------------------------------------------------------------
@ -264,6 +300,88 @@ def test_v1_model_info_specific_id_happy(client, auth_as, configured_router, pat
}
@pytest.fixture
def real_router(monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None) -> litellm.Router:
"""A real Router with a cost-map model, a wildcard and a model the cost map does not know."""
router: Final = litellm.Router(
model_list=[
{
"model_name": "gpt-4o",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-test"},
"model_info": {"id": "known-id"},
},
{
"model_name": "openai/*",
"litellm_params": {"model": "openai/*", "api_key": "sk-test"},
"model_info": {"id": "wildcard-id"},
},
{
"model_name": "mystery",
"litellm_params": {
"model": "openai/mystery-model",
"api_key": "sk-test",
"api_base": "https://mystery.example/v1",
},
"model_info": {"id": "unknown-id"},
},
]
)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "llm_model_list", router.get_model_list())
monkeypatch.setattr(proxy_server, "user_model", None)
monkeypatch.setattr(proxy_server, "prisma_client", None)
return router
@pytest.mark.parametrize("path", ["/v1/model/info", "/model/info"])
def test_v1_model_info_bodies_validate_against_the_declared_response_model(
client: TestClient,
auth_as: Callable[[], AbstractContextManager[object]],
real_router: litellm.Router,
path: str,
) -> None:
"""The orjson body, which FastAPI never validates, fits the declared schema for a cost-map model, a wildcard
expansion and an unknown model, both listed and looked up by id, with credentials gone."""
from litellm.types.proxy.management_endpoints.model_management_endpoints import ModelInfoV1Response
with auth_as():
listing: Final = client.get(path)
by_id: Final = client.get(path, params={"litellm_model_id": "unknown-id"})
assert (listing.status_code, by_id.status_code) == (200, 200), (listing.text, by_id.text)
listed: Final = ModelInfoV1Response.model_validate(listing.json()).data
assert isinstance(listed, tuple)
assert {deployment.model_name for deployment in listed} > {"gpt-4o", "mystery"}
assert all("api_key" not in deployment.litellm_params.model_dump() for deployment in listed)
looked_up: Final = ModelInfoV1Response.model_validate(by_id.json()).data
assert isinstance(looked_up, tuple)
assert [deployment.model_name for deployment in looked_up] == ["mystery"]
@pytest.mark.parametrize("path", ["/v1/model/info", "/model/info"])
def test_v1_model_info_cli_model_body_is_one_deployment(
client: TestClient,
auth_as: Callable[[], AbstractContextManager[object]],
real_router: litellm.Router,
monkeypatch: pytest.MonkeyPatch,
path: str,
) -> None:
"""Started as ``litellm --model``, the route serves ``data`` as one deployment object, the second shape the
schema declares, with an unset non-secret parameter arriving as the string "None"."""
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
ModelInfoV1Deployment,
ModelInfoV1Response,
)
monkeypatch.setattr(proxy_server, "user_model", "gpt-4o")
with auth_as():
response: Final = client.get(path)
assert response.status_code == 200, response.text
declared: Final = ModelInfoV1Response.model_validate(response.json()).data
assert isinstance(declared, ModelInfoV1Deployment)
assert (declared.model_name, declared.litellm_params.model) == ("*", "gpt-4o")
assert declared.litellm_params.model_dump()["max_retries"] == "None"
@pytest.mark.parametrize("path", ["/v1/model/info", "/model/info"])
def test_v1_model_info_no_model_list_error(client, auth_as, null_router, path):
"""Pins ``GET /v1/model/info`` and ``GET /model/info`` (error: no model list)."""

View file

@ -153,7 +153,7 @@ async def _v1_model_info_names(user_api_key_dict: UserAPIKeyAuth) -> list[str]:
async def _model_groups(user_api_key_dict: UserAPIKeyAuth) -> list[str]:
response = await proxy_server.model_group_info(user_api_key_dict=user_api_key_dict)
return [group.model_group for group in response["data"]]
return [group.model_group for group in response.data]
async def _model_by_id_status(model_id: str, user_api_key_dict: UserAPIKeyAuth) -> int:

View file

@ -112,7 +112,7 @@ async def _v1_model_info_names(user_api_key_dict: UserAPIKeyAuth, **kwargs) -> l
async def _model_groups(user_api_key_dict: UserAPIKeyAuth) -> list[str]:
response = await proxy_server.model_group_info(user_api_key_dict=user_api_key_dict)
return [group.model_group for group in response["data"]]
return [group.model_group for group in response.data]
@pytest.mark.asyncio

View file

@ -2369,7 +2369,7 @@ async def test_proxy_model_group_alias_checks(prisma_client, hidden): # noqa: F
user_api_key_dict=UserAPIKeyAuth(models=[]),
)
print(f"resp: {resp}")
models = resp["data"]
models = resp.data
is_model_alias_in_list = False
print(f"model_alias: {model_alias}, models: {models}")
for item in models:
@ -2438,7 +2438,7 @@ async def test_proxy_model_group_info_rerank(prisma_client): # noqa: F811 # py
)
print(resp)
models = resp["data"]
models = resp.data
assert models[0].mode == "rerank"

View file

@ -39054,11 +39054,132 @@ export interface components {
/** Tpm */
tpm?: number | null;
};
/**
* ModelGroupInfoResponse
* @description `{data: [...]}` of `GET /model_group/info`: one `ModelGroupInfoProxy` per model group the caller may see.
*/
ModelGroupInfoResponse: {
/** Data */
data: components["schemas"]["ModelGroupInfoProxy"][];
};
/** ModelInfo */
"ModelInfo-Output": {
/** Access Windows */
access_windows?: components["schemas"]["ModelAccessWindow"][] | null;
/** Allow Fail Open */
allow_fail_open?: boolean | null;
/** Base Model */
base_model?: string | null;
/** Blocked */
blocked?: boolean | null;
/** Cache Creation Input Token Cost */
cache_creation_input_token_cost?: number | null;
/** Cache Read Input Token Cost */
cache_read_input_token_cost?: number | null;
/** Cost Per Ptu Per Hour */
cost_per_ptu_per_hour?: number | null;
/** Created At */
created_at?: string | null;
/** Created By */
created_by?: string | null;
/**
* Db Model
* @default false
*/
db_model: boolean;
/** Discoverable */
discoverable?: boolean | null;
/** Enable Tag Filtering */
enable_tag_filtering?: boolean | null;
/** Id */
id: string | null;
/** Input Cost Per Character */
input_cost_per_character?: number | null;
/** Input Cost Per Token */
input_cost_per_token?: number | null;
/** Internal Router Model */
internal_router_model?: boolean | null;
/**
* Member Auto Router
* @default false
*/
member_auto_router: boolean;
/** Output Cost Per Character */
output_cost_per_character?: number | null;
/** Output Cost Per Token */
output_cost_per_token?: number | null;
/** Ptu Count */
ptu_count?: number | null;
/** Ptu Effective From */
ptu_effective_from?: string | null;
/** Ptu Effective To */
ptu_effective_to?: string | null;
/** Team Id */
team_id?: string | null;
/** Team Public Model Name */
team_public_model_name?: string | null;
/** Tier */
tier?: ("free" | "paid") | null;
/** Tiered Pricing */
tiered_pricing?: {
[key: string]: unknown;
}[] | null;
/** Updated At */
updated_at?: string | null;
/** Updated By */
updated_by?: string | null;
} & {
[key: string]: unknown;
};
/** ModelInfoDelete */
ModelInfoDelete: {
/** Id */
id: string;
};
/**
* ModelInfoV1Deployment
* @description One deployment as `GET /model/info` and `GET /v1/model/info` report it.
*
* Any further deployment-level key passes through from the router row unchanged.
*/
ModelInfoV1Deployment: {
litellm_params: components["schemas"]["ModelInfoV1LiteLLMParams"];
/** @description The configured model_info merged with the cost map entry for the model (pricing, token limits, mode, supports_* capabilities, supported_openai_params, litellm_provider, key), the proxy's discovered model info and pricing_overrides. With the proxy database connected, direct_access is added, and access_via_team_ids for an admin or for a key that belongs to a user. */
model_info: components["schemas"]["ModelInfo-Output"];
/**
* Model Name
* @description The name requests use for this deployment. A team deployment stored under the proxy's internal `model_name_<team_id>_<uuid>` name reports its team_public_model_name instead.
*/
model_name: string;
} & {
[key: string]: unknown;
};
/**
* ModelInfoV1LiteLLMParams
* @description A deployment's litellm_params as `GET /model/info` serves them.
*
* Every configured key passes through except credentials: api_key, client_secret, vertex credentials and AWS
* access keys are removed and every other secret-shaped value is masked, except litellm_credential_name. With the
* proxy started from the CLI with `--model`, every unset parameter arrives as the string "None", or as an empty
* string when its name is secret-shaped.
*/
ModelInfoV1LiteLLMParams: {
/** Model */
model: string;
} & {
[key: string]: unknown;
};
/**
* ModelInfoV1Response
* @description `{data: ...}` of `GET /model/info` and `GET /v1/model/info`.
*
* `data` is one `ModelInfoV1Deployment` per deployment the caller may see. With the proxy started from the CLI
* with `--model` and no config, `data` is that single deployment as one object instead of a list.
*/
ModelInfoV1Response: {
/** Data */
data: components["schemas"]["ModelInfoV1Deployment"][] | components["schemas"]["ModelInfoV1Deployment"];
};
/** ModelInsightDailyMetric */
ModelInsightDailyMetric: {
/** Completion Tokens */
@ -66194,7 +66315,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"application/json": components["schemas"]["ModelInfoV1Response"];
};
};
/** @description Validation Error */
@ -66520,7 +66641,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"application/json": components["schemas"]["ModelGroupInfoResponse"];
};
};
/** @description Validation Error */
@ -80838,7 +80959,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"application/json": components["schemas"]["ModelInfoV1Response"];
};
};
/** @description Validation Error */