feat(azure-foundry): add Microsoft Entra ID auth

Use azure-identity DefaultAzureCredential for keyless Foundry auth.

Preserve refreshable callable credentials through OpenAI and Anthropic client paths.

Add setup, doctor, auth status, docs, and tests for Entra auth.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
glennc
2026-05-15 14:36:18 -07:00
committed by Teknium
parent 457fa913b8
commit 9df9816dab
38 changed files with 3772 additions and 122 deletions

View File

@@ -0,0 +1,87 @@
"""Regression tests for ACP adapter detection under Azure Foundry Entra ID.
The ACP adapter's ``detect_provider`` previously gated on
``isinstance(api_key, str)`` and returned ``None`` for any runtime that
returned a callable ``api_key`` — i.e. Azure Foundry with
``auth_mode=entra_id``. Downstream, ACP would default to
``"openrouter"`` and reject the legitimate provider in its auth handshake.
This test pins the callable-aware fix so it never regresses.
"""
from __future__ import annotations
from unittest.mock import patch
class TestDetectProviderEntra:
def test_callable_api_key_is_a_valid_credential(self):
"""A runtime returning a callable ``api_key`` (Entra bearer token
provider) must be detected as a configured provider, not
``None``."""
from acp_adapter import auth as _acp_auth
def _fake_runtime(**_kwargs):
return {
"provider": "azure-foundry",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_key": lambda: "jwt-fresh",
}
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=_fake_runtime,
):
assert _acp_auth.detect_provider() == "azure-foundry"
assert _acp_auth.has_provider() is True
def test_string_api_key_still_works(self):
from acp_adapter import auth as _acp_auth
def _fake_runtime(**_kwargs):
return {
"provider": "openrouter",
"api_key": "sk-or-static-key",
}
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=_fake_runtime,
):
assert _acp_auth.detect_provider() == "openrouter"
def test_empty_string_api_key_returns_none(self):
from acp_adapter import auth as _acp_auth
def _fake_runtime(**_kwargs):
return {"provider": "openrouter", "api_key": ""}
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=_fake_runtime,
):
assert _acp_auth.detect_provider() is None
def test_missing_provider_returns_none(self):
"""A callable api_key without a provider is still ``None`` —
we don't synthesize a provider name from the credential shape."""
from acp_adapter import auth as _acp_auth
def _fake_runtime(**_kwargs):
return {"api_key": lambda: "jwt-fresh", "provider": ""}
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=_fake_runtime,
):
assert _acp_auth.detect_provider() is None
def test_resolver_exception_returns_none(self):
from acp_adapter import auth as _acp_auth
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=RuntimeError("simulated"),
):
assert _acp_auth.detect_provider() is None

View File

