diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a757f4269902f..9ef766036f426 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1199,6 +1199,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, @@ -1261,7 +1273,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)) @@ -1272,7 +1284,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)) @@ -1286,7 +1298,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 @@ -1295,7 +1307,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)) @@ -1314,7 +1326,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.3"} @@ -1329,7 +1344,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 " @@ -1344,7 +1359,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)", @@ -1376,7 +1394,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) @@ -1395,7 +1413,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/cli.py b/cli.py index 221976ad256f8..8c002052965ea 100644 --- a/cli.py +++ b/cli.py @@ -2033,6 +2033,25 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool: current_model = (self.model or "").strip() changed = False + try: + from hermes_cli.model_normalize import ( + _AGGREGATOR_PROVIDERS, + normalize_model_for_provider, + ) + + if resolved_provider not in _AGGREGATOR_PROVIDERS: + normalized_model = normalize_model_for_provider(current_model, resolved_provider) + if normalized_model and normalized_model != current_model: + if not self._model_is_default: + self.console.print( + f"[yellow]⚠️ Normalized model '{current_model}' to '{normalized_model}' for {resolved_provider}.[/]" + ) + self.model = normalized_model + current_model = normalized_model + changed = True + except Exception: + pass + if resolved_provider == "copilot": try: from hermes_cli.models import copilot_model_api_mode, normalize_copilot_model_id @@ -2078,7 +2097,7 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool: return changed if resolved_provider != "openai-codex": - return False + return changed # 1. Strip provider prefix ("openai/gpt-5.4" → "gpt-5.4") if "/" in current_model: diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 7b5413637dc0c..a200c941b3091 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -76,17 +76,22 @@ "copilot-acp", }) -# Providers whose own naming is authoritative -- pass through unchanged. -_PASSTHROUGH_PROVIDERS: frozenset[str] = frozenset({ +# Providers whose native naming is authoritative -- pass through unchanged. +_AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({ "gemini", + "huggingface", + "openai-codex", +}) + +# Direct providers that accept bare native names but should repair a matching +# provider/ prefix when users copy the aggregator form into config.yaml. +_MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({ "zai", "kimi-coding", "minimax", "minimax-cn", "alibaba", "qwen-oauth", - "huggingface", - "openai-codex", "custom", }) @@ -168,6 +173,40 @@ def _dots_to_hyphens(model_name: str) -> str: return model_name.replace(".", "-") +def _normalize_provider_alias(provider_name: str) -> str: + """Resolve provider aliases to Hermes' canonical ids.""" + raw = (provider_name or "").strip().lower() + if not raw: + return raw + try: + from hermes_cli.models import normalize_provider + + return normalize_provider(raw) + except Exception: + return raw + + +def _strip_matching_provider_prefix(model_name: str, target_provider: str) -> str: + """Strip ``provider/`` only when the prefix matches the target provider. + + This prevents arbitrary slash-bearing model IDs from being mangled on + native providers while still repairing manual config values like + ``zai/glm-5.1`` for the ``zai`` provider. + """ + if "/" not in model_name: + return model_name + + prefix, remainder = model_name.split("/", 1) + if not prefix.strip() or not remainder.strip(): + return model_name + + normalized_prefix = _normalize_provider_alias(prefix) + normalized_target = _normalize_provider_alias(target_provider) + if normalized_prefix and normalized_prefix == normalized_target: + return remainder.strip() + return model_name + + def detect_vendor(model_name: str) -> Optional[str]: """Detect the vendor slug from a bare model name. @@ -305,24 +344,37 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: if not name: return name - provider = (target_provider or "").strip().lower() + provider = _normalize_provider_alias(target_provider) # --- Aggregators: need vendor/model format --- if provider in _AGGREGATOR_PROVIDERS: return _prepend_vendor(name) - # --- Anthropic / OpenCode: strip vendor, dots -> hyphens --- + # --- Anthropic / OpenCode: strip matching provider prefix, dots -> hyphens --- if provider in _DOT_TO_HYPHEN_PROVIDERS: - bare = _strip_vendor_prefix(name) + bare = _strip_matching_provider_prefix(name, provider) + if "/" in bare: + return bare return _dots_to_hyphens(bare) - # --- Copilot: strip vendor, keep dots --- + # --- Copilot: strip matching provider prefix, keep dots --- if provider in _STRIP_VENDOR_ONLY_PROVIDERS: - return _strip_vendor_prefix(name) + return _strip_matching_provider_prefix(name, provider) # --- DeepSeek: map to one of two canonical names --- if provider == "deepseek": - return _normalize_for_deepseek(name) + bare = _strip_matching_provider_prefix(name, provider) + if "/" in bare: + return bare + return _normalize_for_deepseek(bare) + + # --- Direct providers: repair matching provider prefixes only --- + if provider in _MATCHING_PREFIX_STRIP_PROVIDERS: + return _strip_matching_provider_prefix(name, provider) + + # --- Authoritative native providers: preserve user-facing slugs as-is --- + if provider in _AUTHORITATIVE_NATIVE_PROVIDERS: + return name # --- Custom & all others: pass through as-is --- return name diff --git a/run_agent.py b/run_agent.py index 3e7ddc6870b39..ee14fbfc201b4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -606,6 +606,17 @@ def __init__( else: self.api_mode = "chat_completions" + try: + from hermes_cli.model_normalize import ( + _AGGREGATOR_PROVIDERS, + normalize_model_for_provider, + ) + + if self.provider not in _AGGREGATOR_PROVIDERS: + self.model = normalize_model_for_provider(self.model, self.provider) + except Exception: + pass + # Direct OpenAI sessions use the Responses API path. GPT-5.x tool # calls with reasoning are rejected on /v1/chat/completions, and # Hermes is a tool-using client by default. @@ -4974,7 +4985,7 @@ def _try_activate_fallback(self) -> bool: # 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) @@ -4983,6 +4994,12 @@ def _try_activate_fallback(self) -> bool: "Fallback to %s failed: provider not configured", fb_provider) return self._try_activate_fallback() # try next in chain + try: + from hermes_cli.model_normalize import normalize_model_for_provider + + fb_model = normalize_model_for_provider(fb_model, fb_provider) + except Exception: + pass # 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 9ca0c5e5702ad..4c16bcb01003c 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -12,6 +12,17 @@ def _isolate(tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + for env_var in ( + "AUXILIARY_VISION_PROVIDER", + "AUXILIARY_VISION_MODEL", + "AUXILIARY_VISION_BASE_URL", + "AUXILIARY_VISION_API_KEY", + "CONTEXT_VISION_PROVIDER", + "CONTEXT_VISION_MODEL", + "CONTEXT_VISION_BASE_URL", + "CONTEXT_VISION_API_KEY", + ): + monkeypatch.delenv(env_var, raising=False) # Write a minimal config so load_config doesn't fail (hermes_home / "config.yaml").write_text("model:\n default: test-model\n") @@ -149,3 +160,83 @@ def test_nonexistent_named_custom_falls_through(self, tmp_path): # "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/hermes_cli/test_codex_models.py b/tests/hermes_cli/test_codex_models.py index 0d10abf0da822..a924ff4689192 100644 --- a/tests/hermes_cli/test_codex_models.py +++ b/tests/hermes_cli/test_codex_models.py @@ -150,6 +150,12 @@ def test_non_codex_provider_is_noop(self): assert changed is False assert cli.model == "gpt-5.4" + def test_native_provider_prefix_is_stripped_before_agent_startup(self): + cli = _make_cli(model="zai/glm-5.1") + changed = cli._normalize_model_for_provider("zai") + assert changed is True + assert cli.model == "glm-5.1" + def test_bare_codex_model_passes_through(self): cli = _make_cli(model="gpt-5.3-codex") changed = cli._normalize_model_for_provider("openai-codex") diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index 1c94c9db7653d..0bca8d52e3aa2 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -102,6 +102,21 @@ def test_vendor_already_present(self): assert result == "anthropic/claude-sonnet-4.6" +class TestIssue6211NativeProviderPrefixNormalization: + @pytest.mark.parametrize("model,target_provider,expected", [ + ("zai/glm-5.1", "zai", "glm-5.1"), + ("google/gemini-2.5-pro", "gemini", "google/gemini-2.5-pro"), + ("moonshot/kimi-k2.5", "kimi-coding", "kimi-k2.5"), + ("anthropic/claude-sonnet-4.6", "openrouter", "anthropic/claude-sonnet-4.6"), + ("Qwen/Qwen3.5-397B-A17B", "huggingface", "Qwen/Qwen3.5-397B-A17B"), + ("modal/zai-org/GLM-5-FP8", "custom", "modal/zai-org/GLM-5-FP8"), + ]) + def test_native_provider_prefixes_are_only_stripped_on_matching_provider( + self, model, target_provider, expected + ): + assert normalize_model_for_provider(model, target_provider) == expected + + # ── detect_vendor ────────────────────────────────────────────────────── class TestDetectVendor: diff --git a/tests/run_agent/test_fallback_model.py b/tests/run_agent/test_fallback_model.py index df2bc9cb5edc9..ac693caf01940 100644 --- a/tests/run_agent/test_fallback_model.py +++ b/tests/run_agent/test_fallback_model.py @@ -113,6 +113,25 @@ def test_activates_zai_fallback(self): 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"}, diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index a808df0981312..b3a3c1b996d2c 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -137,6 +137,48 @@ def test_aiagent_reuses_existing_errors_log_handler(): root_logger.addHandler(handler) +class TestProviderModelNormalization: + def test_aiagent_strips_matching_native_provider_prefix(self): + with ( + patch( + "run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search") + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + model="zai/glm-5.1", + provider="zai", + base_url="https://api.z.ai/api/paas/v4", + api_key="test-key-1234567890", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert agent.model == "glm-5.1" + + def test_aiagent_keeps_aggregator_vendor_slug(self): + with ( + patch( + "run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search") + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + model="anthropic/claude-sonnet-4.6", + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_key="test-key-1234567890", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert agent.model == "anthropic/claude-sonnet-4.6" + + # --------------------------------------------------------------------------- # Helper to build mock assistant messages (API response objects) # ---------------------------------------------------------------------------