fix: keep model capability flags out of provider requests (#46647)

* fix: keep model capability flags out of provider requests

A supports_* flag or another model map field set on a deployment's litellm_params was forwarded
to the provider as a request field, so Bedrock answered 400 Extra inputs are not permitted on
chat traffic and on the background health check alike. The shared owned-kwarg predicate now
treats every ProviderSpecificModelInfo field as litellm-owned.

Builds on the health-check-only approach in #40952 by leilei3167.

* fix: treat every supports_ model map flag as a capability field and warn when one sits in litellm_params

* fix(router): skip declared litellm params in the capability flag warning and warn for runtime-added deployments

* fix(router): strip model capability flags from the router's deployments instead of every request

* fix(router): keep capability flags out of health probes and read them from the loaded catalog

* perf(router): read the catalog's supports_ field set once per cost map load

Cache the loaded catalog's field names when the cost map snapshot is taken so the capability-flag check stops walking every catalog entry per deployment, and add integration cells that drive the flagged and control deployments through chat, streaming, messages, responses, embeddings, health and model management against a vendor that rejects the flags
This commit is contained in:
Mateo Wang 2026-10-15 07:30:10 -07:00 • committed by GitHub
parent 56dad9077d
commit 18831b818c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 934 additions and 22 deletions

View file

@ -21,6 +21,7 @@ from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from importlib.resources import files
from itertools import chain
from pathlib import Path
from types import MappingProxyType
from typing import Final, Protocol
@ -93,16 +94,23 @@ class GetModelCostMap:
return GetModelCostMap.load_local_model_cost_map_with_revision().model_cost_map
_loaded_catalog: Mapping[str, Mapping[str, object]] = MappingProxyType({})
_loaded_catalog_fields: frozenset[str] = frozenset()
@classmethod
def loaded_model_cost_map(cls) -> Mapping[str, Mapping[str, object]]:
"""The catalog as last loaded (bundled or remote), untouched by ``register_model`` or router registrations."""
return cls._loaded_catalog
@classmethod
def loaded_model_cost_map_fields(cls) -> frozenset[str]:
"""Every field name that at least one entry of the loaded catalog carries."""
return cls._loaded_catalog_fields
@classmethod
def _snapshot_loaded_catalog(cls, model_cost: Mapping[str, object]) -> None:
raw: Final = _CATALOG_ADAPTER.validate_python(model_cost)
cls._loaded_catalog = MappingProxyType({key: MappingProxyType(entry) for key, entry in raw.items()})
cls._loaded_catalog_fields = frozenset(chain.from_iterable(entry.keys() for entry in raw.values()))
@classmethod
def get_backup_model_count(cls) -> int:

View file

@ -32,6 +32,7 @@ from litellm.router_utils.auto_router_model_naming import (
classify_strategy_router_model,
strategy_router_dependencies,
)
from litellm.router_utils.common_utils import is_model_capability_field
from litellm.types.utils import secret_bearing_wif_litellm_params, server_owned_wif_litellm_params
# Provider routing and server-owned federation or OAuth token-exchange fields. Allowed for proxy
@ -777,6 +778,9 @@ def _resolve_health_check_max_tokens(model_info: dict, litellm_params: dict) ->
return None
_PROBE_PARAMS_ADAPTER: Final = TypeAdapter(dict[str, object])
def update_litellm_params_for_health_check(model_info: dict, litellm_params: dict) -> dict:
"""
Update the litellm params for health check.
@ -867,7 +871,8 @@ def update_litellm_params_for_health_check(model_info: dict, litellm_params: dic
"bedrock"
)
return litellm_params
probe_params: Final = _PROBE_PARAMS_ADAPTER.validate_python(litellm_params)
return {key: value for key, value in probe_params.items() if not is_model_capability_field(key)}
_update_litellm_params_for_health_check: Final = update_litellm_params_for_health_check

View file

@ -201,7 +201,9 @@ from litellm.router_utils.common_utils import (
provider_for_generic_call,
resolve_model_group_alias,
truncate_fallback_error_detail,
warn_on_ignored_model_capability_flags,
warn_on_provider_credential_mismatch,
without_model_capability_flags,
without_router_only_kwargs,
)
from litellm.router_utils.cooldown_cache import CooldownCache
@ -503,6 +505,7 @@ def _stream_chunks_have_generated_content(chunks: Sequence[ModelResponseStream])
_NO_SESSION_KWARGS: Final[Mapping[str, Mapping[str, object]]] = MappingProxyType({})
_SESSION_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_MODEL_INFO_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_LITELLM_PARAMS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_SILENT_MODEL_ADAPTER: Final = TypeAdapter(str | list[str])
_RESOLVED_RETRY_POLICY_ADAPTER: Final = TypeAdapter(RetryPolicy | None)
_ROUTING_KWARGS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(Mapping[str, object] | None)
@ -9491,12 +9494,16 @@ class Router:
_litellm_params if zeroed_pricing is None else MappingProxyType({**_litellm_params, **zeroed_pricing})
)
litellm_params: Final[LiteLLM_Params] = LiteLLM_Params.model_validate(dict(**merged_params))
warn_on_provider_credential_mismatch(model_name=_model_name, litellm_params=_litellm_params)
deployment = Deployment(
**deployment_info,
model_name=_model_name,
litellm_params=litellm_params,
model_info=_model_info,
declared_params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(_litellm_params)
warn_on_provider_credential_mismatch(model_name=_model_name, litellm_params=declared_params)
warn_on_ignored_model_capability_flags(model_name=_model_name, litellm_params=declared_params)
deployment = without_model_capability_flags(
Deployment(
**deployment_info,
model_name=_model_name,
litellm_params=litellm_params,
model_info=_model_info,
)
)
for field in CustomPricingLiteLLMParams.model_fields:
if deployment.litellm_params.get(field) is not None:
@ -10317,23 +10324,23 @@ class Router:
if _deployment_model_id and self.has_model_id(_deployment_model_id):
return None
warn_on_provider_credential_mismatch(
model_name=deployment.model_name,
litellm_params=deployment.litellm_params.model_dump(exclude_none=True),
)
declared_params: Final = deployment.litellm_params.model_dump(exclude_none=True)
warn_on_provider_credential_mismatch(model_name=deployment.model_name, litellm_params=declared_params)
warn_on_ignored_model_capability_flags(model_name=deployment.model_name, litellm_params=declared_params)
served: Final = without_model_capability_flags(deployment)
# add to model list
_deployment: Final = deployment.to_json(exclude_none=True)
_deployment: Final = served.to_json(exclude_none=True)
# initialize client
self._add_deployment(deployment=deployment)
self._add_deployment(deployment=served)
Router._register_deployment_pricing(deployment=deployment)
Router._register_deployment_pricing(deployment=served)
# add to model names
self._add_model_to_list_and_index_map(model=_deployment, model_id=deployment.model_info.id)
self.model_names.add(deployment.model_name)
self._sync_deployment_budget_config(deployment=deployment)
return deployment
self._add_model_to_list_and_index_map(model=_deployment, model_id=served.model_info.id)
self.model_names.add(served.model_name)
self._sync_deployment_budget_config(deployment=served)
return served
def _update_deployment_indices_after_removal(self, model_id: str, removal_idx: int) -> None:
"""
@ -10463,12 +10470,13 @@ class Router:
"""
_deployment_model_id: Final = deployment.model_info.id or ""
_deployment_on_router: Final[Deployment | None] = self.get_deployment(model_id=_deployment_model_id)
served: Final = without_model_capability_flags(deployment)
try:
if _deployment_on_router is not None:
# deployment with this model_id exists on the router
if (
deployment.model_name == _deployment_on_router.model_name
and deployment.litellm_params == _deployment_on_router.litellm_params
and served.litellm_params == _deployment_on_router.litellm_params
and deployment.model_info == _deployment_on_router.model_info
):
# No need to update

View file

@ -14,9 +14,16 @@ from litellm._logging import verbose_logger, verbose_router_logger
from litellm.constants import ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS
from litellm.exceptions import BadRequestError
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_structure
from litellm.types.router import CredentialLiteLLMParams, FallbackSkipReason, SkippedFallbackTarget
from litellm.types.utils import LlmProviders
from litellm.types.router import (
CredentialLiteLLMParams,
Deployment,
FallbackSkipReason,
LiteLLM_Params,
SkippedFallbackTarget,
)
from litellm.types.utils import LlmProviders, ProviderSpecificModelInfo
_V = TypeVar("_V")
@ -398,6 +405,43 @@ def provider_for_generic_call(litellm_params: Mapping[str, object]) -> str | Non
return inferred
PROVIDER_SPECIFIC_CAPABILITY_FIELDS: Final = frozenset(ProviderSpecificModelInfo.__annotations__)
_LITELLM_PARAMS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
def is_model_capability_field(name: str) -> bool:
return name in PROVIDER_SPECIFIC_CAPABILITY_FIELDS or (
name.startswith("supports_") and name in GetModelCostMap.loaded_model_cost_map_fields()
)
def without_model_capability_flags(deployment: Deployment) -> Deployment:
flags: Final = frozenset(
key for key in (deployment.litellm_params.model_extra or {}) if is_model_capability_field(key)
)
if not flags:
return deployment
declared_params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(deployment.litellm_params.model_dump())
served_params: Final = {key: value for key, value in declared_params.items() if key not in flags}
return deployment.model_copy(update={"litellm_params": LiteLLM_Params.model_validate(served_params)})
def warn_on_ignored_model_capability_flags(model_name: str, litellm_params: Mapping[str, object]) -> str | None:
flags: Final = sorted(
key for key, value in litellm_params.items() if value is not None and is_model_capability_field(key)
)
if not flags:
return None
warning: Final = (
f"Deployment '{model_name}' sets {flags} under litellm_params, where litellm ignores them and never "
"sends them to the provider. Move them under model_info for litellm to use them."
)
verbose_router_logger.warning(warning)
return warning
def warn_on_provider_credential_mismatch(model_name: str, litellm_params: Mapping[str, object]) -> str | None:
"""
Warn when a deployment carries one provider's credentials but resolves to another.

View file

@ -0,0 +1,716 @@
import asyncio
import json
import re
import threading
import uuid
from collections.abc import Callable, Iterator, Mapping
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from itertools import chain
from pathlib import Path
from types import MappingProxyType
from typing import Final
from urllib.parse import unquote
import anthropic
import httpx
import openai
import pytest
import yaml
from integration._support.anthropic_sse import error_body, message_json
from integration._support.client import Gateway, Scenario, eventually, list_value, object_value, string_value
from integration._support.database import read_rows
from integration._support.openai_wire import answering_model_discovery, chat_reply, responses_reply
from integration._support.process import WorkerHealthcheck, graceful_stop_seconds, owned_proxy_process
from integration._support.provider import MODEL_DISCOVERY
from integration._support.upstream import aws_event_stream_frame
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
_MARKER: Final = re.compile(r"capflag-[0-9a-f]{32}")
_FLAG: Final = re.compile(r"^(supports_|bedrock_output_config_effort_ceiling$)")
_OPENAI_MODEL: Final = "openai/gpt-4o-mini"
_EMBEDDING_MODEL: Final = "openai/text-embedding-3-small"
_CLAUDE_MODEL: Final = "anthropic/claude-sonnet-4-5-20250929"
_BEDROCK_ID: Final = "anthropic.claude-3-haiku-20240307-v1:0"
_PROVIDER_KEY: Final = "integration-provider-key"
_REVOKED_KEY: Final = "sk-revoked-capflag"
_AWS: Final[Mapping[str, JsonValue]] = MappingProxyType(
{
"aws_access_key_id": "AKIASCRIPTEDPROVIDER",
"aws_secret_access_key": "scripted-secret",
"aws_region_name": "us-east-1",
}
)
_VISION: Final[Mapping[str, JsonValue]] = MappingProxyType({"supports_vision": True})
_EMBEDDING: Final = [0.25, 0.5]
_JSON: Final = TypeAdapter(dict[str, JsonValue])
_PROXY_CONFIG: Final = Path(__file__).resolve().parents[1] / "proxy_config.yaml"
_FRESH: Final = httpx.Limits(max_keepalive_connections=0)
@dataclass(frozen=True, slots=True)
class _Vendor:
wire: Wire
outage: threading.Event
def outbound(self) -> tuple[Request, ...]:
return tuple(request for request in self.wire.drain() if (request.method, request.target) != MODEL_DISCOVERY)
@dataclass(frozen=True, slots=True)
class _Exchange:
marker: str
text: str
request: Request
Send = Callable[[str, str], str]
def _marker() -> str:
return f"capflag-{uuid.uuid4().hex}"
def _echo(marker: str) -> str:
return f"echo {marker}"
def _body(request: Request) -> dict[str, JsonValue]:
return _JSON.validate_json(request.body) if request.body else {}
def _flags(value: JsonValue) -> tuple[str, ...]:
if isinstance(value, dict):
own: Final = tuple(key for key in value if _FLAG.match(key))
return own + tuple(chain.from_iterable(_flags(item) for item in value.values()))
if isinstance(value, list):
return tuple(chain.from_iterable(_flags(item) for item in value))
return ()
def _openai_error(status: int, message: str, code: str, param: str | None) -> Reply:
error: Final = {"message": message, "type": "invalid_request_error", "param": param, "code": code}
return Reply(status=status, body=json.dumps({"error": error}).encode())
def _rejection(target: str, flag: str) -> Reply:
if target.startswith("/model/"):
return Reply(
status=400,
body=json.dumps(
{"message": f"The model returned the following errors: {flag}: Extra inputs are not permitted"}
).encode(),
headers=MappingProxyType({"x-amzn-ErrorType": "ValidationException"}),
)
if target == "/v1/messages":
return Reply(status=400, body=error_body(400, f"{flag}: Extra inputs are not permitted"))
return _openai_error(400, f"Unknown parameter: '{flag}'.", "unknown_parameter", flag)
def _converse_reply(text: str) -> Reply:
return Reply(
body=json.dumps(
{
"output": {"message": {"role": "assistant", "content": [{"text": text}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 11, "outputTokens": 4, "totalTokens": 15},
"metrics": {"latencyMs": 1},
}
).encode()
)
def _converse_stream_reply(text: str) -> Reply:
events: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = (
("messageStart", {"role": "assistant"}),
("contentBlockDelta", {"delta": {"text": text}, "contentBlockIndex": 0}),
("messageStop", {"stopReason": "end_turn"}),
("metadata", {"usage": {"inputTokens": 11, "outputTokens": 4, "totalTokens": 15}}),
)
return Reply(
content_type="application/vnd.amazon.eventstream",
chunks=tuple(
aws_event_stream_frame(
{":event-type": kind, ":content-type": "application/json", ":message-type": "event"},
json.dumps(payload).encode(),
)
for kind, payload in events
),
)
def _embedding_reply(model: str) -> Reply:
return Reply(
body=json.dumps(
{
"object": "list",
"data": [{"object": "embedding", "index": 0, "embedding": _EMBEDDING}],
"model": model,
"usage": {"prompt_tokens": 2, "total_tokens": 2},
}
).encode()
)
def _answer(target: str, body: Mapping[str, JsonValue], marker: str) -> Reply:
text: Final = _echo(marker)
model: Final = str(body.get("model", "scripted"))
streamed: Final = body.get("stream") is True
match target:
case "/v1/chat/completions":
return chat_reply(f"chatcmpl-{uuid.uuid4().hex}", model, text, stream=streamed)
case "/v1/responses":
return responses_reply(f"resp_{uuid.uuid4().hex}", model, text, stream=streamed)
case "/v1/embeddings":
return _embedding_reply(model)
case "/v1/messages":
return Reply(body=message_json(f"msg_{uuid.uuid4().hex}", model, text))
case _ if target.endswith("/converse-stream"):
return _converse_stream_reply(text)
case _ if target.endswith("/converse"):
return _converse_reply(text)
case _ if target.endswith("/invoke"):
return Reply(body=message_json(f"msg_{uuid.uuid4().hex}", "claude-3-haiku", text))
case _:
return Reply(status=404, body=b'{"error": "unscripted target"}')
def _vendor(outage: threading.Event) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if outage.is_set():
return Reply(drop_connection=True)
if request.headers.get("authorization", "") == f"Bearer {_REVOKED_KEY}":
return _openai_error(401, "Incorrect API key provided: sk-revo****flag.", "invalid_api_key", None)
target: Final = unquote(request.target.split("?")[0])
body: Final = _body(request)
flags: Final = _flags(body)
if flags:
return _rejection(target, flags[0])
found: Final = _MARKER.search(request.body.decode())
return _answer(target, body, found.group(0) if found else "probe")
return answering_model_discovery(respond)
@pytest.fixture
def vendor() -> Iterator[_Vendor]:
outage: Final = threading.Event()
with wire_server(_vendor(outage)) as wire:
yield _Vendor(wire, outage)
def _proxy(gateway: Gateway) -> str:
return str(gateway.client.base_url).rstrip("/")
def _openai_client(gateway: Gateway) -> openai.OpenAI:
return openai.OpenAI(base_url=f"{_proxy(gateway)}/v1", api_key=gateway.key, max_retries=0)
def _chat(gateway: Gateway, extra_body: Mapping[str, JsonValue] | None = None) -> Send:
def send(model: str, marker: str) -> str:
with _openai_client(gateway) as client:
completion: Final = client.chat.completions.create(
model=model, messages=[{"role": "user", "content": marker}], extra_body=dict(extra_body or {})
)
return completion.choices[0].message.content or ""
return send
def _chat_stream(gateway: Gateway) -> Send:
async def streamed(model: str, marker: str) -> str:
async with openai.AsyncOpenAI(base_url=f"{_proxy(gateway)}/v1", api_key=gateway.key, max_retries=0) as client:
stream: Final = await client.chat.completions.create(
model=model, messages=[{"role": "user", "content": marker}], stream=True
)
return "".join([chunk.choices[0].delta.content or "" async for chunk in stream if chunk.choices])
def send(model: str, marker: str) -> str:
return asyncio.run(streamed(model, marker))
return send
def _responses(gateway: Gateway) -> Send:
def send(model: str, marker: str) -> str:
with _openai_client(gateway) as client:
return client.responses.create(model=model, input=marker).output_text
return send
def _embeddings(gateway: Gateway) -> Send:
def send(model: str, marker: str) -> str:
with _openai_client(gateway) as client:
return json.dumps(client.embeddings.create(model=model, input=marker).data[0].embedding)
return send
def _messages(gateway: Gateway) -> Send:
def send(model: str, marker: str) -> str:
with anthropic.Anthropic(base_url=_proxy(gateway), api_key=gateway.key, max_retries=0) as client:
message: Final = client.messages.create(
model=model, max_tokens=16, messages=[{"role": "user", "content": marker}]
)
return "".join(block.text for block in message.content if block.type == "text")
return send
def _family(name: str, wire: Wire) -> dict[str, JsonValue]:
match name:
case "openai":
return {"model": _OPENAI_MODEL, "api_base": f"{wire.url}/v1"}
case "anthropic":
return {"model": _CLAUDE_MODEL, "api_base": wire.url, "api_key": "sk-ant-scripted"}
case "converse":
return {"model": f"bedrock/converse/{_BEDROCK_ID}", "api_base": wire.url, "api_key": None, **_AWS}
case "invoke":
return {
"model": f"bedrock/invoke/{_BEDROCK_ID}",
"api_base": wire.url,
"aws_bedrock_runtime_endpoint": wire.url,
"api_key": None,
**_AWS,
}
case _:
raise AssertionError(f"unknown family {name}")
def _deployment(
scenario: Scenario,
params: Mapping[str, JsonValue],
model_info: Mapping[str, JsonValue] | None = None,
name: str | None = None,
) -> str:
model_name: Final = name or f"capability-flag-{uuid.uuid4().hex}"
body: Final[dict[str, JsonValue]] = {
"model_name": model_name,
"litellm_params": {"api_key": _PROVIDER_KEY, **params},
"model_info": dict(model_info or {}),
}
created: Final = scenario.gateway.post("/model/new", body)
scenario.cleanups.callback(scenario.delete_model, string_value(object_value(created["model_info"])["id"]))
return model_name
def _idle(gateway: Gateway) -> dict[str, JsonValue]:
return {"model": _OPENAI_MODEL, "api_base": f"{gateway.upstream_url}/v1"}
def _pair(scenario: Scenario, params: Mapping[str, JsonValue], flag: Mapping[str, JsonValue]) -> tuple[str, str]:
return _deployment(scenario, {**params, **flag}), _deployment(scenario, params)
def _exchange(vendor: _Vendor, send: Send, model: str) -> _Exchange:
marker: Final = _marker()
text: Final = send(model, marker)
(request,) = vendor.outbound()
return _Exchange(marker, text, request)
def _shape(request: Request) -> tuple[str, JsonValue]:
return unquote(request.target), json.loads(_MARKER.sub("capflag-marker", request.body.decode()))
def _probe_shape(request: Request) -> tuple[str, dict[str, JsonValue]]:
return unquote(request.target), {key: value for key, value in _body(request).items() if key != "messages"}
def _assert_sends_the_control_request(
vendor: _Vendor, send: Send, flagged: str, control: str, expected: Callable[[str], str] = _echo
) -> None:
sent: Final = _exchange(vendor, send, flagged)
baseline: Final = _exchange(vendor, send, control)
assert sent.text == expected(sent.marker), sent.text
assert baseline.text == expected(baseline.marker), baseline.text
assert _flags(_body(sent.request)) == (), sent.request.body
assert _shape(sent.request) == _shape(baseline.request)
def _entry(gateway: Gateway, path: str, name: str, params: Mapping[str, str] | None = None) -> dict[str, JsonValue]:
entries: Final = list_value(gateway.get(path, params)["data"])
(found,) = tuple(object_value(entry) for entry in entries if object_value(entry)["model_name"] == name)
return found
def _model_id(gateway: Gateway, name: str) -> str:
return string_value(object_value(_entry(gateway, "/model/info", name)["model_info"])["id"])
def _health(gateway: Gateway, name: str) -> tuple[int, JsonValue, JsonValue]:
response: Final = gateway.request("GET", "/health", params={"model": name})
health: Final = _JSON.validate_json(response.content)
return response.status_code, health["healthy_count"], health["unhealthy_count"]
def _fresh_request(gateway: Gateway, method: str, path: str, body: Mapping[str, JsonValue] | None) -> httpx.Response:
with httpx.Client(base_url=_proxy(gateway), timeout=60, trust_env=False, limits=_FRESH) as client:
return client.request(method, path, json=body, headers={"Authorization": f"Bearer {gateway.key}"})
def _fresh_chat(gateway: Gateway, model: str, marker: str) -> str:
response: Final = _fresh_request(
gateway, "POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": marker}]}
)
assert response.status_code == 200, response.text
choice: Final = object_value(list_value(_JSON.validate_json(response.content)["choices"])[0])
return string_value(object_value(choice["message"])["content"])
@pytest.mark.parametrize(
("family", "flag"),
[
("openai", {"supports_vision": True}),
("openai", {"supports_speed": True}),
("openai", {"bedrock_output_config_effort_ceiling": "high"}),
("anthropic", {"supports_vision": True}),
("converse", {"supports_max_reasoning_effort": False}),
("invoke", {"supports_max_reasoning_effort": False}),
],
ids=["openai-static", "openai-catalog_only", "openai-ceiling", "anthropic", "bedrock_converse", "bedrock_invoke"],
)
def test_chat_completions_send_a_flagged_deployment_the_control_request(
gateway: Gateway, vendor: _Vendor, family: str, flag: Mapping[str, JsonValue]
) -> None:
with gateway.scenario() as scenario:
flagged, control = _pair(scenario, _family(family, vendor.wire), flag)
_assert_sends_the_control_request(vendor, _chat(gateway), flagged, control)
@pytest.mark.parametrize("family", ["openai", "converse"])
def test_streamed_chat_completions_send_a_flagged_deployment_the_control_request(
gateway: Gateway, vendor: _Vendor, family: str
) -> None:
flag: Final = {"supports_max_reasoning_effort": False} if family == "converse" else _VISION
with gateway.scenario() as scenario:
flagged, control = _pair(scenario, _family(family, vendor.wire), flag)
_assert_sends_the_control_request(vendor, _chat_stream(gateway), flagged, control)
@pytest.mark.parametrize("family", ["openai", "anthropic", "converse"])
def test_messages_send_a_flagged_deployment_the_control_request(gateway: Gateway, vendor: _Vendor, family: str) -> None:
with gateway.scenario() as scenario:
flagged, control = _pair(scenario, _family(family, vendor.wire), _VISION)
_assert_sends_the_control_request(vendor, _messages(gateway), flagged, control)
def test_responses_send_a_flagged_deployment_the_control_request(gateway: Gateway, vendor: _Vendor) -> None:
with gateway.scenario() as scenario:
flagged, control = _pair(scenario, _family("openai", vendor.wire), _VISION)
_assert_sends_the_control_request(vendor, _responses(gateway), flagged, control)
def test_embeddings_send_a_flagged_deployment_the_control_request(gateway: Gateway, vendor: _Vendor) -> None:
params: Final = {"model": _EMBEDDING_MODEL, "api_base": f"{vendor.wire.url}/v1"}
with gateway.scenario() as scenario:
flagged, control = _pair(scenario, params, {"supports_embedding_image_input": True})
_assert_sends_the_control_request(
vendor, _embeddings(gateway), flagged, control, expected=lambda _marker: json.dumps(_EMBEDDING)
)
def _wildcard(scenario: Scenario, wire: Wire, flag: Mapping[str, JsonValue]) -> str:
prefix: Final = f"capflag{uuid.uuid4().hex}"
_deployment(scenario, {"model": "openai/*", "api_base": f"{wire.url}/v1", **flag}, name=f"{prefix}/*")
return f"{prefix}/gpt-4o-mini"
def test_a_flagged_wildcard_deployment_sends_the_control_request(gateway: Gateway, vendor: _Vendor) -> None:
with gateway.scenario() as scenario:
flagged: Final = _wildcard(scenario, vendor.wire, _VISION)
control: Final = _wildcard(scenario, vendor.wire, {})
_assert_sends_the_control_request(vendor, _chat(gateway), flagged, control)
def test_health_probes_a_flagged_deployment_with_the_control_probe(gateway: Gateway, vendor: _Vendor) -> None:
with gateway.scenario() as scenario:
flagged, control = _pair(scenario, _family("openai", vendor.wire), _VISION)
flagged_health: Final = _health(gateway, flagged)
(flagged_probe,) = vendor.outbound()
control_health: Final = _health(gateway, control)
(control_probe,) = vendor.outbound()
assert flagged_health == control_health == (200, 1, 0)
assert _flags(_body(flagged_probe)) == (), flagged_probe.body
assert _probe_shape(flagged_probe) == _probe_shape(control_probe)
def test_test_connection_probes_flagged_params_with_the_unflagged_probe(gateway: Gateway, vendor: _Vendor) -> None:
params: Final[dict[str, JsonValue]] = {
"model": _OPENAI_MODEL,
"api_base": f"{vendor.wire.url}/v1",
"api_key": _PROVIDER_KEY,
}
flagged: Final = gateway.post("/health/test_connection", {"mode": "chat", "litellm_params": {**params, **_VISION}})
(flagged_probe,) = vendor.outbound()
unflagged: Final = gateway.post("/health/test_connection", {"mode": "chat", "litellm_params": params})
(unflagged_probe,) = vendor.outbound()
assert (flagged["status"], unflagged["status"]) == ("success", "success"), (flagged, unflagged)
assert _flags(_body(flagged_probe)) == (), flagged_probe.body
assert _probe_shape(flagged_probe) == _probe_shape(unflagged_probe)
@pytest.mark.parametrize(
"value",
[7, ["vision"], "", "v" * 5120, None, False],
ids=["int", "list", "empty_string", "five_kb", "null", "false"],
)
def test_any_flag_value_sends_the_control_request(gateway: Gateway, vendor: _Vendor, value: JsonValue) -> None:
with gateway.scenario() as scenario:
flagged, control = _pair(scenario, _family("openai", vendor.wire), {"supports_vision": value})
_assert_sends_the_control_request(vendor, _chat(gateway), flagged, control)
def test_a_flag_in_litellm_params_and_model_info_keeps_the_model_info_value(gateway: Gateway, vendor: _Vendor) -> None:
with gateway.scenario() as scenario:
flagged: Final = _deployment(
scenario, {**_family("openai", vendor.wire), **_VISION}, {"supports_vision": False}
)
control: Final = _deployment(scenario, _family("openai", vendor.wire))
_assert_sends_the_control_request(vendor, _chat(gateway), flagged, control)
model_info: Final = object_value(_entry(gateway, "/model/info", flagged)["model_info"])
assert model_info["supports_vision"] is False, model_info
@pytest.mark.parametrize("key", ["supports_litellm_qa_unknown", "supports_system_prompt"])
def test_an_undeclared_or_provider_supports_key_still_reaches_the_vendor(
gateway: Gateway, vendor: _Vendor, key: str
) -> None:
with gateway.scenario() as scenario:
model: Final = _deployment(scenario, {**_family("openai", vendor.wire), key: True})
with pytest.raises(openai.BadRequestError) as raised:
_chat(gateway)(model, _marker())
(request,) = vendor.outbound()
assert f"Unknown parameter: '{key}'." in str(raised.value), raised.value
assert _flags(_body(request)) == (key,), request.body
def test_a_flagged_deployment_with_a_revoked_key_returns_the_vendor_401(gateway: Gateway, vendor: _Vendor) -> None:
with gateway.scenario() as scenario:
flagged: Final = _deployment(scenario, {**_family("openai", vendor.wire), "api_key": _REVOKED_KEY, **_VISION})
control: Final = _deployment(scenario, _family("openai", vendor.wire))
with pytest.raises(openai.AuthenticationError) as raised:
_chat(gateway)(flagged, _marker())
assert len(vendor.outbound()) == 1
marker: Final = _marker()
answered: Final = _chat(gateway)(control, marker)
assert "Incorrect API key provided" in str(raised.value), raised.value
assert answered == _echo(marker)
def test_a_fallback_to_a_flagged_deployment_sends_the_control_request(gateway: Gateway, vendor: _Vendor) -> None:
with gateway.scenario() as scenario:
broken: Final = _deployment(scenario, {**_family("openai", vendor.wire), "api_key": _REVOKED_KEY})
flagged, control = _pair(scenario, _family("openai", vendor.wire), _VISION)
marker: Final = _marker()
answered: Final = _chat(gateway, {"fallbacks": [flagged]})(broken, marker)
rejected, fallback = vendor.outbound()
baseline: Final = _exchange(vendor, _chat(gateway), control)
assert answered == _echo(marker)
assert rejected.headers["authorization"] == f"Bearer {_REVOKED_KEY}"
assert _flags(_body(fallback)) == (), fallback.body
assert _shape(fallback) == _shape(baseline.request)
@pytest.mark.parametrize(
("path", "by_name"), [("/model/info", False), ("/v2/model/info", True)], ids=["model_info", "v2_model_info"]
)
def test_model_info_lists_the_served_params_without_the_flag(gateway: Gateway, path: str, by_name: bool) -> None:
with gateway.scenario() as scenario:
flagged: Final = _deployment(scenario, {**_idle(gateway), **_VISION})
entry: Final = _entry(gateway, path, flagged, {"search": flagged} if by_name else None)
litellm_params: Final = object_value(entry["litellm_params"])
assert litellm_params["model"] == _OPENAI_MODEL, litellm_params
assert "supports_vision" not in litellm_params, litellm_params
def test_model_group_info_reports_a_flagged_group_like_a_plain_one(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
flagged: Final = _deployment(scenario, {**_idle(gateway), **_VISION})
plain: Final = _deployment(scenario, _idle(gateway))
groups: Final = tuple(
object_value(list_value(gateway.get("/model_group/info", {"model_group": name})["data"])[0])
for name in (flagged, plain)
)
flagged_group, plain_group = (
{key: value for key, value in group.items() if key != "model_group"} for group in groups
)
assert flagged_group == plain_group
def test_supports_system_message_false_still_folds_the_system_prompt(gateway: Gateway, vendor: _Vendor) -> None:
instruction: Final = f"answer briefly {uuid.uuid4().hex}"
marker: Final = _marker()
with gateway.scenario() as scenario:
model: Final = _deployment(scenario, {**_family("openai", vendor.wire), "supports_system_message": False})
with _openai_client(gateway) as client:
completion: Final = client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": instruction}, {"role": "user", "content": marker}],
)
(request,) = vendor.outbound()
messages: Final = list_value(_body(request)["messages"])
assert completion.choices[0].message.content == _echo(marker)
assert [object_value(message)["role"] for message in messages] == ["user"], messages
assert instruction in json.dumps(messages) and marker in json.dumps(messages), messages
assert _flags(_body(request)) == (), request.body
def test_three_identical_requests_to_a_flagged_deployment_each_log_once(gateway: Gateway, vendor: _Vendor) -> None:
marker: Final = _marker()
with gateway.scenario() as scenario:
flagged: Final = _deployment(scenario, {**_family("openai", vendor.wire), **_VISION})
with _openai_client(gateway) as client:
identities: Final = tuple(
client.chat.completions.create(
model=flagged,
messages=[{"role": "user", "content": marker}],
extra_body={"cache": {"no-cache": True}},
).id
for _ in range(3)
)
received: Final = vendor.outbound()
rows: Final = eventually(
lambda: read_rows(
'SELECT request_id FROM "LiteLLM_SpendLogs" WHERE request_id IN (%s, %s, %s)', identities
),
lambda found: sorted(str(row["request_id"]) for row in found) == sorted(identities),
seconds=70,
)
assert len(set(identities)) == 3, identities
assert len(rows) == 3, rows
assert len(received) == 3 and len({request.body for request in received}) == 1, received
assert _flags(_body(received[0])) == (), received[0].body
def _rpm_on_fresh_connections(gateway: Gateway, name: str) -> tuple[JsonValue, ...]:
def read() -> JsonValue:
response: Final = _fresh_request(gateway, "GET", "/model/info", None)
entries: Final = list_value(_JSON.validate_json(response.content)["data"])
(entry,) = tuple(object_value(item) for item in entries if object_value(item)["model_name"] == name)
return object_value(entry["litellm_params"]).get("rpm")
return tuple(read() for _ in range(8))
def test_a_flag_patched_onto_a_db_deployment_never_reaches_the_vendor(gateway: Gateway, vendor: _Vendor) -> None:
with gateway.scenario() as scenario:
patched: Final = _deployment(scenario, _family("openai", vendor.wire))
control: Final = _deployment(scenario, _family("openai", vendor.wire))
response: Final = gateway.request(
"PATCH",
f"/model/{_model_id(gateway, patched)}/update",
{"litellm_params": {"supports_vision": True, "rpm": 7}},
)
assert response.status_code == 200, response.text
eventually(lambda: _rpm_on_fresh_connections(gateway, patched), lambda seen: seen == (7,) * 8, seconds=60)
for _ in range(3):
_assert_sends_the_control_request(vendor, _chat(gateway), patched, control)
def test_updates_during_a_burst_never_send_the_flag(gateway: Gateway, vendor: _Vendor) -> None:
markers: Final = tuple(_marker() for _ in range(20))
with gateway.scenario() as scenario:
flagged: Final = _deployment(scenario, {**_family("openai", vendor.wire), **_VISION})
model_id: Final = _model_id(gateway, flagged)
send: Final = _chat(gateway)
with ThreadPoolExecutor(max_workers=8) as pool:
answers: Final = tuple(pool.submit(send, flagged, marker) for marker in markers)
updates: Final = tuple(
gateway.request(
"PATCH", f"/model/{model_id}/update", {"litellm_params": {"rpm": rpm, "supports_vision": True}}
).status_code
for rpm in (11, 12, 13)
)
texts: Final = tuple(answer.result() for answer in answers)
received: Final = vendor.outbound()
assert updates == (200, 200, 200), updates
assert texts == tuple(_echo(marker) for marker in markers), texts
assert len(received) == 20, received
assert [_flags(_body(request)) for request in received] == [()] * 20, received
def _config_with_flagged_model(directory: Path, name: str, wire: Wire) -> Path:
base: Final = _JSON.validate_python(yaml.safe_load(_PROXY_CONFIG.read_text()))
flagged: Final[dict[str, JsonValue]] = {
"model_name": name,
"litellm_params": {"model": _OPENAI_MODEL, "api_key": _PROVIDER_KEY, "api_base": f"{wire.url}/v1", **_VISION},
}
path: Final = directory / "capability_flags_config.yaml"
path.write_text(yaml.safe_dump({**base, "model_list": [*list_value(base["model_list"]), flagged]}))
return path
@pytest.mark.timeout(int(2 * graceful_stop_seconds() + 120))
def test_each_worker_and_a_replacement_send_config_and_db_flagged_deployments_without_the_flag(
gateway: Gateway, vendor: _Vendor, tmp_path: Path
) -> None:
configured: Final = f"capflag-config-{uuid.uuid4().hex}"
config: Final = _config_with_flagged_model(tmp_path, configured, vendor.wire)
with owned_proxy_process(
gateway, tmp_path, {}, config=config, workers=2, healthcheck=WorkerHealthcheck.LENIENT
) as owned:
with owned.gateway.scenario() as scenario:
stored: Final = _deployment(scenario, {**_family("openai", vendor.wire), **_VISION})
stored_id: Final = _model_id(owned.gateway, stored)
eventually(lambda: owned.serves_only_on_every_worker(stored, stored_id), bool, seconds=120)
first, second = sorted(eventually(owned.worker_pids, lambda pids: len(pids) == 2))
with owned.suspended(second):
on_first: Final = tuple((model, _marker()) for model in (configured, stored))
first_texts: Final = tuple(_fresh_chat(owned.gateway, model, marker) for model, marker in on_first)
with owned.suspended(first):
on_second: Final = tuple((model, _marker()) for model in (configured, stored))
second_texts: Final = tuple(_fresh_chat(owned.gateway, model, marker) for model, marker in on_second)
owned.kill_worker(first)
eventually(
lambda: owned.serves_only_on_every_worker(stored, stored_id),
bool,
seconds=graceful_stop_seconds() + 60,
)
replacement: Final = next(iter(owned.worker_pids() - {second}))
with owned.suspended(second):
on_replacement: Final = tuple((model, _marker()) for model in (configured, stored))
replacement_texts: Final = tuple(
_fresh_chat(owned.gateway, model, marker) for model, marker in on_replacement
)
assert replacement not in (first, second)
received: Final = vendor.outbound()
sent: Final = (*on_first, *on_second, *on_replacement)
assert (*first_texts, *second_texts, *replacement_texts) == tuple(_echo(marker) for _, marker in sent)
assert len(received) == 6, received
assert [_flags(_body(request)) for request in received] == [()] * 6, received
def _burst(send: Send, model: str, size: int) -> tuple[str, ...]:
def attempt(marker: str) -> str:
try:
return "ok" if send(model, marker) == _echo(marker) else "wrong text"
except openai.APIError as error:
return type(error).__name__
with ThreadPoolExecutor(max_workers=size) as pool:
return tuple(pool.map(attempt, tuple(_marker() for _ in range(size))))
def test_a_vendor_outage_between_bursts_reports_unhealthy_then_healthy_and_never_sends_the_flag(
gateway: Gateway, vendor: _Vendor
) -> None:
with gateway.scenario() as scenario:
flagged: Final = _deployment(scenario, {**_family("openai", vendor.wire), **_VISION})
send: Final = _chat(gateway)
before: Final = _burst(send, flagged, 10)
healthy_before: Final = _health(gateway, flagged)
vendor.outage.set()
during: Final = _burst(send, flagged, 10)
down: Final = _health(gateway, flagged)
vendor.outage.clear()
up: Final = _health(gateway, flagged)
after: Final = _burst(send, flagged, 10)
received: Final = vendor.outbound()
assert before == after == ("ok",) * 10, (before, after)
assert "ok" not in during, during
assert (healthy_before, down, up) == ((200, 1, 0), (503, 0, 1), (200, 1, 0))
assert [_flags(_body(request)) for request in received] == [()] * len(received), received

