test_codex_cli_model_picker.py
1 """Regression tests for the /model picker's credential-discovery paths. 2 3 Covers: 4 - Normal path (tokens already in Hermes auth store) 5 - Claude Code fallback (tokens only in ~/.claude/.credentials.json) 6 - Negative case (no credentials anywhere) 7 8 Note: auto-import from ~/.codex/auth.json was removed in #12360 — Hermes 9 now owns its own openai-codex auth state, and users explicitly adopt 10 existing Codex CLI tokens via `hermes auth openai-codex`. The old 11 "Codex CLI shared file" discovery tests were removed with that change. 12 """ 13 14 import base64 15 import json 16 import os 17 import sys 18 import time 19 from pathlib import Path 20 from unittest.mock import patch 21 22 import pytest 23 24 25 def _make_fake_jwt(expiry_offset: int = 3600) -> str: 26 """Build a fake JWT with a future expiry.""" 27 header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode() 28 exp = int(time.time()) + expiry_offset 29 payload_bytes = json.dumps({"exp": exp, "sub": "test"}).encode() 30 payload = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode() 31 return f"{header}.{payload}.fakesig" 32 33 34 @pytest.fixture() 35 def hermes_auth_only_env(tmp_path, monkeypatch): 36 """Tokens already in Hermes auth store (no Codex CLI needed).""" 37 hermes_home = tmp_path / ".hermes" 38 hermes_home.mkdir() 39 40 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 41 # Point CODEX_HOME to nonexistent dir to prove it's not needed 42 monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) 43 44 (hermes_home / "auth.json").write_text(json.dumps({ 45 "version": 2, 46 "providers": { 47 "openai-codex": { 48 "tokens": { 49 "access_token": _make_fake_jwt(), 50 "refresh_token": "fake-refresh", 51 }, 52 "last_refresh": "2026-04-12T00:00:00Z", 53 } 54 }, 55 })) 56 57 for var in [ 58 "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", 59 "NOUS_API_KEY", "DEEPSEEK_API_KEY", 60 ]: 61 monkeypatch.delenv(var, raising=False) 62 63 return hermes_home 64 65 66 def test_normal_path_still_works(hermes_auth_only_env): 67 """openai-codex appears when tokens are already in Hermes auth store.""" 68 from hermes_cli.model_switch import list_authenticated_providers 69 70 providers = list_authenticated_providers( 71 current_provider="openai-codex", 72 max_models=10, 73 ) 74 slugs = [p["slug"] for p in providers] 75 assert "openai-codex" in slugs 76 77 78 @pytest.fixture() 79 def claude_code_only_env(tmp_path, monkeypatch): 80 """Set up an environment where Anthropic credentials only exist in 81 ~/.claude/.credentials.json (Claude Code) — not in env vars or Hermes 82 auth store.""" 83 hermes_home = tmp_path / ".hermes" 84 hermes_home.mkdir() 85 86 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 87 # No Codex CLI 88 monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) 89 90 (hermes_home / "auth.json").write_text( 91 json.dumps({"version": 2, "providers": {}}) 92 ) 93 94 # Claude Code credentials in the correct format 95 claude_dir = tmp_path / ".claude" 96 claude_dir.mkdir() 97 (claude_dir / ".credentials.json").write_text(json.dumps({ 98 "claudeAiOauth": { 99 "accessToken": _make_fake_jwt(), 100 "refreshToken": "fake-refresh", 101 "expiresAt": int(time.time() * 1000) + 3_600_000, 102 } 103 })) 104 105 # Patch Path.home() so the adapter finds the file 106 monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) 107 108 for var in [ 109 "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", 110 "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", 111 "NOUS_API_KEY", "DEEPSEEK_API_KEY", 112 ]: 113 monkeypatch.delenv(var, raising=False) 114 115 return hermes_home 116 117 118 def test_claude_code_file_detected_by_model_picker(claude_code_only_env): 119 """anthropic should appear when credentials only exist in ~/.claude/.credentials.json.""" 120 from hermes_cli.model_switch import list_authenticated_providers 121 122 providers = list_authenticated_providers( 123 current_provider="anthropic", 124 max_models=10, 125 ) 126 slugs = [p["slug"] for p in providers] 127 assert "anthropic" in slugs, ( 128 f"anthropic not found in /model picker providers: {slugs}" 129 ) 130 131 anthropic = next(p for p in providers if p["slug"] == "anthropic") 132 assert anthropic["is_current"] is True 133 assert anthropic["total_models"] > 0 134 135 136 def test_no_codex_when_no_credentials(tmp_path, monkeypatch): 137 """openai-codex should NOT appear when no credentials exist anywhere.""" 138 hermes_home = tmp_path / ".hermes" 139 hermes_home.mkdir() 140 141 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 142 monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) 143 144 (hermes_home / "auth.json").write_text( 145 json.dumps({"version": 2, "providers": {}}) 146 ) 147 148 for var in [ 149 "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", 150 "NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN", 151 "GH_TOKEN", "GEMINI_API_KEY", 152 ]: 153 monkeypatch.delenv(var, raising=False) 154 155 from hermes_cli.model_switch import list_authenticated_providers 156 157 providers = list_authenticated_providers( 158 current_provider="openrouter", 159 max_models=10, 160 ) 161 slugs = [p["slug"] for p in providers] 162 assert "openai-codex" not in slugs, ( 163 "openai-codex should not appear without any credentials" 164 )