fix(providers): honor key_env/api_key_env on Azure Anthropic + accept alias in normalizer (#16935)

Three related fixes around custom env-var-name hints for provider entries.

1. Azure Anthropic path: previously hardcoded to look up AZURE_ANTHROPIC_KEY
   then ANTHROPIC_API_KEY with no way to override.  If a user wrote
     model:
       provider: anthropic
       base_url: https://my-resource.services.ai.azure.com/anthropic
       key_env: MY_CUSTOM_KEY
   the key_env hint was silently ignored and the resolver raised
   'No Azure Anthropic API key found' even when MY_CUSTOM_KEY was set
   in the environment.  The runtime now checks, in order:
     (1) os.getenv(model_cfg.key_env)
     (2) os.getenv(model_cfg.api_key_env)    # docs alias
     (3) model_cfg.api_key                     # inline value
     (4) AZURE_ANTHROPIC_KEY                   # historical default
     (5) ANTHROPIC_API_KEY                     # historical default
   Error message updated to mention key_env as an option.

2. Provider entry normalizer (_normalize_custom_provider_entry): accept
   'api_key_env' as a snake_case alias for 'key_env', and 'apiKeyEnv' as a
   camelCase alias.  Adds both to the _KNOWN_KEYS set so the 'unknown
   config keys ignored' warning doesn't fire on valid configs.

3. _VALID_CUSTOM_PROVIDER_FIELDS: add 'key_env'.  That set documents
   supported custom_providers entry fields; it was drifting from reality
   since key_env has been read at runtime in auxiliary_client.py,
   runtime_provider.py, and main.py for a while.

Docs: website/docs/guides/azure-foundry.md now uses the canonical key_env
field and notes that api_key_env / keyEnv / apiKeyEnv are accepted as
aliases.

Validation: 12 new tests in test_runtime_provider_resolution.py covering
all 5 Azure Anthropic resolution paths + 4 normalizer-alias tests.  Pass
rate across related suites (165 + 46 tests): 100%.

Co-authored-by: teknium1 <teknium@users.noreply.github.com>
This commit is contained in:
Teknium 2026-04-28 02:12:08 -07:00 committed by GitHub
parent 4148e85b3a
commit bd10acd747
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 239 additions and 7 deletions

View file

@ -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.<name>` 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