diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a7a4639784..940bdfd450 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1174,6 +1174,18 @@ def _to_async_client(sync_client, model: str): return AsyncOpenAI(**async_kwargs), model +def _normalize_resolved_model(model_name: Optional[str], provider: str) -> Optional[str]: + """Normalize a resolved model for the provider that will receive it.""" + if not model_name: + return model_name + try: + from hermes_cli.model_normalize import normalize_model_for_provider + + return normalize_model_for_provider(model_name, provider) + except Exception: + return model_name + + def resolve_provider_client( provider: str, model: str = None, @@ -1236,7 +1248,7 @@ def resolve_provider_client( logger.warning("resolve_provider_client: openrouter requested " "but OPENROUTER_API_KEY not set") return None, None - final_model = model or default + final_model = _normalize_resolved_model(model or default, provider) return (_to_async_client(client, final_model) if async_mode else (client, final_model)) @@ -1247,7 +1259,7 @@ def resolve_provider_client( logger.warning("resolve_provider_client: nous requested " "but Nous Portal not configured (run: hermes auth)") return None, None - final_model = model or default + final_model = _normalize_resolved_model(model or default, provider) return (_to_async_client(client, final_model) if async_mode else (client, final_model)) @@ -1261,7 +1273,7 @@ def resolve_provider_client( logger.warning("resolve_provider_client: openai-codex requested " "but no Codex OAuth token found (run: hermes model)") return None, None - final_model = model or _CODEX_AUX_MODEL + final_model = _normalize_resolved_model(model or _CODEX_AUX_MODEL, provider) raw_client = OpenAI(api_key=codex_token, base_url=_CODEX_AUX_BASE_URL) return (raw_client, final_model) # Standard path: wrap in CodexAuxiliaryClient adapter @@ -1270,7 +1282,7 @@ def resolve_provider_client( logger.warning("resolve_provider_client: openai-codex requested " "but no Codex OAuth token found (run: hermes model)") return None, None - final_model = model or default + final_model = _normalize_resolved_model(model or default, provider) return (_to_async_client(client, final_model) if async_mode else (client, final_model)) @@ -1289,7 +1301,10 @@ def resolve_provider_client( "but base_url is empty" ) return None, None - final_model = model or _read_main_model() or "gpt-4o-mini" + final_model = _normalize_resolved_model( + model or _read_main_model() or "gpt-4o-mini", + provider, + ) extra = {} if "api.kimi.com" in custom_base.lower(): extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"} @@ -1304,7 +1319,7 @@ def resolve_provider_client( _resolve_api_key_provider): client, default = try_fn() if client is not None: - final_model = model or default + final_model = _normalize_resolved_model(model or default, provider) return (_to_async_client(client, final_model) if async_mode else (client, final_model)) logger.warning("resolve_provider_client: custom/main requested " @@ -1319,7 +1334,10 @@ def resolve_provider_client( custom_base = custom_entry.get("base_url", "").strip() custom_key = custom_entry.get("api_key", "").strip() or "no-key-required" if custom_base: - final_model = model or _read_main_model() or "gpt-4o-mini" + final_model = _normalize_resolved_model( + model or _read_main_model() or "gpt-4o-mini", + provider, + ) client = OpenAI(api_key=custom_key, base_url=custom_base) logger.debug( "resolve_provider_client: named custom provider %r (%s)", @@ -1351,7 +1369,7 @@ def resolve_provider_client( if client is None: logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found") return None, None - final_model = model or default_model + final_model = _normalize_resolved_model(model or default_model, provider) return (_to_async_client(client, final_model) if async_mode else (client, final_model)) creds = resolve_api_key_provider_credentials(provider) @@ -1370,7 +1388,7 @@ def resolve_provider_client( ) default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "") - final_model = model or default_model + final_model = _normalize_resolved_model(model or default_model, provider) # Provider-specific headers headers = {} diff --git a/run_agent.py b/run_agent.py index 565daa02cb..16509f69b0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5020,7 +5020,7 @@ class AIAgent: # when no explicit key is in the fallback config. if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint: fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None - fb_client, _ = resolve_provider_client( + fb_client, resolved_fb_model = resolve_provider_client( fb_provider, model=fb_model, raw_codex=True, explicit_base_url=fb_base_url_hint, explicit_api_key=fb_api_key_hint) @@ -5029,6 +5029,7 @@ class AIAgent: "Fallback to %s failed: provider not configured", fb_provider) return self._try_activate_fallback() # try next in chain + fb_model = resolved_fb_model or fb_model # Determine api_mode from provider / base URL fb_api_mode = "chat_completions" diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index 9ca0c5e570..a07833cc7b 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -149,3 +149,83 @@ class TestResolveProviderClientNamedCustom: # "coffee" doesn't exist in custom_providers client, model = resolve_provider_client("coffee", "test") assert client is None + + +class TestResolveProviderClientModelNormalization: + """Direct-provider auxiliary routing should normalize models like main runtime.""" + + def test_matching_native_prefix_is_stripped_for_main_provider(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "zai/glm-5.1", "provider": "zai"}, + }) + with ( + patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={ + "api_key": "glm-key", + "base_url": "https://api.z.ai/api/paas/v4", + }), + patch("agent.auxiliary_client.OpenAI") as mock_openai, + ): + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client("main", "zai/glm-5.1") + + assert client is not None + assert model == "glm-5.1" + + def test_non_matching_prefix_is_preserved_for_direct_provider(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "zai/glm-5.1", "provider": "zai"}, + }) + with ( + patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={ + "api_key": "glm-key", + "base_url": "https://api.z.ai/api/paas/v4", + }), + patch("agent.auxiliary_client.OpenAI") as mock_openai, + ): + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client("zai", "google/gemini-2.5-pro") + + assert client is not None + assert model == "google/gemini-2.5-pro" + + def test_aggregator_vendor_slug_is_preserved(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client( + "openrouter", "anthropic/claude-sonnet-4.6" + ) + + assert client is not None + assert model == "anthropic/claude-sonnet-4.6" + + +class TestResolveVisionProviderClientModelNormalization: + """Vision auto-routing should reuse the same provider-specific normalization.""" + + def test_vision_auto_strips_matching_main_provider_prefix(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "zai/glm-5.1", "provider": "zai"}, + }) + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={ + "api_key": "glm-key", + "base_url": "https://api.z.ai/api/paas/v4", + }), + patch("agent.auxiliary_client.OpenAI") as mock_openai, + ): + mock_openai.return_value = MagicMock() + from agent.auxiliary_client import resolve_vision_provider_client + + provider, client, model = resolve_vision_provider_client() + + assert provider == "zai" + assert client is not None + assert model == "glm-5.1" diff --git a/tests/run_agent/test_fallback_model.py b/tests/run_agent/test_fallback_model.py index df2bc9cb5e..ac693caf01 100644 --- a/tests/run_agent/test_fallback_model.py +++ b/tests/run_agent/test_fallback_model.py @@ -113,6 +113,25 @@ class TestTryActivateFallback: assert agent.provider == "zai" assert agent.client is mock_client + def test_fallback_uses_resolved_normalized_model(self): + agent = _make_agent( + fallback_model={"provider": "zai", "model": "zai/glm-5.1"}, + ) + mock_client = _mock_resolve( + api_key="sk-zai-key", + base_url="https://api.z.ai/api/paas/v4", + ) + with patch( + "agent.auxiliary_client.resolve_provider_client", + return_value=(mock_client, "glm-5.1"), + ): + result = agent._try_activate_fallback() + + assert result is True + assert agent.model == "glm-5.1" + assert agent.provider == "zai" + assert agent.client is mock_client + def test_activates_kimi_fallback(self): agent = _make_agent( fallback_model={"provider": "kimi-coding", "model": "kimi-k2.5"},