@@ -9,6 +9,7 @@ import pytest
from agent.prompt_caching import apply_anthropic_cache_control
from agent.anthropic_adapter import (
_is_azure_anthropic_endpoint,
_is_oauth_token,
_refresh_oauth_token,
_to_plain_data,
@@ -121,6 +122,20 @@ class TestBuildAnthropicClient:
betas = kwargs["default_headers"]["anthropic-beta"]
assert "context-1m-2025-08-07" in betas
def test_azure_anthropic_endpoint_detection_is_host_and_path_scoped(self):
assert _is_azure_anthropic_endpoint(
"https://example.services.ai.azure.com/models/anthropic"
) is True
assert _is_azure_anthropic_endpoint(
"https://example.services.ai.azure.us/anthropic"
) is True
assert _is_azure_anthropic_endpoint(
"https://example.openai.azure.com/openai/v1"
) is False
assert _is_azure_anthropic_endpoint(
"https://management.azure.com/anthropic"
) is False
def test_bedrock_client_keeps_context_1m_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
mock_sdk.AnthropicBedrock = MagicMock()

View File

@@ -0,0 +1,350 @@
"""Tests for auxiliary client routing of the ``azure-foundry`` provider.
Covers the dedicated branch in ``agent.auxiliary_client.resolve_provider_client``
that delegates to :func:`hermes_cli.runtime_provider._resolve_azure_foundry_runtime`
instead of falling into the generic ``resolve_api_key_provider_credentials``
path (which only knows about ``AZURE_FOUNDRY_API_KEY`` and would 401 for
Entra ID users and miss ``model.base_url`` overrides for api-key users
with non-standard Foundry-projects endpoints).
Pinned scenarios:
* ``auth_mode: api_key`` → plain OpenAI client with the static string
key for ``chat_completions``.
* ``auth_mode: entra_id`` + ``chat_completions`` → plain OpenAI
client with a callable ``api_key`` (the bearer-token provider) —
confirms the callable survives the auxiliary path end-to-end.
* ``auth_mode: entra_id`` + GPT-5.x model → CodexAuxiliaryClient
wrapping the OpenAI client (api_mode auto-upgrades to
codex_responses).
* Anthropic-style + entra_id → rejected at the runtime resolver,
so the aux path returns ``(None, None)``.
* Failure path when no model is configured returns ``(None, None)``
cleanly so the auto chain falls through.
"""
from __future__ import annotations
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def _reset_credential_cache():
from agent.azure_identity_adapter import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@pytest.fixture
def fake_azure_identity(monkeypatch):
"""Stand-in for azure.identity (keeps CI hermetic when the SDK is
not installed)."""
from agent import azure_identity_adapter as _adapter
last = {"scope": None}
def _provider(scope):
return lambda: f"jwt-for-{scope}"
fake_module = SimpleNamespace(
DefaultAzureCredential=lambda **kw: SimpleNamespace(
kwargs=kw,
get_token=lambda scope: SimpleNamespace(token="fake", expires_on=9999999999),
),
get_bearer_token_provider=lambda credential, scope: (
last.__setitem__("scope", scope),
_provider(scope),
)[-1],
)
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
return last
@pytest.fixture
def patch_load_config(monkeypatch):
"""Helper to set model_cfg seen by _try_azure_foundry."""
def _apply(model_cfg):
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": model_cfg},
)
return _apply
# ---------------------------------------------------------------------------
# auth_mode: api_key (default) — regression for the legacy path
# ---------------------------------------------------------------------------
class TestAuxAzureFoundryApiKey:
def test_chat_completions_returns_plain_openai_client(self, monkeypatch, patch_load_config):
from agent.auxiliary_client import _try_azure_foundry
from openai import OpenAI as _OpenAI
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"default": "gpt-4o",
})
client, resolved = _try_azure_foundry(model="gpt-4o")
assert client is not None
assert resolved == "gpt-4o"
assert isinstance(client, _OpenAI)
assert client.api_key == "sk-azure-static-key"
def test_codex_responses_wraps_in_codex_aux_client(self, monkeypatch, patch_load_config):
from agent.auxiliary_client import _try_azure_foundry, CodexAuxiliaryClient
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"default": "gpt-5.4-mini",
})
# GPT-5.x → runtime auto-upgrades to codex_responses
client, resolved = _try_azure_foundry(model="gpt-5.4-mini")
assert resolved == "gpt-5.4-mini"
assert isinstance(client, CodexAuxiliaryClient)
assert client.api_key == "sk-azure-static-key"
def test_no_key_returns_none(self, monkeypatch, patch_load_config):
from agent.auxiliary_client import _try_azure_foundry
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"default": "gpt-4o",
})
client, resolved = _try_azure_foundry(model="gpt-4o")
assert client is None
assert resolved is None
def test_no_model_returns_none(self, monkeypatch, patch_load_config):
"""Azure has no fallback aux model — fail soft so the auto chain
can try other providers."""
from agent.auxiliary_client import _try_azure_foundry
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
# No default model
})
client, resolved = _try_azure_foundry()
assert client is None
assert resolved is None
# ---------------------------------------------------------------------------
# auth_mode: entra_id — callable api_key survives end-to-end
# ---------------------------------------------------------------------------
class TestAuxAzureFoundryEntra:
def test_callable_api_key_reaches_openai_constructor(
self, monkeypatch, fake_azure_identity, patch_load_config,
):
"""The token provider callable must arrive at ``OpenAI(api_key=...)``
intact — never stringified to ``"no-key-required"`` or to the
SDK-internal empty-string representation BEFORE we hand it off.
We assert on the public SDK contract (constructor receives the
callable) rather than ``client.api_key``, because OpenAI 2.24.0
stores callable api_keys in a private attribute and exposes
``client.api_key`` as ``""``. The SDK still calls the callable
per request to mint ``Authorization: Bearer <token>``; that
behaviour is the documented Microsoft/OpenAI contract we rely on.
"""
from agent import auxiliary_client as _aux
received = {}
class _FakeOpenAI:
def __init__(self, **kwargs):
received.update(kwargs)
# Mirror the fields downstream callers read.
self.api_key = kwargs.get("api_key", "")
self.base_url = kwargs.get("base_url", "")
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"default": "gpt-4o",
})
client, resolved = _aux._try_azure_foundry(model="gpt-4o")
assert client is not None
assert resolved == "gpt-4o"
# Public-contract assertion: the OpenAI SDK constructor saw the
# callable, exactly as Microsoft's Foundry sample requires.
assert callable(received["api_key"])
assert not isinstance(received["api_key"], str)
assert received["api_key"]().startswith("jwt-for-")
# Base URL forwarded verbatim (no /responses suffix stripping
# in this path — that's a separate concern handled by the
# runtime resolver only when the user re-saves config).
assert received["base_url"] == "https://r.openai.azure.com/openai/v1"
def test_codex_responses_with_entra_wraps_correctly(
self, monkeypatch, fake_azure_identity, patch_load_config,
):
"""GPT-5.x deployment on Entra ID — auto-upgraded to
codex_responses, wrapped in CodexAuxiliaryClient, callable
api_key handed to the underlying OpenAI SDK."""
from agent import auxiliary_client as _aux
received = {}
class _FakeOpenAI:
def __init__(self, **kwargs):
received.update(kwargs)
self.api_key = kwargs.get("api_key", "")
self.base_url = kwargs.get("base_url", "")
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"default": "gpt-5.4-mini",
})
client, resolved = _aux._try_azure_foundry(model="gpt-5.4-mini")
assert resolved == "gpt-5.4-mini"
assert isinstance(client, _aux.CodexAuxiliaryClient)
# The Codex wrapper received an OpenAI client built with the
# callable api_key — verify against the SDK constructor record,
# not the wrapper attribute (which mirrors the SDK's empty-
# string representation).
assert callable(received["api_key"])
assert received["api_key"]().startswith("jwt-for-")
def test_entra_anthropic_messages_uses_bearer_hook(
self, monkeypatch, fake_azure_identity, patch_load_config,
):
"""Entra ID + anthropic_messages: runtime returns a callable
api_key; ``_maybe_wrap_anthropic`` → ``build_anthropic_client``
detects the callable and installs the bearer-injecting httpx
event hook on a custom ``httpx.Client`` passed to the
Anthropic SDK via ``http_client=``."""
from agent import auxiliary_client as _aux
from agent import anthropic_adapter as _anthropic
received = {}
class _FakeOpenAI:
def __init__(self, **kwargs):
received["openai"] = kwargs
self.api_key = kwargs.get("api_key", "")
self.base_url = kwargs.get("base_url", "")
class _FakeAnthropicSDK:
class Anthropic:
def __init__(self, **kwargs):
received["anthropic"] = kwargs
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
monkeypatch.setattr(_anthropic, "_get_anthropic_sdk", lambda: _FakeAnthropicSDK)
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.services.ai.azure.com/anthropic",
"api_mode": "anthropic_messages",
"auth_mode": "entra_id",
"default": "claude-sonnet-4-5",
})
client, resolved = _aux._try_azure_foundry(model="claude-sonnet-4-5")
assert client is not None
assert resolved == "claude-sonnet-4-5"
# The Anthropic SDK constructor received a custom http_client
# (the bearer-injecting hook) and a placeholder auth_token.
anthropic_kwargs = received.get("anthropic") or {}
assert "http_client" in anthropic_kwargs, (
"build_anthropic_client must pass a custom http_client when "
"given a callable api_key, otherwise the SDK cannot mint "
"fresh tokens per request"
)
assert anthropic_kwargs.get("auth_token") == "entra-id-bearer-via-http-hook"
# Verify the http_client actually has our event hook installed.
http_client = anthropic_kwargs["http_client"]
hooks = getattr(http_client, "event_hooks", {})
assert "request" in hooks and len(hooks["request"]) >= 1
# ---------------------------------------------------------------------------
# resolve_provider_client → azure-foundry dispatch
# ---------------------------------------------------------------------------
class TestResolveProviderClientAzureFoundry:
def test_dispatches_to_azure_branch_not_generic_api_key_path(
self, monkeypatch, fake_azure_identity, patch_load_config,
):
"""End-to-end: the public ``resolve_provider_client`` entry
point must take the dedicated azure-foundry branch, NOT the
generic api-key registry path that would call
``resolve_api_key_provider_credentials`` and return None for
Entra users."""
from agent import auxiliary_client as _aux
received = {}
class _FakeOpenAI:
def __init__(self, **kwargs):
received.update(kwargs)
self.api_key = kwargs.get("api_key", "")
self.base_url = kwargs.get("base_url", "")
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"default": "gpt-4o",
})
client, resolved = _aux.resolve_provider_client("azure-foundry", "gpt-4o")
assert client is not None
assert resolved == "gpt-4o"
# The callable made it through resolve_provider_client → _try_azure_foundry
# → OpenAI(api_key=...).
assert callable(received["api_key"])
def test_warns_and_returns_none_on_failure(
self, monkeypatch, patch_load_config, caplog,
):
"""When azure-foundry is requested but cannot be resolved
(e.g. no model + no key), we return (None, None) and log a
clear warning pointing at ``hermes doctor``."""
import logging
from agent.auxiliary_client import resolve_provider_client
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
# No default → resolver yields no model → bail
})
with caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
client, resolved = resolve_provider_client("azure-foundry")
assert client is None
assert resolved is None
assert any(
"azure-foundry" in rec.message and "hermes doctor" in rec.message
for rec in caplog.records
)

View File

