diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 61facf2c..8b6def39 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -108,21 +108,17 @@ DEFAULT_CONFIG = { # Hermes will automatically switch to this model for the remainder of the session. # Set to None / omit to disable fallback. # - # Built-in providers (auto-resolve base_url and API key from env): - # openrouter (OPENROUTER_API_KEY) — best fallback, routes to any model - # openai (OPENAI_API_KEY) — GPT-4.1, o3, etc. - # nous (NOUS_API_KEY) — Nous inference API - # deepseek (DEEPSEEK_API_KEY) — DeepSeek models - # together (TOGETHER_API_KEY) — Together AI - # groq (GROQ_API_KEY) — Groq (fast inference) - # fireworks (FIREWORKS_API_KEY) — Fireworks AI - # mistral (MISTRAL_API_KEY) — Mistral models - # gemini (GEMINI_API_KEY) — Google Gemini + # Supported providers (auto-resolve base_url and API key from env): + # openrouter (OPENROUTER_API_KEY) — routes to any model + # zai (ZAI_API_KEY) — Z.AI / GLM + # kimi-coding (KIMI_API_KEY) — Kimi / Moonshot + # minimax (MINIMAX_API_KEY) — MiniMax + # minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China) # # For any other OpenAI-compatible endpoint, use base_url + api_key_env. "fallback_model": { "provider": "", # provider name from the list above - "model": "", # model slug, e.g. "anthropic/claude-sonnet-4", "gpt-4.1" + "model": "", # model slug, e.g. "anthropic/claude-sonnet-4" # Optional overrides (usually auto-resolved from provider): # "base_url": "", # custom endpoint URL # "api_key_env": "", # env var name for API key (e.g. "MY_CUSTOM_KEY") diff --git a/run_agent.py b/run_agent.py index 97644785..23f7ac71 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2161,16 +2161,14 @@ class AIAgent: # ── Provider fallback ────────────────────────────────────────────────── # Maps provider id → (default_base_url, [env_var_names]) + # Only includes providers that Hermes actually supports. + # For anything else, use base_url + api_key_env in the config. _FALLBACK_PROVIDERS = { "openrouter": (OPENROUTER_BASE_URL, ["OPENROUTER_API_KEY"]), - "openai": ("https://api.openai.com/v1", ["OPENAI_API_KEY"]), - "nous": ("https://inference-api.nousresearch.com/v1", ["NOUS_API_KEY"]), - "deepseek": ("https://api.deepseek.com/v1", ["DEEPSEEK_API_KEY"]), - "together": ("https://api.together.xyz/v1", ["TOGETHER_API_KEY"]), - "groq": ("https://api.groq.com/openai/v1", ["GROQ_API_KEY"]), - "fireworks": ("https://api.fireworks.ai/inference/v1", ["FIREWORKS_API_KEY"]), - "mistral": ("https://api.mistral.ai/v1", ["MISTRAL_API_KEY"]), - "gemini": ("https://generativelanguage.googleapis.com/v1beta/openai", ["GEMINI_API_KEY", "GOOGLE_API_KEY"]), + "zai": ("https://api.z.ai/api/paas/v4", ["ZAI_API_KEY", "Z_AI_API_KEY"]), + "kimi-coding": ("https://api.moonshot.ai/v1", ["KIMI_API_KEY"]), + "minimax": ("https://api.minimax.io/v1", ["MINIMAX_API_KEY"]), + "minimax-cn": ("https://api.minimaxi.com/v1", ["MINIMAX_CN_API_KEY"]), } def _try_activate_fallback(self) -> bool: @@ -2224,6 +2222,8 @@ class AIAgent: "X-OpenRouter-Title": "Hermes Agent", "X-OpenRouter-Categories": "productivity,cli-agent", } + elif "api.kimi.com" in fb_base_url.lower(): + client_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.0"} self.client = OpenAI(**client_kwargs) self._client_kwargs = client_kwargs diff --git a/tests/test_fallback_model.py b/tests/test_fallback_model.py index d348a259..2603c16c 100644 --- a/tests/test_fallback_model.py +++ b/tests/test_fallback_model.py @@ -64,7 +64,7 @@ class TestTryActivateFallback: assert agent._try_activate_fallback() is False def test_returns_false_for_missing_model(self): - agent = _make_agent(fallback_model={"provider": "openai"}) + agent = _make_agent(fallback_model={"provider": "openrouter"}) assert agent._try_activate_fallback() is False def test_activates_openrouter_fallback(self): @@ -88,33 +88,47 @@ class TestTryActivateFallback: # OpenRouter should get attribution headers assert "default_headers" in call_kwargs - def test_activates_openai_fallback(self): + def test_activates_zai_fallback(self): agent = _make_agent( - fallback_model={"provider": "openai", "model": "gpt-4.1"}, + fallback_model={"provider": "zai", "model": "glm-5"}, ) with ( - patch.dict("os.environ", {"OPENAI_API_KEY": "sk-openai-key"}), + patch.dict("os.environ", {"ZAI_API_KEY": "sk-zai-key"}), patch("run_agent.OpenAI") as mock_openai, ): result = agent._try_activate_fallback() assert result is True - assert agent.model == "gpt-4.1" - assert agent.provider == "openai" + assert agent.model == "glm-5" + assert agent.provider == "zai" call_kwargs = mock_openai.call_args[1] - assert call_kwargs["api_key"] == "sk-openai-key" - assert "openai.com" in call_kwargs["base_url"] + assert call_kwargs["api_key"] == "sk-zai-key" + assert "z.ai" in call_kwargs["base_url"].lower() - def test_activates_deepseek_fallback(self): + def test_activates_kimi_fallback(self): agent = _make_agent( - fallback_model={"provider": "deepseek", "model": "deepseek-chat"}, + fallback_model={"provider": "kimi-coding", "model": "kimi-k2.5"}, ) with ( - patch.dict("os.environ", {"DEEPSEEK_API_KEY": "sk-ds-key"}), + patch.dict("os.environ", {"KIMI_API_KEY": "sk-kimi-key"}), patch("run_agent.OpenAI"), ): assert agent._try_activate_fallback() is True - assert agent.model == "deepseek-chat" - assert agent.provider == "deepseek" + assert agent.model == "kimi-k2.5" + assert agent.provider == "kimi-coding" + + def test_activates_minimax_fallback(self): + agent = _make_agent( + fallback_model={"provider": "minimax", "model": "MiniMax-M2.5"}, + ) + with ( + patch.dict("os.environ", {"MINIMAX_API_KEY": "sk-mm-key"}), + patch("run_agent.OpenAI") as mock_openai, + ): + assert agent._try_activate_fallback() is True + assert agent.model == "MiniMax-M2.5" + assert agent.provider == "minimax" + call_kwargs = mock_openai.call_args[1] + assert "minimax.io" in call_kwargs["base_url"] def test_only_fires_once(self): agent = _make_agent( @@ -131,10 +145,10 @@ class TestTryActivateFallback: def test_returns_false_when_no_api_key(self): """Fallback should fail gracefully when the API key env var is unset.""" agent = _make_agent( - fallback_model={"provider": "deepseek", "model": "deepseek-chat"}, + fallback_model={"provider": "minimax", "model": "MiniMax-M2.5"}, ) - # Ensure DEEPSEEK_API_KEY is not in the environment - env = {k: v for k, v in os.environ.items() if k != "DEEPSEEK_API_KEY"} + # Ensure MINIMAX_API_KEY is not in the environment + env = {k: v for k, v in os.environ.items() if k != "MINIMAX_API_KEY"} with patch.dict("os.environ", env, clear=True): assert agent._try_activate_fallback() is False assert agent._fallback_activated is False @@ -182,15 +196,28 @@ class TestTryActivateFallback: def test_prompt_caching_disabled_for_non_openrouter(self): agent = _make_agent( - fallback_model={"provider": "openai", "model": "gpt-4.1"}, + fallback_model={"provider": "zai", "model": "glm-5"}, ) with ( - patch.dict("os.environ", {"OPENAI_API_KEY": "sk-oai-key"}), + patch.dict("os.environ", {"ZAI_API_KEY": "sk-zai-key"}), patch("run_agent.OpenAI"), ): agent._try_activate_fallback() assert agent._use_prompt_caching is False + def test_zai_alt_env_var(self): + """Z.AI should also check Z_AI_API_KEY as fallback env var.""" + agent = _make_agent( + fallback_model={"provider": "zai", "model": "glm-5"}, + ) + with ( + patch.dict("os.environ", {"Z_AI_API_KEY": "sk-alt-key"}), + patch("run_agent.OpenAI") as mock_openai, + ): + assert agent._try_activate_fallback() is True + call_kwargs = mock_openai.call_args[1] + assert call_kwargs["api_key"] == "sk-alt-key" + # ============================================================================= # Fallback config init @@ -220,18 +247,14 @@ class TestFallbackInit: # ============================================================================= class TestProviderCredentials: - """Verify that each known provider resolves its API key correctly.""" + """Verify that each supported provider resolves its API key correctly.""" @pytest.mark.parametrize("provider,env_var,base_url_fragment", [ ("openrouter", "OPENROUTER_API_KEY", "openrouter"), - ("openai", "OPENAI_API_KEY", "openai.com"), - ("deepseek", "DEEPSEEK_API_KEY", "deepseek.com"), - ("together", "TOGETHER_API_KEY", "together.xyz"), - ("groq", "GROQ_API_KEY", "groq.com"), - ("fireworks", "FIREWORKS_API_KEY", "fireworks.ai"), - ("mistral", "MISTRAL_API_KEY", "mistral.ai"), - ("gemini", "GEMINI_API_KEY", "googleapis.com"), - ("nous", "NOUS_API_KEY", "nousresearch.com"), + ("zai", "ZAI_API_KEY", "z.ai"), + ("kimi-coding", "KIMI_API_KEY", "moonshot.ai"), + ("minimax", "MINIMAX_API_KEY", "minimax.io"), + ("minimax-cn", "MINIMAX_CN_API_KEY", "minimaxi.com"), ]) def test_provider_resolves(self, provider, env_var, base_url_fragment): agent = _make_agent(