diff --git a/api/config.py b/api/config.py index a75e4518..9931748b 100644 --- a/api/config.py +++ b/api/config.py @@ -2541,6 +2541,31 @@ def resolve_model_provider(model_id: str) -> tuple: m.get('id', '') for m in _PROVIDER_MODELS[_canon_config_provider] if isinstance(m, dict) and isinstance(m.get('id'), str) } + # The active provider may be defined ENTIRELY via config.yaml `providers:` + # (no static _PROVIDER_MODELS entry) with its own `models:` allowlist. Fold + # that allowlist into the ownership set too, so the active provider owns its + # own declared models and can't be hijacked by another providers. + # entry that happens to list the same bare id earlier in config order (#5511). + if _canon_config_provider: + _providers_cfg_own = cfg.get('providers', {}) + if isinstance(_providers_cfg_own, dict): + for _slug, _pdef in _providers_cfg_own.items(): + if not isinstance(_pdef, dict): + continue + if _canonicalise_provider_id(_slug) != _canon_config_provider: + continue + if _canon_config_provider == "copilot": + continue # copilot.models is a settings map, not an allowlist + _own_models = _pdef.get('models') + if isinstance(_own_models, list): + for _m in _own_models: + _mid = str(_m.get('id') or '') if isinstance(_m, dict) else str(_m or '') + if _mid.strip(): + _provider_models_set.add(_mid.strip()) + elif isinstance(_own_models, dict): + _provider_models_set.update( + str(k).strip() for k in _own_models if isinstance(k, str) and str(k).strip() + ) _skip_custom_providers = ( _is_explicit_non_custom_provider and ( diff --git a/tests/test_model_resolver.py b/tests/test_model_resolver.py index 37b65f70..0f6f5ee5 100644 --- a/tests/test_model_resolver.py +++ b/tests/test_model_resolver.py @@ -897,3 +897,25 @@ def test_providers_scan_ownership_guard_canonicalises_gemini_alias_5511(): "aliased active Gemini provider that owns the model must not be hijacked " f"by providers.openai.models; got {provider!r}" ) + + +def test_providers_scan_active_own_providers_entry_owns_over_other_slug_5511(): + """An active provider defined purely via config.yaml `providers:` (no static + catalog entry) owns the models in its OWN providers..models allowlist, + so another provider's entry listing the same bare id (even earlier in config + order) must NOT hijack it (#5511 gate finding 5).""" + # 'openai' entry lists gpt-x first, but the ACTIVE provider (myprov) also + # declares gpt-x in its own providers.myprov.models — active must win. + model, provider, base_url = _resolve_with_providers( + 'gpt-x', + { + 'openai': {'models': ['gpt-x']}, + 'myprov': {'base_url': 'https://my.example/v1', 'models': ['gpt-x']}, + }, + provider='myprov', + ) + assert provider == 'myprov', ( + "active provider's own providers: allowlist must own its model over " + f"another slug's overlapping entry; got {provider!r}" + ) + assert base_url == 'https://my.example/v1'