diff --git a/api/config.py b/api/config.py index 2704d066d..bcbc71667 100644 --- a/api/config.py +++ b/api/config.py @@ -2515,8 +2515,19 @@ def model_with_provider_context(model_id: str, model_provider: str | None = None if provider == "openrouter": return f"@{provider}:{model}" - # For non-OpenRouter slash IDs, keep the ID intact so existing custom/proxy - # base_url routing and portal-provider handling remain in charge. + # Explicit providers configured in config.yaml (for example local llama.cpp, + # Ollama, LM Studio, vLLM, or other OpenAI-compatible endpoints) must keep + # their provider hint even when the model ID is HuggingFace-style and + # contains '/'. Otherwise a selected local model such as + # 'unsloth/gemma-4-12b-it-GGUF:UD-Q4_K_XL' inherits the default provider + # (e.g. openai-codex) and is sent to the wrong backend. + providers_cfg = cfg.get("providers") if isinstance(cfg, dict) else {} + if isinstance(providers_cfg, dict) and provider in providers_cfg: + return f"@{provider}:{model}" + + # For non-OpenRouter slash IDs without an explicit configured provider, + # keep the ID intact so existing custom/proxy base_url routing and + # portal-provider handling remain in charge. if "/" in model: return model diff --git a/api/routes.py b/api/routes.py index a637ad56d..e8988bf77 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3713,6 +3713,15 @@ def _resolve_compatible_session_model_state( """ model = str(model_id or "").strip() requested_provider = _clean_session_model_provider(model_provider) + if model and requested_provider and model.startswith(f"@{requested_provider}:"): + try: + from api.config import cfg as _active_cfg + + providers_cfg = _active_cfg.get("providers") if isinstance(_active_cfg, dict) else {} + except Exception: + providers_cfg = {} + if isinstance(providers_cfg, dict) and requested_provider in providers_cfg: + return model, requested_provider, False if model and requested_provider: # Only safe when the model itself does not carry an ``@provider:model`` # qualifier — qualified strings require the catalog to decide whether diff --git a/tests/test_issue1855_resolve_model_provider_fast_path.py b/tests/test_issue1855_resolve_model_provider_fast_path.py index 0cbb321d8..4e91256ce 100644 --- a/tests/test_issue1855_resolve_model_provider_fast_path.py +++ b/tests/test_issue1855_resolve_model_provider_fast_path.py @@ -30,7 +30,6 @@ Coverage: through). """ import pathlib -import re from unittest.mock import patch @@ -175,6 +174,40 @@ class TestSlowPathStillFires: "cross-provider artifacts (#1253)." ) + def test_configured_provider_qualified_model_skips_catalog(self): + """Configured providers already carry trusted routing context.""" + import api.config as config + from api.routes import _resolve_compatible_session_model_state + + old_cfg = dict(config.cfg) + config.cfg["model"] = { + "provider": "openai-codex", + "default": "gpt-5.5", + } + config.cfg["providers"] = { + "local-llama": { + "base_url": "http://127.0.0.1:8088/v1", + "api_key": "test-key", + }, + } + try: + with patch("api.routes.get_available_models") as mock_catalog: + mock_catalog.side_effect = AssertionError("catalog should not be called") + result = _resolve_compatible_session_model_state( + "@local-llama:unsloth/gemma-4-12b-it-GGUF:UD-Q4_K_XL", + "local-llama", + ) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + + assert result == ( + "@local-llama:unsloth/gemma-4-12b-it-GGUF:UD-Q4_K_XL", + "local-llama", + False, + ) + assert mock_catalog.call_count == 0 + def test_no_requested_provider_goes_to_slow_path(self): """When provider isn't supplied, slow path must repair from catalog.""" from api.routes import _resolve_compatible_session_model_state diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index 1318dea0e..42f57c079 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -522,6 +522,37 @@ def test_non_openrouter_slash_model_provider_context_stays_unqualified(): assert runtime_model == "anthropic/claude-sonnet-4.6" +def test_configured_provider_slash_model_keeps_provider_context(): + """Configured OpenAI-compatible providers need explicit context for slash IDs.""" + import api.config as config + + old_cfg = dict(config.cfg) + config.cfg["model"] = { + "provider": "openai-codex", + "default": "gpt-5.5", + } + config.cfg["providers"] = { + "local-llama": { + "base_url": "http://127.0.0.1:8088/v1", + "api_key": "test-key", + }, + } + try: + runtime_model = config.model_with_provider_context( + "unsloth/gemma-4-12b-it-GGUF:UD-Q4_K_XL", + "local-llama", + ) + model, provider, base_url = config.resolve_model_provider(runtime_model) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + + assert runtime_model == "@local-llama:unsloth/gemma-4-12b-it-GGUF:UD-Q4_K_XL" + assert model == "unsloth/gemma-4-12b-it-GGUF:UD-Q4_K_XL" + assert provider == "local-llama" + assert base_url == "http://127.0.0.1:8088/v1" + + def test_cursor_acp_slash_model_always_gets_provider_hint(): """ACP subprocess models with '/' must not fall through to config default.""" import api.config as config