""" 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, custom_providers=None): """Helper: temporarily set config.cfg model/custom provider sections, 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 {} if custom_providers is not None: config.cfg['custom_providers'] = custom_providers 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' # ── @provider:model hint routing (Issue #138 v2) ──────────────────────── def test_provider_hint_routes_to_specific_provider(): """@minimax:MiniMax-M2.7 routes to minimax provider directly.""" model, provider, base_url = _resolve_with_config( '@minimax:MiniMax-M2.7', provider='anthropic', ) assert model == 'MiniMax-M2.7' assert provider == 'minimax' assert base_url is None # resolve_runtime_provider will fill this def test_provider_hint_zai(): """@zai:GLM-5 routes to zai provider directly.""" model, provider, base_url = _resolve_with_config( '@zai:GLM-5', provider='openai', ) assert model == 'GLM-5' assert provider == 'zai' def test_provider_hint_deepseek(): """@deepseek:deepseek-chat routes to deepseek provider.""" model, provider, base_url = _resolve_with_config( '@deepseek:deepseek-chat', provider='anthropic', ) assert model == 'deepseek-chat' assert provider == 'deepseek' def test_slash_prefix_non_default_still_routes_openrouter(): """minimax/MiniMax-M2.7 (old format) still routes through openrouter.""" model, provider, base_url = _resolve_with_config( 'minimax/MiniMax-M2.7', provider='anthropic', ) assert model == 'minimax/MiniMax-M2.7' assert provider == 'openrouter' def test_custom_provider_model_with_slash_routes_to_named_custom_provider(): """Slash-containing custom endpoint model IDs must not be mistaken for OpenRouter models.""" model, provider, base_url = _resolve_with_config( 'google/gemma-4-26b-a4b', provider='openrouter', base_url='https://openrouter.ai/api/v1', custom_providers=[{ 'name': 'Local LM Studio', 'base_url': 'http://lmstudio.local:1234/v1', 'model': 'google/gemma-4-26b-a4b', }], ) assert model == 'google/gemma-4-26b-a4b' assert provider == 'custom:local-lm-studio' assert base_url == 'http://lmstudio.local:1234/v1' # ── get_available_models() @provider: hint behaviour ────────────────────── def _available_models_with_provider(provider): """Helper: temporarily set active_provider in config.""" old_cfg = dict(config.cfg) config.cfg['model'] = {'provider': provider} try: return config.get_available_models() finally: config.cfg.clear() config.cfg.update(old_cfg) def test_non_default_provider_models_use_hint_prefix(): """With anthropic as default, minimax model IDs should use @minimax: prefix.""" result = _available_models_with_provider('anthropic') groups = {g['provider']: g['models'] for g in result['groups']} if 'MiniMax' in groups: for m in groups['MiniMax']: assert m['id'].startswith('@minimax:'), ( f"Expected @minimax: prefix, got: {m['id']!r}" ) def test_no_duplicate_when_default_model_is_prefixed(): """Issue #147 Bug 2: 'anthropic/claude-opus-4.6' as default_model must not inject a duplicate alongside the existing bare 'claude-opus-4.6' entry in the same provider group.""" import api.config as _cfg old_cfg = dict(_cfg.cfg) _cfg.cfg['model'] = { 'provider': 'anthropic', 'default': 'anthropic/claude-opus-4.6', } try: result = _cfg.get_available_models() norm = lambda mid: mid.split('/', 1)[-1] if '/' in mid else mid # Check each group individually: no group should have two entries that # normalize to the same bare model name for g in result['groups']: bare_ids = [norm(m['id']) for m in g['models']] duplicates = [mid for mid in set(bare_ids) if bare_ids.count(mid) > 1] assert not duplicates, ( f"Provider group '{g['provider']}' has duplicate models after normalization: " f"{duplicates}\nFull group: {[m['id'] for m in g['models']]}" ) finally: _cfg.cfg.clear() _cfg.cfg.update(old_cfg) def test_default_provider_models_not_prefixed(): """The active provider's models remain bare (no @prefix added).""" import api.config as _cfg raw_anthropic_ids = {m['id'] for m in _cfg._PROVIDER_MODELS.get('anthropic', [])} result = _available_models_with_provider('anthropic') groups = {g['provider']: g['models'] for g in result['groups']} if 'Anthropic' in groups: returned_ids = {m['id'] for m in groups['Anthropic']} for bare_id in raw_anthropic_ids: assert bare_id in returned_ids, ( f"_PROVIDER_MODELS entry '{bare_id}' is missing from the Anthropic group" )