/ tests / hermes_cli / test_gmi_provider.py
test_gmi_provider.py
  1  """Focused tests for GMI Cloud first-class provider wiring."""
  2  
  3  from __future__ import annotations
  4  
  5  import contextlib
  6  import io
  7  import sys
  8  import types
  9  from argparse import Namespace
 10  from unittest.mock import patch
 11  
 12  import pytest
 13  
 14  if "dotenv" not in sys.modules:
 15      fake_dotenv = types.ModuleType("dotenv")
 16      fake_dotenv.load_dotenv = lambda *args, **kwargs: None
 17      sys.modules["dotenv"] = fake_dotenv
 18  
 19  from hermes_cli.auth import resolve_provider
 20  from hermes_cli.config import load_config
 21  from hermes_cli.models import (
 22      CANONICAL_PROVIDERS,
 23      _PROVIDER_LABELS,
 24      _PROVIDER_MODELS,
 25      normalize_provider,
 26      provider_model_ids,
 27  )
 28  from agent.auxiliary_client import resolve_provider_client
 29  from agent.model_metadata import get_model_context_length
 30  
 31  
 32  @pytest.fixture(autouse=True)
 33  def _clear_provider_env(monkeypatch):
 34      for key in (
 35          "OPENROUTER_API_KEY",
 36          "OPENAI_API_KEY",
 37          "ANTHROPIC_API_KEY",
 38          "GOOGLE_API_KEY",
 39          "GLM_API_KEY",
 40          "KIMI_API_KEY",
 41          "MINIMAX_API_KEY",
 42          "GMI_API_KEY",
 43          "GMI_BASE_URL",
 44      ):
 45          monkeypatch.delenv(key, raising=False)
 46  
 47  
 48  class TestGmiAliases:
 49      @pytest.mark.parametrize("alias", ["gmi", "gmi-cloud", "gmicloud"])
 50      def test_alias_resolves(self, alias, monkeypatch):
 51          monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
 52          assert resolve_provider(alias) == "gmi"
 53  
 54      def test_models_normalize_provider(self):
 55          assert normalize_provider("gmi-cloud") == "gmi"
 56          assert normalize_provider("gmicloud") == "gmi"
 57  
 58      def test_providers_normalize_provider(self):
 59          from hermes_cli.providers import normalize_provider as normalize_provider_in_providers
 60  
 61          assert normalize_provider_in_providers("gmi-cloud") == "gmi"
 62          assert normalize_provider_in_providers("gmicloud") == "gmi"
 63  
 64  
 65  class TestGmiConfigRegistry:
 66      def test_optional_env_vars_include_gmi(self):
 67          from hermes_cli.config import OPTIONAL_ENV_VARS
 68  
 69          assert "GMI_API_KEY" in OPTIONAL_ENV_VARS
 70          assert OPTIONAL_ENV_VARS["GMI_API_KEY"]["category"] == "provider"
 71          assert OPTIONAL_ENV_VARS["GMI_API_KEY"]["password"] is True
 72          assert OPTIONAL_ENV_VARS["GMI_API_KEY"]["url"] == "https://www.gmicloud.ai/"
 73  
 74          assert "GMI_BASE_URL" in OPTIONAL_ENV_VARS
 75          assert OPTIONAL_ENV_VARS["GMI_BASE_URL"]["category"] == "provider"
 76          assert OPTIONAL_ENV_VARS["GMI_BASE_URL"]["password"] is False
 77          # ENV_VARS_BY_VERSION entries are not needed for providers added after
 78          # _config_version 22 (the current baseline) — users discover GMI via
 79          # hermes model, not via upgrade prompts.
 80  
 81  
 82  class TestGmiModelCatalog:
 83      def test_static_model_fallback_exists(self):
 84          assert "gmi" in _PROVIDER_MODELS
 85          models = _PROVIDER_MODELS["gmi"]
 86          assert "zai-org/GLM-5.1-FP8" in models
 87          assert "deepseek-ai/DeepSeek-V3.2" in models
 88          assert "moonshotai/Kimi-K2.5" in models
 89          assert "anthropic/claude-sonnet-4.6" in models
 90  
 91      def test_canonical_provider_entry(self):
 92          slugs = [p.slug for p in CANONICAL_PROVIDERS]
 93          assert "gmi" in slugs
 94  
 95      def test_provider_model_ids_prefers_live_api(self, monkeypatch):
 96          monkeypatch.setattr(
 97              "hermes_cli.auth.resolve_api_key_provider_credentials",
 98              lambda provider_id: {
 99                  "provider": provider_id,
100                  "api_key": "gmi-live-key",
101                  "base_url": "https://api.gmi-serving.com/v1",
102                  "source": "GMI_API_KEY",
103              },
104          )
105          monkeypatch.setattr(
106              "hermes_cli.models.fetch_api_models",
107              lambda api_key, base_url: [
108                  "openai/gpt-5.4-mini",
109                  "zai-org/GLM-5.1-FP8",
110              ],
111          )
112  
113          assert provider_model_ids("gmi") == [
114              "openai/gpt-5.4-mini",
115              "zai-org/GLM-5.1-FP8",
116          ]
117  
118      def test_provider_model_ids_falls_back_to_static_models(self, monkeypatch):
119          monkeypatch.setattr(
120              "hermes_cli.auth.resolve_api_key_provider_credentials",
121              lambda provider_id: {
122                  "provider": provider_id,
123                  "api_key": "gmi-live-key",
124                  "base_url": "https://api.gmi-serving.com/v1",
125                  "source": "GMI_API_KEY",
126              },
127          )
128          monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda api_key, base_url: None)
129  
130          assert provider_model_ids("gmi") == list(_PROVIDER_MODELS["gmi"])
131  
132  
133  class TestGmiProvidersModule:
134      def test_overlay_exists(self):
135          from hermes_cli.providers import HERMES_OVERLAYS
136  
137          assert "gmi" in HERMES_OVERLAYS
138          overlay = HERMES_OVERLAYS["gmi"]
139          assert overlay.transport == "openai_chat"
140          assert overlay.extra_env_vars == ("GMI_API_KEY",)
141          assert overlay.base_url_override == "https://api.gmi-serving.com/v1"
142          assert overlay.base_url_env_var == "GMI_BASE_URL"
143          assert not overlay.is_aggregator
144  
145      def test_provider_label(self):
146          assert _PROVIDER_LABELS["gmi"] == "GMI Cloud"
147  
148  
149  class TestGmiDoctor:
150      def test_provider_env_hints_include_gmi(self):
151          from hermes_cli.doctor import _PROVIDER_ENV_HINTS
152  
153          assert "GMI_API_KEY" in _PROVIDER_ENV_HINTS
154  
155      def test_run_doctor_checks_gmi_models_endpoint(self, monkeypatch, tmp_path):
156          from hermes_cli import doctor as doctor_mod
157  
158          home = tmp_path / ".hermes"
159          home.mkdir(parents=True, exist_ok=True)
160          (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
161          (home / ".env").write_text("GMI_API_KEY=***\n", encoding="utf-8")
162          project = tmp_path / "project"
163          project.mkdir(exist_ok=True)
164  
165          monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
166          monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
167          monkeypatch.setattr(doctor_mod, "_DHH", str(home))
168          monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
169  
170          for env_name in (
171              "OPENROUTER_API_KEY",
172              "OPENAI_API_KEY",
173              "ANTHROPIC_API_KEY",
174              "ANTHROPIC_TOKEN",
175              "GLM_API_KEY",
176              "ZAI_API_KEY",
177              "Z_AI_API_KEY",
178              "KIMI_API_KEY",
179              "KIMI_CN_API_KEY",
180              "ARCEEAI_API_KEY",
181              "DEEPSEEK_API_KEY",
182              "HF_TOKEN",
183              "DASHSCOPE_API_KEY",
184              "MINIMAX_API_KEY",
185              "MINIMAX_CN_API_KEY",
186              "AI_GATEWAY_API_KEY",
187              "KILOCODE_API_KEY",
188              "OPENCODE_ZEN_API_KEY",
189              "OPENCODE_GO_API_KEY",
190              "XIAOMI_API_KEY",
191          ):
192              monkeypatch.delenv(env_name, raising=False)
193  
194          fake_model_tools = types.SimpleNamespace(
195              check_tool_availability=lambda *a, **kw: ([], []),
196              TOOLSET_REQUIREMENTS={},
197          )
198          monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
199  
200          try:
201              from hermes_cli import auth as _auth_mod
202  
203              monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
204              monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
205          except Exception:
206              pass
207  
208          calls = []
209  
210          def fake_get(url, headers=None, timeout=None):
211              calls.append((url, headers, timeout))
212              return types.SimpleNamespace(status_code=200)
213  
214          import httpx
215  
216          monkeypatch.setattr(httpx, "get", fake_get)
217  
218          buf = io.StringIO()
219          with contextlib.redirect_stdout(buf):
220              doctor_mod.run_doctor(Namespace(fix=False))
221          out = buf.getvalue()
222  
223          assert "API key or custom endpoint configured" in out
224          assert "GMI Cloud" in out
225          assert any(url == "https://api.gmi-serving.com/v1/models" for url, _, _ in calls)
226  
227  
228  class TestGmiModelMetadata:
229      def test_url_to_provider(self):
230          from agent.model_metadata import _URL_TO_PROVIDER
231  
232          assert _URL_TO_PROVIDER.get("api.gmi-serving.com") == "gmi"
233  
234      def test_provider_prefixes(self):
235          from agent.model_metadata import _PROVIDER_PREFIXES
236  
237          assert "gmi" in _PROVIDER_PREFIXES
238          assert "gmi-cloud" in _PROVIDER_PREFIXES
239          assert "gmicloud" in _PROVIDER_PREFIXES
240  
241      def test_infer_from_url(self):
242          from agent.model_metadata import _infer_provider_from_url
243  
244          assert _infer_provider_from_url("https://api.gmi-serving.com/v1") == "gmi"
245  
246      def test_known_gmi_endpoint_still_uses_endpoint_metadata(self):
247          with patch(
248              "agent.model_metadata.get_cached_context_length",
249              return_value=None,
250          ), patch(
251              "agent.model_metadata.fetch_endpoint_model_metadata",
252              return_value={"anthropic/claude-opus-4.6": {"context_length": 409600}},
253          ), patch(
254              "agent.models_dev.lookup_models_dev_context",
255              return_value=None,
256          ), patch(
257              "agent.model_metadata.fetch_model_metadata",
258              return_value={},
259          ):
260              result = get_model_context_length(
261                  "anthropic/claude-opus-4.6",
262                  base_url="https://api.gmi-serving.com/v1",
263                  api_key="gmi-test-key",
264                  provider="custom",
265              )
266  
267          assert result == 409600
268  
269  
270  class TestGmiAuxiliary:
271      def test_aux_default_model(self):
272          from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
273  
274          assert _API_KEY_PROVIDER_AUX_MODELS["gmi"] == "google/gemini-3.1-flash-lite-preview"
275  
276      def test_resolve_provider_client_uses_gmi_aux_default(self, monkeypatch):
277          monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
278  
279          with patch("agent.auxiliary_client.OpenAI") as mock_openai:
280              mock_openai.return_value = object()
281              client, model = resolve_provider_client("gmi")
282  
283          assert client is not None
284          assert model == "google/gemini-3.1-flash-lite-preview"
285          assert mock_openai.call_args.kwargs["api_key"] == "gmi-test-key"
286          assert mock_openai.call_args.kwargs["base_url"] == "https://api.gmi-serving.com/v1"
287  
288      def test_resolve_provider_client_accepts_gmi_alias(self, monkeypatch):
289          monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
290  
291          with patch("agent.auxiliary_client.OpenAI") as mock_openai:
292              mock_openai.return_value = object()
293              client, model = resolve_provider_client("gmi-cloud")
294  
295          assert client is not None
296          assert model == "google/gemini-3.1-flash-lite-preview"
297  
298  
299  class TestGmiMainFlow:
300      def test_chat_parser_accepts_gmi_provider(self, monkeypatch):
301          recorded: dict[str, str] = {}
302  
303          monkeypatch.setattr("hermes_cli.config.get_container_exec_info", lambda: None)
304          monkeypatch.setattr(
305              "hermes_cli.main.cmd_chat",
306              lambda args: recorded.setdefault("provider", args.provider),
307          )
308          monkeypatch.setattr(sys, "argv", ["hermes", "chat", "--provider", "gmi"])
309  
310          from hermes_cli.main import main
311  
312          main()
313  
314          assert recorded["provider"] == "gmi"
315  
316      def test_select_provider_and_model_routes_gmi_to_generic_flow(self, monkeypatch):
317          recorded: dict[str, str] = {}
318  
319          monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda *args, **kwargs: None)
320  
321          def fake_prompt_provider_choice(choices, default=0):
322              return next(i for i, label in enumerate(choices) if label.startswith("GMI Cloud"))
323  
324          def fake_model_flow_api_key_provider(config, provider_id, current_model=""):
325              recorded["provider_id"] = provider_id
326  
327          monkeypatch.setattr("hermes_cli.main._prompt_provider_choice", fake_prompt_provider_choice)
328          monkeypatch.setattr("hermes_cli.main._model_flow_api_key_provider", fake_model_flow_api_key_provider)
329  
330          from hermes_cli.main import select_provider_and_model
331  
332          select_provider_and_model()
333  
334          assert recorded["provider_id"] == "gmi"
335  
336      def test_model_flow_api_key_provider_persists_gmi_selection(self, monkeypatch):
337          monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
338  
339          with patch(
340              "hermes_cli.models.fetch_api_models",
341              return_value=["zai-org/GLM-5.1-FP8", "openai/gpt-5.4-mini"],
342          ), patch(
343              "hermes_cli.auth._prompt_model_selection",
344              return_value="openai/gpt-5.4-mini",
345          ), patch(
346              "hermes_cli.auth.deactivate_provider",
347          ), patch(
348              "builtins.input",
349              return_value="",
350          ):
351              from hermes_cli.main import _model_flow_api_key_provider
352  
353              _model_flow_api_key_provider(load_config(), "gmi", "old-model")
354  
355          import yaml
356          from hermes_constants import get_hermes_home
357  
358          config = yaml.safe_load((get_hermes_home() / "config.yaml").read_text()) or {}
359          model_cfg = config.get("model")
360          assert isinstance(model_cfg, dict)
361          assert model_cfg["provider"] == "gmi"
362          assert model_cfg["default"] == "openai/gpt-5.4-mini"
363          assert model_cfg["base_url"] == "https://api.gmi-serving.com/v1"