diff --git a/CHANGELOG.md b/CHANGELOG.md index d048426b..63520be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.681] — 2026-06-26 — Release YK (Anthropic shows in the model picker when configured via OAuth token) + +### Fixed + +- **The model picker now detects Anthropic when it's configured through an OAuth token instead of `ANTHROPIC_API_KEY`.** WebUI's env-fallback provider detection only looked for `ANTHROPIC_API_KEY`, so an Anthropic setup using OAuth token env vars (`ANTHROPIC_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN`, which the shared agent already accepts) fell out of parity and the provider didn't surface. The fallback now derives the Anthropic env-var set from the shared agent provider registry (with a built-in fallback list), and env values are stripped before the availability check so a whitespace-only token can't false-positive the provider. Thanks @rodboev. (#4995, fixes #4770) + ## [v0.51.680] — 2026-06-26 — Release YJ (session-index rebuild can't be clobbered by a late worker) ### Fixed diff --git a/api/config.py b/api/config.py index c9732669..9c2ec86e 100644 --- a/api/config.py +++ b/api/config.py @@ -1171,6 +1171,37 @@ _PROVIDER_ALIASES = { } +def _get_anthropic_fallback_env_vars() -> tuple[str, ...]: + """Read Anthropic auth env vars from the shared agent registry when available.""" + fallback = ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + ) + try: + from hermes_cli.auth import PROVIDER_REGISTRY + + anthropic = ( + PROVIDER_REGISTRY.get("anthropic") + if isinstance(PROVIDER_REGISTRY, dict) + else None + ) + env_vars = getattr(anthropic, "api_key_env_vars", None) + if not env_vars: + return fallback + + out = [] + for _var in env_vars: + if not isinstance(_var, str): + continue + _normalized = _var.strip() + if _normalized and _normalized not in out: + out.append(_normalized) + return tuple(out) if out else fallback + except Exception: + return fallback + + def _resolve_provider_alias(name: str) -> str: """Return the canonical provider slug for *name*. @@ -5687,8 +5718,9 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa except Exception: logger.debug("Failed to parse hermes env file") all_env = {**env_keys} + _anthropic_env_vars = _get_anthropic_fallback_env_vars() for k in ( - "ANTHROPIC_API_KEY", + *_anthropic_env_vars, "OPENAI_API_KEY", "OPENROUTER_API_KEY", "GOOGLE_API_KEY", @@ -5707,10 +5739,10 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", ): - val = _thread_local_env_value(k) + val = _thread_local_env_value(k).strip() if val: all_env[k] = val - if all_env.get("ANTHROPIC_API_KEY"): + if any(all_env.get(env_var) for env_var in _anthropic_env_vars): detected_providers.add("anthropic") if all_env.get("OPENAI_API_KEY"): # hermes-agent registers its OPENAI_API_KEY/OPENAI_BASE_URL provider diff --git a/tests/test_issue4770_anthropic_oauth_detection.py b/tests/test_issue4770_anthropic_oauth_detection.py new file mode 100644 index 00000000..edebacfb --- /dev/null +++ b/tests/test_issue4770_anthropic_oauth_detection.py @@ -0,0 +1,124 @@ +"""Regression tests for #4770 - Anthropic OAuth env vars in fallback detection.""" + +import builtins +import sys + +import api.config as config + + +def _force_env_fallback(monkeypatch): + """Force get_available_models() into the env-based fallback branch.""" + real_import = builtins.__import__ + monkeypatch.delitem(sys.modules, "hermes_cli.auth", raising=False) + monkeypatch.delitem(sys.modules, "hermes_cli", raising=False) + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name in ("hermes_cli.models", "hermes_cli.auth"): + raise ImportError(name) + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + +def _clear_provider_env(monkeypatch): + for key in ( + "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + "GLM_API_KEY", + "KIMI_API_KEY", + "DEEPSEEK_API_KEY", + "XIAOMI_API_KEY", + "OPENCODE_ZEN_API_KEY", + "OPENCODE_GO_API_KEY", + "OPENCODE_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "LM_API_KEY", + "LM_BASE_URL", + ): + monkeypatch.delenv(key, raising=False) + + +def _run_available_models_with_cfg(monkeypatch, tmp_path, cfg): + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + old_models_cache_path = config._models_cache_path + old_config_path_getter = config._get_config_path + + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml") + monkeypatch.setattr("api.profiles.get_active_hermes_home", lambda: tmp_path, raising=False) + config.cfg.clear() + config.cfg.update(cfg) + config._cfg_mtime = 0.0 + config.invalidate_models_cache() + try: + return config.get_available_models() + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + monkeypatch.setattr(config, "_models_cache_path", old_models_cache_path) + monkeypatch.setattr(config, "_get_config_path", old_config_path_getter) + config.invalidate_models_cache() + + +def _provider_groups(result): + return {group["provider_id"]: group for group in result["groups"]} + + +def test_anthropic_token_env_var_surfaces_anthropic_models(monkeypatch, tmp_path): + """ANTHROPIC_TOKEN should detect the Anthropic provider through fallback.""" + _clear_provider_env(monkeypatch) + _force_env_fallback(monkeypatch) + monkeypatch.setenv("ANTHROPIC_TOKEN", "token-only-value") + + result = _run_available_models_with_cfg(monkeypatch, tmp_path, {"model": {}}) + groups = _provider_groups(result) + assert "anthropic" in groups, ( + "ANTHROPIC_TOKEN should surface the Anthropic picker provider when the " + "hermes_cli path is unavailable." + ) + assert groups["anthropic"]["provider"] == "Anthropic" + assert groups["anthropic"]["models"], "Anthropic fallback should include model entries" + + +def test_whitespace_only_anthropic_oauth_env_vars_do_not_surface_anthropic(monkeypatch, tmp_path): + """A whitespace-only ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN must NOT + false-positive the Anthropic provider (env values are stripped before the + availability check).""" + for ws_var in ("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"): + _clear_provider_env(monkeypatch) + _force_env_fallback(monkeypatch) + monkeypatch.setenv(ws_var, " \t ") + + result = _run_available_models_with_cfg(monkeypatch, tmp_path, {"model": {}}) + groups = _provider_groups(result) + assert "anthropic" not in groups, ( + f"a whitespace-only {ws_var} must not surface the Anthropic provider" + ) + + +def test_claude_code_oauth_token_env_var_surfaces_anthropic_models(monkeypatch, tmp_path): + """CLAUDE_CODE_OAUTH_TOKEN should detect the Anthropic provider through fallback.""" + _clear_provider_env(monkeypatch) + _force_env_fallback(monkeypatch) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-token-only-value") + + result = _run_available_models_with_cfg(monkeypatch, tmp_path, {"model": {}}) + groups = _provider_groups(result) + assert "anthropic" in groups, ( + "CLAUDE_CODE_OAUTH_TOKEN should surface the Anthropic picker provider when " + "the hermes_cli path is unavailable." + ) + assert groups["anthropic"]["provider"] == "Anthropic" + assert groups["anthropic"]["models"], "Anthropic fallback should include model entries"