From e829fa50d540972cb0d1b8d8772fae5633c743f0 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sun, 5 Apr 2026 13:58:37 -0700 Subject: [PATCH] fix: OpenRouter models stripped of prefix, causing 404 (#116) When config has provider=openrouter and model=openrouter/free, resolve_model_provider() stripped the 'openrouter/' prefix because prefix == config_provider. This sent 'free' to OpenRouter's API, which returned 404 (model not found). OpenRouter always needs the full provider/model path (e.g. openrouter/free, anthropic/claude-sonnet-4.6). The prefix-stripping logic is only correct for direct-API providers. Fix: skip prefix stripping entirely when config_provider is 'openrouter'. Return the full model_id with provider='openrouter'. Added 7 unit tests for resolve_model_provider() covering: - openrouter/free keeps full path (the bug) - openrouter cross-provider models keep full path - direct API providers still strip prefix correctly - cross-provider routing to openrouter - bare model names use config provider - empty model returns defaults Co-authored-by: Claude Opus 4.6 (1M context) --- api/config.py | 4 ++ tests/test_model_resolver.py | 100 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/test_model_resolver.py diff --git a/api/config.py b/api/config.py index 6742652..ba6b5de 100644 --- a/api/config.py +++ b/api/config.py @@ -391,6 +391,10 @@ def resolve_model_provider(model_id: str) -> tuple: if '/' in model_id: prefix, bare = model_id.split('/', 1) + # OpenRouter always needs the full provider/model path (e.g. openrouter/free, + # anthropic/claude-sonnet-4.6). Never strip the prefix for OpenRouter. + if config_provider == 'openrouter': + return model_id, 'openrouter', config_base_url # If prefix matches config provider exactly, strip it and use that provider directly. # e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API if config_provider and prefix == config_provider: diff --git a/tests/test_model_resolver.py b/tests/test_model_resolver.py new file mode 100644 index 0000000..a80ff62 --- /dev/null +++ b/tests/test_model_resolver.py @@ -0,0 +1,100 @@ +""" +Tests for resolve_model_provider() model routing logic. +Verifies that model IDs are correctly resolved to (model, provider, base_url) +tuples for different provider configurations. +""" +import api.config as config + + +def _resolve_with_config(model_id, provider=None, base_url=None, default=None): + """Helper: temporarily set config.cfg model section, call resolve, restore.""" + old_cfg = dict(config.cfg) + model_cfg = {} + if provider: + model_cfg['provider'] = provider + if base_url: + model_cfg['base_url'] = base_url + if default: + model_cfg['default'] = default + config.cfg['model'] = model_cfg if model_cfg else {} + try: + return config.resolve_model_provider(model_id) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + + +# ── OpenRouter prefix handling ──────────────────────────────────────────── + +def test_openrouter_free_keeps_full_path(): + """openrouter/free must NOT be stripped to 'free' when provider is openrouter.""" + model, provider, base_url = _resolve_with_config( + 'openrouter/free', provider='openrouter', + base_url='https://openrouter.ai/api/v1', + ) + assert model == 'openrouter/free', f"Expected 'openrouter/free', got '{model}'" + assert provider == 'openrouter' + + +def test_openrouter_model_with_provider_prefix(): + """anthropic/claude-sonnet-4.6 via openrouter keeps full path.""" + model, provider, base_url = _resolve_with_config( + 'anthropic/claude-sonnet-4.6', provider='openrouter', + base_url='https://openrouter.ai/api/v1', + ) + assert model == 'anthropic/claude-sonnet-4.6' + assert provider == 'openrouter' + + +# ── Direct provider prefix stripping ───────────────────────────────────── + +def test_anthropic_prefix_stripped_for_direct_api(): + """anthropic/claude-sonnet-4.6 strips prefix when provider is anthropic.""" + model, provider, base_url = _resolve_with_config( + 'anthropic/claude-sonnet-4.6', provider='anthropic', + ) + assert model == 'claude-sonnet-4.6' + assert provider == 'anthropic' + + +def test_openai_prefix_stripped_for_direct_api(): + """openai/gpt-5.4-mini strips prefix when provider is openai.""" + model, provider, base_url = _resolve_with_config( + 'openai/gpt-5.4-mini', provider='openai', + ) + assert model == 'gpt-5.4-mini' + assert provider == 'openai' + + +# ── Cross-provider routing ─────────────────────────────────────────────── + +def test_cross_provider_routes_through_openrouter(): + """Picking openai model when config is anthropic routes via openrouter.""" + model, provider, base_url = _resolve_with_config( + 'openai/gpt-5.4-mini', provider='anthropic', + ) + assert model == 'openai/gpt-5.4-mini' + assert provider == 'openrouter' + assert base_url is None # openrouter uses its own endpoint + + +# ── Bare model names ───────────────────────────────────────────────────── + +def test_bare_model_uses_config_provider(): + """A model name without / uses the config provider and base_url.""" + model, provider, base_url = _resolve_with_config( + 'gemma-4-26B', provider='custom', + base_url='http://192.168.1.160:4000', + ) + assert model == 'gemma-4-26B' + assert provider == 'custom' + assert base_url == 'http://192.168.1.160:4000' + + +def test_empty_model_returns_config_defaults(): + """Empty model string returns config provider and base_url.""" + model, provider, base_url = _resolve_with_config( + '', provider='anthropic', + ) + assert model == '' + assert provider == 'anthropic'