/ tests / hermes_cli / test_codex_cli_model_picker.py
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      )