test_doctor.py
1 """Tests for hermes_cli.doctor.""" 2 3 import os 4 import sys 5 import types 6 import io 7 import contextlib 8 from argparse import Namespace 9 from types import SimpleNamespace 10 11 import pytest 12 13 import hermes_cli.doctor as doctor 14 import hermes_cli.gateway as gateway_cli 15 from hermes_cli import doctor as doctor_mod 16 from hermes_cli.doctor import _has_provider_env_config 17 18 19 class TestDoctorPlatformHints: 20 def test_termux_package_hint(self, monkeypatch): 21 monkeypatch.setenv("TERMUX_VERSION", "0.118.3") 22 monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") 23 assert doctor._is_termux() is True 24 assert doctor._python_install_cmd() == "python -m pip install" 25 assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep" 26 27 def test_non_termux_package_hint_defaults_to_apt(self, monkeypatch): 28 monkeypatch.delenv("TERMUX_VERSION", raising=False) 29 monkeypatch.setenv("PREFIX", "/usr") 30 monkeypatch.setattr(sys, "platform", "linux") 31 assert doctor._is_termux() is False 32 assert doctor._python_install_cmd() == "uv pip install" 33 assert doctor._system_package_install_cmd("ripgrep") == "sudo apt install ripgrep" 34 35 36 class TestProviderEnvDetection: 37 def test_detects_openai_api_key(self): 38 content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***" 39 assert _has_provider_env_config(content) 40 41 def test_detects_custom_endpoint_without_openrouter_key(self): 42 content = "OPENAI_BASE_URL=http://localhost:8080/v1\n" 43 assert _has_provider_env_config(content) 44 45 def test_detects_kimi_cn_api_key(self): 46 content = "KIMI_CN_API_KEY=sk-test\n" 47 assert _has_provider_env_config(content) 48 49 def test_returns_false_when_no_provider_settings(self): 50 content = "TERMINAL_ENV=local\n" 51 assert not _has_provider_env_config(content) 52 53 54 class TestDoctorEnvFileEncoding: 55 """Regression for #18637 (bug 3): `hermes doctor` crashed on Windows 56 Chinese locale (GBK) because `.env` was read with Path.read_text() which 57 defaults to the system locale encoding, not UTF-8.""" 58 59 def test_doctor_reads_env_as_utf8_even_when_locale_is_not_utf8( 60 self, monkeypatch, tmp_path 61 ): 62 import pathlib 63 64 hermes_home = tmp_path / ".hermes" 65 hermes_home.mkdir() 66 # Write a UTF-8 .env containing an em dash (U+2014 = e2 80 94). The 67 # 0x94 byte is exactly the one the issue reporter hit: it's invalid 68 # as a GBK trailing byte in this position, so locale-default reads 69 # raise UnicodeDecodeError on Chinese Windows. 70 env_path = hermes_home / ".env" 71 env_path.write_text( 72 "OPENAI_API_KEY=sk-test # em-dash here — should not crash\n", 73 encoding="utf-8", 74 ) 75 76 monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home) 77 78 orig_read_text = pathlib.Path.read_text 79 80 def gbk_like_read_text(self, encoding=None, errors=None, **kwargs): 81 # Simulate a GBK locale: refuse to decode this specific UTF-8 82 # .env unless the caller pins encoding="utf-8". 83 if self == env_path and encoding != "utf-8": 84 raise UnicodeDecodeError( 85 "gbk", b"\x94", 0, 1, "illegal multibyte sequence" 86 ) 87 return orig_read_text(self, encoding=encoding, errors=errors, **kwargs) 88 89 monkeypatch.setattr(pathlib.Path, "read_text", gbk_like_read_text) 90 91 # Short-circuit the expensive tool-availability probe — we only 92 # need doctor to reach the .env read without crashing. 93 fake_model_tools = types.SimpleNamespace( 94 check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)), 95 TOOLSET_REQUIREMENTS={}, 96 ) 97 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 98 99 # Run doctor. If the .env read still uses locale encoding, this 100 # raises UnicodeDecodeError and the test fails. 101 with pytest.raises(SystemExit): 102 doctor_mod.run_doctor(Namespace(fix=False)) 103 104 105 class TestDoctorToolAvailabilityOverrides: 106 def test_marks_honcho_available_when_configured(self, monkeypatch): 107 monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: True) 108 109 available, unavailable = doctor._apply_doctor_tool_availability_overrides( 110 [], 111 [{"name": "honcho", "env_vars": [], "tools": ["query_user_context"]}], 112 ) 113 114 assert available == ["honcho"] 115 assert unavailable == [] 116 117 def test_leaves_honcho_unavailable_when_not_configured(self, monkeypatch): 118 monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: False) 119 120 honcho_entry = {"name": "honcho", "env_vars": [], "tools": ["query_user_context"]} 121 available, unavailable = doctor._apply_doctor_tool_availability_overrides( 122 [], 123 [honcho_entry], 124 ) 125 126 assert available == [] 127 assert unavailable == [honcho_entry] 128 129 130 class TestHonchoDoctorConfigDetection: 131 def test_reports_configured_when_enabled_with_api_key(self, monkeypatch): 132 fake_config = SimpleNamespace(enabled=True, api_key="***") 133 134 monkeypatch.setattr( 135 "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", 136 lambda: fake_config, 137 ) 138 139 assert doctor._honcho_is_configured_for_doctor() 140 141 def test_reports_not_configured_without_api_key(self, monkeypatch): 142 fake_config = SimpleNamespace(enabled=True, api_key="") 143 144 monkeypatch.setattr( 145 "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", 146 lambda: fake_config, 147 ) 148 149 assert not doctor._honcho_is_configured_for_doctor() 150 151 152 def test_run_doctor_sets_interactive_env_for_tool_checks(monkeypatch, tmp_path): 153 """Doctor should present CLI-gated tools as available in CLI context.""" 154 project_root = tmp_path / "project" 155 hermes_home = tmp_path / ".hermes" 156 project_root.mkdir() 157 hermes_home.mkdir() 158 159 monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project_root) 160 monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home) 161 monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) 162 163 seen = {} 164 165 def fake_check_tool_availability(*args, **kwargs): 166 seen["interactive"] = os.getenv("HERMES_INTERACTIVE") 167 raise SystemExit(0) 168 169 fake_model_tools = types.SimpleNamespace( 170 check_tool_availability=fake_check_tool_availability, 171 TOOLSET_REQUIREMENTS={}, 172 ) 173 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 174 175 with pytest.raises(SystemExit): 176 doctor_mod.run_doctor(Namespace(fix=False)) 177 178 assert seen["interactive"] == "1" 179 180 181 def test_check_gateway_service_linger_warns_when_disabled(monkeypatch, tmp_path, capsys): 182 unit_path = tmp_path / "hermes-gateway.service" 183 unit_path.write_text("[Unit]\n") 184 185 monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) 186 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path) 187 monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (False, "")) 188 189 issues = [] 190 doctor._check_gateway_service_linger(issues) 191 192 out = capsys.readouterr().out 193 assert "Gateway Service" in out 194 assert "Systemd linger disabled" in out 195 assert "loginctl enable-linger" in out 196 assert issues == [ 197 "Enable linger for the gateway user service: sudo loginctl enable-linger $USER" 198 ] 199 200 201 def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypatch, tmp_path, capsys): 202 unit_path = tmp_path / "missing.service" 203 204 monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) 205 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path) 206 207 issues = [] 208 doctor._check_gateway_service_linger(issues) 209 210 out = capsys.readouterr().out 211 assert out == "" 212 assert issues == [] 213 214 215 def test_doctor_reports_vercel_backend_diagnostics(monkeypatch, tmp_path): 216 monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox") 217 monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13") 218 monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "2048") 219 monkeypatch.setenv("VERCEL_TOKEN", "super-secret-value") 220 monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False) 221 monkeypatch.setenv("VERCEL_TEAM_ID", "team") 222 monkeypatch.setattr(doctor_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None) 223 224 fake_model_tools = types.SimpleNamespace( 225 check_tool_availability=lambda *a, **kw: ([], []), 226 TOOLSET_REQUIREMENTS={}, 227 ) 228 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 229 230 buf = io.StringIO() 231 with contextlib.redirect_stdout(buf): 232 doctor_mod.run_doctor(Namespace(fix=False)) 233 234 out = buf.getvalue() 235 assert "Vercel runtime" in out 236 assert "python3.13" in out 237 assert "Vercel custom disk unsupported" in out 238 assert "Vercel auth incomplete" in out 239 assert "VERCEL_PROJECT_ID" in out 240 assert "Vercel auth mode: incomplete access token" in out 241 assert "Vercel auth present env: VERCEL_TOKEN, VERCEL_TEAM_ID" in out 242 assert "Vercel auth missing env: VERCEL_PROJECT_ID" in out 243 assert "super-secret-value" not in out 244 assert "snapshot filesystem only" in out 245 246 247 # ── Memory provider section (doctor should only check the *active* provider) ── 248 249 250 class TestDoctorMemoryProviderSection: 251 """The ◆ Memory Provider section should respect memory.provider config.""" 252 253 def _make_hermes_home(self, tmp_path, provider=""): 254 """Create a minimal HERMES_HOME with config.yaml.""" 255 home = tmp_path / ".hermes" 256 home.mkdir(parents=True, exist_ok=True) 257 import yaml 258 config = {"memory": {"provider": provider}} if provider else {"memory": {}} 259 (home / "config.yaml").write_text(yaml.dump(config)) 260 return home 261 262 def _run_doctor_and_capture(self, monkeypatch, tmp_path, provider=""): 263 """Run doctor and capture stdout.""" 264 home = self._make_hermes_home(tmp_path, provider) 265 monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) 266 monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") 267 monkeypatch.setattr(doctor_mod, "_DHH", str(home)) 268 (tmp_path / "project").mkdir(exist_ok=True) 269 270 # Stub tool availability (returns empty) so doctor runs past it 271 fake_model_tools = types.SimpleNamespace( 272 check_tool_availability=lambda *a, **kw: ([], []), 273 TOOLSET_REQUIREMENTS={}, 274 ) 275 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 276 277 # Stub auth checks to avoid real API calls 278 try: 279 from hermes_cli import auth as _auth_mod 280 monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) 281 monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) 282 except Exception: 283 pass 284 285 import io, contextlib 286 buf = io.StringIO() 287 with contextlib.redirect_stdout(buf): 288 doctor_mod.run_doctor(Namespace(fix=False)) 289 return buf.getvalue() 290 291 def test_no_provider_shows_builtin_ok(self, monkeypatch, tmp_path): 292 out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="") 293 assert "Memory Provider" in out 294 assert "Built-in memory active" in out 295 # Should NOT mention Honcho or Mem0 errors 296 assert "Honcho API key" not in out 297 assert "Mem0" not in out 298 299 def test_honcho_provider_not_installed_shows_fail(self, monkeypatch, tmp_path): 300 # Make honcho import fail 301 monkeypatch.setitem( 302 sys.modules, "plugins.memory.honcho.client", None 303 ) 304 out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="honcho") 305 assert "Memory Provider" in out 306 # Should show failure since honcho is set but not importable 307 assert "Built-in memory active" not in out 308 309 def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path): 310 # Make mem0 import fail 311 monkeypatch.setitem(sys.modules, "plugins.memory.mem0", None) 312 out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0") 313 assert "Memory Provider" in out 314 assert "Built-in memory active" not in out 315 316 317 def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path): 318 helper = TestDoctorMemoryProviderSection() 319 monkeypatch.setenv("TERMUX_VERSION", "0.118.3") 320 monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") 321 322 real_which = doctor_mod.shutil.which 323 324 def fake_which(cmd): 325 if cmd in {"docker", "node", "npm"}: 326 return None 327 return real_which(cmd) 328 329 monkeypatch.setattr(doctor_mod.shutil, "which", fake_which) 330 331 out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="") 332 333 assert "Docker backend is not available inside Termux" in out 334 assert "Node.js not found (browser tools are optional in the tested Termux path)" in out 335 assert "Install Node.js on Termux with: pkg install nodejs" in out 336 assert "Termux browser setup:" in out 337 assert "1) pkg install nodejs" in out 338 assert "2) npm install -g agent-browser" in out 339 assert "3) agent-browser install" in out 340 assert "docker not found (optional)" not in out 341 342 343 def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, tmp_path): 344 home = tmp_path / ".hermes" 345 home.mkdir(parents=True, exist_ok=True) 346 347 import yaml 348 349 (home / "config.yaml").write_text( 350 yaml.dump( 351 { 352 "model": { 353 "provider": "volcengine-plan", 354 "default": "doubao-seed-2.0-code", 355 }, 356 "providers": { 357 "volcengine-plan": { 358 "name": "volcengine-plan", 359 "base_url": "https://ark.cn-beijing.volces.com/api/coding/v3", 360 "default_model": "doubao-seed-2.0-code", 361 "models": {"doubao-seed-2.0-code": {}}, 362 } 363 }, 364 } 365 ) 366 ) 367 368 monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) 369 monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") 370 monkeypatch.setattr(doctor_mod, "_DHH", str(home)) 371 (tmp_path / "project").mkdir(exist_ok=True) 372 373 fake_model_tools = types.SimpleNamespace( 374 check_tool_availability=lambda *a, **kw: ([], []), 375 TOOLSET_REQUIREMENTS={}, 376 ) 377 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 378 379 try: 380 from hermes_cli import auth as _auth_mod 381 monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) 382 monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) 383 except Exception: 384 pass 385 386 buf = io.StringIO() 387 with contextlib.redirect_stdout(buf): 388 doctor_mod.run_doctor(Namespace(fix=False)) 389 390 out = buf.getvalue() 391 assert "model.provider 'volcengine-plan' is not a recognised provider" not in out 392 393 394 def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path): 395 home = tmp_path / ".hermes" 396 home.mkdir(parents=True, exist_ok=True) 397 (home / "config.yaml").write_text( 398 "model:\n" 399 " provider: custom\n" 400 " default: local-model\n" 401 " base_url: http://localhost:8000/v1\n", 402 encoding="utf-8", 403 ) 404 405 monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) 406 monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") 407 monkeypatch.setattr(doctor_mod, "_DHH", str(home)) 408 (tmp_path / "project").mkdir(exist_ok=True) 409 410 fake_model_tools = types.SimpleNamespace( 411 check_tool_availability=lambda *a, **kw: ([], []), 412 TOOLSET_REQUIREMENTS={}, 413 ) 414 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 415 416 try: 417 from hermes_cli import auth as _auth_mod 418 monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) 419 monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) 420 except Exception: 421 pass 422 423 buf = io.StringIO() 424 with contextlib.redirect_stdout(buf): 425 doctor_mod.run_doctor(Namespace(fix=False)) 426 427 out = buf.getvalue() 428 assert "model.provider 'custom' is not a recognised provider" not in out 429 430 431 @pytest.mark.parametrize( 432 ("provider", "default_model"), 433 [ 434 ("ai-gateway", "anthropic/claude-sonnet-4.6"), 435 ("opencode-zen", "anthropic/claude-sonnet-4.6"), 436 ("kilocode", "anthropic/claude-sonnet-4.6"), 437 ("kimi-coding", "kimi-k2"), 438 ], 439 ) 440 def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases( 441 monkeypatch, tmp_path, provider, default_model 442 ): 443 home = tmp_path / ".hermes" 444 home.mkdir(parents=True, exist_ok=True) 445 (home / "config.yaml").write_text( 446 "model:\n" 447 f" provider: {provider}\n" 448 f" default: {default_model}\n", 449 encoding="utf-8", 450 ) 451 452 monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) 453 monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") 454 monkeypatch.setattr(doctor_mod, "_DHH", str(home)) 455 (tmp_path / "project").mkdir(exist_ok=True) 456 457 fake_model_tools = types.SimpleNamespace( 458 check_tool_availability=lambda *a, **kw: ([], []), 459 TOOLSET_REQUIREMENTS={}, 460 ) 461 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 462 463 try: 464 from hermes_cli import auth as _auth_mod 465 monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) 466 monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) 467 except Exception: 468 pass 469 470 buf = io.StringIO() 471 with contextlib.redirect_stdout(buf): 472 doctor_mod.run_doctor(Namespace(fix=False)) 473 474 out = buf.getvalue() 475 assert f"model.provider '{provider}' is not a recognised provider" not in out 476 assert f"model.provider '{provider}' is unknown" not in out 477 if provider in {"ai-gateway", "opencode-zen", "kilocode"}: 478 assert ( 479 f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'" 480 not in out 481 ) 482 483 484 485 486 def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path): 487 home = tmp_path / ".hermes" 488 home.mkdir(parents=True, exist_ok=True) 489 (home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8") 490 (home / "config.yaml").write_text( 491 "model:\n" 492 " provider: kimi-coding-cn\n" 493 " default: kimi-k2.6\n", 494 encoding="utf-8", 495 ) 496 497 monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) 498 monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") 499 monkeypatch.setattr(doctor_mod, "_DHH", str(home)) 500 (tmp_path / "project").mkdir(exist_ok=True) 501 502 fake_model_tools = types.SimpleNamespace( 503 check_tool_availability=lambda *a, **kw: ([], []), 504 TOOLSET_REQUIREMENTS={}, 505 ) 506 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 507 508 try: 509 from hermes_cli import auth as _auth_mod 510 monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) 511 monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) 512 monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True}) 513 except Exception: 514 pass 515 516 buf = io.StringIO() 517 with contextlib.redirect_stdout(buf): 518 doctor_mod.run_doctor(Namespace(fix=False)) 519 520 out = buf.getvalue() 521 assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out 522 523 524 def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path): 525 home = tmp_path / ".hermes" 526 home.mkdir(parents=True, exist_ok=True) 527 (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") 528 project = tmp_path / "project" 529 project.mkdir(exist_ok=True) 530 531 monkeypatch.setenv("TERMUX_VERSION", "0.118.3") 532 monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") 533 monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) 534 monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) 535 monkeypatch.setattr(doctor_mod, "_DHH", str(home)) 536 monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None) 537 538 fake_model_tools = types.SimpleNamespace( 539 check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]), 540 TOOLSET_REQUIREMENTS={ 541 "terminal": {"name": "terminal"}, 542 "browser": {"name": "browser"}, 543 }, 544 ) 545 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 546 547 try: 548 from hermes_cli import auth as _auth_mod 549 monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) 550 monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) 551 except Exception: 552 pass 553 554 import io, contextlib 555 buf = io.StringIO() 556 with contextlib.redirect_stdout(buf): 557 doctor_mod.run_doctor(Namespace(fix=False)) 558 out = buf.getvalue() 559 560 assert "✓ browser" not in out 561 assert "browser" in out 562 assert "system dependency not met" in out 563 assert "agent-browser is not installed (expected in the tested Termux path)" in out 564 assert "npm install -g agent-browser && agent-browser install" in out 565 566 567 def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, tmp_path): 568 home = tmp_path / ".hermes" 569 home.mkdir(parents=True, exist_ok=True) 570 (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") 571 (home / ".env").write_text("KIMI_CN_API_KEY=sk-test\n", encoding="utf-8") 572 project = tmp_path / "project" 573 project.mkdir(exist_ok=True) 574 575 monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) 576 monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) 577 monkeypatch.setattr(doctor_mod, "_DHH", str(home)) 578 monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test") 579 580 fake_model_tools = types.SimpleNamespace( 581 check_tool_availability=lambda *a, **kw: ([], []), 582 TOOLSET_REQUIREMENTS={}, 583 ) 584 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 585 586 try: 587 from hermes_cli import auth as _auth_mod 588 monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) 589 monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) 590 except Exception: 591 pass 592 593 calls = [] 594 595 def fake_get(url, headers=None, timeout=None): 596 calls.append((url, headers, timeout)) 597 return types.SimpleNamespace(status_code=200) 598 599 import httpx 600 monkeypatch.setattr(httpx, "get", fake_get) 601 602 import io, contextlib 603 buf = io.StringIO() 604 with contextlib.redirect_stdout(buf): 605 doctor_mod.run_doctor(Namespace(fix=False)) 606 out = buf.getvalue() 607 608 assert "API key or custom endpoint configured" in out 609 assert "Kimi / Moonshot (China)" in out 610 assert "str expected, not NoneType" not in out 611 assert any(url == "https://api.moonshot.cn/v1/models" for url, _, _ in calls) 612 613 614 @pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"]) 615 def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url): 616 home = tmp_path / ".hermes" 617 home.mkdir(parents=True, exist_ok=True) 618 (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") 619 (home / ".env").write_text("OPENCODE_GO_API_KEY=***\n", encoding="utf-8") 620 project = tmp_path / "project" 621 project.mkdir(exist_ok=True) 622 623 monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) 624 monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) 625 monkeypatch.setattr(doctor_mod, "_DHH", str(home)) 626 monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test") 627 if base_url: 628 monkeypatch.setenv("OPENCODE_GO_BASE_URL", base_url) 629 else: 630 monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False) 631 632 fake_model_tools = types.SimpleNamespace( 633 check_tool_availability=lambda *a, **kw: ([], []), 634 TOOLSET_REQUIREMENTS={}, 635 ) 636 monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) 637 638 try: 639 from hermes_cli import auth as _auth_mod 640 monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) 641 monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) 642 except ImportError: 643 pass 644 645 calls = [] 646 647 def fake_get(url, headers=None, timeout=None): 648 calls.append((url, headers, timeout)) 649 return types.SimpleNamespace(status_code=200) 650 651 import httpx 652 monkeypatch.setattr(httpx, "get", fake_get) 653 654 import io, contextlib 655 buf = io.StringIO() 656 with contextlib.redirect_stdout(buf): 657 doctor_mod.run_doctor(Namespace(fix=False)) 658 out = buf.getvalue() 659 660 assert any( 661 "OpenCode Go" in line and "(key configured)" in line 662 for line in out.splitlines() 663 ) 664 assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls) 665 assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls)