feat(proxy): surface deployment-advertised max_model_len in GET /v1/models

OpenAI-compatible inference servers such as vLLM and SGLang report the
per-request context length of a deployment under the field name
max_model_len on their own /v1/models, and operators already register
that value into LiteLLM deployment model_info. create_model_info_response
now passes it through: it reads max_model_len from the cost map and from
the concrete deployment's model_info (deployment wins), and includes it
on /v1/models and /v1/models/{model_id} whenever it is known. Malformed
config values are treated as absent via the shared coerce_token_limit
helper, matching the existing max_input_tokens/max_output_tokens behavior,
so the listing continues to degrade gracefully instead of failing.

Adds Router.get_configured_max_model_len, an O(1) deployment index lookup
with no pattern matching or deep copies, and declares the field on the
cost-map ModelInfo schema and the ModelInfoResponse listing type.

Tests cover cost-map surfacing, deployment override, omission when unset,
wildcard non-expansion, numeric-string coercion, and malformed values.
This commit is contained in:
todayim 2026-08-05 14:57:53 +08:00
parent 4fcaf7d736
commit e3641ee685
6 changed files with 175 additions and 3 deletions

View file

@ -6454,9 +6454,11 @@ def create_model_info_response(
max_input_tokens: int | None = None
max_output_tokens: int | None = None
max_model_len: int | None = None
if model_cost_info is not None:
max_input_tokens = coerce_token_limit(model_cost_info.get("max_input_tokens"))
max_output_tokens = coerce_token_limit(model_cost_info.get("max_output_tokens"))
max_model_len = coerce_token_limit(model_cost_info.get("max_model_len"))
mode: Final = model_cost_info.get("mode")
if isinstance(mode, str):
base["mode"] = mode
@ -6467,11 +6469,19 @@ def create_model_info_response(
max_input_tokens = configured_input
if configured_output is not None:
max_output_tokens = configured_output
# Coerce here as well: callers with duck-typed routers (and mocks that
# auto-stub unknown methods) must not leak an arbitrary object into the
# response. get_configured_max_model_len already coerces real values.
configured_model_len: Final = coerce_token_limit(llm_router.get_configured_max_model_len(model_id))
if configured_model_len is not None:
max_model_len = configured_model_len
if max_input_tokens is not None:
base["max_input_tokens"] = max_input_tokens
if max_output_tokens is not None:
base["max_output_tokens"] = max_output_tokens
if max_model_len is not None:
base["max_model_len"] = max_model_len
if not include_metadata:
return base

View file

@ -8653,6 +8653,25 @@ class Router:
coerce_token_limit(model_info.get("max_output_tokens")),
)
def get_configured_max_model_len(self, model_name: str) -> "int | None":
"""
Return max_model_len explicitly configured in a concrete deployment's
model_info for model_name, via O(1) index lookup.
OpenAI-compatible inference servers such as vLLM and SGLang report the
per-request context length of a deployment under this name, so the field
uses their spelling rather than a LiteLLM-specific one. Returns None for
wildcard-expanded or unknown names, and treats a malformed configured
value as absent rather than failing the listing. Like
get_configured_token_limits, this never triggers pattern matching or deep
copies, so it is safe to call per listed model on the /v1/models hot path.
"""
deployment: Final = self.get_deployment_by_model_group_name(model_group_name=model_name)
if deployment is None:
return None
return coerce_token_limit(deployment.model_info.get("max_model_len"))
def get_deployment_credentials_with_provider(
self, model_id: str, team_id: str | None = None
) -> dict[str, Any] | None:

View file

@ -10,9 +10,10 @@ class ModelInfoMetadata(TypedDict):
class ModelInfoResponse(TypedDict):
"""OpenAI-compatible model object. `mode`, `max_input_tokens`, and
`max_output_tokens` are attached when the cost map knows them; `metadata`
is present only when the endpoint is called with include_metadata=true.
"""OpenAI-compatible model object. `mode`, `max_input_tokens`,
`max_output_tokens`, and `max_model_len` are attached when configured or
known to the cost map; `metadata` is present only when the endpoint is
called with include_metadata=true.
"""
id: str
@ -22,4 +23,5 @@ class ModelInfoResponse(TypedDict):
mode: NotRequired[str]
max_input_tokens: NotRequired[int]
max_output_tokens: NotRequired[int]
max_model_len: NotRequired[int]
metadata: NotRequired[ModelInfoMetadata]

View file

@ -197,6 +197,9 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
max_tokens: Required[Optional[int]]
max_input_tokens: Required[Optional[int]]
max_output_tokens: Required[Optional[int]]
# per-request context length as advertised by OpenAI-compatible backends
# (vLLM and SGLang report it under this name on their own /v1/models)
max_model_len: Optional[int]
input_cost_per_token: Required[Optional[float]]
input_cost_per_token_flex: Optional[float] # OpenAI flex service tier pricing
input_cost_per_token_priority: Optional[float] # OpenAI priority service tier pricing

View file

@ -684,6 +684,68 @@ def test_create_model_info_response_omits_limits_when_lookup_raises():
assert "max_output_tokens" not in response
def test_create_model_info_response_includes_max_model_len_from_cost_map():
response = create_model_info_response(
model_id="some-model",
provider="openai",
llm_router=None,
get_model_info=lambda _model: _fake_model_info(max_model_len=131072),
)
assert response["max_model_len"] == 131072
def test_create_model_info_response_omits_max_model_len_when_unknown():
response = create_model_info_response(
model_id="some-model",
provider="openai",
llm_router=None,
get_model_info=lambda _model: _fake_model_info(max_input_tokens=8191),
)
assert response["max_input_tokens"] == 8191
assert "max_model_len" not in response
def test_create_model_info_response_deployment_max_model_len_overrides_cost_map():
router = MagicMock()
router.get_configured_token_limits.return_value = (None, None)
router.get_configured_max_model_len.return_value = 65536
response = create_model_info_response(
model_id="some-model",
provider="openai",
llm_router=router,
get_model_info=lambda _model: _fake_model_info(max_model_len=131072),
)
assert response["max_model_len"] == 65536
def test_create_model_info_response_survives_malformed_configured_max_model_len():
from litellm import Router
router = Router(
model_list=[
{
"model_name": "bad-ctx-model",
"litellm_params": {"model": "openai/some-unmapped-model"},
"model_info": {"max_model_len": "131,072"},
}
]
)
response = create_model_info_response(
model_id="bad-ctx-model",
provider="openai",
llm_router=router,
get_model_info=_raise_unmapped,
)
assert response["id"] == "bad-ctx-model"
assert "max_model_len" not in response
def test_create_model_info_response_no_router_keeps_base_fields():
response = create_model_info_response(
model_id="totally-unknown-model-xyz",

View file

@ -6590,6 +6590,82 @@ def test_get_configured_token_limits_coerces_numeric_strings():
assert router.get_configured_token_limits("quoted-limits-model") == (32000, 8000)
def test_get_configured_max_model_len_reads_deployment_model_info():
router = litellm.Router(
model_list=[
{
"model_name": "my-custom-model",
"litellm_params": {"model": "openai/some-unmapped-model"},
"model_info": {"max_model_len": 131072},
}
]
)
assert router.get_configured_max_model_len("my-custom-model") == 131072
def test_get_configured_max_model_len_returns_none_for_unset_or_unknown():
router = litellm.Router(
model_list=[
{
"model_name": "no-ctx-model",
"litellm_params": {"model": "openai/some-unmapped-model"},
}
]
)
assert router.get_configured_max_model_len("no-ctx-model") is None
assert router.get_configured_max_model_len("not-a-real-model") is None
def test_get_configured_max_model_len_skips_wildcard_pattern_matching():
router = litellm.Router(
model_list=[
{
"model_name": "bedrock/*",
"litellm_params": {"model": "bedrock/*"},
"model_info": {"max_model_len": 12345},
}
]
)
with patch.object(
router.pattern_router, "route", side_effect=AssertionError("pattern route called")
):
assert router.get_configured_max_model_len("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") is None
def test_get_configured_max_model_len_treats_malformed_values_as_absent():
malformed = ["", "131,072", True, [131072], {"len": 131072}]
router = litellm.Router(
model_list=[
{
"model_name": f"bad-ctx-{i}",
"litellm_params": {"model": "openai/some-unmapped-model"},
"model_info": {"max_model_len": bad},
}
for i, bad in enumerate(malformed)
]
)
for i in range(len(malformed)):
assert router.get_configured_max_model_len(f"bad-ctx-{i}") is None
def test_get_configured_max_model_len_coerces_numeric_strings():
router = litellm.Router(
model_list=[
{
"model_name": "quoted-ctx-model",
"litellm_params": {"model": "openai/some-unmapped-model"},
"model_info": {"max_model_len": "131072"},
}
]
)
assert router.get_configured_max_model_len("quoted-ctx-model") == 131072
@pytest.mark.asyncio
async def test_acreate_batch_request_bedrock_tags_override_deployment_tags():
import httpx