diff --git a/api/config.py b/api/config.py index 9ec667a29..5c38aa4ba 100644 --- a/api/config.py +++ b/api/config.py @@ -2522,14 +2522,20 @@ def resolve_model_provider(model_id: str) -> tuple: ) _default_model = model_cfg.get('default') if isinstance(model_cfg, dict) else None # Owns model if it appears in the static catalog for the configured provider. + # _PROVIDER_MODELS is keyed by CANONICAL slug (e.g. 'zai', not the 'z-ai' + # alias a user may write in config), so canonicalise config_provider before + # the lookup — otherwise an aliased active provider gets an empty ownership + # set and _skip_custom_providers guard-2 silently fails, letting another + # providers..models entry hijack an active-owned model (#5511). + _canon_config_provider = _canonicalise_provider_id(config_provider) if config_provider else "" _provider_models_set: set[str] = set() if ( - config_provider is not None - and config_provider in _PROVIDER_MODELS - and isinstance(_PROVIDER_MODELS[config_provider], list) + _canon_config_provider + and _canon_config_provider in _PROVIDER_MODELS + and isinstance(_PROVIDER_MODELS[_canon_config_provider], list) ): _provider_models_set = { - m.get('id', '') for m in _PROVIDER_MODELS[config_provider] + m.get('id', '') for m in _PROVIDER_MODELS[_canon_config_provider] if isinstance(m, dict) and isinstance(m.get('id'), str) } _skip_custom_providers = ( @@ -2577,7 +2583,7 @@ def resolve_model_provider(model_id: str) -> tuple: # active provider (#5511 gate finding — e.g. active ai-gateway + default # gpt-5 was being pulled to providers.openai.models.gpt-5). In that case # restrict the scan to the active provider's own canonical slug. - _active_slug = _canonicalise_provider_id(config_provider) if config_provider else "" + _active_slug = _canon_config_provider for slug, pdef in providers_cfg.items(): if not isinstance(pdef, dict): continue diff --git a/tests/test_model_resolver.py b/tests/test_model_resolver.py index d24fe96d4..6d05dbbc8 100644 --- a/tests/test_model_resolver.py +++ b/tests/test_model_resolver.py @@ -845,3 +845,29 @@ def test_providers_scan_active_provider_own_entry_still_matches_5511(): ) assert provider == 'myprov', f"active provider's own entry must match; got {provider!r}" assert base_url == 'https://my.example/v1' + + +def test_providers_scan_ownership_guard_canonicalises_aliased_active_provider_5511(): + """An ALIASED active provider (e.g. 'z-ai' → canonical 'zai') must still be + recognized as owning its catalog models, so another providers..models + entry can't hijack an active-owned model (#5511 latent-bug gate finding — + _provider_models_set was built with the raw alias, missing the canonical + _PROVIDER_MODELS key, so the ownership guard silently failed).""" + import api.config as config + # Pick a real catalog model id owned by the canonical 'zai' provider. + zai_models = config._PROVIDER_MODELS.get('zai') or [] + zai_ids = [m.get('id') for m in zai_models if isinstance(m, dict) and m.get('id')] + if not zai_ids: + import pytest + pytest.skip("no zai catalog models to exercise the alias ownership guard") + owned = zai_ids[0] + model, provider, base_url = _resolve_with_providers( + owned, + {'openai': {'models': [owned]}}, + provider='z-ai', # aliased form the user may write in config + ) + assert config._canonicalise_provider_id('z-ai') == 'zai' + assert provider == 'z-ai', ( + "aliased active provider (z-ai→zai) that owns the model must not be " + f"hijacked by providers.openai.models; got {provider!r}" + )