View file

@ -8,6 +8,7 @@ import asyncio, importlib, importlib.resources as importlib_get_model, json, lit
import os
import sys
import threading
from typing import Final
import pytest
@ -440,6 +441,22 @@ async def test_loaded_catalog_snapshot_follows_the_fetched_map_and_ignores_later
assert GetModelCostMap.loaded_model_cost_map()["gpt-5.4-mini"]["max_input_tokens"] == 777
@pytest.mark.asyncio
async def test_loaded_catalog_fields_follow_each_fetched_map():
added = json.loads(_real_map_bytes())
added["gpt-5.4-mini"]["supports_litellm_qa_capability"] = True
with_field, _ = _mock_client([httpx.Response(200, content=json.dumps(added).encode())])
without_field, _ = _mock_client([httpx.Response(200, content=_real_map_bytes())])
await refetch_model_cost_map(url=_URL, sleep=_SleepRecorder(), rng=random.Random(0), client=with_field)
fields_after_adding: Final = GetModelCostMap.loaded_model_cost_map_fields()
await refetch_model_cost_map(url=_URL, sleep=_SleepRecorder(), rng=random.Random(0), client=without_field)
assert "supports_litellm_qa_capability" in fields_after_adding
assert "supports_litellm_qa_capability" not in GetModelCostMap.loaded_model_cost_map_fields()
assert "max_input_tokens" in GetModelCostMap.loaded_model_cost_map_fields()
@pytest.mark.asyncio
async def test_refetch_revision_follows_the_bytes_not_the_url():
edited = json.loads(_real_map_bytes())