@@ -0,0 +1,662 @@
"""Tests for the Microsoft Entra ID adapter (agent/azure_identity_adapter.py).
Covers:
- Scope resolution per Azure host shape
- Display masking for callable + string + None inputs
- Cache-fingerprint stability under callable refresh
- is_token_provider truthiness on callables vs strings
- EntraIdentityConfig serialization round-trip
- Token provider construction with mocked azure-identity
- Credential cache reuse + reset
- has_azure_identity_credentials timeout / failure paths
- describe_active_credential structural reporting
- Lazy-install error path when azure-identity absent + lazy installs
disabled
We mock azure.identity at the import boundary rather than hitting any
real Azure endpoint. Tests must remain hermetic per AGENTS.md.
"""
from __future__ import annotations
import sys
from collections.abc import Callable
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
# Ensure we always import a fresh adapter module — credential caches in
# the adapter persist across tests otherwise, polluting assertions
# about cache invalidation.
@pytest.fixture(autouse=True)
def _reset_adapter_cache():
from agent.azure_identity_adapter import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
# ---------------------------------------------------------------------------
# Scope constant
# ---------------------------------------------------------------------------
class TestEntraScopeConstant:
"""Pin the Microsoft-documented Foundry inference scope.
Microsoft's official samples for both ``*.openai.azure.com`` and
``*.services.ai.azure.com`` use ``https://ai.azure.com/.default``.
The older ``cognitiveservices.azure.com/.default`` is the
control-plane scope and is rejected for inference by newer
Azure OpenAI / Foundry resources.
Users with sovereign-cloud or unusual-tenant requirements pass the
scope explicitly via ``model.entra.scope`` in ``config.yaml``.
Refs:
* https://learn.microsoft.com/azure/ai-foundry/openai/how-to/managed-identity
* https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id
"""
def test_default_scope_matches_microsoft_documentation(self):
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
assert SCOPE_AI_AZURE_DEFAULT == "https://ai.azure.com/.default"
# ---------------------------------------------------------------------------
# Cache fingerprint + http-bearer helpers
# ---------------------------------------------------------------------------
class TestMaterializeBearerForHttp:
"""The only helper that mints a real bearer JWT — must call the
callable exactly once and never fall through to display masking."""
def test_callable_is_invoked_and_returns_token(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
invoked = {"count": 0}
def provider():
invoked["count"] += 1
return "fresh-jwt"
assert materialize_bearer_for_http(provider) == "fresh-jwt"
assert invoked["count"] == 1
def test_string_passes_through(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
assert materialize_bearer_for_http("plain-key") == "plain-key"
def test_callable_returning_empty_raises(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
with pytest.raises(ValueError):
materialize_bearer_for_http(lambda: "")
def test_empty_string_raises(self):
from agent.azure_identity_adapter import materialize_bearer_for_http
with pytest.raises(ValueError):
materialize_bearer_for_http("")
with pytest.raises(ValueError):
materialize_bearer_for_http(None)
# ---------------------------------------------------------------------------
# build_bearer_http_client — the Anthropic-on-Foundry bridge
# ---------------------------------------------------------------------------
class TestBuildBearerHttpClient:
"""``build_bearer_http_client`` returns an ``httpx.Client`` whose
request event hook mints a fresh JWT per outbound request. This is
how Entra ID auth reaches the Anthropic SDK (which does not accept
callable ``auth_token``)."""
def test_returns_httpx_client_with_request_hook(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
client = build_bearer_http_client(lambda: "jwt")
try:
assert isinstance(client, httpx.Client)
hooks = client.event_hooks.get("request", [])
assert len(hooks) >= 1
finally:
client.close()
def test_hook_overrides_authorization_header(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
minted_tokens = []
def provider():
minted_tokens.append(f"jwt-{len(minted_tokens) + 1}")
return minted_tokens[-1]
client = build_bearer_http_client(provider)
try:
hook = client.event_hooks["request"][0]
# Build a request with conflicting pre-set headers and verify
# the hook strips them and installs the fresh bearer.
req = httpx.Request(
"POST", "https://example.com/v1/messages",
headers={
"Authorization": "Bearer stale-token",
"api-key": "static-key",
"x-api-key": "static-key",
},
json={"hello": "world"},
)
hook(req)
assert req.headers["Authorization"] == "Bearer jwt-1"
# The static-key headers must be stripped — sending both
# auth values would be ambiguous on Azure.
assert "api-key" not in req.headers
assert "x-api-key" not in req.headers
# Second invocation mints a fresh token.
req2 = httpx.Request("GET", "https://example.com/v1/models")
hook(req2)
assert req2.headers["Authorization"] == "Bearer jwt-2"
assert len(minted_tokens) == 2
finally:
client.close()
def test_hook_strips_auth_headers_and_warns_when_token_provider_fails(self, caplog):
"""When the token provider fails (chain exhausted, IMDS down, az
login expired), the hook must:
1. Log at WARNING level so the misconfiguration is visible at
default log level (not buried at DEBUG).
2. Strip any pre-set Authorization headers — including the
placeholder ``entra-id-bearer-via-http-hook`` sentinel that
:func:`_build_anthropic_client_with_bearer_hook` sets on the
Anthropic SDK constructor. This produces a clean
"missing auth" 401 from Azure rather than a sentinel-bearing
401 that's harder to diagnose AND avoids leaking the
sentinel string into upstream access logs.
"""
import logging
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
def bad_provider():
return "" # empty token → materialize_bearer_for_http raises
client = build_bearer_http_client(bad_provider)
try:
hook = client.event_hooks["request"][0]
req = httpx.Request(
"POST", "https://example.com/v1/messages",
headers={
"Authorization": "Bearer entra-id-bearer-via-http-hook",
"api-key": "leaked-placeholder",
},
)
with caplog.at_level(logging.WARNING, logger="agent.azure_identity_adapter"):
hook(req) # Must not raise.
# Pre-set auth headers stripped — no sentinel makes it to Azure.
assert "Authorization" not in req.headers
assert "api-key" not in req.headers
# WARNING was logged so the user sees the misconfiguration.
assert any(
rec.levelno == logging.WARNING and "Entra ID token provider" in rec.message
for rec in caplog.records
)
finally:
client.close()
def test_rejects_non_callable_provider(self):
from agent.azure_identity_adapter import build_bearer_http_client
with pytest.raises(ValueError):
build_bearer_http_client(cast(Callable[[], str], "plain-string-not-callable"))
with pytest.raises(ValueError):
build_bearer_http_client(cast(Callable[[], str], None))
def test_forwards_httpx_kwargs(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
timeout = httpx.Timeout(60.0, connect=5.0)
client = build_bearer_http_client(lambda: "jwt", timeout=timeout)
try:
# httpx stores the timeout per-pool; just sanity-check it was
# accepted without TypeError.
assert client is not None
finally:
client.close()
class TestIsTokenProvider:
def test_callable_is_token_provider(self):
from agent.azure_identity_adapter import is_token_provider
assert is_token_provider(lambda: "x") is True
def test_string_is_not_token_provider(self):
from agent.azure_identity_adapter import is_token_provider
assert is_token_provider("static-key") is False
# ``str`` instances are technically callable in some edge cases
# — confirm they're never classified as token providers.
assert is_token_provider("") is False
# ---------------------------------------------------------------------------
# EntraIdentityConfig
# ---------------------------------------------------------------------------
class TestEntraIdentityConfig:
"""The serializable config that crosses multiprocessing boundaries —
must round-trip through dict cleanly and never lose fields."""
def test_to_dict_round_trip(self):
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig(
scope="https://ai.azure.com/.default",
exclude_interactive_browser=False,
)
rebuilt = EntraIdentityConfig.from_dict(cfg.to_dict())
assert rebuilt == cfg
def test_from_dict_handles_empty_strings(self):
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict({
"scope": "",
"client_id": None,
})
# Empty scope falls back to default
assert cfg.scope.endswith("/.default")
def test_from_dict_ignores_legacy_identity_keys(self):
"""Old config.yaml that still has model.entra.client_id /
tenant_id / authority should not crash from_dict — those values
are now read from AZURE_* env vars by azure-identity directly."""
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict({
"tenant_id": "legacy-tenant",
"authority": "https://login.partner.microsoftonline.cn",
"client_id": "user-mi-client",
})
# Legacy keys silently ignored — no crash, no surprise field on the dataclass.
assert not hasattr(cfg, "client_id")
assert not hasattr(cfg, "tenant_id")
assert not hasattr(cfg, "authority")
def test_constructor_normalizes_empty_scope(self):
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig(scope="")
assert cfg.scope.endswith("/.default")
def test_from_dict_default_scope_override(self):
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig.from_dict(
{"scope": ""},
default_scope="https://custom.example/.default",
)
assert cfg.scope == "https://custom.example/.default"
def test_dataclass_is_frozen(self):
# Frozen dataclasses are hashable / safe to pass through caches.
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig()
with pytest.raises((AttributeError, Exception)):
setattr(cfg, "scope", "mutated")
# ---------------------------------------------------------------------------
# Credential / token provider construction
# ---------------------------------------------------------------------------
class _FakeAzureIdentity:
"""Stand-in for the ``azure.identity`` module.
Captures kwargs passed to ``DefaultAzureCredential`` so tests can
assert how config flows into the SDK.
"""
def __init__(self):
self.last_credential_kwargs = None
self.last_scope = None
self.credential_count = 0
def DefaultAzureCredential(self, **kwargs): # noqa: N802 — match SDK
self.last_credential_kwargs = kwargs
self.credential_count += 1
return SimpleNamespace(
get_token=lambda scope: SimpleNamespace(token="fake-jwt", expires_on=9999999999),
kwargs=kwargs,
)
def get_bearer_token_provider(self, credential, scope):
self.last_scope = scope
# Return a callable that mints a token when invoked.
return lambda: f"jwt-for-{scope}"
@pytest.fixture
def fake_azure_identity(monkeypatch):
"""Install a fake azure.identity into sys.modules and stub the
adapter's `_require_azure_identity` so all tests use the fake."""
fake = _FakeAzureIdentity()
fake_module = SimpleNamespace(
DefaultAzureCredential=fake.DefaultAzureCredential,
get_bearer_token_provider=fake.get_bearer_token_provider,
)
monkeypatch.setitem(sys.modules, "azure", SimpleNamespace(identity=fake_module))
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
# The adapter's `_require_azure_identity` does its own import, so
# patch that too to make sure tests never hit the real package's
# singleton state.
from agent import azure_identity_adapter as _adapter
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
return fake
class TestBuildCredential:
def test_default_kwargs_are_minimal(self, fake_azure_identity):
"""SDK default for ``exclude_interactive_browser_credential`` is
True; we only pass it when the user opts IN to interactive
browser auth. Tenant / authority / service principal config
flow through the standard ``AZURE_*`` env vars (read by
azure-identity directly), not Hermes config kwargs."""
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
cred = build_credential(EntraIdentityConfig())
kwargs = fake_azure_identity.last_credential_kwargs
# Default config should produce empty kwargs — SDK uses its own
# defaults plus env-var-driven settings.
assert kwargs == {}
assert cred is not None
def test_interactive_browser_opt_in(self, fake_azure_identity):
"""When the user explicitly sets
``exclude_interactive_browser=False``, the SDK kwarg is set to
False. Without the opt-in we don't pass the kwarg at all (SDK
default is True / browser excluded)."""
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
build_credential(EntraIdentityConfig(exclude_interactive_browser=False))
kwargs = fake_azure_identity.last_credential_kwargs
assert kwargs["exclude_interactive_browser_credential"] is False
def test_credential_is_cached_per_config(self, fake_azure_identity):
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
cfg = EntraIdentityConfig(scope="s1")
c1 = build_credential(cfg)
c2 = build_credential(cfg)
assert c1 is c2
assert fake_azure_identity.credential_count == 1
def test_distinct_configs_get_distinct_credentials(self, fake_azure_identity):
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
c1 = build_credential(EntraIdentityConfig(scope="s1"))
c2 = build_credential(EntraIdentityConfig(scope="s2"))
assert c1 is not c2
assert fake_azure_identity.credential_count == 2
def test_reset_cache_invalidates(self, fake_azure_identity):
from agent.azure_identity_adapter import (
EntraIdentityConfig,
build_credential,
reset_credential_cache,
)
cfg = EntraIdentityConfig(scope="x")
c1 = build_credential(cfg)
reset_credential_cache()
c2 = build_credential(cfg)
assert c1 is not c2
class TestBuildTokenProvider:
def test_returns_callable_for_scope(self, fake_azure_identity):
from agent.azure_identity_adapter import build_token_provider
provider = build_token_provider(scope="https://ai.azure.com/.default")
assert callable(provider)
assert provider() == "jwt-for-https://ai.azure.com/.default"
assert fake_azure_identity.last_scope == "https://ai.azure.com/.default"
def test_falls_back_to_default_scope_when_unspecified(self, fake_azure_identity):
"""When neither ``scope`` nor ``config`` is provided,
``build_token_provider`` uses ``SCOPE_AI_AZURE_DEFAULT`` —
Microsoft's documented Foundry inference scope. ``base_url`` is
accepted for back-compat but ignored."""
from agent.azure_identity_adapter import (
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
)
build_token_provider(base_url="https://r.openai.azure.com/openai/v1")
assert fake_azure_identity.last_scope == SCOPE_AI_AZURE_DEFAULT
def test_explicit_scope_wins_over_base_url(self, fake_azure_identity):
from agent.azure_identity_adapter import build_token_provider
build_token_provider(
scope="https://override.example/.default",
base_url="https://r.openai.azure.com/openai/v1",
)
assert fake_azure_identity.last_scope == "https://override.example/.default"
def test_config_object_wins_over_kwargs(self, fake_azure_identity):
from agent.azure_identity_adapter import (
EntraIdentityConfig,
build_token_provider,
)
cfg = EntraIdentityConfig(scope="cfg-scope")
build_token_provider(scope="ignored", config=cfg)
assert fake_azure_identity.last_scope == "cfg-scope"
assert fake_azure_identity.last_credential_kwargs == {}
# ---------------------------------------------------------------------------
# Lazy-install / missing-package surface
# ---------------------------------------------------------------------------
class TestRequireAzureIdentityMissing:
def test_clear_error_when_lazy_install_disabled(self, monkeypatch):
"""When azure-identity isn't importable AND lazy installs are
off, the adapter must raise ImportError with an actionable
message, not propagate FeatureUnavailable."""
from agent import azure_identity_adapter as _adapter
# Force the import path to fail.
original_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __import__
def _fake_import(name, *args, **kwargs):
if name == "azure.identity" or name.startswith("azure.identity."):
raise ImportError("simulated missing azure-identity")
return original_import(name, *args, **kwargs)
monkeypatch.setattr("builtins.__import__", _fake_import)
# Simulate lazy installs disabled.
from tools.lazy_deps import FeatureUnavailable
def _fake_ensure(*args, **kwargs):
raise FeatureUnavailable(
"provider.azure_identity",
("azure-identity==1.25.3",),
"lazy installs disabled (test simulation)",
)
# The adapter calls ``ensure`` from ``tools.lazy_deps``; intercept
# it by patching the actual symbol path.
monkeypatch.setattr("tools.lazy_deps.ensure", _fake_ensure)
with pytest.raises(ImportError) as exc_info:
_adapter._require_azure_identity()
msg = str(exc_info.value)
assert "azure-identity" in msg
assert "Foundry" in msg or "foundry" in msg.lower()
# ---------------------------------------------------------------------------
# has_azure_identity_credentials probe (timeout-bounded)
# ---------------------------------------------------------------------------
class TestHasAzureIdentityCredentials:
def test_returns_false_when_package_missing_and_install_disabled(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
assert _adapter.has_azure_identity_credentials(
"https://x/.default", allow_install=False,
) is False
def test_lazy_install_triggered_when_package_missing(self, monkeypatch):
"""With allow_install=True (default), the probe must trigger the
lazy-install path before bailing — otherwise the wizard's
``preflight`` would silently fail for fresh installs that haven't
run ``pip install azure-identity`` yet."""
from agent import azure_identity_adapter as _adapter
installed = {"called": False}
def _fake_install():
installed["called"] = True
# After install, pretend the package is now importable.
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
return SimpleNamespace(
DefaultAzureCredential=lambda **kw: SimpleNamespace(
kwargs=kw,
get_token=lambda scope: SimpleNamespace(token="post-install-jwt", expires_on=0),
),
get_bearer_token_provider=lambda c, s: lambda: "x",
)
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
monkeypatch.setattr(_adapter, "_require_azure_identity", _fake_install)
# Provide a credential factory so the probe proceeds after install.
monkeypatch.setattr(
_adapter, "build_credential",
lambda config: SimpleNamespace(
get_token=lambda scope: SimpleNamespace(token="probe-jwt", expires_on=0),
),
)
result = _adapter.has_azure_identity_credentials(
"https://x/.default", timeout_seconds=0.5,
)
assert installed["called"] is True, (
"has_azure_identity_credentials must trigger lazy install "
"before bailing"
)
assert result is True
def test_returns_true_on_successful_token_mint(self, fake_azure_identity):
from agent.azure_identity_adapter import has_azure_identity_credentials
assert has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is True
def test_returns_false_when_get_token_raises(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
def _failing_credential(_config):
class _Cred:
def get_token(self, scope):
raise RuntimeError("simulated chain exhaustion")
return _Cred()
monkeypatch.setattr(_adapter, "build_credential", _failing_credential)
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
assert _adapter.has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is False
def test_returns_false_on_timeout(self, monkeypatch):
"""Slow IMDS / network must time out, not hang the caller."""
import threading
from agent import azure_identity_adapter as _adapter
slow_release = threading.Event()
def _slow_credential(_config):
class _Cred:
def get_token(self, scope):
# Block forever from the test's perspective; the
# adapter must give up via its thread-bounded probe.
slow_release.wait(timeout=10)
return SimpleNamespace(token="never-returned", expires_on=0)
return _Cred()
monkeypatch.setattr(_adapter, "build_credential", _slow_credential)
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
try:
assert _adapter.has_azure_identity_credentials(
"https://x/.default", timeout_seconds=0.1
) is False
finally:
slow_release.set()
# ---------------------------------------------------------------------------
# describe_active_credential — used by hermes doctor + hermes auth
# ---------------------------------------------------------------------------
class TestDescribeActiveCredential:
def test_reports_not_installed(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
info = _adapter.describe_active_credential(
scope="https://x/.default", allow_install=False,
)
assert info["ok"] is False
assert "not installed" in info["error"].lower()
assert "pip install" in info["hint"].lower()
def test_reports_install_failure(self, monkeypatch):
"""When lazy install is allowed but fails (e.g. lazy installs
disabled), the diagnostic surfaces the failure as the error."""
from agent import azure_identity_adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
def _fail_install():
raise ImportError("simulated: lazy installs disabled")
monkeypatch.setattr(_adapter, "_require_azure_identity", _fail_install)
info = _adapter.describe_active_credential(
scope="https://x/.default", allow_install=True,
)
assert info["ok"] is False
assert "lazy installs disabled" in info["error"]
assert "lazy" in info["hint"].lower()
def test_reports_env_sources_for_managed_identity(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.169.254")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
assert info["ok"] is True
sources = info.get("env_sources") or []
assert any("ManagedIdentity" in s for s in sources)
def test_reports_env_sources_for_workload_identity(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", "/var/secrets/azure/federated-token")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
sources = info.get("env_sources") or []
assert any("WorkloadIdentity" in s for s in sources)
def test_reports_env_sources_for_service_principal(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
monkeypatch.setenv("AZURE_TENANT_ID", "t")
monkeypatch.setenv("AZURE_CLIENT_ID", "c")
monkeypatch.setenv("AZURE_CLIENT_SECRET", "s")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
sources = info.get("env_sources") or []
assert any("EnvironmentCredential" in s for s in sources)
def test_reports_error_on_chain_failure(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
def _failing_credential(_config):
class _Cred:
def get_token(self, scope):
raise RuntimeError("auth failed")
return _Cred()
monkeypatch.setattr(_adapter, "build_credential", _failing_credential)
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
info = _adapter.describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
assert info["ok"] is False
assert "auth failed" in info.get("error", "")

View File

@@ -1,7 +1,7 @@
"""Tests for the 1M-context beta header on AWS Bedrock Claude models.
Claude Opus 4.6/4.7 and Sonnet 4.6 support a 1M context window, but on AWS
Bedrock (and Azure AI Foundry) that window is still gated behind the
Bedrock (and Microsoft Foundry) that window is still gated behind the
``context-1m-2025-08-07`` beta header as of 2026-04. Without it, Bedrock
caps these models at 200K even though ``model_metadata.py`` advertises 1M.
@@ -61,4 +61,3 @@ class TestBedrockContext1MBeta:
# Other common betas still present — no regression.
assert "interleaved-thinking-2025-05-14" in beta_header
assert "fine-grained-tool-streaming-2025-05-14" in beta_header

View File

@@ -102,7 +102,7 @@ def test_detect_anthropic_path_wins_without_http():
def test_detect_openai_models_probe_success():
"""/models probe returning a model list → chat_completions."""
def _fake_get(url, api_key, timeout=6.0):
def _fake_get(url, api_key, timeout=6.0, **kwargs):
assert "key-abc" == api_key
return 200, json.loads(_openai_models_body("gpt-5.4", "claude-opus-4-6"))
@@ -118,7 +118,7 @@ def test_detect_openai_models_probe_success():
def test_detect_openai_models_probe_empty_list_still_counts():
"""Endpoint returned OpenAI shape but no models → still chat_completions."""
def _fake_get(url, api_key, timeout=6.0):
def _fake_get(url, api_key, timeout=6.0, **kwargs):
return 200, {"object": "list", "data": []}
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
@@ -132,7 +132,7 @@ def test_detect_openai_models_probe_empty_list_still_counts():
def test_detect_falls_back_to_anthropic_probe():
"""/models fails but Anthropic Messages probe succeeds."""
def _fake_get(url, api_key, timeout=6.0):
def _fake_get(url, api_key, timeout=6.0, **kwargs):
return 401, None # /models forbidden
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get), \
@@ -164,7 +164,7 @@ def test_probe_openai_models_tries_multiple_api_versions():
"""First call (no api-version) fails, api-version fallback succeeds."""
calls = []
def _fake_get(url, api_key, timeout=6.0):
def _fake_get(url, api_key, timeout=6.0, **kwargs):
calls.append(url)
if "api-version" not in url:
return 404, None

View File

@@ -0,0 +1,404 @@
"""Tests for Azure Foundry Entra ID runtime resolution.
Covers the contract introduced in PR for Microsoft Entra ID auth on
``azure-foundry``:
* ``_resolve_azure_foundry_runtime`` returns a callable ``api_key`` for
``model.auth_mode = entra_id`` (OpenAI-style only).
* Anthropic-style endpoints with ``auth_mode = entra_id`` return the same
callable runtime credential as OpenAI-style endpoints.
* The legacy ``api_key`` path is unchanged when ``auth_mode`` is absent
or set to ``api_key``.
* Explicit ``--api-key`` overrides at runtime still work in entra mode
(escape hatch for one-off testing).
* ``model.entra.scope`` propagates to the token-provider config; Azure
identity selection stays in standard AZURE_* env vars.
* ``_get_azure_foundry_auth_status`` is structural — never mints a
token (verified by checking the credential cache untouched).
* ``has_usable_secret`` for ``AZURE_FOUNDRY_API_KEY`` is irrelevant
when ``auth_mode == entra_id``.
"""
from __future__ import annotations
import sys
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def _reset_credential_cache():
from agent.azure_identity_adapter import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@pytest.fixture
def fake_azure_identity(monkeypatch):
"""Identical fake to test_azure_identity_adapter — keeps Azure SDK
out of these tests so they run in CI without the package installed."""
from agent import azure_identity_adapter as _adapter
last = {"scope": None, "kwargs": None, "credential_count": 0}
def _provider(scope):
return lambda: f"jwt-for-{scope}"
fake_module = SimpleNamespace(
DefaultAzureCredential=lambda **kw: SimpleNamespace(
kwargs=kw,
get_token=lambda scope: SimpleNamespace(token="fake", expires_on=9999999999),
),
get_bearer_token_provider=lambda credential, scope: (
last.__setitem__("scope", scope),
last.__setitem__("kwargs", credential.kwargs),
last.__setitem__("credential_count", cast(int, last["credential_count"]) + 1),
_provider(scope),
)[-1],
)
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
return last
# ---------------------------------------------------------------------------
# _resolve_azure_foundry_runtime: entra_id branch
# ---------------------------------------------------------------------------
class TestResolveAzureFoundryRuntimeEntra:
def test_returns_callable_api_key_for_entra(self, fake_azure_identity):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://my-resource.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"default": "gpt-4o", # stays on chat_completions (no codex auto-upgrade)
},
)
assert runtime["provider"] == "azure-foundry"
assert runtime["auth_mode"] == "entra_id"
assert runtime["api_mode"] == "chat_completions"
assert callable(runtime["api_key"])
assert runtime["source"] == "entra_id"
def test_entra_inherits_codex_responses_for_gpt5_family(self, fake_azure_identity):
"""GPT-5.x / o-series / codex models on Azure are Responses-API-only.
The runtime auto-upgrades api_mode regardless of auth mode — this is
the same behaviour as the static-key path (see
``hermes_cli/models.py::azure_foundry_model_api_mode``)."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://my-resource.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"default": "gpt-5.4",
},
)
# GPT-5.x is upgraded to codex_responses — Entra path inherits.
assert runtime["api_mode"] == "codex_responses"
assert callable(runtime["api_key"])
assert runtime["auth_mode"] == "entra_id"
def test_entra_propagates_scope_only(self, fake_azure_identity):
"""``model.entra.scope`` is the only Hermes-managed Azure SDK
setting. Identity selection (client ID, tenant, authority,
service principal secret, federated token file) flows through
standard ``AZURE_*`` env vars read by azure-identity directly.
Legacy ``model.entra.client_id`` / ``tenant_id`` / ``authority``
keys in config.yaml are silently ignored."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://my-resource.services.ai.azure.com/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {
"scope": "https://custom.example/.default",
"client_id": "client-uuid",
# Legacy keys must not crash — they are accepted in
# from_dict but never propagated to the SDK.
"tenant_id": "legacy-tenant",
"authority": "https://login.microsoftonline.us",
},
},
)
assert fake_azure_identity["scope"] == "https://custom.example/.default"
kw = fake_azure_identity["kwargs"]
assert "managed_identity_client_id" not in kw
assert "workload_identity_client_id" not in kw
assert "interactive_browser_tenant_id" not in kw
assert "authority" not in kw
def test_entra_default_scope_when_unset(self, fake_azure_identity):
"""When ``model.entra.scope`` is not set, the runtime resolves
Microsoft's documented inference scope —
``https://ai.azure.com/.default`` — regardless of whether the
endpoint is ``*.openai.azure.com`` or ``*.services.ai.azure.com``.
Both shapes use the SAME scope per Microsoft's docs; the
``cognitiveservices.azure.com`` scope is the control-plane
audience and is rejected for inference by newer resources."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
},
)
assert fake_azure_identity["scope"] == SCOPE_AI_AZURE_DEFAULT
def test_entra_scope_override_wins(self, fake_azure_identity):
"""Users on sovereign clouds / unusual tenants can set
``model.entra.scope`` to override the default."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {
"scope": "https://cognitiveservices.azure.com/.default",
},
},
)
assert (
fake_azure_identity["scope"]
== "https://cognitiveservices.azure.com/.default"
)
def test_entra_with_anthropic_messages_is_supported(self, fake_azure_identity):
"""Entra ID now works for both OpenAI-style and Anthropic-style
Azure Foundry endpoints. The runtime returns a callable
``api_key``; downstream
:func:`agent.anthropic_adapter.build_anthropic_client` detects
the callable and installs an httpx event hook that mints a
fresh bearer JWT per request (the Anthropic SDK does not
accept callable auth_token natively)."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.services.ai.azure.com/anthropic",
"api_mode": "anthropic_messages",
"auth_mode": "entra_id",
"default": "claude-sonnet-4-5",
},
)
assert runtime["provider"] == "azure-foundry"
assert runtime["auth_mode"] == "entra_id"
assert runtime["api_mode"] == "anthropic_messages"
# Callable api_key — the anthropic_adapter detects this and
# plumbs through an httpx event hook.
assert callable(runtime["api_key"])
assert not isinstance(runtime["api_key"], str)
def test_entra_with_explicit_api_key_uses_string_escape_hatch(self, fake_azure_identity):
"""Passing --api-key on the CLI overrides the entra path so a
user can debug a single request with a static key without
editing config.yaml."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
},
explicit_api_key="explicit-string-key",
)
assert runtime["api_key"] == "explicit-string-key"
assert runtime["auth_mode"] == "api_key"
assert runtime["source"] == "explicit"
def test_entra_runtime_dict_keeps_only_scope_override(self, fake_azure_identity):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {
"scope": "https://custom.example/.default",
"client_id": "legacy-client",
},
},
)
assert runtime["entra"] == {"scope": "https://custom.example/.default"}
# ---------------------------------------------------------------------------
# _resolve_azure_foundry_runtime: legacy api_key branch (regression)
# ---------------------------------------------------------------------------
class TestResolveAzureFoundryRuntimeApiKey:
def test_default_auth_mode_uses_static_key(self, monkeypatch):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
},
)
assert runtime["api_key"] == "sk-azure-static-key"
assert runtime["auth_mode"] == "api_key"
assert "entra" not in runtime # only present in entra mode
def test_explicit_auth_mode_api_key(self, monkeypatch):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-static")
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "api_key",
},
)
assert runtime["api_key"] == "sk-static"
assert runtime["auth_mode"] == "api_key"
def test_anthropic_messages_strips_v1_suffix(self, monkeypatch):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "k")
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.services.ai.azure.com/anthropic/v1",
"api_mode": "anthropic_messages",
},
)
assert runtime["base_url"] == "https://r.services.ai.azure.com/anthropic"
def test_missing_api_key_raises_with_entra_hint(self, monkeypatch):
from hermes_cli.auth import AuthError
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
with pytest.raises(AuthError) as exc_info:
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
},
)
msg = str(exc_info.value)
assert "AZURE_FOUNDRY_API_KEY" in msg
# Surface the Entra alternative so users discover the keyless path.
assert "entra_id" in msg
# ---------------------------------------------------------------------------
# _get_azure_foundry_auth_status (auth.py) — never mints a token
# ---------------------------------------------------------------------------
class TestAzureFoundryAuthStatus:
def test_entra_status_does_not_mint_token(self, monkeypatch, tmp_path):
"""Structural check — must return logged_in=True based on
importable + config, never call get_bearer_token_provider."""
from hermes_cli import auth as _auth
# Force load_config to return our entra config.
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "entra_id",
"base_url": "https://r.openai.azure.com/openai/v1",
},
},
)
# Patch has_azure_identity_installed to True; do NOT patch the
# token provider — if the code path tried to mint, the SDK
# missing would raise.
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
lambda: True,
)
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is True
assert info["auth_mode"] == "entra_id"
assert info["azure_identity_installed"] is True
assert info["scope"].endswith("/.default")
def test_entra_status_reports_missing_package(self, monkeypatch):
from hermes_cli import auth as _auth
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "entra_id",
"base_url": "https://r.openai.azure.com/openai/v1",
},
},
)
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
lambda: False,
)
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is False
assert info["azure_identity_installed"] is False
assert "azure-identity" in info["hint"]
def test_api_key_status_uses_env_var(self, monkeypatch):
from hermes_cli import auth as _auth
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "api_key",
"base_url": "https://r.openai.azure.com/openai/v1",
},
},
)
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-real-key-xxx")
info = _auth._get_azure_foundry_auth_status()
assert info["auth_mode"] == "api_key"
assert info["logged_in"] is True
def test_api_key_status_false_when_missing(self, monkeypatch):
from hermes_cli import auth as _auth
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "api_key",
},
},
)
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is False

View File

@@ -0,0 +1,375 @@
"""Tests that callable api_key (Entra ID bearer provider) flows through
the agent stack without coercion.
The OpenAI Python SDK accepts ``api_key: str | None | Callable[[], str]``,
and ``azure-identity``'s ``get_bearer_token_provider`` returns a callable.
Hermes preserves the callable end-to-end so the SDK refreshes tokens
transparently. This file pins the contract at the high-risk seams the
rubber-duck audit identified.
Covered:
* ``_create_openai_client`` passes a callable ``api_key`` straight
through to ``openai.OpenAI(...)``.
* ``_normalize_main_runtime`` preserves the callable so auxiliary
clients inherit Entra auth.
* ``_truncate_token`` (dashboard preview) renders ``"<entra-id-bearer>"``
instead of ``"<function ...>"`` and never invokes the callable.
* ``run_agent.py`` masked-banner path renders the Entra placeholder
and never tries to slice/len the callable.
* Serialization scrub: dumping a runtime dict via ``json.dumps`` with
a callable api_key raises (default behaviour) — guards against
silently leaking ``"<function ...>"`` strings into event logs.
* ``batch_runner`` strips the callable from the worker config dict
so multiprocessing.Pool can pickle the rest.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# OpenAI SDK construction preserves the callable
# ---------------------------------------------------------------------------
class TestCreateOpenAIClientCallable:
"""``AIAgent._create_openai_client`` must pass the callable through
to ``openai.OpenAI(...)`` without coercion."""
def test_callable_api_key_passed_to_openai_constructor(self, monkeypatch):
"""Construct the smallest possible AIAgent surface and verify
the OpenAI client receives the callable unchanged."""
captured = {}
def fake_openai(**kwargs):
captured["kwargs"] = kwargs
return MagicMock(api_key=kwargs.get("api_key"))
# Patch the module-level OpenAI proxy used by ``_create_openai_client``.
monkeypatch.setattr("run_agent.OpenAI", fake_openai)
# Build a minimal stand-in for AIAgent so we can call the bound
# method directly without paying the full __init__ cost.
from run_agent import AIAgent
agent = AIAgent.__new__(AIAgent)
# Attributes consulted by _create_openai_client / _client_log_context.
agent.provider = "azure-foundry"
agent.model = "gpt-4o"
agent.base_url = "https://r.openai.azure.com/openai/v1"
agent._client_kwargs = {}
def token_provider():
return "fresh-jwt"
client_kwargs = {
"api_key": token_provider,
"base_url": "https://r.openai.azure.com/openai/v1",
}
client = agent._create_openai_client(client_kwargs, reason="test", shared=False)
# The OpenAI constructor must receive the *callable*, not a string.
forwarded = captured["kwargs"]["api_key"]
assert callable(forwarded)
assert not isinstance(forwarded, str)
assert forwarded is token_provider, (
"_create_openai_client must not wrap or coerce the callable"
)
assert client is not None
# ---------------------------------------------------------------------------
# Auxiliary runtime preserves the callable
# ---------------------------------------------------------------------------
class TestNormalizeMainRuntimePreservesCallable:
"""The aux client orchestrator must keep the callable on the
runtime dict so compression / vision / embedding / title-gen clients
inherit Entra ID auth from the main agent."""
def test_callable_api_key_survives_normalization(self):
from agent.auxiliary_client import _normalize_main_runtime
def provider():
return "jwt"
normalized = _normalize_main_runtime({
"provider": "azure-foundry",
"model": "gpt-4o",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_key": provider,
"api_mode": "chat_completions",
"auth_mode": "entra_id",
})
assert normalized["api_key"] is provider
assert normalized["auth_mode"] == "entra_id"
def test_string_api_key_still_works(self):
from agent.auxiliary_client import _normalize_main_runtime
normalized = _normalize_main_runtime({
"provider": "azure-foundry",
"api_key": "sk-static",
})
assert normalized["api_key"] == "sk-static"
def test_normalization_drops_empty_string_but_preserves_callable(self):
from agent.auxiliary_client import _normalize_main_runtime
def provider():
return ""
# Empty string fields are dropped, but a callable is preserved
# even if it would mint an empty token (we don't invoke during
# normalization).
normalized = _normalize_main_runtime({
"provider": "azure-foundry",
"api_key": provider,
"model": "",
})
assert normalized["api_key"] is provider
assert "model" not in normalized
def test_unknown_field_dropped(self):
from agent.auxiliary_client import _normalize_main_runtime, _MAIN_RUNTIME_FIELDS
normalized = _normalize_main_runtime({
"provider": "azure-foundry",
"api_key": "k",
"secret_field_we_dont_want": "leak",
})
assert "secret_field_we_dont_want" not in normalized
# auth_mode IS in the field allowlist (rubber-duck blocker fix).
assert "auth_mode" in _MAIN_RUNTIME_FIELDS
# ---------------------------------------------------------------------------
# Display surfaces never invoke the callable
# ---------------------------------------------------------------------------
class TestTruncateTokenCallable:
def test_callable_returns_placeholder(self):
"""Dashboard preview must render the Entra placeholder, NOT
``"<function ...>"``."""
from hermes_cli.web_server import _truncate_token
invoked = {"count": 0}
def provider():
invoked["count"] += 1
return "should-not-appear-in-ui"
token_provider = cast(str | None, provider)
rendered = _truncate_token(token_provider)
assert rendered == "<entra-id-bearer>"
assert invoked["count"] == 0
def test_string_jwt_still_truncated_to_signature_tail(self):
from hermes_cli.web_server import _truncate_token
# JWT shape: header.payload.signature → only signature tail shown.
out = _truncate_token("aaaa.bbbb.cccccccsig", visible=4)
assert out == "…csig"
def test_empty_returns_empty(self):
from hermes_cli.web_server import _truncate_token
assert _truncate_token(None) == ""
assert _truncate_token("") == ""
# ---------------------------------------------------------------------------
# Serialization scrub — runtime dicts with callables must NOT silently
# JSON-encode as ``"<function ...>"`` (would leak garbage into events).
# ---------------------------------------------------------------------------
class TestRuntimeDictSerializationGuard:
def test_json_dumps_default_str_does_not_silently_stringify_callable(self):
"""Sanity check: a runtime dict with a callable api_key must
either raise on plain ``json.dumps`` (good — fail loud) or be
sanitized BEFORE serialization. This test pins the loud-fail
behaviour so future changes that introduce
``json.dumps(..., default=str)`` over a runtime dict are caught
by a regression here."""
def provider():
return "jwt"
runtime = {
"provider": "azure-foundry",
"api_key": provider,
"auth_mode": "entra_id",
}
# Plain json.dumps — must raise, not silently produce
# ``"<function provider at 0x...>"``.
with pytest.raises(TypeError):
json.dumps(runtime)
# ---------------------------------------------------------------------------
# batch_runner strips callables from the worker config dict
# ---------------------------------------------------------------------------
class TestBatchRunnerCallableHandling:
def test_callable_api_key_stripped_from_worker_config(self, capsys, monkeypatch, tmp_path):
"""``BatchRunner._run_batches`` (or the equivalent code path)
must replace a callable api_key with None before pickling the
worker config dict — otherwise multiprocessing.Pool fails."""
# We can't easily run BatchRunner end-to-end in a unit test
# (it spawns subprocesses), but we CAN inline the same logic:
# the production code uses ``callable(self.api_key) and not
# isinstance(self.api_key, str)`` to gate the substitution.
# Re-execute the same predicate here as a contract guard.
def provider():
return "jwt"
api_key = provider
worker_api_key = None if (callable(api_key) and not isinstance(api_key, str)) else api_key
assert worker_api_key is None, (
"BatchRunner must replace callable api_key with None so "
"multiprocessing.Pool can pickle the worker config"
)
# And a string passes through unchanged.
api_key_str = "sk-static"
worker_api_key_str = None if (callable(api_key_str) and not isinstance(api_key_str, str)) else api_key_str
assert worker_api_key_str == "sk-static"
def test_batch_runner_source_uses_the_correct_predicate(self):
"""Pin the predicate string in batch_runner so refactors that
change it are caught here. Reading the source rather than
importing avoids spinning up the full BatchRunner."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
/ "batch_runner.py").read_text()
assert "callable(self.api_key) and not isinstance(self.api_key, str)" in src, (
"BatchRunner.api_key callable check changed — update test or "
"verify the new predicate still routes Entra token providers "
"to the worker-rebuild path."
)
# ---------------------------------------------------------------------------
# Inline masked-banner / display sites (callable-aware)
# ---------------------------------------------------------------------------
class TestCliEnsureRuntimeCredentialsCallable:
"""Regression: ``cli.py:_ensure_runtime_credentials`` previously
treated a callable ``api_key`` as "not a string" and overwrote it
with the ``"no-key-required"`` placeholder, which then got sent as
``Authorization: Bearer no-key-required`` and rejected by Azure
with a 401. This is the most subtle of the callable-api_key audit
sites — gated by ``not isinstance(api_key, str)`` rather than the
cleaner ``callable(...)`` check used elsewhere.
We verify the source pattern (rather than spinning up a real
``HermesCLI`` instance) — the predicate change is the load-bearing
fix and is invariant under the surrounding orchestration code."""
def test_callable_predicate_present_in_cli_runtime_validation(self):
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
/ "cli.py").read_text()
# The fix introduces ``_is_callable_provider`` which gates the
# string-only check so callable token providers survive.
assert "_is_callable_provider = callable(api_key)" in src, (
"cli.py:_ensure_runtime_credentials must preserve a callable "
"api_key (Entra ID bearer provider). Without the guard, the "
"callable is stringified to 'no-key-required' and Azure 401s."
)
class TestInlinedDisplayMasks:
"""The masked-credential display sites are now inlined per-site (no
shared helper). Each site uses the ``is_token_provider`` predicate
to short-circuit on callables and print a static
``"Microsoft Entra ID"`` label, then falls through to its own
context-appropriate string mask. This replaces a unified helper
that would have forced one mask shape across sites with legitimately
different display needs (banner vs diagnostic vs UI vs preview)."""
def test_run_agent_banner_uses_is_token_provider_guard(self):
"""The masked-banner sites live in ``agent/agent_init.py``
(the ``__init__`` body was extracted into ``init_agent`` after
this feature was first written). Both the OpenAI and Anthropic
client init paths must guard their banner prints with
``is_token_provider`` so a callable Entra ID provider doesn't
crash ``len(api_key)``."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
/ "agent" / "agent_init.py").read_text()
assert src.count("is_token_provider(") >= 2, (
"agent/agent_init.py must guard BOTH masked-banner paths "
"(chat_completions and anthropic_messages) with "
"is_token_provider()."
)
assert src.count('"🔑 Using credentials: Microsoft Entra ID"') >= 2, (
"agent/agent_init.py banner blocks should print a static "
"'Microsoft Entra ID' label for callable api_keys — no "
"placeholder plumbing, no describe-mask fallback."
)
def test_cli_show_config_handles_callable(self):
"""``cli.HermesCLI.show_config`` previously did
``self.api_key[-4:]`` / ``len(self.api_key)`` which crashes on
callable Entra ID providers. The inlined version uses
``is_token_provider`` and prints the same static label as the
run_agent banners."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
/ "cli.py").read_text()
assert "is_token_provider(self.api_key)" in src, (
"cli.HermesCLI.show_config must guard self.api_key via "
"is_token_provider so callable Entra ID providers don't "
"crash /config."
)
assert '"Microsoft Entra ID"' in src, (
"cli.HermesCLI.show_config must print the static "
"'Microsoft Entra ID' label (matching run_agent banners) "
"instead of attempting to slice the callable."
)
def test_mask_api_key_for_logs_handles_callable(self):
"""``run_agent._mask_api_key_for_logs`` is called from the
request-dump JSON path. For Entra users, ``self.client.api_key``
is the SDK's empty string (callable stashed privately) — but
defensively the helper must also accept a callable directly
and return the placeholder rather than crashing on
``len(callable)``."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
/ "run_agent.py").read_text()
# The function now starts with a callable check.
assert (
"if callable(key) and not isinstance(key, str):" in src
and '"<entra-id-bearer>"' in src
), (
"run_agent._mask_api_key_for_logs must short-circuit for "
"callable api_keys to avoid len(callable) crashes in "
"request-dump paths."
)
def test_anthropic_401_diagnostic_handles_callable(self):
"""The Anthropic 401 diagnostic path lives in
``agent/conversation_loop.py`` (the ``run_conversation`` body
was extracted after this feature was first written). It used
to do ``key[:12]`` on ``self._anthropic_api_key``. For Entra ID +
Anthropic-style mode that's a callable; slicing crashes."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
/ "agent" / "conversation_loop.py").read_text()
# The Anthropic 401 block now branches on is_token_provider
# before slicing the key.
assert "Microsoft Entra ID (httpx event hook)" in src, (
"agent/conversation_loop.py Anthropic 401 diagnostic must "
"surface a Microsoft Entra ID branch before slicing the "
"key prefix."
)