From ed183784d4d580b90e1860f4c215d99491e07663 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sun, 10 May 2026 10:49:12 -0700 Subject: [PATCH] fix: prefer active provider for default model overlap --- api/config.py | 32 +++++++++++++++++----- tests/test_model_resolver.py | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/api/config.py b/api/config.py index d3df1cef..dd0073d3 100644 --- a/api/config.py +++ b/api/config.py @@ -1517,18 +1517,36 @@ def resolve_model_provider(model_id: str) -> tuple: # Custom providers declared in config.yaml should win over slash-based # OpenRouter heuristics. Their model IDs commonly contain '/' too. - custom_providers = cfg.get("custom_providers", []) - if isinstance(custom_providers, list): + # However, when the active provider is an explicit non-custom provider and + # the requested model_id is the configured default model, that active + # provider takes precedence over overlapping custom_providers[] entries. + # Otherwise WebUI routes to custom: instead of the intended endpoint + # and can surface a 401 from the wrong provider (#1922). + # For all other cases, preserve custom_providers[] routing for explicitly + # selected custom provider models. + _is_explicit_non_custom_provider = ( + config_provider is not None + and config_provider != 'custom' + and not config_provider.startswith('custom:') + ) + _default_model = model_cfg.get('default') if isinstance(model_cfg, dict) else None + _skip_custom_providers = ( + _is_explicit_non_custom_provider + and _default_model is not None + and model_id == _default_model + ) + custom_providers = cfg.get('custom_providers', []) + if isinstance(custom_providers, list) and not _skip_custom_providers: for entry in custom_providers: if not isinstance(entry, dict): continue - entry_model = (entry.get("model") or "").strip() - entry_name = (entry.get("name") or "").strip() - entry_base_url = (entry.get("base_url") or "").strip() + entry_model = (entry.get('model') or '').strip() + entry_name = (entry.get('name') or '').strip() + entry_base_url = (entry.get('base_url') or '').strip() entry_model_ids = set() if entry_model: entry_model_ids.add(entry_model) - entry_models = entry.get("models") + entry_models = entry.get('models') if isinstance(entry_models, dict): entry_model_ids.update( key.strip() @@ -1536,7 +1554,7 @@ def resolve_model_provider(model_id: str) -> tuple: if isinstance(key, str) and key.strip() ) if entry_name and model_id in entry_model_ids: - provider_hint = "custom:" + entry_name.lower().replace(" ", "-") + provider_hint = 'custom:' + entry_name.lower().replace(' ', '-') return model_id, provider_hint, entry_base_url or None # @provider:model format — explicit provider hint from the dropdown. diff --git a/tests/test_model_resolver.py b/tests/test_model_resolver.py index 410e77f6..1bc45831 100644 --- a/tests/test_model_resolver.py +++ b/tests/test_model_resolver.py @@ -179,6 +179,58 @@ def test_custom_provider_models_dict_routes_to_named_custom_provider(): assert base_url == 'http://127.0.0.1:8080/v1' +# ── Issue #1922: default model shadowed by overlapping custom_providers[] ── + +def test_default_model_not_shadowed_by_overlapping_custom_provider(): + r'''Regression test for #1922. + + When the active provider is an explicit non-custom provider (e.g. ai-gateway, + openrouter, xiaomi) AND the requested model_id matches the configured default + model, the active provider's base_url must take precedence over an overlapping + custom_providers[] entry. Otherwise the WebUI routes to 'custom:' with + the wrong endpoint, causing 401 errors. + + This test mirrors the reported scenario: + - provider: ai-gateway + - base_url: https://api.ai-gateway.example/v1 + - default: gpt-5.4 + - An overlapping custom_providers[] entry with the same default model + + Expected: active provider (ai-gateway) wins over custom provider. + ''' + model, provider, base_url = _resolve_with_config( + 'gpt-5.4', + provider='ai-gateway', + base_url='https://api.ai-gateway.example/v1', + default='gpt-5.4', + custom_providers=[{ + 'name': 'My Custom Endpoint', + 'base_url': 'http://localhost:8080/v1', + 'model': 'gpt-5.4', + }], + ) + assert model == 'gpt-5.4', f'Expected model=gpt-5.4, got {model!r}' + assert provider == 'ai-gateway', f'Expected provider=ai-gateway, got {provider!r}' + assert base_url == 'https://api.ai-gateway.example/v1', f'Expected base_url from active provider, got {base_url!r}' + + +def test_default_model_shadowed_with_xiaomi_provider(): + r'''Same regression test with provider=xiaomi instead of ai-gateway.''' + model, provider, base_url = _resolve_with_config( + 'deepseek-v4-flash', + provider='xiaomi', + default='deepseek-v4-flash', + custom_providers=[{ + 'name': 'LiteLLM Proxy', + 'base_url': 'http://127.0.0.1:8080/v1', + 'model': 'deepseek-v4-flash', + }], + ) + assert model == 'deepseek-v4-flash' + assert provider == 'xiaomi' + assert base_url is None # xiaomi has no config base_url in this test + + # ── get_available_models() @provider: hint behaviour ──────────────────────