fix(proxy): keep routed models in wildcard model info, skip key-only OpenRouter catalogs, cache failed listings briefly

This commit is contained in:
mateo-berri 2026-10-28 15:59:45 -07:00
parent 1d5caf0b1f
commit 99f6fa1364
5 changed files with 74 additions and 4 deletions

View file

@ -30,6 +30,8 @@ _CREDENTIAL_LITELLM_PARAM_FIELDS = set(CredentialLiteLLMParams.model_fields)
_MODEL_LISTING_POOL: Final = ThreadPoolExecutor(thread_name_prefix="litellm-model-listing")
_PROVIDERS_LISTED_ONLY_THROUGH_A_DEPLOYMENT: Final = frozenset({LlmProviders.OPENROUTER.value})
_Listed: Final = TypeVar("_Listed")
@ -495,6 +497,20 @@ def get_known_models_from_wildcard(
return list(dict.fromkeys(suffix_appended_wildcard_models))
@functools.lru_cache(maxsize=256)
def _wildcard_name_regex(name_pattern: str) -> re.Pattern[str]:
return re.compile(PatternMatchRouter().pattern_to_regex(name_pattern))
def _routed_deployment_model(name_pattern: str, listed_name: str, deployment_model: str) -> str:
name_match: Final = _wildcard_name_regex(name_pattern).match(listed_name)
if name_match is None:
return listed_name
return PatternMatchRouter.set_deployment_model_name(
matched_pattern=name_match, litellm_deployment_litellm_model=deployment_model
)
def expand_wildcard_deployments_for_model_info(
deployments: list[dict[str, object]],
llm_router: Router | None = None,
@ -546,12 +562,18 @@ def expand_wildcard_deployments_for_model_info(
row["model_name"] = name
params = row.get("litellm_params")
if isinstance(params, dict):
params["model"] = name
params["model"] = _routed_deployment_model(model_name, name, litellm_model)
expanded.append(row)
return expanded
def _known_models_without_a_deployment(wildcard_model: str) -> Sequence[str]:
if wildcard_model.partition("/")[0] in _PROVIDERS_LISTED_ONLY_THROUGH_A_DEPLOYMENT:
return ()
return get_known_models_from_wildcard(wildcard_model=wildcard_model, litellm_params=None)
def _get_wildcard_models(
unique_models: list[str],
return_wildcard_routes: bool | None = False,
@ -582,7 +604,7 @@ def _get_wildcard_models(
)
)
else:
all_wildcard_models.extend(get_known_models_from_wildcard(wildcard_model=model, litellm_params=None))
all_wildcard_models.extend(_known_models_without_a_deployment(model))
for model in models_to_remove:
unique_models.remove(model)

View file

@ -8386,15 +8386,18 @@ class AvailableModelsCache(InMemoryCache):
custom_llm_provider: str,
litellm_params: LiteLLM_Params | None,
available_models: list[str],
ttl: float | None = None,
):
"""Set cached model info"""
cache_key: Final = self._get_cache_key(custom_llm_provider, litellm_params)
self.set_cache(cache_key, copy.deepcopy(available_models))
self.set_cache(cache_key, copy.deepcopy(available_models), ttl=ttl)
# Global cache instance
_model_cache: Final = AvailableModelsCache()
_FAILED_PROVIDER_LISTING_TTL_SECONDS: Final = 30
def _infer_valid_provider_from_env_vars(
custom_llm_provider: str | None = None,
@ -8455,6 +8458,9 @@ def _get_valid_models_from_provider_api(
return models
except Exception as e:
verbose_logger.warning("Error getting valid models: %s", e)
_model_cache.set_cached_model_info(
custom_llm_provider, litellm_params, [], ttl=_FAILED_PROVIDER_LISTING_TTL_SECONDS
)
return []

View file

@ -1185,6 +1185,35 @@ def test_openrouter_wildcard_lists_the_live_catalog_with_each_vendor_namespace(
)
@respx.mock
def test_key_granted_openrouter_wildcard_without_an_openrouter_deployment_lists_no_environment_catalog(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import litellm
from litellm import Router
from litellm.proxy.auth.model_checks import get_complete_model_list
monkeypatch.setattr(litellm, "check_provider_endpoint", True)
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-environment-only")
catalog: Final = respx.get("https://openrouter.ai/api/v1/models/user").mock(
return_value=httpx.Response(200, json={"data": [{"id": "anthropic/claude-haiku-5-5"}]})
)
router: Final = Router(
model_list=[{"model_name": "or-team/*", "litellm_params": {"model": "openrouter/*", "api_key": "sk-or-team"}}]
)
listed: Final = get_complete_model_list(
key_models=["openrouter/*"],
team_models=[],
proxy_model_list=router.model_names,
user_model=None,
infer_model_from_keys=False,
llm_router=router,
)
assert (listed, catalog.call_count) == ([], 0)
@respx.mock
def test_custom_prefix_that_starts_a_discovered_id_still_prefixes_every_listed_model(
monkeypatch: pytest.MonkeyPatch,

View file

@ -572,7 +572,7 @@ async def test_v1_model_info_star_wildcard_filter_keeps_provider_expansion(monke
allowed_model_names=allowed_model_names,
)
assert [model["model_name"] for model in result] == ["gpt-4o"]
assert [(model["model_name"], model["litellm_params"]["model"]) for model in result] == [("gpt-4o", "openai/gpt-4o")]
assert router.wildcard_routed_models("gpt-4o", None) == frozenset({"openai/gpt-4o"})

View file

@ -8895,6 +8895,19 @@ def reset_mock_cache() -> None:
_model_cache.flush_cache()
@respx.mock
def test_get_valid_models_does_not_ask_a_failing_provider_again_on_the_next_listing(reset_mock_cache: None) -> None:
catalog: Final = respx.get("https://openrouter.ai/api/v1/models/user").mock(return_value=httpx.Response(503))
deployment: Final = LiteLLM_Params(model="openrouter/*", api_key="sk-or-during-an-outage")
listings: Final = tuple(
get_valid_models(check_provider_endpoint=True, custom_llm_provider="openrouter", litellm_params=deployment)
for _ in range(2)
)
assert (listings, catalog.call_count) == (([], []), 1)
@pytest.fixture
def isolated_openai_model_sets(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(