View file

@ -34,6 +34,22 @@ async def test_update_litellm_params_max_tokens_default(monkeypatch):
assert updated_params["max_tokens"] == 16
def test_update_litellm_params_for_health_check_drops_model_capability_flags():
probe_params: Final = update_litellm_params_for_health_check(
model_info={},
litellm_params={
"model": "bedrock/invoke/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"aws_region_name": "us-west-2",
"supports_max_reasoning_effort": False,
"supports_system_message": False,
},
)
assert "supports_max_reasoning_effort" not in probe_params
assert probe_params["aws_region_name"] == "us-west-2"
assert probe_params["supports_system_message"] is False
@pytest.mark.asyncio
async def test_update_litellm_params_max_tokens_custom():
"""

View file

@ -1,11 +1,14 @@
import logging
from itertools import chain
from typing import Dict, Final, List, Optional, Union
from unittest.mock import Mock
import pytest
import litellm
from litellm import Router
from litellm.constants import ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
from litellm.proxy._types import UserAPIKeyAuth
from litellm.router_utils.common_utils import (
_deployment_supports_web_search,
@ -20,7 +23,8 @@ from litellm.router_utils.common_utils import (
PROVIDER_SCOPED_CREDENTIAL_PARAMS,
warn_on_provider_credential_mismatch,
)
from litellm.types.router import FallbackSkipReason, SkippedFallbackTarget
from litellm.types.router import Deployment, FallbackSkipReason, LiteLLM_Params, ModelInfo, SkippedFallbackTarget
from litellm.types.utils import ProviderSpecificModelInfo
class TestFilterTeamBasedModels:
@ -846,3 +850,97 @@ class TestWarnOnProviderCredentialMismatch:
def test_provider_for_generic_call(litellm_params, expected, monkeypatch):
monkeypatch.setenv("AZURE_AI_API_BASE", "https://unrelated.openai.azure.com")
assert provider_for_generic_call(litellm_params) == expected
def test_router_warns_only_for_a_capability_flag_set_under_litellm_params(caplog):
bedrock_model: Final = "bedrock/invoke/us.anthropic.claude-sonnet-4-5-20250929-v1:0"
with caplog.at_level(logging.WARNING, logger="LiteLLM Router"):
Router(
model_list=[
{
"model_name": "flag-in-litellm-params",
"litellm_params": {"model": bedrock_model, "supports_max_reasoning_effort": False},
},
{
"model_name": "flag-in-model-info",
"litellm_params": {"model": bedrock_model},
"model_info": {"supports_max_reasoning_effort": False},
},
{
"model_name": "declared-litellm-param",
"litellm_params": {"model": bedrock_model, "supports_system_message": False},
},
]
)
capability_warnings: Final = [r.getMessage() for r in caplog.records if "model_info" in r.getMessage()]
assert len(capability_warnings) == 1, capability_warnings
assert "flag-in-litellm-params" in capability_warnings[0]
assert "supports_max_reasoning_effort" in capability_warnings[0]
def test_router_serves_deployments_without_model_capability_flags():
capability_flags: Final = frozenset(ProviderSpecificModelInfo.__annotations__) | frozenset(
key for key in chain.from_iterable(GetModelCostMap.loaded_model_cost_map().values()) if key.startswith("supports_")
)
declared_params: Final = {"supports_system_message": False, "supports_system_prompt": True}
router: Final = Router(
model_list=[
{
"model_name": "every-flag",
"litellm_params": {
"model": "replicate/meta/meta-llama-3-8b-instruct",
**dict.fromkeys(capability_flags - declared_params.keys(), True),
**declared_params,
},
}
]
)
served_params: Final = router.get_model_list()[0]["litellm_params"]
assert sorted(capability_flags & served_params.keys() - declared_params.keys()) == []
assert {key: served_params.get(key) for key in declared_params} == declared_params
def test_router_keeps_a_provider_param_that_another_deployments_model_info_names():
Router(
model_list=[
{
"model_name": "registers-the-name",
"litellm_params": {"model": "openai/litellm-qa-registered-capability"},
"model_info": {"supports_system_prompt": True},
}
]
)
router: Final = Router(
model_list=[
{
"model_name": "replicate-system-prompt",
"litellm_params": {"model": "replicate/meta/meta-llama-3-8b-instruct", "supports_system_prompt": True},
}
]
)
assert router.get_model_list()[0]["litellm_params"].get("supports_system_prompt") is True
def test_router_warns_once_and_serves_no_flag_for_a_deployment_synced_from_the_db(caplog):
router: Final = Router(model_list=[])
db_deployment: Final = Deployment(
model_name="db-model",
litellm_params=LiteLLM_Params(
model="bedrock/invoke/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
supports_max_reasoning_effort=False,
),
model_info=ModelInfo(id="db-model-id"),
)
with caplog.at_level(logging.WARNING, logger="LiteLLM Router"):
first_sync: Final = router.upsert_deployment(db_deployment)
second_sync: Final = router.upsert_deployment(db_deployment)
capability_warnings: Final = [r.getMessage() for r in caplog.records if "model_info" in r.getMessage()]
assert first_sync is not None
assert second_sync is None
assert len(capability_warnings) == 1, capability_warnings
assert "db-model" in capability_warnings[0]
assert "supports_max_reasoning_effort" not in router.get_model_list()[0]["litellm_params"]