diff --git a/cli.py b/cli.py index 2b9cf36a50..fb0691148a 100644 --- a/cli.py +++ b/cli.py @@ -2027,6 +2027,25 @@ class HermesCLI: 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 @@ -2072,7 +2091,7 @@ class HermesCLI: 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 3034fa2741..c5123f3911 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -168,6 +168,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 +339,33 @@ 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) + + # --- Native passthrough providers: strip only matching provider prefixes --- + if provider in _PASSTHROUGH_PROVIDERS - {"custom", "huggingface", "openai-codex"}: + return _strip_matching_provider_prefix(name, provider) # --- Custom & all others: pass through as-is --- return name diff --git a/run_agent.py b/run_agent.py index d22543f85e..565daa02cb 100644 --- a/run_agent.py +++ b/run_agent.py @@ -606,6 +606,17 @@ class AIAgent: 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. diff --git a/tests/hermes_cli/test_codex_models.py b/tests/hermes_cli/test_codex_models.py index 0d10abf0da..a924ff4689 100644 --- a/tests/hermes_cli/test_codex_models.py +++ b/tests/hermes_cli/test_codex_models.py @@ -150,6 +150,12 @@ class TestNormalizeModelForProvider: 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 1c94c9db76..531698cb65 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -102,6 +102,21 @@ class TestAggregatorProviders: 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", "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_run_agent.py b/tests/run_agent/test_run_agent.py index 85d27245b4..e7957cdda7 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -138,6 +138,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) # ---------------------------------------------------------------------------