diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5ea762306d..5d9c789283 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2233,12 +2233,18 @@ def _normalize_custom_provider_entry( "baseUrl": "base_url", "apiMode": "api_mode", "keyEnv": "key_env", + "apiKeyEnv": "key_env", # alias — OpenClaw-compatible + docs variant "defaultModel": "default_model", "contextLength": "context_length", "rateLimitDelay": "rate_limit_delay", } + # api_key_env is a documented snake_case alias for key_env (see + # website/docs/guides/azure-foundry.md). Normalize it up front so the + # rest of the normalizer treats it as the canonical field. + if "api_key_env" in entry and "key_env" not in entry: + entry["key_env"] = entry["api_key_env"] _KNOWN_KEYS = { - "name", "api", "url", "base_url", "api_key", "key_env", + "name", "api", "url", "base_url", "api_key", "key_env", "api_key_env", "api_mode", "transport", "model", "default_model", "models", "context_length", "rate_limit_delay", "request_timeout_seconds", "stale_timeout_seconds", @@ -2493,6 +2499,9 @@ _KNOWN_ROOT_KEYS = { _VALID_CUSTOM_PROVIDER_FIELDS = { "name", "base_url", "api_key", "api_mode", "model", "models", "context_length", "rate_limit_delay", + # key_env is read at runtime by runtime_provider.py and auxiliary_client.py + # — include it here so the set accurately describes the supported schema. + "key_env", } # Fields that look like they should be inside custom_providers, not at root diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c87b9c42ce..e2883c883f 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1124,13 +1124,34 @@ def resolve_runtime_provider( cfg_base_url and "azure.com" in cfg_base_url.lower() ) if _is_azure_endpoint: - token = ( - os.getenv("AZURE_ANTHROPIC_KEY", "").strip() - or os.getenv("ANTHROPIC_API_KEY", "").strip() - ) + # Honor user-specified env var hints on the model config before + # falling back to the built-in AZURE_ANTHROPIC_KEY / ANTHROPIC_API_KEY + # chain. Accept both `key_env` (Hermes canonical — matches the + # custom_providers field name) and `api_key_env` (documented in the + # Azure Foundry guide and read by most Hermes-compatible importers). + # Matches the config.yaml examples in website/docs/guides/azure-foundry.md. + token = "" + for hint_key in ("key_env", "api_key_env"): + env_var = str(model_cfg.get(hint_key) or "").strip() + if env_var: + token = os.getenv(env_var, "").strip() + if token: + break + # Next: an inline api_key on the model config (useful in multi-profile + # setups that want to avoid env-var juggling). + if not token: + token = str(model_cfg.get("api_key") or "").strip() + # Finally fall back to the historical fixed names. + if not token: + token = ( + os.getenv("AZURE_ANTHROPIC_KEY", "").strip() + or os.getenv("ANTHROPIC_API_KEY", "").strip() + ) if not token: raise AuthError( - "No Azure Anthropic API key found. Set AZURE_ANTHROPIC_KEY or ANTHROPIC_API_KEY." + "No Azure Anthropic API key found. Set AZURE_ANTHROPIC_KEY or " + "ANTHROPIC_API_KEY, or point key_env/api_key_env in your " + "config.yaml model section at a custom env var." ) else: from agent.anthropic_adapter import resolve_anthropic_token diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index f251a3f241..03c6fec339 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -1762,3 +1762,203 @@ class TestAzureFoundryResolution: assert resolved["api_mode"] == "codex_responses" + + +# ────────────────────────────────────────────────────────────────────────── +# Azure Anthropic — honor user-specified env var hints (key_env / api_key_env) +# +# When the user points provider=anthropic at an Azure Foundry base URL, the +# runtime resolver previously hardcoded `AZURE_ANTHROPIC_KEY` and +# `ANTHROPIC_API_KEY` as the only env var sources. This meant +# `key_env: MY_CUSTOM_VAR` on the model config was silently ignored — and +# the Azure Foundry docs that showed `api_key_env:` were broken as a result. +# +# These tests lock in the priority chain: +# 1. model_cfg.key_env → os.getenv(value) +# 2. model_cfg.api_key_env → os.getenv(value) (docs alias) +# 3. model_cfg.api_key (inline value) +# 4. AZURE_ANTHROPIC_KEY env var +# 5. ANTHROPIC_API_KEY env var +# ────────────────────────────────────────────────────────────────────────── + + +class TestAzureAnthropicEnvVarHint: + _AZURE_URL = "https://my-resource.services.ai.azure.com/anthropic" + + def _cfg(self, **overrides): + base = {"provider": "anthropic", "base_url": self._AZURE_URL} + base.update(overrides) + return base + + def test_key_env_hint_picks_custom_var(self, monkeypatch): + """model.key_env names a non-default env var → that var's value is used.""" + monkeypatch.delenv("AZURE_ANTHROPIC_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("MY_CUSTOM_AZURE_KEY", "from-custom-var") + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_get_model_config", + lambda: self._cfg(key_env="MY_CUSTOM_AZURE_KEY")) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + + assert resolved["api_key"] == "from-custom-var" + assert resolved["base_url"] == self._AZURE_URL + + def test_api_key_env_alias_honored(self, monkeypatch): + """The `api_key_env` alias (used in azure-foundry docs) also works.""" + monkeypatch.delenv("AZURE_ANTHROPIC_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("DOCS_VARIANT_KEY", "from-docs-alias") + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_get_model_config", + lambda: self._cfg(api_key_env="DOCS_VARIANT_KEY")) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + + assert resolved["api_key"] == "from-docs-alias" + + def test_key_env_beats_fallback_chain(self, monkeypatch): + """key_env takes priority over AZURE_ANTHROPIC_KEY / ANTHROPIC_API_KEY.""" + monkeypatch.setenv("AZURE_ANTHROPIC_KEY", "should-not-win") + monkeypatch.setenv("ANTHROPIC_API_KEY", "should-not-win-either") + monkeypatch.setenv("MY_PROVIDER_KEY", "winning-key") + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_get_model_config", + lambda: self._cfg(key_env="MY_PROVIDER_KEY")) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + + assert resolved["api_key"] == "winning-key" + + def test_inline_api_key_on_model_cfg(self, monkeypatch): + """model.api_key (inline value) works for single-config setups.""" + monkeypatch.delenv("AZURE_ANTHROPIC_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_get_model_config", + lambda: self._cfg(api_key="inline-azure-key")) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + + assert resolved["api_key"] == "inline-azure-key" + + def test_azure_anthropic_key_still_works_as_fallback(self, monkeypatch): + """Historical fixed-name env vars still resolve when no hint is set.""" + monkeypatch.setenv("AZURE_ANTHROPIC_KEY", "historical-key") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_get_model_config", lambda: self._cfg()) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + + assert resolved["api_key"] == "historical-key" + + def test_key_env_points_at_unset_var_falls_through(self, monkeypatch): + """If key_env names an env var that isn't set, fall through to the + historical fixed names rather than failing outright.""" + monkeypatch.setenv("AZURE_ANTHROPIC_KEY", "fallback-works") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("UNSET_VAR", raising=False) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_get_model_config", + lambda: self._cfg(key_env="UNSET_VAR")) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + + assert resolved["api_key"] == "fallback-works" + + def test_no_key_anywhere_raises_helpful_error(self, monkeypatch): + """When nothing resolves, the error message mentions key_env as an option.""" + monkeypatch.delenv("AZURE_ANTHROPIC_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_get_model_config", lambda: self._cfg()) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + + with pytest.raises(rp.AuthError, match="key_env"): + rp.resolve_runtime_provider(requested="anthropic") + + def test_non_azure_anthropic_path_ignores_key_env(self, monkeypatch): + """key_env is only consulted on Azure endpoints — non-Azure Anthropic + still goes through the regular resolve_anthropic_token chain.""" + monkeypatch.setenv("MY_KEY", "custom-key-value") + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr(rp, "_get_model_config", lambda: { + "provider": "anthropic", + "base_url": "https://api.anthropic.com", # non-Azure + "key_env": "MY_KEY", + }) + monkeypatch.setattr(rp, "load_pool", lambda provider: None) + called = {"resolve_anthropic_token": False} + def _fake_resolve(): + called["resolve_anthropic_token"] = True + return "token-from-resolver" + monkeypatch.setattr( + "agent.anthropic_adapter.resolve_anthropic_token", + _fake_resolve, + ) + + resolved = rp.resolve_runtime_provider(requested="anthropic") + + # The normal chain runs — key_env is not consulted off-Azure. + assert called["resolve_anthropic_token"] is True + assert resolved["api_key"] == "token-from-resolver" + + +# ────────────────────────────────────────────────────────────────────────── +# custom_providers / providers normalizer — api_key_env alias for key_env +# ────────────────────────────────────────────────────────────────────────── + + +class TestProviderEntryApiKeyEnvAlias: + """The `providers.` and `custom_providers[i]` normalizer must accept + `api_key_env` as an alias for `key_env` so configs written against the + documented Azure Foundry YAML shape (or imported from other tools that + use `api_key_env`) resolve correctly.""" + + def test_snake_case_api_key_env_normalizes_to_key_env(self): + from hermes_cli.config import _normalize_custom_provider_entry + entry = { + "name": "vendor", + "base_url": "https://api.vendor.example.com/v1", + "api_key_env": "MY_VENDOR_KEY", + } + normalized = _normalize_custom_provider_entry(dict(entry), provider_key="vendor") + assert normalized is not None + assert normalized.get("key_env") == "MY_VENDOR_KEY" + + def test_camel_case_api_key_env_normalizes_to_key_env(self): + from hermes_cli.config import _normalize_custom_provider_entry + entry = { + "name": "vendor", + "base_url": "https://api.vendor.example.com/v1", + "apiKeyEnv": "MY_VENDOR_KEY", + } + normalized = _normalize_custom_provider_entry(dict(entry), provider_key="vendor") + assert normalized is not None + assert normalized.get("key_env") == "MY_VENDOR_KEY" + + def test_key_env_wins_if_both_forms_present(self): + """If both key_env and api_key_env are set, the canonical key_env wins.""" + from hermes_cli.config import _normalize_custom_provider_entry + entry = { + "name": "vendor", + "base_url": "https://api.vendor.example.com/v1", + "key_env": "CANONICAL", + "api_key_env": "ALIAS", + } + normalized = _normalize_custom_provider_entry(dict(entry), provider_key="vendor") + assert normalized is not None + assert normalized.get("key_env") == "CANONICAL" + + def test_valid_fields_set_lists_key_env(self): + """The _VALID_CUSTOM_PROVIDER_FIELDS documentation set must include + key_env so the set stays in sync with what the runtime actually reads.""" + from hermes_cli.config import _VALID_CUSTOM_PROVIDER_FIELDS + assert "key_env" in _VALID_CUSTOM_PROVIDER_FIELDS diff --git a/website/docs/guides/azure-foundry.md b/website/docs/guides/azure-foundry.md index 29c62e1458..218eadadc3 100644 --- a/website/docs/guides/azure-foundry.md +++ b/website/docs/guides/azure-foundry.md @@ -102,12 +102,14 @@ If you already have `provider: anthropic` configured and just want to point it a model: provider: anthropic base_url: https://my-resource.services.ai.azure.com/anthropic - api_key_env: AZURE_ANTHROPIC_KEY + key_env: AZURE_ANTHROPIC_KEY default: claude-sonnet-4-6 ``` With `AZURE_ANTHROPIC_KEY` set in `~/.hermes/.env`. Hermes detects `azure.com` in the base URL and short-circuits around the Claude Code OAuth token chain so the Azure key is used directly with `x-api-key` auth. +`key_env` is the canonical snake_case field name; `api_key_env` (and the camelCase `keyEnv` / `apiKeyEnv`) are accepted as aliases. If both `key_env` and `AZURE_ANTHROPIC_KEY`/`ANTHROPIC_API_KEY` are set, the `key_env`-named env var wins. + ## Model discovery Azure does **not** expose a pure-API-key endpoint to list your *deployed* model deployments. Deployment enumeration requires Azure Resource Manager authentication (`az cognitiveservices account deployment list`) with an Azure AD principal, not the inference API key.