mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
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:
parent
4148e85b3a
commit
bd10acd747
4 changed files with 239 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue