diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index ae47422cf..39efa219c 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -236,6 +236,43 @@ def _write_claude_code_credentials(access_token: str, refresh_token: str, expire logger.debug("Failed to write refreshed credentials: %s", e) +def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]] = None) -> Optional[str]: + """Resolve a token from Claude Code credential files, refreshing if needed.""" + creds = creds or read_claude_code_credentials() + if creds and is_claude_code_token_valid(creds): + logger.debug("Using Claude Code credentials (auto-detected)") + return creds["accessToken"] + if creds: + logger.debug("Claude Code credentials expired — attempting refresh") + refreshed = _refresh_oauth_token(creds) + if refreshed: + return refreshed + logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate") + return None + + +def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[str, Any]]) -> Optional[str]: + """Prefer Claude Code creds when a persisted env OAuth token would shadow refresh. + + Hermes historically persisted setup tokens into ANTHROPIC_TOKEN. That makes + later refresh impossible because the static env token wins before we ever + inspect Claude Code's refreshable credential file. If we have a refreshable + Claude Code credential record, prefer it over the static env OAuth token. + """ + if not env_token or not _is_oauth_token(env_token) or not isinstance(creds, dict): + return None + if not creds.get("refreshToken"): + return None + + resolved = _resolve_claude_code_token_from_credentials(creds) + if resolved and resolved != env_token: + logger.debug( + "Preferring Claude Code credential file over static env OAuth token so refresh can proceed" + ) + return resolved + return None + + def resolve_anthropic_token() -> Optional[str]: """Resolve an Anthropic token from all available sources. @@ -248,28 +285,28 @@ def resolve_anthropic_token() -> Optional[str]: Returns the token string or None. """ + creds = read_claude_code_credentials() + # 1. Hermes-managed OAuth/setup token env var token = os.getenv("ANTHROPIC_TOKEN", "").strip() if token: + preferred = _prefer_refreshable_claude_code_token(token, creds) + if preferred: + return preferred return token # 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens) cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip() if cc_token: + preferred = _prefer_refreshable_claude_code_token(cc_token, creds) + if preferred: + return preferred return cc_token # 3. Claude Code credential file - creds = read_claude_code_credentials() - if creds and is_claude_code_token_valid(creds): - logger.debug("Using Claude Code credentials (auto-detected)") - return creds["accessToken"] - elif creds: - # Token expired — attempt to refresh - logger.debug("Claude Code credentials expired — attempting refresh") - refreshed = _refresh_oauth_token(creds) - if refreshed: - return refreshed - logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate") + resolved_claude_token = _resolve_claude_code_token_from_credentials(creds) + if resolved_claude_token: + return resolved_claude_token # 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY. # This remains as a compatibility fallback for pre-migration Hermes configs. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7a932d9e4..bdde858d3 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1092,6 +1092,13 @@ def save_anthropic_oauth_token(value: str, save_fn=None): writer("ANTHROPIC_API_KEY", "") +def use_anthropic_claude_code_credentials(save_fn=None): + """Use Claude Code's own credential files instead of persisting env tokens.""" + writer = save_fn or save_env_value + writer("ANTHROPIC_TOKEN", "") + writer("ANTHROPIC_API_KEY", "") + + def save_anthropic_api_key(value: str, save_fn=None): """Persist an Anthropic API key and clear the OAuth/setup-token slot.""" writer = save_fn or save_env_value diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8137c550b..37af245fc 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1591,8 +1591,30 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): def _run_anthropic_oauth_flow(save_env_value): """Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" - from agent.anthropic_adapter import run_oauth_setup_token - from hermes_cli.config import save_anthropic_oauth_token + from agent.anthropic_adapter import ( + run_oauth_setup_token, + read_claude_code_credentials, + is_claude_code_token_valid, + ) + from hermes_cli.config import ( + save_anthropic_oauth_token, + use_anthropic_claude_code_credentials, + ) + + def _activate_claude_code_credentials_if_available() -> bool: + try: + creds = read_claude_code_credentials() + except Exception: + creds = None + if creds and ( + is_claude_code_token_valid(creds) + or bool(creds.get("refreshToken")) + ): + use_anthropic_claude_code_credentials(save_fn=save_env_value) + print(" ✓ Claude Code credentials linked.") + print(" Hermes will use Claude's credential store directly instead of copying a setup-token into ~/.hermes/.env.") + return True + return False try: print() @@ -1601,6 +1623,8 @@ def _run_anthropic_oauth_flow(save_env_value): print() token = run_oauth_setup_token() if token: + if _activate_claude_code_credentials_if_available(): + return True save_anthropic_oauth_token(token, save_fn=save_env_value) print(" ✓ OAuth credentials saved.") return True diff --git a/run_agent.py b/run_agent.py index ec51c37c6..f2f71aca7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2613,6 +2613,43 @@ class AIAgent: return True + def _try_refresh_anthropic_client_credentials(self) -> bool: + if self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key"): + return False + + try: + from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client + + new_token = resolve_anthropic_token() + except Exception as exc: + logger.debug("Anthropic credential refresh failed: %s", exc) + return False + + if not isinstance(new_token, str) or not new_token.strip(): + return False + new_token = new_token.strip() + if new_token == self._anthropic_api_key: + return False + + try: + self._anthropic_client.close() + except Exception: + pass + + try: + self._anthropic_client = build_anthropic_client(new_token, getattr(self, "_anthropic_base_url", None)) + except Exception as exc: + logger.warning("Failed to rebuild Anthropic client after credential refresh: %s", exc) + return False + + self._anthropic_api_key = new_token + return True + + def _anthropic_messages_create(self, api_kwargs: dict): + if self.api_mode == "anthropic_messages": + self._try_refresh_anthropic_client_credentials() + return self._anthropic_client.messages.create(**api_kwargs) + def _interruptible_api_call(self, api_kwargs: dict): """ Run the API call in a background thread so the main conversation loop @@ -2629,7 +2666,7 @@ class AIAgent: if self.api_mode == "codex_responses": result["response"] = self._run_codex_stream(api_kwargs) elif self.api_mode == "anthropic_messages": - result["response"] = self._anthropic_client.messages.create(**api_kwargs) + result["response"] = self._anthropic_messages_create(api_kwargs) else: result["response"] = self.client.chat.completions.create(**api_kwargs) except Exception as e: @@ -3267,7 +3304,7 @@ class AIAgent: tools=[memory_tool_def], max_tokens=5120, reasoning_config=None, ) - response = self._anthropic_client.messages.create(**ant_kwargs) + response = self._anthropic_messages_create(ant_kwargs) elif not _aux_available: api_kwargs = { "model": self.model, @@ -4018,7 +4055,7 @@ class AIAgent: from agent.anthropic_adapter import build_anthropic_kwargs as _bak, normalize_anthropic_response as _nar _ant_kw = _bak(model=self.model, messages=api_messages, tools=None, max_tokens=self.max_tokens, reasoning_config=self.reasoning_config) - summary_response = self._anthropic_client.messages.create(**_ant_kw) + summary_response = self._anthropic_messages_create(_ant_kw) _msg, _ = _nar(summary_response) final_response = (_msg.content or "").strip() else: @@ -4048,7 +4085,7 @@ class AIAgent: from agent.anthropic_adapter import build_anthropic_kwargs as _bak2, normalize_anthropic_response as _nar2 _ant_kw2 = _bak2(model=self.model, messages=api_messages, tools=None, max_tokens=self.max_tokens, reasoning_config=self.reasoning_config) - retry_response = self._anthropic_client.messages.create(**_ant_kw2) + retry_response = self._anthropic_messages_create(_ant_kw2) _retry_msg, _ = _nar2(retry_response) final_response = (_retry_msg.content or "").strip() else: @@ -4822,12 +4859,8 @@ class AIAgent: and not anthropic_auth_retry_attempted ): anthropic_auth_retry_attempted = True - # Try re-reading Claude Code credentials (they may have been refreshed) - from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client, _is_oauth_token - new_token = resolve_anthropic_token() - if new_token and new_token != self._anthropic_api_key: - self._anthropic_api_key = new_token - self._anthropic_client = build_anthropic_client(new_token, getattr(self, "_anthropic_base_url", None)) + from agent.anthropic_adapter import _is_oauth_token + if self._try_refresh_anthropic_client_credentials(): print(f"{self.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...") continue # Credential refresh didn't help — show diagnostic info diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 9ede37e41..541d8e2bc 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -181,6 +181,33 @@ class TestResolveAnthropicToken: monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "cc-auto-token" + def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + cred_file = tmp_path / ".claude" / ".credentials.json" + cred_file.parent.mkdir(parents=True) + cred_file.write_text(json.dumps({ + "claudeAiOauth": { + "accessToken": "cc-auto-token", + "refreshToken": "refresh-token", + "expiresAt": int(time.time() * 1000) + 3600_000, + } + })) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + assert resolve_anthropic_token() == "cc-auto-token" + + def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exists(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + claude_json = tmp_path / ".claude.json" + claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-managed-key"})) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + assert resolve_anthropic_token() == "sk-ant-oat01-static-token" + class TestRefreshOauthToken: def test_returns_none_without_refresh_token(self): @@ -279,6 +306,27 @@ class TestResolveWithRefresh: assert result == "refreshed-token" + def test_static_env_oauth_token_does_not_block_refreshable_claude_creds(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-expired-env-token") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + cred_file = tmp_path / ".claude" / ".credentials.json" + cred_file.parent.mkdir(parents=True) + cred_file.write_text(json.dumps({ + "claudeAiOauth": { + "accessToken": "expired-claude-creds-token", + "refreshToken": "valid-refresh", + "expiresAt": int(time.time() * 1000) - 3600_000, + } + })) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"): + result = resolve_anthropic_token() + + assert result == "refreshed-token" + class TestRunOauthSetupToken: def test_raises_when_claude_not_installed(self, monkeypatch): diff --git a/tests/test_anthropic_oauth_flow.py b/tests/test_anthropic_oauth_flow.py new file mode 100644 index 000000000..3b52831aa --- /dev/null +++ b/tests/test_anthropic_oauth_flow.py @@ -0,0 +1,51 @@ +"""Tests for Anthropic OAuth setup flow behavior.""" + +from hermes_cli.config import load_env, save_env_value + + +def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr( + "agent.anthropic_adapter.run_oauth_setup_token", + lambda: "sk-ant-oat01-from-claude-setup", + ) + monkeypatch.setattr( + "agent.anthropic_adapter.read_claude_code_credentials", + lambda: { + "accessToken": "cc-access-token", + "refreshToken": "cc-refresh-token", + "expiresAt": 9999999999999, + }, + ) + monkeypatch.setattr( + "agent.anthropic_adapter.is_claude_code_token_valid", + lambda creds: True, + ) + + from hermes_cli.main import _run_anthropic_oauth_flow + + save_env_value("ANTHROPIC_TOKEN", "stale-env-token") + assert _run_anthropic_oauth_flow(save_env_value) is True + + env_vars = load_env() + assert env_vars["ANTHROPIC_TOKEN"] == "" + assert env_vars["ANTHROPIC_API_KEY"] == "" + output = capsys.readouterr().out + assert "Claude Code credentials linked" in output + + +def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("agent.anthropic_adapter.run_oauth_setup_token", lambda: None) + monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) + monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False) + monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token") + + from hermes_cli.main import _run_anthropic_oauth_flow + + assert _run_anthropic_oauth_flow(save_env_value) is True + + env_vars = load_env() + assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-manual-token" + output = capsys.readouterr().out + assert "Setup-token saved" in output diff --git a/tests/test_anthropic_provider_persistence.py b/tests/test_anthropic_provider_persistence.py index fd55d21b7..4c2c47280 100644 --- a/tests/test_anthropic_provider_persistence.py +++ b/tests/test_anthropic_provider_persistence.py @@ -17,6 +17,21 @@ def test_save_anthropic_oauth_token_uses_token_slot_and_clears_api_key(tmp_path, assert env_vars["ANTHROPIC_API_KEY"] == "" +def test_use_anthropic_claude_code_credentials_clears_env_slots(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials + + save_anthropic_oauth_token("sk-ant-oat01-token") + use_anthropic_claude_code_credentials() + + env_vars = load_env() + assert env_vars["ANTHROPIC_TOKEN"] == "" + assert env_vars["ANTHROPIC_API_KEY"] == "" + + def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, monkeypatch): home = tmp_path / "hermes" home.mkdir() @@ -24,8 +39,8 @@ def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, mon from hermes_cli.config import save_anthropic_api_key - save_anthropic_api_key("sk-ant-api03-test-key") + save_anthropic_api_key("sk-ant-api03-key") env_vars = load_env() - assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-test-key" + assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-key" assert env_vars["ANTHROPIC_TOKEN"] == "" diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 59c4a052a..c3673eb1e 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -2085,6 +2085,92 @@ class TestAnthropicBaseUrlPassthrough: assert not passed_url or passed_url is None +class TestAnthropicCredentialRefresh: + def test_try_refresh_anthropic_client_credentials_rebuilds_client(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, + ): + old_client = MagicMock() + new_client = MagicMock() + mock_build.side_effect = [old_client, new_client] + agent = AIAgent( + api_key="sk-ant-oat01-stale-token", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._anthropic_client = old_client + agent._anthropic_api_key = "sk-ant-oat01-stale-token" + agent._anthropic_base_url = "https://api.anthropic.com" + + with ( + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-fresh-token"), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=new_client) as rebuild, + ): + assert agent._try_refresh_anthropic_client_credentials() is True + + old_client.close.assert_called_once() + rebuild.assert_called_once_with("sk-ant-oat01-fresh-token", "https://api.anthropic.com") + assert agent._anthropic_client is new_client + assert agent._anthropic_api_key == "sk-ant-oat01-fresh-token" + + def test_try_refresh_anthropic_client_credentials_returns_false_when_token_unchanged(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-oat01-same-token", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + old_client = MagicMock() + agent._anthropic_client = old_client + agent._anthropic_api_key = "sk-ant-oat01-same-token" + + with ( + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-same-token"), + patch("agent.anthropic_adapter.build_anthropic_client") as rebuild, + ): + assert agent._try_refresh_anthropic_client_credentials() is False + + old_client.close.assert_not_called() + rebuild.assert_not_called() + + def test_anthropic_messages_create_preflights_refresh(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-oat01-current-token", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + response = SimpleNamespace(content=[]) + agent._anthropic_client = MagicMock() + agent._anthropic_client.messages.create.return_value = response + + with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=True) as refresh: + result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"}) + + refresh.assert_called_once_with() + agent._anthropic_client.messages.create.assert_called_once_with(model="claude-sonnet-4-20250514") + assert result is response + + # =================================================================== # _streaming_api_call tests # =================================================================== diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md index 68fe537c4..08f950509 100644 --- a/website/docs/developer-guide/provider-runtime.md +++ b/website/docs/developer-guide/provider-runtime.md @@ -86,6 +86,13 @@ When provider resolution selects `anthropic`, Hermes uses: - the native Anthropic Messages API - `agent/anthropic_adapter.py` for translation +Credential resolution for native Anthropic now prefers refreshable Claude Code credentials over copied env tokens when both are present. In practice that means: + +- Claude Code credential files are treated as the preferred source when they include refreshable auth +- manual `ANTHROPIC_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN` values still work as explicit overrides +- Hermes preflights Anthropic credential refresh before native Messages API calls +- Hermes still retries once on a 401 after rebuilding the Anthropic client, as a fallback path + ## OpenAI Codex path Codex uses a separate Responses API path: diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 7fed47a21..66be25fd6 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -43,7 +43,7 @@ hermes setup # Or configure everything at once |----------|-----------|---------------| | **Nous Portal** | Subscription-based, zero-config | OAuth login via `hermes model` | | **OpenAI Codex** | ChatGPT OAuth, uses Codex models | Device code auth via `hermes model` | -| **Anthropic** | Claude models directly (Pro/Max or API key) | API key or Claude Code setup-token | +| **Anthropic** | Claude models directly (Pro/Max or API key) | `hermes model` with Claude Code auth, or an Anthropic API key | | **OpenRouter** | Multi-provider routing across many models | Enter your API key | | **Z.AI** | GLM / Zhipu-hosted models | Set `GLM_API_KEY` / `ZAI_API_KEY` | | **Kimi / Moonshot** | Moonshot-hosted coding and chat models | Set `KIMI_API_KEY` | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 6fcc96a2b..d4f633ee0 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -26,9 +26,9 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/v1`) | | `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)) | | `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/v1`) | -| `ANTHROPIC_API_KEY` | Anthropic API key or setup-token ([console.anthropic.com](https://console.anthropic.com/)) | -| `ANTHROPIC_TOKEN` | Anthropic OAuth/setup token (alternative to `ANTHROPIC_API_KEY`) | -| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code setup-token (same as `ANTHROPIC_TOKEN`) | +| `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) | +| `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override | +| `CLAUDE_CODE_OAUTH_TOKEN` | Explicit Claude Code token override if you export one manually | | `HERMES_MODEL` | Preferred model name (checked before `LLM_MODEL`, used by gateway) | | `LLM_MODEL` | Default model name (fallback when not set in config.yaml) | | `VOICE_TOOLS_OPENAI_KEY` | OpenAI key for OpenAI speech-to-text and text-to-speech providers | @@ -36,6 +36,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config ## Provider Auth (OAuth) +For native Anthropic auth, Hermes prefers Claude Code's own credential files when they exist because those credentials can refresh automatically. Environment variables such as `ANTHROPIC_TOKEN` remain useful as manual overrides, but they are no longer the preferred path for Claude Pro/Max login. + | Variable | Description | |----------|-------------| | `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` (default: `auto`) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 4615ff06a..71525764e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -63,7 +63,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro |----------|-------| | **Nous Portal** | `hermes model` (OAuth, subscription-based) | | **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) | -| **Anthropic** | `hermes model` (API key, setup-token, or Claude Code auto-detect) | +| **Anthropic** | `hermes model` (Claude Pro/Max via Claude Code auth, Anthropic API key, or manual setup-token) | | **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` | | **z.ai / GLM** | `GLM_API_KEY` in `~/.hermes/.env` (provider: `zai`) | | **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) | @@ -85,17 +85,23 @@ Use Claude models directly through the Anthropic API — no OpenRouter proxy nee ```bash # With an API key (pay-per-token) -export ANTHROPIC_API_KEY=sk-ant-api03-... +export ANTHROPIC_API_KEY=*** hermes chat --provider anthropic --model claude-sonnet-4-6 -# With a Claude Code setup-token (Pro/Max subscription) -export ANTHROPIC_API_KEY=sk-ant-oat01-... # from 'claude setup-token' +# Preferred: authenticate through `hermes model` +# Hermes will use Claude Code's credential store directly when available +hermes model + +# Manual override with a setup-token (fallback / legacy) +export ANTHROPIC_TOKEN=*** # setup-token or manual OAuth token hermes chat --provider anthropic -# Auto-detect Claude Code credentials (if you have Claude Code installed) -hermes chat --provider anthropic # reads ~/.claude.json automatically +# Auto-detect Claude Code credentials (if you already use Claude Code) +hermes chat --provider anthropic # reads Claude Code credential files automatically ``` +When you choose Anthropic OAuth through `hermes model`, Hermes prefers Claude Code's own credential store over copying the token into `~/.hermes/.env`. That keeps refreshable Claude credentials refreshable. + Or set it permanently: ```yaml model: