{"repo":"NousResearch/hermes-agent","public_id":"25162","display_label":"Azure Foundry Microsoft Entra ID bearer token auth","summary":"These issues and pull requests concern adding Microsoft Entra ID and bearer token authentication support to the Azure Foundry provider. Multiple implementation attempts appear to address the same feature request.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28101,"merged_at":"2026-05-18T17:14:39+00:00"},"resolution":{"state":"resolved","by":"pr-28101"},"freshness":{"state":"verified","last_verified_at":"2026-05-18T17:14:39+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":25162,"pr_number":28101,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-18T17:14:39+00:00"}],"members":[{"kind":"issue","number":25162,"title":"[Feature]: Azure Foundry provider should support Microsoft Entra ID / Bearer Token auth","url":null,"state":"closed"},{"kind":"pr","number":26561,"title":"feat(auth): support Azure Foundry bearer tokens","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":27509,"title":"feat(azure-foundry): add Microsoft Entra ID auth","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9df9816da","number":0},{"kind":"commit","value":"65e0c49b7","number":0},{"kind":"pr","value":"#28101","number":28101},{"kind":"pr","value":"#27022","number":27022},{"kind":"pr","value":"#25162","number":25162}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28101,"title":"feat(azure-foundry): add Microsoft Entra ID auth (salvage #27509)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/auth.py","agent/agent_init.py","agent/agent_runtime_helpers.py","agent/anthropic_adapter.py","agent/auxiliary_client.py","agent/azure_identity_adapter.py","agent/chat_completion_helpers.py","agent/context_compressor.py","agent/conversation_compression.py","agent/conversation_loop.py","batch_runner.py","cli-config.yaml.example","cli.py","hermes_cli/auth.py","hermes_cli/auth_commands.py","hermes_cli/azure_detect.py","hermes_cli/doctor.py","hermes_cli/main.py","hermes_cli/runtime_provider.py","hermes_cli/web_server.py","plugins/model-providers/azure-foundry/__init__.py","plugins/model-providers/azure-foundry/plugin.yaml","pyproject.toml","run_agent.py","scripts/release.py","tests/acp_adapter/test_detect_provider_entra.py","tests/agent/test_anthropic_adapter.py","tests/agent/test_auxiliary_client_azure_foundry.py","tests/agent/test_azure_identity_adapter.py","tests/agent/test_bedrock_1m_context.py","tests/hermes_cli/test_azure_detect.py","tests/hermes_cli/test_azure_foundry_entra.py","tests/run_agent/test_callable_api_key.py","tools/lazy_deps.py","tui_gateway/server.py","website/docs/guides/azure-foundry.md","website/docs/reference/environment-variables.md","website/docs/user-guide/features/fallback-providers.md"],"fix_hunks":[{"file":"acp_adapter/auth.py","hunks":["@@ -9,13 +9,24 @@\n \n \n def detect_provider() -> Optional[str]:\n-    \"\"\"Resolve the active Hermes runtime provider, or None if unavailable.\"\"\"\n+    \"\"\"Resolve the active Hermes runtime provider, or None if unavailable.\n+\n+    Treats a ``Callable`` ``api_key`` (Azure Foundry Entra ID bearer\n+    token provider — see :mod:`agent.azure_identity_adapter`) as a valid\n+    credential. Without this, ACP sessions for Entra-configured Foundry\n+    deployments silently default to ``\"openrouter\"`` and the ACP auth\n+    handshake rejects the legitimate provider.\n+    \"\"\"\n     try:\n         from hermes_cli.runtime_provider import resolve_runtime_provider\n         runtime = resolve_runtime_provider()\n         api_key = runtime.get(\"api_key\")\n         provider = runtime.get(\"provider\")\n-        if isinstance(api_key, str) and api_key.strip() and isinstance(provider, str) and provider.strip():\n+        if not isinstance(provider, str) or not provider.strip():\n+            return None\n+        is_string_key = isinstance(api_key, str) and api_key.strip()\n+        is_callable_provider = callable(api_key) and not isinstance(api_key, str)\n+        if is_string_key or is_callable_provider:\n             return provider.strip().lower()\n     except Exception:\n         return None"]},{"file":"agent/agent_init.py","hunks":["@@ -560,7 +560,16 @@ def init_agent(\n             agent._client_kwargs = {}\n             if not agent.quiet_mode:\n                 print(f\"🤖 AI Agent initialized with model: {agent.model} (Anthropic native)\")\n-                if effective_key and len(effective_key) > 12:\n+                # ``effective_key`` may be a callable Entra ID bearer\n+                # provider for Azure Foundry anthropic_messages mode.\n+                # The Anthropic adapter installs an httpx event hook\n+                # that mints a fresh JWT per request — we never\n+                # invoke or inspect the callable in the banner.\n+                from agent.azure_identity_adapter import is_token_provider\n+\n+                if is_token_provider(effective_key):\n+                    print(\"🔑 Using credentials: Microsoft Entra ID\")\n+                elif isinstance(effective_key, str) and len(effective_key) > 12:\n                     print(f\"🔑 Using token: [redacted:secret-assignment]\")\n     elif agent.api_mode == \"bedrock_converse\":\n         # AWS Bedrock — uses boto3 directly, no OpenAI client needed.","@@ -764,12 +773,19 @@ def init_agent(\n                 print(f\"🤖 AI Agent initialized with model: {agent.model}\")\n                 if base_url:\n                     print(f\"🔗 Using custom base URL: {base_url}\")\n-                # Always show API key info (masked) for debugging auth issues\n+                # ``api_key`` may be a callable Entra ID bearer\n+                # provider (Azure Foundry). The OpenAI SDK mints a\n+                # fresh JWT per request internally — the banner\n+                # never invokes or inspects the callable.\n+                from agent.azure_identity_adapter import is_token_provider\n+\n                 key_used = client_kwargs.get(\"api_key\", \"none\")\n-                if key_used and key_used != \"dummy-key\" and len(key_used) > 12:\n+                if is_token_provider(key_used):\n+                    print(\"🔑 Using credentials: Microsoft Entra ID\")\n+                elif isinstance(key_used, str) and key_used and key_used != \"dummy-key\" and len(key_used) > 12:\n                     print(f\"🔑 Using API key: [redacted:secret-assignment]\")\n                 else:\n-                    print(f\"⚠️  Warning: API key appears invalid or missing (got: '{key_used[:20] if key_used else 'none'}...')\")\n+                    print(\"⚠️  Warning: API key appears invalid or missing\")\n         except Exception as e:\n             raise RuntimeError(f\"Failed to initialize OpenAI client: {e}\")\n     ","@@ -1395,7 +1411,12 @@ def init_agent(\n             _ra().logger.debug(\"Invalid ollama_num_ctx config value: %r\", _ollama_num_ctx_override)\n     if agent._ollama_num_ctx is None and agent.base_url and is_local_endpoint(agent.base_url):\n         try:\n-            _detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=[redacted:secret-assignment] or \"\")\n+            # ``agent.api_key`` may be a callable (Entra token provider).\n+            # Ollama detection makes a manual HTTP request and expects a\n+            # string — Azure Foundry isn't a local endpoint so this branch\n+            # never fires for Entra, but guard defensively.\n+            _key_for_ollama = agent.api_key if isinstance(agent.api_key, str) else \"\"\n+            _detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=[redacted:secret-assignment] or \"\")\n             if _detected and _detected > 0:\n                 agent._ollama_num_ctx = _detected\n         except Exception as exc:"]},{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1390,10 +1390,16 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo\n             _sm_custom_providers = get_compatible_custom_providers(_sm_cfg)\n         except Exception:\n             _sm_custom_providers = None\n+        # ``agent.api_key`` may be a callable (Azure Foundry Entra ID\n+        # token provider). ``get_model_context_length`` expects a\n+        # string for its live-probe paths; for Foundry the context\n+        # length normally resolves via config or static catalogs and\n+        # never hits a probe, but coerce to empty string defensively.\n+        _ctx_api_key = [redacted:secret-assignment] if isinstance(agent.api_key, str) else \"\"\n         new_context_length = get_model_context_length(\n             agent.model,\n             base_url=agent.base_url,\n-            api_key=[redacted:secret-assignment]\n+            api_key=[redacted:secret-assignment]\n             provider=agent.provider,\n             config_context_length=getattr(agent, \"_config_context_length\", None),\n             custom_providers=_sm_custom_providers,","@@ -1402,7 +1408,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo\n             model=agent.model,\n             context_length=new_context_length,\n             base_url=agent.base_url,\n-            api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n+            api_key=[redacted:secret-assignment]  # context_compressor forwards to call_llm; callable preserved\n             provider=agent.provider,\n             api_mode=agent.api_mode,\n         )"]},{"file":"agent/anthropic_adapter.py","hunks":["@@ -17,6 +17,7 @@\n import platform\n import subprocess\n from pathlib import Path\n+from urllib.parse import urlparse\n \n from hermes_constants import get_hermes_home\n from typing import Any, Dict, List, Optional, Tuple","@@ -364,7 +365,7 @@ def _normalize_base_url_text(base_url) -> str:\n def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:\n     \"\"\"Return True for non-Anthropic endpoints using the Anthropic Messages API.\n \n-    Third-party proxies (Azure AI Foundry, AWS Bedrock, self-hosted) authenticate\n+    Third-party proxies (Microsoft Foundry, AWS Bedrock, self-hosted) authenticate\n     with their own API keys via x-api-key, not Anthropic OAuth tokens. OAuth\n     detection should be skipped for these endpoints.\n     \"\"\"","@@ -508,6 +509,29 @@ def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool:\n     )\n \n \n+def _is_azure_anthropic_endpoint(base_url: str | None) -> bool:\n+    \"\"\"Return True for Azure-hosted Anthropic Messages endpoints.\n+\n+    Covers both the modern Foundry host family (``*.services.ai.azure.*``)\n+    and the legacy Azure OpenAI host family (``*.openai.azure.*``) when\n+    serving Anthropic's ``/anthropic`` route. Used to opt-in those hosts\n+    to the ``api-version`` query-param plumbing required by Azure.\n+\n+    Intentionally avoids a finite allow-list of TLD suffixes so it works\n+    across sovereign / private Azure clouds.\n+    \"\"\"\n+    normalized = _normalize_base_url_text(base_url)\n+    if not normalized:\n+        return False\n+    parsed = urlparse(normalized)\n+    host = (parsed.hostname or \"\").lower().rstrip(\".\")\n+    path = (parsed.path or \"\").lower()\n+    host_padded = f\".{host}.\"\n+    is_foundry_host = \".services.ai.azure.\" in host_padded\n+    is_legacy_azoai_host = \".openai.azure.\" in host_padded\n+    return (is_foundry_host or is_legacy_azoai_host) and \"/anthropic\" in path\n+\n+\n def _common_betas_for_base_url(\n     base_url: str | None,\n     *,","@@ -523,7 +547,7 @@ def _common_betas_for_base_url(\n \n     The ``context-1m-2025-08-07`` beta is not sent to native Anthropic by\n     default because some subscriptions reject it. Add it only for endpoint\n-    families that still require it for 1M context, currently Azure AI Foundry.\n+    families that still require it for 1M context, currently Microsoft Foundry.\n     Bedrock uses its own client helper below and opts in explicitly.\n \n     ``drop_context_1m_beta=True`` strips the 1M-context beta from any path that","@@ -540,8 +564,81 @@ def _common_betas_for_base_url(\n     return betas\n \n \n+def _build_anthropic_client_with_bearer_hook(\n+    token_provider,\n+    base_url: str = None,\n+    timeout: float = None,\n+    *,\n+    drop_context_1m_beta: bool = False,\n+):\n+    \"\"\"Anthropic-on-Foundry Entra ID variant of :func:`build_anthropic_client`.\n+\n+    Anthropic SDK 0.86.0 stores ``api_key`` / ``auth_token`` as static\n+    strings; there is no callable-token contract. To get per-request\n+    bearer refresh (Microsoft's documented Foundry pattern), we hand\n+    the SDK a custom ``httpx.Client`` whose request event hook mints a\n+    fresh JWT from the Entra credential chain and rewrites\n+    ``Authorization: Bearer <jwt>`` on every outbound request. The SDK\n+    ignores its own auth logic when ``http_client`` is provided (the\n+    hook strips any pre-set Authorization).\n+\n+    The placeholder ``auth_token`` is required because the SDK raises\n+    ``AnthropicError`` at construction if neither ``api_key`` nor\n+    ``auth_token`` is set — but the hook overrides it per-request so\n+    the placeholder value never reaches Azure.\n+    \"\"\"\n+    _anthropic_sdk = _get_anthropic_sdk()\n+    if _anthropic_sdk is None:\n+        raise ImportError(\n+            \"The 'anthropic' package is required for Azure Foundry Anthropic-style \"\n+            \"endpoints with Entra ID auth. Install with: pip install 'anthropic>=0.39.0'\"\n+        )\n+\n+    normalize_proxy_env_vars()\n+\n+    from httpx import Timeout\n+    from agent.azure_identity_adapter import build_bearer_http_client\n+\n+    _read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0\n+    timeout_obj = Timeout(timeout=float(_read_timeout), connect=10.0)\n+\n+    # Strip any trailing /v1 — the Anthropic SDK appends /v1/messages.\n+    normalized_base_url = _normalize_base_url_text(base_url)\n+    if normalized_base_url:\n+        import re as _re\n+        normalized_base_url = _re.sub(r\"/v1/?$\", \"\", normalized_base_url.rstrip(\"/\"))\n+\n+    http_client = build_bearer_http_client(token_provider, timeout=timeout_obj)\n+\n+    kwargs = {\n+        \"timeout\": timeout_obj,\n+        \"http_client\": http_client,\n+        # The SDK requires *something* for api_key/auth_token. Our\n+        # event hook overrides Authorization per request so this value\n+        # is never sent. The sentinel string makes accidental leaks\n+        # diagnosable in logs.\n+        \"auth_token\": \"[redacted:secret-assignment]\",\n+    }\n+\n+    if normalized_base_url:\n+        if _is_azure_anthropic_endpoint(normalized_base_url) and \"api-version\" not in normalized_base_url:\n+            kwargs[\"base_url\"] = normalized_base_url\n+            kwargs[\"default_query\"] = {\"api-version\": \"2025-04-15\"}\n+        else:\n+            kwargs[\"base_url\"] = normalized_base_url\n+\n+    common_betas = _common_betas_for_base_url(\n+        normalized_base_url,\n+        drop_context_1m_beta=drop_context_1m_beta,\n+    )\n+    if common_betas:\n+        kwargs[\"default_headers\"] = {\"anthropic-beta\": \",\".join(common_betas)}\n+\n+    return _anthropic_sdk.Anthropic(**kwargs)\n+\n+\n def build_anthropic_client(\n-    api_key: str,\n+    api_key,\n     base_url: str = None,\n     timeout: float = None,\n     *,","@@ -549,6 +646,17 @@ def build_anthropic_client(\n ):\n     \"\"\"Create an Anthropic client, auto-detecting setup-tokens vs API keys.\n \n+    ``api_key`` accepts either:\n+\n+    * a static ``str`` — the historical contract for all key-based and\n+      OAuth flows.\n+    * a ``Callable[[], str]`` — an Entra ID bearer token provider from\n+      :mod:`agent.azure_identity_adapter`. The Anthropic SDK itself\n+      requires a static string, so when given a callable we construct\n+      a custom ``httpx.Client`` with a request event hook that mints a\n+      fresh JWT per outbound request and rewrites the ``Authorization``\n+      header. The SDK never sees the callable directly.\n+\n     If *timeout* is provided it overrides the default 900s read timeout.  The\n     connect timeout stays at 10s.  Callers pass this from the per-provider /\n     per-model ``request_timeout_seconds`` config so Anthropic-native and","@@ -570,6 +678,14 @@ def build_anthropic_client(\n             \"Install it with: pip install 'anthropic>=0.39.0'\"\n         )\n \n+    # Callable api_key → Entra ID bearer provider path. Delegated to a\n+    # helper so the existing static-key code below stays unchanged.\n+    if callable(api_key) and not isinstance(api_key, str):\n+        return _build_anthropic_client_with_bearer_hook(\n+            api_key, base_url, timeout,\n+            drop_context_1m_beta=drop_context_1m_beta,\n+        )\n+\n     normalize_proxy_env_vars()\n \n     from httpx import Timeout","@@ -584,8 +700,7 @@ def build_anthropic_client(\n         # Pass it via default_query so the SDK appends it to every request URL\n         # without corrupting the base_url (appending it directly produces\n         # malformed paths like /anthropic?api-version=.../v1/messages).\n-        _is_azure_endpoint = \"azure.com\" in normalized_base_url.lower()\n-        if _is_azure_endpoint and \"api-version\" not in normalized_base_url:\n+        if _is_azure_anthropic_endpoint(normalized_base_url) and \"api-version\" not in normalized_base_url:\n             kwargs[\"base_url\"] = normalized_base_url.rstrip(\"/\")\n             kwargs[\"default_query\"] = {\"api-version\": \"2025-04-15\"}\n         else:","@@ -615,7 +730,7 @@ def build_anthropic_client(\n         if common_betas:\n             kwargs[\"default_headers\"] = {\"anthropic-beta\": \",\".join(common_betas)}\n     elif _is_third_party_anthropic_endpoint(base_url):\n-        # Third-party proxies (Azure AI Foundry, AWS Bedrock, etc.) use their\n+        # Third-party proxies (Microsoft Foundry, AWS Bedrock, etc.) use their\n         # own API keys with x-api-key auth. Skip OAuth detection — their keys\n         # don't follow Anthropic's sk-ant-* prefix convention and would be\n         # misclassified as OAuth tokens.","@@ -1757,7 +1872,7 @@ def convert_messages_to_anthropic(\n     # causing HTTP 400 \"Invalid signature in thinking block\".\n     #\n     # Signatures are Anthropic-proprietary.  Third-party endpoints\n-    # (MiniMax, Azure AI Foundry, self-hosted proxies) cannot validate\n+    # (MiniMax, Microsoft Foundry, self-hosted proxies) cannot validate\n     # them and will reject them outright.  When targeting a third-party\n     # endpoint, strip ALL thinking/redacted_thinking blocks from every\n     # assistant message — the third-party will generate its own","@@ -2103,5 +2218,3 @@ def build_anthropic_kwargs(\n         kwargs[\"extra_headers\"] = {\"anthropic-beta\": \",\".join(betas)}\n \n     return kwargs\n-\n-"]},{"file":"agent/auxiliary_client.py","hunks":["@@ -1891,6 +1891,120 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:\n     return CodexAuxiliaryClient(real_client, model), model\n \n \n+def _try_azure_foundry(\n+    *,\n+    model: Optional[str] = None,\n+    explicit_api_key: [redacted:secret-assignment] = None,\n+    explicit_base_url: Optional[str] = None,\n+    api_mode: Optional[str] = None,\n+) -> Tuple[Optional[Any], Optional[str]]:\n+    \"\"\"Resolve an Azure Foundry auxiliary client via the runtime resolver.\n+\n+    Mirrors the ``_try_anthropic`` / ``_try_nous`` shape but delegates to\n+    :func:`hermes_cli.runtime_provider._resolve_azure_foundry_runtime` —\n+    the same resolver the main agent uses — so:\n+\n+    * ``auth_mode: api_key`` (default) gets the static\n+      ``AZURE_FOUNDRY_API_KEY`` string.\n+    * ``auth_mode: entra_id`` gets a callable bearer-token provider\n+      (``Callable[[], str]`` from\n+      :mod:`agent.azure_identity_adapter`).\n+    * Per-model ``api_mode`` auto-routing for GPT-5.x / o-series /\n+      codex models works.\n+    * ``model.entra.{tenant_id,client_id,authority,scope}`` config\n+      fields propagate.\n+    * Non-default ``model.base_url`` overrides are honored.\n+\n+    The OpenAI SDK accepts both shapes for ``api_key`` so the caller\n+    can forward the result without coercion.\n+\n+    Returns ``(client, model)`` or ``(None, None)`` on failure.\n+    \"\"\"\n+    try:\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        from hermes_cli.auth import AuthError\n+        from hermes_cli.config import load_config\n+    except ImportError:\n+        return None, None\n+\n+    try:\n+        cfg = load_config()\n+        model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n+        if not isinstance(model_cfg, dict):\n+            model_cfg = {}\n+    except Exception:\n+        model_cfg = {}\n+\n+    try:\n+        runtime = _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg=model_cfg,\n+            explicit_api_key=[redacted:secret-assignment]\n+            explicit_base_url=explicit_base_url,\n+            target_model=model,\n+        )\n+    except AuthError as exc:\n+        logger.debug(\"Auxiliary azure-foundry: %s\", exc)\n+        return None, None\n+    except Exception as exc:\n+        logger.debug(\"Auxiliary azure-foundry runtime error: %s\", exc)\n+        return None, None\n+\n+    api_key = runtime.get(\"api_key\")\n+    base_url = str(runtime.get(\"base_url\", \"\") or \"\")\n+    runtime_api_mode = api_mode or runtime.get(\"api_mode\") or \"chat_completions\"\n+\n+    # Empty-string check on api_key here would be wrong for callable\n+    # token providers (callables are truthy and non-empty by definition).\n+    # Bail only when api_key is None / empty string.\n+    _has_key = bool(api_key) if not callable(api_key) else True\n+    if not _has_key or not base_url:\n+        return None, None\n+\n+    final_model = _normalize_resolved_model(\n+        model or str(model_cfg.get(\"default\") or \"\"),\n+        \"azure-foundry\",\n+    )\n+    if not final_model:\n+        # No fallback aux model for Azure — the user must have a\n+        # deployment name. Surface that as \"no client\" so the auto\n+        # chain falls through to the next provider rather than 404ing.\n+        logger.debug(\n+            \"Auxiliary azure-foundry: no model resolved (model=%r, default=%r)\",\n+            model, model_cfg.get(\"default\"),\n+        )\n+        return None, None\n+\n+    # Azure pre-v1 endpoints sometimes carry api-version query params\n+    # in the base URL; the OpenAI SDK drops them when joining paths,\n+    # so lift them out and pass via default_query.\n+    extra: Dict[str, Any] = {}\n+    _clean_base, _dq = _extract_url_query_params(base_url)\n+    if _dq:\n+        extra[\"default_query\"] = _dq\n+\n+    client = OpenAI(api_key=[redacted:secret-assignment] base_url=_clean_base, **extra)\n+\n+    if runtime_api_mode == \"codex_responses\":\n+        # GPT-5.x / o-series / codex models on Azure Foundry are\n+        # Responses-API-only — wrap so chat.completions.create() is\n+        # translated to /responses behind the scenes.\n+        return CodexAuxiliaryClient(client, final_model), final_model\n+\n+    if runtime_api_mode == \"anthropic_messages\":\n+        # Forward ``api_key`` verbatim — for static keys it's a string,\n+        # for Entra ID it's a callable. ``_maybe_wrap_anthropic`` →\n+        # ``build_anthropic_client`` detects the callable and installs\n+        # the bearer-injecting httpx hook.\n+        return _maybe_wrap_anthropic(\n+            client, final_model, api_key,\n+            base_url, runtime_api_mode,\n+        ), final_model\n+\n+    # chat_completions — return the plain OpenAI client.\n+    return client, final_model\n+\n+\n def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]:\n     try:\n         from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token","@@ -1946,20 +2060,31 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona\n     \"_resolve_api_key_provider\": \"[redacted:secret-assignment]\",\n }\n \n-_MAIN_RUNTIME_FIELDS = (\"provider\", \"model\", \"base_url\", \"api_key\", \"api_mode\")\n+_MAIN_RUNTIME_FIELDS = (\"provider\", \"model\", \"base_url\", \"api_key\", \"api_mode\", \"auth_mode\")\n+\n \n+def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]:\n+    \"\"\"Return a sanitized copy of a live main-runtime override.\n \n-def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, str]:\n-    \"\"\"Return a sanitized copy of a live main-runtime override.\"\"\"\n+    Most fields are stripped strings. ``api_key`` may legitimately be a\n+    zero-arg callable (Azure Foundry Entra ID token provider) — preserve\n+    those as-is so auxiliary clients inherit the same authentication\n+    surface as the main agent. The OpenAI SDK accepts ``Callable[[], str]``\n+    for ``api_key`` and calls it before every request.\n+    \"\"\"\n     if not isinstance(main_runtime, dict):\n         return {}\n-    normalized: Dict[str, str] = {}\n+    normalized: Dict[str, Any] = {}\n     for field in _MAIN_RUNTIME_FIELDS:\n         value = main_runtime.get(field)\n+        # Preserve a callable api_key (Entra ID bearer provider) unchanged.\n+        if field == \"api_key\" and callable(value) and not isinstance(value, str):\n+            normalized[field] = value\n+            continue\n         if isinstance(value, str) and value.strip():\n             normalized[field] = value.strip()\n     provider = normalized.get(\"provider\")\n-    if provider:\n+    if isinstance(provider, str):\n         normalized[\"provider\"] = provider.lower()\n     return normalized\n ","@@ -2751,10 +2876,10 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option\n     auxiliary_is_nous = False  # Reset — _try_nous() will set True if it wins\n     runtime = _normalize_main_runtime(main_runtime)\n     runtime_provider = runtime.get(\"provider\", \"\")\n-    runtime_model = runtime.get(\"model\", \"\")\n-    runtime_base_url = runtime.get(\"base_url\", \"\")\n+    runtime_model = str(runtime.get(\"model\") or \"\")\n+    runtime_base_url = str(runtime.get(\"base_url\") or \"\")\n     runtime_api_key = runtime.get(\"api_key\", \"\")\n-    runtime_api_mode = runtime.get(\"api_mode\", \"\")\n+    runtime_api_mode = str(runtime.get(\"api_mode\") or \"\")\n \n     # ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named\n     #    provider (not 'custom').  This catches the common \"env poisoning\"","@@ -2782,8 +2907,8 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option\n     # on aggregators (OpenRouter, Nous) who previously got routed to a\n     # cheap provider-side default.  Explicit per-task overrides set via\n     # config.yaml (auxiliary.<task>.provider) still win over this.\n-    main_provider = runtime_provider or _read_main_provider()\n-    main_model = runtime_model or _read_main_model()\n+    main_provider = str(runtime_provider or _read_main_provider() or \"\")\n+    main_model = str(runtime_model or _read_main_model() or \"\")\n     if (main_provider and main_model\n             and main_provider not in {\"auto\", \"\"}):\n         resolved_provider = main_provider","@@ -3177,7 +3302,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n             if client is not None:\n                 final_model = _normalize_resolved_model(model or default, provider)\n                 _cbase = str(getattr(client, \"base_url\", \"\") or \"\")\n-                _ckey = str(getattr(client, \"api_key\", \"\") or \"\")\n+                # ``client.api_key`` may be a callable (Azure Foundry Entra\n+                # bearer provider). Pass empty string for the wrapper-detection\n+                # path — wrapping decisions are based on base_url + api_mode.\n+                _raw_ckey = getattr(client, \"api_key\", \"\")\n+                _ckey = \"\" if (callable(_raw_ckey) and not isinstance(_raw_ckey, str)) else str(_raw_ckey or \"\")\n                 client = _wrap_if_needed(client, final_model, _cbase, _ckey)\n                 return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode\n                         else (client, final_model))","@@ -3289,6 +3418,40 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\",\n     except ImportError:\n         pass\n \n+    # ── Azure Foundry (delegates to runtime resolver for auth_mode-aware routing) ─\n+    #\n+    # The generic PROVIDER_REGISTRY path below uses\n+    # ``resolve_api_key_provider_credentials`` which only knows about the\n+    # static ``AZURE_FOUNDRY_API_KEY`` env var. That misses two important\n+    # cases for the ``azure-foundry`` provider:\n+    #\n+    #   1. ``model.auth_mode: entra_id`` — no static key exists; we need\n+    #      a callable bearer-token provider from ``azure_identity_adapter``.\n+    #   2. Non-default ``model.base_url`` (Foundry projects path) — the\n+    #      env-var-only resolver doesn't apply config-yaml-driven URL\n+    #      overrides.\n+    #\n+    # Delegate to the same runtime resolver the main agent uses so\n+    # auxiliary tasks (title generation, compression, vision, embedding,\n+    # session search) inherit the user's full Azure config.\n+    if provider == \"azure-foundry\":\n+        client, default_model = _try_azure_foundry(\n+            model=model,\n+            explicit_api_key=[redacted:secret-assignment]\n+            explicit_base_url=explicit_base_url,\n+            api_mode=api_mode,\n+        )\n+        if client is None:\n+            logger.warning(\n+                \"resolve_provider_client: azure-foundry requested but \"\n+                \"runtime resolution failed (run: hermes doctor for \"\n+                \"diagnostics)\"\n+            )\n+            return None, None\n+        final_model = _normalize_resolved_model(model or default_model, provider)\n+        return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode\n+                else (client, final_model))\n+\n     # ── API-key providers from PROVIDER_REGISTRY ─────────────────────\n     try:\n         from hermes_cli.auth import ("]},{"file":"agent/azure_identity_adapter.py","hunks":["@@ -0,0 +1,555 @@\n+\"\"\"Microsoft Entra ID adapter for Microsoft Foundry.\n+\n+Provides keyless authentication for Microsoft Foundry deployments using the\n+`azure-identity` SDK's `DefaultAzureCredential` chain (env service principal\n+→ workload identity → managed identity → VS Code → Azure CLI → azd →\n+PowerShell → broker).\n+\n+Architecture mirrors `agent/bedrock_adapter.py`:\n+\n+* Lazy import. `azure-identity` is only loaded when ``model.auth_mode =\n+  entra_id`` is selected. Users who stick with `AZURE_FOUNDRY_API_KEY`\n+  never pay the import cost.\n+* SDK-callable contract. The public entry point ``build_token_provider``\n+  returns a zero-arg callable produced by ``get_bearer_token_provider`` —\n+  this is exactly the value Microsoft's documented sample plugs into\n+  ``OpenAI(api_key=[redacted:secret-assignment] base_url=...)``. The OpenAI SDK calls\n+  it before every request, so token refresh is transparent.\n+* Three explicit consumer-side helpers (display / cache / http-bearer)\n+  rather than one generic \"materialize\" function — splitting them by\n+  purpose prevents accidental token-minting in logging paths or token\n+  leakage into cache keys / dashboard JSON.\n+* No persisted JWT. ``azure-identity`` caches in-process and (where\n+  available) in the OS keychain or ``~/.IdentityService``. Hermes does\n+  not duplicate that storage in ``auth.json``.\n+\n+Reference: https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id\n+\n+Requires: ``azure-identity`` (optional dependency — only needed when\n+``model.auth_mode = entra_id``).\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import functools\n+import logging\n+import os\n+import threading\n+from dataclasses import dataclass\n+from typing import Any, Callable, Dict, Optional\n+\n+logger = logging.getLogger(__name__)\n+\n+# Microsoft-documented scope for Foundry inference auth. Both the new\n+# Foundry portal and the legacy Azure OpenAI managed-identity docs use\n+# this scope for ALL Foundry endpoint shapes (*.openai.azure.com,\n+# *.services.ai.azure.com, *.ai.azure.com). The older control-plane\n+# scope ``https://cognitiveservices.azure.com/.default`` is for ARM\n+# resource management and is rejected for inference by newer\n+# resources — users with that requirement override via\n+# ``model.entra.scope`` in config.yaml.\n+SCOPE_AI_AZURE_DEFAULT = \"https://ai.azure.com/.default\"\n+\n+# ---------------------------------------------------------------------------\n+# Lazy SDK import — only loaded when the Entra path is actually used.\n+# ---------------------------------------------------------------------------\n+\n+_AZURE_IDENTITY_FEATURE = \"provider.azure_identity\"\n+\n+\n+def has_azure_identity_installed() -> bool:\n+    \"\"\"Return True if `azure-identity` can be imported right now.\n+\n+    Cheap check — does not walk the credential chain.\n+    \"\"\"\n+    try:\n+        import azure.identity  # noqa: F401\n+        return True\n+    except Exception:\n+        return False\n+\n+\n+def _require_azure_identity():\n+    \"\"\"Import ``azure.identity``, lazy-installing it if allowed.\n+\n+    Raises ``ImportError`` with a clear actionable message when the\n+    package is missing and lazy installs are disabled.\n+    \"\"\"\n+    try:\n+        import azure.identity as _ai\n+        return _ai\n+    except ImportError:\n+        try:\n+            from tools.lazy_deps import ensure, FeatureUnavailable\n+        except ImportError as exc:\n+            raise ImportError(\n+                \"The 'azure-identity' package is required for Azure AI \"\n+                \"Foundry Entra ID authentication. Install it with: \"\n+                \"pip install azure-identity\"\n+            ) from exc\n+\n+        try:\n+            ensure(_AZURE_IDENTITY_FEATURE, prompt=False)\n+        except FeatureUnavailable as exc:\n+            raise ImportError(\n+                \"The 'azure-identity' package is required for Azure AI \"\n+                \"Foundry Entra ID authentication. \" + str(exc)\n+            ) from exc\n+\n+        # Retry import after lazy install.\n+        import azure.identity as _ai  # noqa: WPS440\n+        return _ai\n+\n+\n+def reset_credential_cache() -> None:\n+    \"\"\"Clear the cached ``DefaultAzureCredential``. Used by tests and\n+    profile switches.\n+\n+    Defensive against tests that ``monkeypatch.setattr`` over\n+    ``build_credential`` with a plain (non-lru-cached) function — those\n+    won't expose ``cache_clear()`` until pytest reverts the patch.\n+    \"\"\"\n+    cache_clear = getattr(build_credential, \"cache_clear\", None)\n+    if callable(cache_clear):\n+        cache_clear()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Token-provider construction\n+# ---------------------------------------------------------------------------\n+\n+\n+@dataclass(frozen=True)\n+class EntraIdentityConfig:\n+    \"\"\"Serializable Entra ID config.\n+\n+    Captures the Hermes-managed Entra knobs we need outside Azure SDK\n+    environment configuration. Everything else\n+    (tenant ID, service principal secret, federated token file, sovereign\n+    cloud authority, etc.) flows through azure-identity's standard\n+    ``AZURE_*`` env vars — see the Bedrock pattern in\n+    ``hermes_cli/runtime_provider.py:1310-1377`` for the analogous\n+    \"let the SDK read env\" approach.\n+\n+    ``scope`` is Microsoft's documented Foundry inference audience. Almost\n+    everyone uses the default; sovereign-cloud / non-standard tenants can\n+    override via ``model.entra.scope``. Identity selection (user-assigned\n+    managed identity, workload identity, service principal, tenant, authority)\n+    stays in the standard Azure SDK env vars such as ``AZURE_CLIENT_ID``.\n+\n+    ``exclude_interactive_browser`` is kept as an internal constructor knob\n+    so probes stay non-interactive by default. It is not written by the setup\n+    wizard.\n+\n+    The dataclass is frozen so it's hashable for ``functools.lru_cache``\n+    keying, and serializable across multiprocessing boundaries (workers\n+    rebuild the credential inside their own process).\n+    \"\"\"\n+\n+    scope: str = SCOPE_AI_AZURE_DEFAULT\n+    exclude_interactive_browser: bool = True\n+\n+    def __post_init__(self) -> None:\n+        scope = str(self.scope or \"\").strip() or SCOPE_AI_AZURE_DEFAULT\n+        object.__setattr__(self, \"scope\", scope)\n+\n+    def to_dict(self) -> Dict[str, Any]:\n+        return {\n+            \"scope\": self.scope,\n+            \"exclude_interactive_browser\": self.exclude_interactive_browser,\n+        }\n+\n+    @classmethod\n+    def from_dict(cls, data: Optional[Dict[str, Any]],\n+                  *, default_scope: Optional[str] = None) -> \"EntraIdentityConfig\":\n+        data = data or {}\n+        scope = str(data.get(\"scope\") or \"\").strip() or default_scope or SCOPE_AI_AZURE_DEFAULT\n+        exclude_browser = bool(data.get(\"exclude_interactive_browser\", True))\n+        return cls(\n+            scope=scope,\n+            exclude_interactive_browser=exclude_browser,\n+        )\n+\n+\n+def _build_default_credential(config: EntraIdentityConfig) -> Any:\n+    \"\"\"Construct a ``DefaultAzureCredential`` for ``config``.\n+\n+    Only Hermes-selected knobs are passed as kwargs. Everything else\n+    (tenant, service principal secret, federated token file, sovereign\n+    cloud authority, etc.) is read by ``azure-identity`` from the\n+    standard ``AZURE_*`` environment variables — see Microsoft's\n+    documented credential resolution chain. Users configure those in\n+    ``~/.hermes/.env`` or the deployment environment.\n+    \"\"\"\n+    ai = _require_azure_identity()\n+    kwargs: Dict[str, Any] = {}\n+    # SDK default is True (browser excluded); only pass when the user\n+    # explicitly opts in to interactive browser auth.\n+    if not config.exclude_interactive_browser:\n+        kwargs[\"exclude_interactive_browser_credential\"] = False\n+    return ai.DefaultAzureCredential(**kwargs)\n+\n+\n[redacted:email]_cache(maxsize=1)\n+def build_credential(config: EntraIdentityConfig) -> Any:\n+    \"\"\"Return the cached ``DefaultAzureCredential`` for ``config``.\n+\n+    Hermes processes use exactly one Entra config at a time (the\n+    ``model.entra.*`` block in config.yaml drives every aux task,\n+    subagent, and credential probe in the session). ``maxsize=1`` is\n+    intentional: it reflects the actual usage pattern and keeps the\n+    cache trivially small.\n+\n+    ``EntraIdentityConfig`` is a frozen dataclass, so it's hashable and\n+    safe as an LRU-cache key. ``functools.lru_cache`` is thread-safe in\n+    CPython.\n+\n+    If two distinct configs are ever passed (tests do this; production\n+    rarely), the LRU eviction handles it correctly — each call still\n+    returns a credential matching its config; only one is cached at a\n+    time. Use :func:`reset_credential_cache` to clear (e.g. in tests).\n+    \"\"\"\n+    return _build_default_credential(config)\n+\n+\n+def build_token_provider(scope: Optional[str] = None,\n+                         *,\n+                         config: Optional[EntraIdentityConfig] = None,\n+                         base_url: Optional[str] = None,\n+                         exclude_interactive_browser: bool = True,\n+                         ) -> Callable[[], str]:\n+    \"\"\"Return a zero-arg callable that mints a fresh Entra bearer JWT.\n+\n+    The returned callable is exactly what Microsoft's documented Foundry\n+    sample expects::\n+\n+        from openai import OpenAI\n+        client = OpenAI(\n+            base_url=\"https://my-resource.openai.azure.com/openai/v1/\",\n+            api_key=build_token_provider(),\n+        )\n+\n+    Scope resolution order:\n+      1. ``config.scope`` when a config object is supplied\n+      2. explicit ``scope`` kwarg\n+      3. ``SCOPE_AI_AZURE_DEFAULT`` (Microsoft's documented Foundry scope)\n+\n+    ``base_url`` is unused today and kept for back-compat. Tenant /\n+    service-principal / sovereign-cloud configuration flows through\n+    ``azure-identity``'s standard ``AZURE_*`` environment variables —\n+    see :func:`_build_default_credential` for the rationale.\n+\n+    NOT serializable across process boundaries. For multiprocessing\n+    workers, serialize the ``EntraIdentityConfig`` and rebuild the\n+    provider inside the worker.\n+    \"\"\"\n+    ai = _require_azure_identity()\n+    if config is None:\n+        config = EntraIdentityConfig(\n+            scope=scope or SCOPE_AI_AZURE_DEFAULT,\n+            exclude_interactive_browser=exclude_interactive_browser,\n+        )\n+    credential = [redacted:secret-assignment]\n+    return ai.get_bearer_token_provider(credential, config.scope)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Credential probing\n+# ---------------------------------------------------------------------------\n+\n+\n+def has_azure_identity_credentials(scope: Optional[str] = None,\n+                                   *,\n+                                   config: Optional[EntraIdentityConfig] = None,\n+                                   timeout_seconds: float = 10.0,\n+                                   allow_install: bool = True,\n+                                   **overrides: Any) -> bool:\n+    \"\"\"Best-effort probe: can `DefaultAzureCredential` mint a token now?\n+\n+    Runs ``credential.get_token(scope)`` under a thread-based timeout so\n+    a slow token service can't hang the caller. Returns False on any\n+    error — never raises. Use for ``hermes doctor`` /\n+    ``hermes auth status`` / wizard preflight.\n+\n+    ``allow_install``: when True (default) and ``azure-identity`` is not\n+    importable, the adapter triggers the standard lazy-install path\n+    (subject to ``security.allow_lazy_installs``) before probing. Set\n+    False to make this strictly an \"is installed?\" check — used on hot\n+    paths like CLI startup where we never want pip to run.\n+\n+    NOT used by ``is_provider_configured()`` — that path is structural\n+    only (no token mint), so CLI startup doesn't pay this latency.\n+    \"\"\"\n+    if not has_azure_identity_installed():\n+        if not allow_install:\n+            return False\n+        try:\n+            _require_azure_identity()\n+        except ImportError as exc:\n+            logger.debug(\"azure-identity lazy install unavailable: %s\", exc)\n+            return False\n+    if config is None:\n+        effective_scope = (scope or \"\").strip() or SCOPE_AI_AZURE_DEFAULT\n+        config = EntraIdentityConfig(scope=effective_scope, **overrides)\n+\n+    result = {\"ok\": False}\n+\n+    def _probe() -> None:\n+        try:\n+            credential = [redacted:secret-assignment]\n+            tok = credential.get_token(config.scope)\n+            result[\"ok\"] = bool(getattr(tok, \"token\", None))\n+        except Exception as exc:\n+            logger.debug(\"Entra credential probe failed: %s\", exc)\n+            result[\"ok\"] = False\n+\n+    thread = threading.Thread(target=_probe, daemon=True)\n+    thread.start()\n+    thread.join(timeout=max(0.01, timeout_seconds))\n+    if thread.is_alive():\n+        logger.debug(\"Entra token service probe timed out after %ss\", timeout_seconds)\n+        return False\n+    return bool(result.get(\"ok\"))\n+\n+\n+def describe_active_credential(config: Optional[EntraIdentityConfig] = None,\n+                               *,\n+                               scope: Optional[str] = None,\n+                               timeout_seconds: float = 10.0,\n+                               allow_install: bool = True,\n+                               **overrides: Any) -> Dict[str, Any]:\n+    \"\"\"Return diagnostic info about the active credential chain.\n+\n+    Best-effort: runs ``get_token()`` and inspects what came back.\n+    Designed for ``hermes doctor`` and the wizard preflight — never\n+    raises, returns ``{\"ok\": False, \"error\": ...}`` on failure.\n+\n+    ``allow_install``: when True (default) and ``azure-identity`` is not\n+    importable, the adapter triggers the standard lazy-install path\n+    (subject to ``security.allow_lazy_installs``) before probing. The\n+    install failure is surfaced as the diagnostic error when it fails.\n+    Set False for hot CLI paths that should never trigger pip.\n+\n+    ``azure-identity`` doesn't expose the winning inner credential as\n+    a public field, so we report a coarse picture (env vars present,\n+    token expiry, claims-derived tenant) rather than the credential\n+    class name. Users wanting the precise class can run with\n+    ``AZURE_LOG_LEVEL=DEBUG``.\n+    \"\"\"\n+    info: Dict[str, Any] = {\"ok\": False}\n+    if not has_azure_identity_installed():\n+        if not allow_install:\n+            info[\"error\"] = \"azure-identity not installed\"\n+            info[\"hint\"] = (\n+                \"pip install azure-identity (or rely on lazy install at \"\n+                \"first use)\"\n+            )\n+            return info\n+        try:\n+            _require_azure_identity()\n+        except ImportError as exc:\n+            info[\"error\"] = str(exc) or \"azure-identity not installed\"\n+            info[\"hint\"] = (\n+                \"pip install azure-identity manually, or enable lazy \"\n+                \"installs (security.allow_lazy_installs: true in \"\n+                \"config.yaml).\"\n+            )\n+            return info\n+\n+    if config is None:\n+        effective_scope = (scope or \"\").strip() or SCOPE_AI_AZURE_DEFAULT\n+        config = EntraIdentityConfig(scope=effective_scope, **overrides)\n+\n+    info[\"scope\"] = config.scope\n+    # Tenant / authority / service-principal config flow through the\n+    # standard ``AZURE_*`` env vars; surface them below.\n+    if os.environ.get(\"AZURE_TENANT_ID\", \"\").strip():\n+        info[\"tenant_id_env\"] = os.environ[\"AZURE_TENANT_ID\"].strip()\n+\n+    # Surface which env-var sources are present without minting yet.\n+    env_sources = []\n+    if os.environ.get(\"AZURE_FEDERATED_TOKEN_FILE\", \"\").strip():\n+        env_sources.append(\"WorkloadIdentityCredential (AZURE_FEDERATED_TOKEN_FILE)\")\n+    if (os.environ.get(\"AZURE_CLIENT_ID\", \"\").strip()\n+            and os.environ.get(\"AZURE_CLIENT_SECRET\", \"\").strip()\n+            and os.environ.get(\"AZURE_TENANT_ID\", \"\").strip()):\n+        env_sources.append(\"EnvironmentCredential (client secret)\")\n+    if os.environ.get(\"IDENTITY_ENDPOINT\", \"\").strip() or os.environ.get(\"MSI_ENDPOINT\", \"\").strip():\n+        env_sources.append(\"ManagedIdentityCredential (IDENTITY_ENDPOINT)\")\n+    info[\"env_sources\"] = env_sources\n+\n+    # Now try minting.\n+    result: Dict[str, Any] = {}\n+\n+    def _probe() -> None:\n+        try:\n+            credential = [redacted:secret-assignment]\n+            tok = credential.get_token(config.scope)\n+            result[\"token\"] = tok\n+        except Exception as exc:\n+            result[\"error\"] = str(exc)\n+\n+    thread = threading.Thread(target=_probe, daemon=True)\n+    thread.start()\n+    thread.join(timeout=max(0.01, timeout_seconds))\n+    if thread.is_alive():\n+        info[\"error\"] = f\"Token probe timed out after {timeout_seconds:.0f}s\"\n+        info[\"hint\"] = (\n+            \"DefaultAzureCredential can be slow when the token service is unreachable \"\n+            \"or when az login state is stale. Try `az login` or set \"\n+            \"AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET.\"\n+        )\n+        return info\n+\n+    if \"error\" in result:\n+        info[\"error\"] = result[\"error\"]\n+        return info\n+\n+    token = result.get(\"token\")\n+    if token is None:\n+        info[\"error\"] = \"credential chain exhausted\"\n+        return info\n+\n+    info[\"ok\"] = True\n+    info[\"expires_on\"] = getattr(token, \"expires_on\", None)\n+    return info\n+\n+\n+# ---------------------------------------------------------------------------\n+# Consumer-side helpers — split by purpose to prevent accidental token\n+# minting in logging / cache-key / dashboard paths.\n+# ---------------------------------------------------------------------------\n+\n+\n+def is_token_provider(value: Any) -> bool:\n+    \"\"\"Return True when ``value`` is a callable Entra token provider.\n+\n+    Used at the seams where a consumer must decide between\n+    string-API-key semantics and bearer-callable semantics.\n+    \"\"\"\n+    return callable(value) and not isinstance(value, str)\n+\n+\n+def materialize_bearer_for_http(value: Any) -> str:\n+    \"\"\"Return a fresh Bearer JWT for a manual HTTP request.\n+\n+    Only call this at sites that must construct an ``Authorization``\n+    header outside the OpenAI SDK (e.g. ``hermes_cli/azure_detect.py``).\n+    Calls the callable exactly once and returns the resulting token.\n+\n+    **Anthropic SDK integration:** the Anthropic Python SDK does not\n+    accept a ``Callable[[], str]`` for ``auth_token``. Instead,\n+    :func:`build_bearer_http_client` returns an ``httpx.Client`` whose\n+    request event hook calls this function and rewrites the\n+    ``Authorization`` header per request — and that client is passed to\n+    the Anthropic SDK via ``http_client=...``. See\n+    :func:`agent.anthropic_adapter.build_anthropic_client` for the\n+    consumer.\n+\n+    Raises ``ValueError`` if ``value`` is not a callable token provider\n+    or non-empty string.\n+    \"\"\"\n+    if is_token_provider(value):\n+        token = value()\n+        if not isinstance(token, str) or not token:\n+            raise ValueError(\"token provider returned empty value\")\n+        return token\n+    if isinstance(value, str) and value:\n+        return value\n+    raise ValueError(\"no usable api_key / token provider\")\n+\n+\n+def build_bearer_http_client(token_provider: [redacted:secret-assignment] str], **httpx_kwargs: Any) -> Any:\n+    \"\"\"Return an ``httpx.Client`` that mints a fresh Entra bearer JWT\n+    per outbound request.\n+\n+    The Anthropic SDK (≤ 0.86.0 at the time of writing) stores\n+    ``api_key`` / ``auth_token`` as static strings and computes the\n+    ``Authorization`` header at construction time. To get per-request\n+    token refresh (the Microsoft-recommended Foundry pattern for\n+    callable bearer providers), we install an httpx ``request`` event\n+    hook on a custom client and pass that client to the SDK via\n+    ``http_client=...``. The hook:\n+\n+      1. Calls :func:`materialize_bearer_for_http` to mint a fresh JWT\n+         (azure-identity caches internally — this is cheap when the\n+         cached token is still valid).\n+      2. Strips any pre-set ``Authorization`` / ``api-key`` /\n+         ``x-api-key`` headers the SDK may have added (avoids\n+         conflicting auth values).\n+      3. Sets ``Authorization: Bearer <fresh-jwt>``.\n+\n+    ``token_provider`` must be a zero-arg callable returning a string —\n+    typically the result of :func:`build_token_provider`.\n+\n+    ``httpx_kwargs`` are forwarded verbatim to ``httpx.Client(...)`` so\n+    callers can attach a ``timeout``, ``transport``, ``proxy``, etc.\n+\n+    Raises ``ImportError`` if ``httpx`` is not installed (it is a\n+    transitive dependency of both ``openai`` and ``anthropic`` SDKs, so\n+    in practice always available when this helper is reached).\n+    \"\"\"\n+    if not is_token_provider(token_provider):\n+        raise ValueError(\n+            \"build_bearer_http_client requires a zero-arg callable \"\n+            \"token provider\"\n+        )\n+\n+    try:\n+        import httpx\n+    except ImportError as exc:  # pragma: no cover — httpx ships with openai/anthropic\n+        raise ImportError(\n+            \"httpx is required for Entra ID bearer auth on Microsoft Foundry \"\n+            \"Anthropic-style endpoints. It is normally a transitive \"\n+            \"dependency of the openai/anthropic SDKs.\"\n+        ) from exc\n+\n+    def _inject_bearer(request: \"httpx.Request\") -> None:\n+        try:\n+            token = [redacted:secret-assignment]\n+        except ValueError as exc:\n+            # Token provider failed (chain exhausted, token service unreachable,\n+            # az login expired, etc.). Strip any auth headers the SDK\n+            # may have set — including our own placeholder sentinel\n+            # ``entra-id-bearer-via-http-hook`` from\n+            # ``_build_anthropic_client_with_bearer_hook`` — so the\n+            # outbound request hits Azure with NO Authorization rather\n+            # than with the placeholder. Azure returns a clean 401\n+            # \"missing auth\" that is easier to diagnose than a 401\n+            # against the sentinel string, and the sentinel never\n+            # appears in upstream access logs.\n+            #\n+            # Log at WARNING (not DEBUG) so the misconfiguration is\n+            # visible at default log levels.\n+            logger.warning(\n+                \"Bearer hook: Entra ID token provider returned empty (%s) \"\n+                \"— stripping Authorization headers. Azure will respond 401. \"\n+                \"Run `hermes doctor` or `az login` to recover.\",\n+                exc,\n+            )\n+            for header_name in (\"Authorization\", \"authorization\", \"Api-Key\", \"api-key\", \"X-Api-Key\", \"x-api-key\"):\n+                request.headers.pop(header_name, None)\n+            return\n+        for header_name in (\"Authorization\", \"authorization\", \"Api-Key\", \"api-key\", \"X-Api-Key\", \"x-api-key\"):\n+            request.headers.pop(header_name, None)\n+        request.headers[\"Authorization\"] = f\"Bearer {token}\"\n+\n+    return httpx.Client(\n+        event_hooks={\"request\": [_inject_bearer]},\n+        **httpx_kwargs,\n+    )\n+\n+\n+__all__ = [\n+    \"EntraIdentityConfig\",\n+    \"SCOPE_AI_AZURE_DEFAULT\",\n+    \"build_bearer_http_client\",\n+    \"build_credential\",\n+    \"build_token_provider\",\n+    \"describe_active_credential\",\n+    \"has_azure_identity_credentials\",\n+    \"has_azure_identity_installed\",\n+    \"is_token_provider\",\n+    \"materialize_bearer_for_http\",\n+    \"reset_credential_cache\",\n+]"]},{"file":"agent/chat_completion_helpers.py","hunks":["@@ -866,9 +866,14 @@ def try_activate_fallback(agent, reason: \"FailoverReason | None\" = None) -> bool\n         # the fallback activation drops to 128K even when config says 204800.\n         if hasattr(agent, 'context_compressor') and agent.context_compressor:\n             from agent.model_metadata import get_model_context_length\n+            # ``agent.api_key`` may be callable (Entra ID); the\n+            # context-length resolver expects a string for live\n+            # probes. Foundry typically resolves via config/static\n+            # catalogs anyway, so coerce defensively.\n+            _fb_ctx_api_key = [redacted:secret-assignment] if isinstance(agent.api_key, str) else \"\"\n             fb_context_length = get_model_context_length(\n                 agent.model, base_url=agent.base_url,\n-                api_key=[redacted:secret-assignment] provider=agent.provider,\n+                api_key=[redacted:secret-assignment] provider=agent.provider,\n                 config_context_length=getattr(agent, \"_config_context_length\", None),\n                 custom_providers=getattr(agent, \"_custom_providers\", None),\n             )","@@ -876,7 +881,7 @@ def try_activate_fallback(agent, reason: \"FailoverReason | None\" = None) -> bool\n                 model=agent.model,\n                 context_length=fb_context_length,\n                 base_url=agent.base_url,\n-                api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n+                api_key=[redacted:secret-assignment] \"api_key\", \"\"),  # callable preserved → call_llm\n                 provider=agent.provider,\n             )\n "]},{"file":"agent/context_compressor.py","hunks":["@@ -486,7 +486,7 @@ def update_model(\n         model: str,\n         context_length: int,\n         base_url: str = \"\",\n-        api_key: str = \"\",\n+        api_key: Any = \"\",\n         provider: str = \"\",\n         api_mode: str = \"\",\n     ) -> None:"]},{"file":"agent/conversation_compression.py","hunks":["@@ -103,7 +103,15 @@ def check_compression_model_feasibility(agent: Any) -> None:\n             return\n \n         aux_base_url = str(getattr(client, \"base_url\", \"\"))\n-        aux_api_key = [redacted:secret-assignment] \"api_key\", \"\"))\n+        # ``client.api_key`` may be a callable (Azure Foundry Entra ID\n+        # bearer provider). The context-length resolver chain expects a\n+        # string, but it only needs a key for live catalogue probes\n+        # (provider model lists). For Entra clients the model-metadata\n+        # chain still resolves via models.dev + hardcoded family\n+        # fallbacks, which don't require auth — pass empty string rather\n+        # than minting a bearer JWT just to look up a context length.\n+        _raw_aux_key = getattr(client, \"api_key\", \"\")\n+        aux_api_key = \"\" if (callable(_raw_aux_key) and not isinstance(_raw_aux_key, str)) else str(_raw_aux_key or \"\")\n \n         aux_context = get_model_context_length(\n             aux_model,"]},{"file":"agent/conversation_loop.py","hunks":["@@ -1807,7 +1807,11 @@ def _stop_spinner():\n                         # that survives message/tool sanitization (#6843).\n                         _credential_sanitized = False\n                         _raw_key = getattr(agent, \"api_key\", None) or \"\"\n-                        if _raw_key:\n+                        # Entra ID bearer providers are callables — their\n+                        # minted JWTs are always ASCII, so no sanitization\n+                        # is needed (and ``_strip_non_ascii`` would crash\n+                        # on a callable input).\n+                        if _raw_key and isinstance(_raw_key, str):\n                             _clean_key = _strip_non_ascii(_raw_key)\n                             if _clean_key != _raw_key:\n                                 agent.api_key = [redacted:secret-assignment]","@@ -2080,15 +2084,26 @@ def _stop_spinner():\n                 ):\n                     anthropic_auth_retry_attempted = True\n                     from agent.anthropic_adapter import _is_oauth_token\n+                    from agent.azure_identity_adapter import is_token_provider\n                     if agent._try_refresh_anthropic_client_credentials():\n                         print(f\"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...\")\n                         continue\n                     # Credential refresh didn't help — show diagnostic info\n                     key = agent._anthropic_api_key\n-                    auth_method = \"Bearer (OAuth/setup-token)\" if _is_oauth_token(key) else \"x-api-key (API key)\"\n                     print(f\"{agent.log_prefix}🔐 Anthropic 401 — authentication failed.\")\n-                    print(f\"{agent.log_prefix}   Auth method: {auth_method}\")\n-                    print(f\"{agent.log_prefix}   Token prefix: {key[:12]}...\" if key and len(key) > 12 else f\"{agent.log_prefix}   Token: (empty or short)\")\n+                    if is_token_provider(key):\n+                        # Azure Foundry Entra ID — the bearer token is\n+                        # minted per-request by an httpx event hook on a\n+                        # custom http_client passed to the SDK. The 401\n+                        # means Azure rejected the JWT (RBAC role missing,\n+                        # az login expired, IMDS unreachable, etc.).\n+                        print(f\"{agent.log_prefix}   Auth method: Microsoft Entra ID (httpx event hook)\")\n+                        print(f\"{agent.log_prefix}   Run `hermes doctor` for credential-chain diagnostics, or\")\n+                        print(f\"{agent.log_prefix}   `az login` if your developer session expired.\")\n+                    else:\n+                        auth_method = \"Bearer (OAuth/setup-token)\" if _is_oauth_token(key) else \"x-api-key (API key)\"\n+                        print(f\"{agent.log_prefix}   Auth method: {auth_method}\")\n+                        print(f\"{agent.log_prefix}   Token prefix: {key[:12]}...\" if isinstance(key, str) and len(key) > 12 else f\"{agent.log_prefix}   Token: (empty or short)\")\n                     print(f\"{agent.log_prefix}   Troubleshooting:\")\n                     from hermes_constants import display_hermes_home as _dhh_fn\n                     _dhh = _dhh_fn()"]},{"file":"batch_runner.py","hunks":["@@ -862,13 +862,32 @@ def run(self, resume: bool = False):\n                 \"last_updated\": None\n             }\n         \n-        # Prepare configuration for workers\n+        # Prepare configuration for workers.\n+        #\n+        # ``self.api_key`` may be a zero-arg callable (Azure Foundry Entra ID\n+        # bearer provider returned by ``agent.azure_identity_adapter``). Such\n+        # closures are not safely picklable across the multiprocessing.Pool\n+        # boundary. Drop the callable here and let each worker rebuild its\n+        # own provider via ``resolve_runtime_provider()``, which reads\n+        # ``model.auth_mode`` from ``config.yaml`` and constructs a fresh\n+        # token provider in the worker process (azure-identity caches\n+        # in-process so each worker gets its own short-lived cache).\n+        if callable(self.api_key) and not isinstance(self.api_key, str):\n+            worker_api_key = None\n+            print(\n+                \"ℹ️  Detected Entra ID bearer provider — workers will rebuild \"\n+                \"credentials from config.yaml in each process.\",\n+                flush=True,\n+            )\n+        else:\n+            worker_api_key = self.api_key\n+\n         config = {\n             \"distribution\": self.distribution,\n             \"model\": self.model,\n             \"max_iterations\": self.max_iterations,\n             \"base_url\": self.base_url,\n-            \"api_key\": self.api_key,\n+            \"api_key\": [redacted:secret-assignment]\n             \"verbose\": self.verbose,\n             \"ephemeral_system_prompt\": self.ephemeral_system_prompt,\n             \"log_prefix_chars\": self.log_prefix_chars,"]},{"file":"cli-config.yaml.example","hunks":["@@ -30,6 +30,7 @@ model:\n   #   \"ollama-cloud\" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings)\n   #   \"kilocode\"     - KiloCode gateway (requires: KILOCODE_API_KEY)\n   #   \"ai-gateway\"   - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)\n+  #   \"azure-foundry\" - Microsoft Foundry / Azure OpenAI (API key or Entra ID)\n   #   \"lmstudio\"     - LM Studio local server (optional: LM_API_KEY, defaults to http://127.0.0.1:1234/v1)\n   #\n   # Local servers (LM Studio, Ollama, vLLM, llama.cpp):","@@ -45,6 +46,14 @@ model:\n   # api_key: \"[redacted:secret-assignment]\"  # Uncomment to set here instead of .env\n   base_url: \"https://openrouter.ai/api/v1\"\n \n+  # Azure Foundry keyless auth example:\n+  # provider: \"azure-foundry\"\n+  # base_url: \"https://<resource>.openai.azure.com/openai/v1\"\n+  # auth_mode: \"entra_id\"      # DefaultAzureCredential: az login, managed identity, workload identity, etc.\n+  # default: \"gpt-4o\"          # Deployment/model name\n+  # entra:\n+  #   scope: \"https://ai.azure.com/.default\"  # Optional; this is the default.\n+\n   # ── Token limits — two settings, easy to confuse ──────────────────────────\n   #\n   # context_length: TOTAL context window (input + output tokens combined)."]},{"file":"cli.py","hunks":["@@ -4251,7 +4251,13 @@ def _ensure_runtime_credentials(self) -> bool:\n         resolved_acp_command = runtime.get(\"command\")\n         resolved_acp_args = list(runtime.get(\"args\") or [])\n         resolved_credential_pool = runtime.get(\"credential_pool\")\n-        if not isinstance(api_key, str) or not api_key:\n+        # A callable api_key is a bearer-token provider (Azure Foundry\n+        # Entra ID — ``azure_identity_adapter.build_token_provider``).\n+        # The OpenAI SDK accepts ``Callable[[], str]`` for ``api_key`` and\n+        # invokes it before every request. Skip the string-only validation\n+        # and placeholder substitution for callables.\n+        _is_callable_provider = callable(api_key) and not isinstance(api_key, str)\n+        if not _is_callable_provider and (not isinstance(api_key, str) or not api_key):\n             # Custom / local endpoints (llama.cpp, ollama, vLLM, etc.) often\n             # don't require authentication.  When a base_url IS configured but\n             # no API key was found, use a placeholder so the OpenAI SDK","@@ -5723,7 +5729,15 @@ def show_config(self):\n             config_path = project_config_path\n         config_status = \"(loaded)\" if config_path.exists() else \"(not found)\"\n         \n-        api_key_display = '[redacted:secret-assignment]' + self.api_key[-4:] if self.api_key and len(self.api_key) > 4 else 'Not set!'\n+        # ``self.api_key`` may be a callable (Azure Foundry Entra ID bearer\n+        # provider). Never invoke it; just identify the auth surface.\n+        from agent.azure_identity_adapter import is_token_provider\n+        if is_token_provider(self.api_key):\n+            api_key_display = \"[redacted:secret-assignment] Entra ID\"\n+        elif isinstance(self.api_key, str) and len(self.api_key) > 12:\n+            api_key_display = f\"{self.api_key[:8]}...{self.api_key[-4:]}\"\n+        else:\n+            api_key_display = \"Not set!\"\n         \n         print()\n         title = \"(^_^) Configuration\""]},{"file":"hermes_cli/auth.py","hunks":["@@ -5334,7 +5334,9 @@ def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]:\n \n def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:\n     \"\"\"Generic auth status dispatcher.\"\"\"\n-    target = provider_id or get_active_provider()\n+    target = (provider_id or get_active_provider() or \"\").strip().lower()\n+    if not target:\n+        return {\"logged_in\": False}\n     if target == \"spotify\":\n         return get_spotify_auth_status()\n     if target == \"nous\":","@@ -5351,6 +5353,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:\n         return get_minimax_oauth_auth_status()\n     if target == \"copilot-acp\":\n         return get_external_process_provider_status(target)\n+    if target == \"azure-foundry\":\n+        return _get_azure_foundry_auth_status()\n     # API-key providers\n     pconfig = PROVIDER_REGISTRY.get(target)\n     if pconfig and pconfig.auth_type == \"api_key\":","@@ -5365,6 +5369,83 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:\n     return {\"logged_in\": False}\n \n \n+def _get_azure_foundry_auth_status() -> Dict[str, Any]:\n+    \"\"\"Return structural auth status for Azure Foundry.\n+\n+    ``logged_in`` is structural, matching other non-OAuth provider status\n+    checks:\n+\n+      * ``auth_mode == \"entra_id\"`` AND ``azure-identity`` is importable\n+        (we do NOT mint a token here; ``hermes doctor`` runs the live\n+        probe and reports whether the credential chain can acquire one).\n+      * ``auth_mode == \"api_key\"`` (default) AND ``AZURE_FOUNDRY_API_KEY``\n+        is set with a usable value.\n+\n+    Never invokes the Entra credential chain — keeps CLI startup latency\n+    flat regardless of token-service / az login state.\n+    \"\"\"\n+    info: Dict[str, Any] = {\"provider\": \"azure-foundry\"}\n+    try:\n+        from hermes_cli.config import load_config, get_env_value\n+        cfg = load_config()\n+    except Exception:\n+        cfg = {}\n+\n+    model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else None\n+    auth_mode = \"api_key\"\n+    base_url = \"\"\n+    if isinstance(model_cfg, dict):\n+        auth_mode = str(model_cfg.get(\"auth_mode\") or \"api_key\").strip().lower() or \"api_key\"\n+        base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n+    info[\"auth_mode\"] = auth_mode\n+    info[\"base_url\"] = base_url\n+\n+    if auth_mode == \"entra_id\":\n+        try:\n+            from agent.azure_identity_adapter import (\n+                EntraIdentityConfig,\n+                SCOPE_AI_AZURE_DEFAULT,\n+                has_azure_identity_installed,\n+            )\n+            installed = has_azure_identity_installed()\n+            entra_cfg = {}\n+            if isinstance(model_cfg, dict) and isinstance(model_cfg.get(\"entra\"), dict):\n+                entra_cfg = model_cfg[\"entra\"]\n+            identity_config = EntraIdentityConfig.from_dict(\n+                entra_cfg,\n+                default_scope=SCOPE_AI_AZURE_DEFAULT,\n+            )\n+            info[\"azure_identity_installed\"] = installed\n+            info[\"scope\"] = identity_config.scope\n+            info[\"credential_probe\"] = \"not_run\"\n+            info[\"credential_verified\"] = False\n+            info[\"logged_in\"] = bool(installed)\n+            if not installed:\n+                info[\"hint\"] = (\n+                    \"azure-identity not installed. Install with: \"\n+                    \"pip install azure-identity  (or rely on Hermes' \"\n+                    \"lazy-install at first use).\"\n+                )\n+            else:\n+                info[\"hint\"] = (\n+                    \"azure-identity is installed; live credential validation \"\n+                    \"is skipped here. Run `hermes doctor` to verify token acquisition.\"\n+                )\n+            return info\n+        except Exception as exc:\n+            info[\"logged_in\"] = False\n+            info[\"error\"] = f\"azure-identity check failed: {exc}\"\n+            return info\n+\n+    # api_key mode (default)\n+    try:\n+        api_key = get_env_value(\"AZURE_FOUNDRY_API_KEY\") or os.getenv(\"AZURE_FOUNDRY_API_KEY\", \"\")\n+    except Exception:\n+        api_key = os.getenv(\"AZURE_FOUNDRY_API_KEY\", \"\")\n+    info[\"logged_in\"] = has_usable_secret(api_key)\n+    return info\n+\n+\n def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:\n     \"\"\"Resolve API key and base URL for an API-key provider.\n "]},{"file":"hermes_cli/auth_commands.py","hunks":["@@ -566,6 +566,54 @@ def _interactive_auth() -> None:\n             print()\n     except ImportError:\n         pass  # boto3 or bedrock_adapter not available\n+\n+    # Show Azure Foundry Entra ID status\n+    try:\n+        from hermes_cli.config import load_config\n+        _cfg = load_config()\n+        _model_cfg = _cfg.get(\"model\") if isinstance(_cfg, dict) else None\n+        if isinstance(_model_cfg, dict):\n+            _cfg_provider = str(_model_cfg.get(\"provider\") or \"\").strip().lower()\n+            _cfg_auth_mode = str(_model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n+            if _cfg_provider == \"azure-foundry\" and _cfg_auth_mode == \"entra_id\":\n+                from agent.azure_identity_adapter import (\n+                    EntraIdentityConfig,\n+                    SCOPE_AI_AZURE_DEFAULT,\n+                    describe_active_credential,\n+                    has_azure_identity_installed,\n+                )\n+                _base_url = str(_model_cfg.get(\"base_url\") or \"\").strip()\n+                _entra = _model_cfg.get(\"entra\") or {}\n+                if not isinstance(_entra, dict):\n+                    _entra = {}\n+                _scope = (\n+                    str(_entra.get(\"scope\") or \"\").strip()\n+                    or SCOPE_AI_AZURE_DEFAULT\n+                )\n+                print(f\"azure-foundry (Microsoft Entra ID):\")\n+                print(f\"  Endpoint: {_base_url or '(not configured)'}\")\n+                print(f\"  Scope: {_scope}\")\n+                if not has_azure_identity_installed():\n+                    print(\"  Status: ⚠ azure-identity not installed \"\n+                          \"(pip install azure-identity)\")\n+                else:\n+                    _entra_cfg = EntraIdentityConfig(\n+                        scope=_scope,\n+                    )\n+                    _info = describe_active_credential(config=_entra_cfg, timeout_seconds=10.0)\n+                    _env_sources = _info.get(\"env_sources\") or []\n+                    if _info.get(\"ok\"):\n+                        _tag = \", \".join(_env_sources) if _env_sources else \"default chain\"\n+                        print(f\"  Status: ✓ token acquired ({_tag})\")\n+                    else:\n+                        _err = _info.get(\"error\") or \"credential chain exhausted\"\n+                        print(f\"  Status: ⚠ {_err}\")\n+                        _hint = _info.get(\"hint\")\n+                        if _hint:\n+                            print(f\"  Hint: {_hint}\")\n+                print()\n+    except Exception:\n+        pass\n     print()\n \n     # Main menu"]},{"file":"hermes_cli/azure_detect.py","hunks":["@@ -1,6 +1,6 @@\n \"\"\"Azure Foundry endpoint auto-detection.\n \n-Inspect an Azure AI Foundry / Azure OpenAI endpoint to determine:\n+Inspect a Microsoft Foundry / Azure OpenAI endpoint to determine:\n   - API transport (OpenAI-style ``chat_completions`` vs\n     Anthropic-style ``anthropic_messages``)\n   - Available models (best effort — Azure does not expose a deployment","@@ -19,6 +19,16 @@\n still a useful hint — the user picks a familiar model name and we look\n up its context length from the catalog.\n \n+Authentication modes:\n+  - ``api_key`` (default): the wizard passes an ``api_key`` string; the\n+    probe sends both ``api-key:`` and ``Authorization: Bearer`` headers\n+    so we hit any Azure deployment regardless of which header it expects.\n+  - ``entra_id``: the wizard passes a ``token_provider`` callable from\n+    :mod:`agent.azure_identity_adapter`. The probe mints exactly one\n+    bearer JWT, sends **only** ``Authorization: Bearer <jwt>`` (never\n+    ``api-key:``), and never persists the token. This matches Microsoft's\n+    documented contract for keyless inference.\n+\n The detector never crashes on errors (every HTTP call is wrapped in a\n broad try/except).  Callers get a :class:`DetectionResult` with whatever\n information could be gathered, and fall back to manual entry for the","@@ -31,7 +41,7 @@\n import logging\n import re\n from dataclasses import dataclass, field\n-from typing import Optional\n+from typing import Any, Callable, Optional\n from urllib import request as urllib_request\n from urllib.error import HTTPError, URLError\n from urllib.parse import urlparse","@@ -79,15 +89,73 @@ class DetectionResult:\n     is_anthropic: bool = False\n \n \n-def _http_get_json(url: str, api_key: str, timeout: float = 6.0) -> tuple[int, Optional[dict]]:\n-    \"\"\"GET a URL with ``api-key`` + ``Authorization`` headers.  Return\n+def _resolve_credential(api_key: Any,\n+                        token_provider: [redacted:secret-assignment] str]] = None,\n+                        ) -> tuple[Optional[str], str]:\n+    \"\"\"Coerce wizard inputs into a (token, mode) pair.\n+\n+    Returns ``(token_or_None, mode)`` where ``mode`` is:\n+      - ``\"entra_id\"`` when a callable token provider was supplied — the\n+        returned token is a freshly minted bearer JWT, sent ONLY in\n+        ``Authorization: Bearer``.\n+      - ``\"api_key\"`` when a string key was supplied — the returned token\n+        is the raw API key, sent in BOTH ``api-key:`` and\n+        ``Authorization: Bearer`` headers (preserves the original\n+        broad-compat probe behaviour).\n+      - ``(\"\", \"api_key\")`` when neither yields a value.\n+\n+    Bearer minting failures degrade to ``(\"\", \"entra_id\")`` so the caller\n+    can still report \"detection incomplete\" rather than crashing.\n+    \"\"\"\n+    # Token-provider path (callable wins when both supplied).\n+    if token_provider is not None and callable(token_provider):\n+        try:\n+            token = token_provider()\n+            return (str(token) if token else None), \"entra_id\"\n+        except Exception as exc:\n+            logger.debug(\"azure_detect: token_provider failed: %s\", exc)\n+            return None, \"entra_id\"\n+    if callable(api_key) and not isinstance(api_key, str):\n+        try:\n+            token = api_key()\n+            return (str(token) if token else None), \"entra_id\"\n+        except Exception as exc:\n+            logger.debug(\"azure_detect: api_key callable failed: %s\", exc)\n+            return None, \"entra_id\"\n+    # API-key path.\n+    if isinstance(api_key, str) and api_key:\n+        return api_key, \"api_key\"\n+    return None, \"api_key\"\n+\n+\n+def _apply_auth_headers(req: urllib_request.Request,\n+                        token: [redacted:secret-assignment]\n+                        mode: str) -> None:\n+    \"\"\"Attach the right auth headers to ``req`` based on credential mode.\"\"\"\n+    if not token:\n+        return\n+    if mode == \"entra_id\":\n+        # Bearer-only: do NOT also set api-key, which would log a JWT in\n+        # a header slot intended for static keys.\n+        req.add_header(\"Authorization\", f\"Bearer {token}\")\n+    else:\n+        # Legacy broad-compat behaviour: send both headers so we land on\n+        # any Azure resource regardless of which it accepts.\n+        req.add_header(\"api-key\", token)\n+        req.add_header(\"Authorization\", f\"Bearer {token}\")\n+\n+\n+def _http_get_json(url: str,\n+                   api_key: Any,\n+                   timeout: float = 6.0,\n+                   *,\n+                   token_provider: [redacted:secret-assignment] str]] = None,\n+                   ) -> tuple[int, Optional[dict]]:\n+    \"\"\"GET a URL with the appropriate auth headers.  Return\n     ``(status_code, parsed_json_or_None)``.  Never raises.\"\"\"\n+    token, mode = _resolve_credential(api_key, token_provider)\n     req = urllib_request.Request(url, method=\"GET\")\n-    # Azure OpenAI uses ``api-key``.  Some Azure deployments (and\n-    # Anthropic-style routes) use ``Authorization: Bearer``.  Send both\n-    # so we probe once per URL rather than twice.\n-    req.add_header(\"api-key\", api_key)\n-    req.add_header(\"Authorization\", f\"Bearer {api_key}\")\n+    _apply_auth_headers(req, token, mode)\n     req.add_header(\"User-Agent\", \"hermes-agent/azure-detect\")\n     try:\n         with urllib_request.urlopen(req, timeout=timeout) as resp:","@@ -140,7 +208,11 @@ def _extract_model_ids(payload: dict) -> list[str]:\n     return ids\n \n \n-def _probe_openai_models(base_url: str, api_key: str) -> tuple[bool, list[str]]:\n+def _probe_openai_models(base_url: str,\n+                         api_key: Any,\n+                         *,\n+                         token_provider: [redacted:secret-assignment] str]] = None,\n+                         ) -> tuple[bool, list[str]]:\n     \"\"\"Probe ``<base>/models`` for an OpenAI-shaped response.\n \n     Returns ``(ok, models)``.  ``ok`` is True iff the endpoint accepted","@@ -156,7 +228,7 @@ def _probe_openai_models(base_url: str, api_key: str) -> tuple[bool, list[str]]:\n         candidates.append(f\"{base_url}/models?api-version={v}\")\n \n     for url in candidates:\n-        status, body = _http_get_json(url, api_key)\n+        status, body = _http_get_json(url, api_key, token_provider=[redacted:secret-assignment]\n         if status == 200 and body is not None:\n             ids = _extract_model_ids(body)\n             if ids:","@@ -172,7 +244,11 @@ def _probe_openai_models(base_url: str, api_key: str) -> tuple[bool, list[str]]:\n     return False, []\n \n \n-def _probe_anthropic_messages(base_url: str, api_key: str) -> bool:\n+def _probe_anthropic_messages(base_url: str,\n+                              api_key: Any,\n+                              *,\n+                              token_provider: [redacted:secret-assignment] str]] = None,\n+                              ) -> bool:\n     \"\"\"Send a zero-token request to ``<base>/v1/messages`` and check\n     whether the endpoint at least *recognises* the Anthropic Messages\n     shape (any 4xx that mentions ``messages`` or ``model``, or a 400","@@ -187,8 +263,8 @@ def _probe_anthropic_messages(base_url: str, api_key: str) -> bool:\n         \"messages\": [{\"role\": \"user\", \"content\": \"ping\"}],\n     }).encode(\"utf-8\")\n     req = urllib_request.Request(url, method=\"POST\", data=payload)\n-    req.add_header(\"api-key\", api_key)\n-    req.add_header(\"Authorization\", f\"Bearer {api_key}\")\n+    token, mode = _resolve_credential(api_key, token_provider)\n+    _apply_auth_headers(req, token, mode)\n     req.add_header(\"anthropic-version\", \"2023-06-01\")\n     req.add_header(\"content-type\", \"application/json\")\n     req.add_header(\"User-Agent\", \"hermes-agent/azure-detect\")","@@ -218,13 +294,23 @@ def _probe_anthropic_messages(base_url: str, api_key: str) -> bool:\n         return False\n \n \n-def detect(base_url: str, api_key: str) -> DetectionResult:\n+def detect(base_url: str,\n+           api_key: Any = \"\",\n+           *,\n+           token_provider: [redacted:secret-assignment] str]] = None,\n+           ) -> DetectionResult:\n     \"\"\"Inspect an Azure endpoint and describe its transport + models.\n \n     Call this from the wizard before asking the user to pick an API\n     mode manually.  The caller should treat the returned\n     :class:`DetectionResult` as *advisory* — if ``api_mode`` is None,\n     fall back to asking the user.\n+\n+    ``api_key`` may be a string (legacy API-key auth — sends both\n+    ``api-key:`` and ``Authorization: Bearer``) or a callable returning\n+    a bearer JWT (Entra ID auth — sends ONLY ``Authorization: Bearer``).\n+    ``token_provider`` is an alternative explicit name for the callable\n+    form; if both are supplied the callable wins.\n     \"\"\"\n     result = DetectionResult()\n ","@@ -244,7 +330,7 @@ def detect(base_url: str, api_key: str) -> DetectionResult:\n \n     # 2. Try the OpenAI-style /models probe.  If this works, the\n     #    endpoint definitely speaks OpenAI wire.\n-    ok, models = _probe_openai_models(base_url, api_key)\n+    ok, models = _probe_openai_models(base_url, api_key, token_provider=[redacted:secret-assignment]\n     if ok:\n         result.models_probe_ok = True\n         result.models = models","@@ -259,7 +345,7 @@ def detect(base_url: str, api_key: str) -> DetectionResult:\n     # 3. Fallback: probe the Anthropic Messages shape.  Slower and more\n     #    intrusive than /models, so only run it when the OpenAI probe\n     #    failed.\n-    if _probe_anthropic_messages(base_url, api_key):\n+    if _probe_anthropic_messages(base_url, api_key, token_provider=[redacted:secret-assignment]\n         result.is_anthropic = True\n         result.api_mode = \"anthropic_messages\"\n         result.reason = \"Endpoint accepts Anthropic Messages shape\"","@@ -273,11 +359,26 @@ def detect(base_url: str, api_key: str) -> DetectionResult:\n     return result\n \n \n-def lookup_context_length(model: str, base_url: str, api_key: str) -> Optional[int]:\n+def lookup_context_length(model: str,\n+                          base_url: str,\n+                          api_key: Any = \"\",\n+                          *,\n+                          token_provider: [redacted:secret-assignment] str]] = None,\n+                          ) -> Optional[int]:\n     \"\"\"Thin wrapper around :func:`agent.model_metadata.get_model_context_length`\n     that returns ``None`` when only the fallback default (128k) would\n     fire, so the wizard can distinguish \"we actually know this\" from\n-    \"we guessed.\"\"\"\n+    \"we guessed.\n+\n+    For Entra-ID mode pass a callable as ``api_key`` (or via\n+    ``token_provider=``); the wrapped resolver expects a string, so we\n+    mint one bearer JWT here for the single lookup. The resolver itself\n+    only reads catalog metadata over HTTP — no SDK client is built — so\n+    the minted token is consumed for at most one /models probe.\n+    \"\"\"\n+    model_id = str(model or \"\").strip()\n+    if not model_id:\n+        return None\n     try:\n         from agent.model_metadata import (\n             DEFAULT_FALLBACK_CONTEXT,","@@ -286,8 +387,13 @@ def lookup_context_length(model: str, base_url: str, api_key: str) -> Optional[i\n     except Exception:\n         return None\n \n+    # Resolve the credential once. For Entra mode this calls the token\n+    # provider; for legacy api_key this is a no-op string pass-through.\n+    token, mode = _resolve_credential(api_key, token_provider)\n+    effective_key = token or \"\"\n+\n     try:\n-        n = get_model_context_length(model, base_url=base_url, api_key=[redacted:secret-assignment]\n+        n = get_model_context_length(model_id, base_url=base_url, api_key=[redacted:secret-assignment]\n     except Exception as exc:\n         logger.debug(\"azure_detect: context length lookup failed: %s\", exc)\n         return None"]},{"file":"hermes_cli/doctor.py","hunks":["@@ -1613,6 +1613,87 @@ def _probe_bedrock() -> _ConnectivityResult:\n                  f\"bedrock:ListFoundationModels\"],\n             )\n \n+    def _probe_azure_entra() -> _ConnectivityResult:\n+        \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n+\n+        Skipped unless the active config has ``model.provider:\n+        azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n+        the token-service / CLI chain for users on plain API-key Azure.\n+\n+        Bounded by a 10s timeout (via\n+        :func:`agent.azure_identity_adapter.describe_active_credential`)\n+        so a slow token service can't pad the doctor run.\n+        \"\"\"\n+        label = \"Azure Foundry (Entra ID)\".ljust(28)\n+        try:\n+            from hermes_cli.config import load_config\n+            cfg = load_config()\n+            model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n+            if not isinstance(model_cfg, dict):\n+                return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n+            cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n+            auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n+            if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n+                return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n+        except Exception:\n+            return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n+\n+        try:\n+            from agent.azure_identity_adapter import (\n+                EntraIdentityConfig,\n+                SCOPE_AI_AZURE_DEFAULT,\n+                describe_active_credential,\n+                has_azure_identity_installed,\n+            )\n+        except Exception as exc:\n+            return _ConnectivityResult(\n+                \"Azure Foundry (Entra ID)\",\n+                [(color(\"⚠\", Colors.YELLOW), label,\n+                  color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n+                [f\"Azure Foundry adapter import failed: {exc}\"],\n+            )\n+\n+        if not has_azure_identity_installed():\n+            return _ConnectivityResult(\n+                \"Azure Foundry (Entra ID)\",\n+                [(color(\"⚠\", Colors.YELLOW), label,\n+                  color(\"(azure-identity not installed)\", Colors.DIM))],\n+                [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n+            )\n+\n+        base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n+        entra_cfg = model_cfg.get(\"entra\") or {}\n+        if not isinstance(entra_cfg, dict):\n+            entra_cfg = {}\n+        scope = (\n+            str(entra_cfg.get(\"scope\") or \"\").strip()\n+            or SCOPE_AI_AZURE_DEFAULT\n+        )\n+        config = EntraIdentityConfig(\n+            scope=scope,\n+        )\n+        info = describe_active_credential(config=config, timeout_seconds=10.0)\n+        if info.get(\"ok\"):\n+            env_sources = info.get(\"env_sources\") or []\n+            tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n+            return _ConnectivityResult(\n+                \"Azure Foundry (Entra ID)\",\n+                [(color(\"✓\", Colors.GREEN), label,\n+                  color(f\"({tag}, scope={scope})\", Colors.DIM))],\n+                [],\n+            )\n+        err = info.get(\"error\") or \"credential chain exhausted\"\n+        hint = info.get(\"hint\") or (\n+            \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n+            \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n+        )\n+        return _ConnectivityResult(\n+            \"Azure Foundry (Entra ID)\",\n+            [(color(\"⚠\", Colors.YELLOW), label,\n+              color(f\"({err})\", Colors.DIM))],\n+            [f\"Azure Foundry Entra: {err}. {hint}\"],\n+        )\n+\n     # Build the probe submission list in display order\n     _probes.append((\"OpenRouter API\", _probe_openrouter))\n     _probes.append((\"Anthropic API\", _probe_anthropic))","@@ -1630,6 +1711,7 @@ def _probe_bedrock() -> _ConnectivityResult:\n                                 _probe_apikey_provider(p, e, u, b, s)))\n \n     _probes.append((\"AWS Bedrock\", _probe_bedrock))\n+    _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n \n     # Print a single status line so users see something happening, then\n     # fan out. ``\\r`` clears it once the first real result line lands."]},{"file":"hermes_cli/main.py","hunks":["@@ -3535,11 +3535,27 @@ def _save_custom_provider(\n \n \n def _model_flow_azure_foundry(config, current_model=\"\"):\n-    \"\"\"Azure Foundry provider: configure endpoint, API mode, API key, and model.\n+    \"\"\"Azure Foundry provider: configure endpoint, auth mode, API mode, and model.\n \n     Azure Foundry supports both OpenAI-style (``/v1/chat/completions``) and\n-    Anthropic-style (``/v1/messages``) endpoints.  The wizard auto-detects\n-    the transport and available models when possible:\n+    Anthropic-style (``/v1/messages``) endpoints, and two authentication\n+    modes:\n+\n+    * **API key** (default) — uses ``AZURE_FOUNDRY_API_KEY`` from .env.\n+    * **Microsoft Entra ID** — keyless, RBAC-based auth via the\n+      ``azure-identity`` SDK (Managed Identity / Workload Identity / az\n+      login / VS Code / azd / service principal env vars). Works on both\n+      OpenAI-style and Anthropic-style endpoints — Microsoft RBAC is\n+      per-resource and the same ``Azure AI User`` role grants\n+      both. For OpenAI-style the OpenAI SDK's native callable\n+      ``api_key=`` contract is used; for Anthropic-style an\n+      ``httpx.Client`` with a request event hook (built by\n+      :func:`agent.azure_identity_adapter.build_bearer_http_client`)\n+      mints a fresh JWT per request because the Anthropic SDK does not\n+      accept a callable ``auth_token`` natively.\n+\n+    The wizard auto-detects the transport and available models when\n+    possible:\n \n     * URLs ending in ``/anthropic`` → Anthropic Messages API.\n     * Successful ``GET <base>/models`` probe → OpenAI-style + populates","@@ -3566,9 +3582,14 @@ def _model_flow_azure_foundry(config, current_model=\"\"):\n     if isinstance(model_cfg, dict) and model_cfg.get(\"provider\") == \"azure-foundry\":\n         current_base_url = str(model_cfg.get(\"base_url\", \"\") or \"\")\n         current_api_mode = str(model_cfg.get(\"api_mode\", \"\") or \"\")\n+        current_auth_mode = str(model_cfg.get(\"auth_mode\") or \"api_key\").strip().lower() or \"api_key\"\n+        _cur_entra = model_cfg.get(\"entra\") or {}\n+        current_entra = _cur_entra if isinstance(_cur_entra, dict) else {}\n     else:\n         current_base_url = \"\"\n         current_api_mode = \"\"\n+        current_auth_mode = \"api_key\"\n+        current_entra = {}\n \n     current_api_key = get_env_value(\"AZURE_FOUNDRY_API_KEY\") or \"\"\n ","@@ -3583,22 +3604,29 @@ def _model_flow_azure_foundry(config, current_model=\"\"):\n     print()\n \n     if current_base_url:\n-        print(f\"  Current endpoint: {current_base_url}\")\n+        print(f\"  Current endpoint:  {current_base_url}\")\n     if current_api_mode:\n         _lbl = (\n             \"OpenAI-style\"\n             if current_api_mode == \"chat_completions\"\n             else \"Anthropic-style\"\n         )\n-        print(f\"  Current API mode: {_lbl}\")\n-    if current_api_key:\n-        print(f\"  Current API key:  {current_api_key[:8]}...\")\n+        print(f\"  Current API mode:  {_lbl}\")\n+    if current_auth_mode == \"entra_id\":\n+        print(f\"  Current auth mode: Microsoft Entra ID (keyless)\")\n+    elif current_api_key:\n+        print(f\"  Current auth mode: API key ({current_api_key[:8]}...)\")\n     print()\n \n     # ── Step 1: endpoint URL ─────────────────────────────────────────\n     try:\n+        _placeholder = (\n+            current_base_url\n+            or \"e.g. https://<resource>.openai.azure.com/openai/v1 \"\n+              \"or https://<resource>.services.ai.azure.com/anthropic\"\n+        )\n         base_url = input(\n-            f\"API endpoint URL [{current_base_url or 'e.g. https://your-resource.openai.azure.com/openai/v1'}]: \"\n+            f\"API endpoint URL [{_placeholder}]: \"\n         ).strip()\n     except (KeyboardInterrupt, EOFError):\n         print(\"\\nCancelled.\")","@@ -3612,25 +3640,125 @@ def _model_flow_azure_foundry(config, current_model=\"\"):\n         print(f\"Invalid URL: {effective_url} (must start with http:// or https://)\")\n         return\n \n-    # ── Step 2: API key ──────────────────────────────────────────────\n+    # ── Step 2: authentication mode ──────────────────────────────────\n     print()\n+    print(\"Authentication:\")\n+    print(\"  1. API key                  (AZURE_FOUNDRY_API_KEY in .env)\")\n+    print(\"  2. Microsoft Entra ID       (managed identity / workload identity / az login)\")\n+    print(\"     Recommended by Microsoft. Works for both OpenAI-style and Anthropic-style endpoints.\")\n+    print(\"     Requires the 'Azure AI User' role on the Foundry resource.\")\n     try:\n-        api_key = getpass.getpass(\n-            f\"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: \"\n-        ).strip()\n+        _auth_default = \"2\" if current_auth_mode == \"entra_id\" else \"1\"\n+        auth_choice = (\n+            input(f\"Authentication mode [1/2] ({_auth_default}): \").strip()\n+            or _auth_default\n+        )\n     except (KeyboardInterrupt, EOFError):\n         print(\"\\nCancelled.\")\n         return\n+    use_entra = auth_choice == \"2\"\n+    auth_mode_label = \"entra_id\" if use_entra else \"api_key\"\n \n-    effective_key = api_key or current_api_key\n-    if not effective_key:\n-        print(\"No API key provided. Cancelled.\")\n-        return\n+    # ── Step 3: credentials (key OR Entra preflight) ─────────────────\n+    effective_key: str = \"\"\n+    entra_overrides: dict = {}\n+    token_provider = None  # callable when entra\n+    entra_scope = \"\"\n+\n+    if use_entra:\n+        try:\n+            from agent.azure_identity_adapter import (\n+                EntraIdentityConfig,\n+                SCOPE_AI_AZURE_DEFAULT,\n+                build_token_provider,\n+                describe_active_credential,\n+                has_azure_identity_installed,\n+            )\n+        except ImportError as exc:\n+            print()\n+            print(f\"⚠ Could not import azure-identity adapter: {exc}\")\n+            print(\"  Falling back to API key auth.\")\n+            use_entra = False\n+            auth_mode_label = \"api_key\"\n+\n+    if use_entra:\n+        print()\n+        if not has_azure_identity_installed():\n+            print(\"◐ The 'azure-identity' package is not installed yet.\")\n+            print(\n+                \"  Hermes will install it now (the preflight below \"\n+                \"triggers the lazy-install). To skip lazy installs, \"\n+                \"run:  pip install azure-identity\"\n+            )\n+\n+        # Preserve only the optional scope override. Identity selection\n+        # (tenant, user-assigned MI, workload identity, service principal)\n+        # stays in Azure SDK env vars such as AZURE_CLIENT_ID.\n+        _persisted_scope_override = str(current_entra.get(\"scope\") or \"\").strip()\n+        entra_scope = _persisted_scope_override or SCOPE_AI_AZURE_DEFAULT\n \n-    # ── Step 3: auto-detect transport + models ───────────────────────\n+        entra_overrides = {}\n+        if _persisted_scope_override:\n+            entra_overrides[\"scope\"] = _persisted_scope_override\n+\n+        print()\n+        print(\"◐ Probing Microsoft Entra ID credential chain (up to 10s)...\")\n+        _config = EntraIdentityConfig(\n+            scope=entra_scope,\n+        )\n+        info = describe_active_credential(config=_config, timeout_seconds=10.0)\n+        if info.get(\"ok\"):\n+            env_sources = info.get(\"env_sources\") or []\n+            tag = \", \".join(env_sources) if env_sources else \"default chain\"\n+            print(f\"✓ Entra ID token acquired ({tag}, scope={entra_scope})\")\n+        else:\n+            err = info.get(\"error\") or \"credential chain exhausted\"\n+            hint = info.get(\"hint\") or (\n+                \"Run `az login`, attach a managed identity to this VM, or \"\n+                \"set AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET.\"\n+            )\n+            print(f\"⚠ {err}\")\n+            print(f\"  Hint: {hint}\")\n+            try:\n+                ans = input(\"Save Entra config anyway and validate later? [Y/n]: \").strip().lower()\n+            except (KeyboardInterrupt, EOFError):\n+                print(\"\\nCancelled.\")\n+                return\n+            if ans and ans not in (\"y\", \"yes\"):\n+                print(\"Cancelled.\")\n+                return\n+\n+        # Build the token provider for the detection probe (best-effort —\n+        # if the credential chain failed above, this will silently return\n+        # None inside azure_detect and the probe falls back to manual).\n+        try:\n+            token_provider = [redacted:secret-assignment]\n+        except Exception as exc:\n+            print(f\"⚠ Could not build token provider for probing: {exc}\")\n+            token_provider = None\n+    else:\n+        print()\n+        try:\n+            api_key = getpass.getpass(\n+                f\"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: \"\n+            ).strip()\n+        except (KeyboardInterrupt, EOFError):\n+            print(\"\\nCancelled.\")\n+            return\n+\n+        effective_key = api_key or current_api_key\n+        if not effective_key:\n+            print(\"No API key provided. Cancelled.\")\n+            return\n+\n+    # ── Step 4: auto-detect transport + models ───────────────────────\n     print()\n     print(\"◐ Probing endpoint to auto-detect transport and models...\")\n-    detection = azure_detect.detect(effective_url, effective_key)\n+    detection = azure_detect.detect(\n+        effective_url,\n+        api_key=[redacted:secret-assignment]\n+        token_provider=[redacted:secret-assignment]\n+    )\n \n     discovered_models: list[str] = list(detection.models)\n     api_mode: str = detection.api_mode or \"\"","@@ -3665,7 +3793,7 @@ def _model_flow_azure_foundry(config, current_model=\"\"):\n             return\n         api_mode = \"anthropic_messages\" if mode_choice == \"2\" else \"chat_completions\"\n \n-    # ── Step 4: model name ───────────────────────────────────────────\n+    # ── Step 5: model name ───────────────────────────────────────────\n     print()\n     effective_model = \"\"\n     if discovered_models:","@@ -3704,15 +3832,17 @@ def _model_flow_azure_foundry(config, current_model=\"\"):\n         print(\"No model name provided. Cancelled.\")\n         return\n \n-    # ── Step 5: context-length lookup ────────────────────────────────\n+    # ── Step 6: context-length lookup ────────────────────────────────\n     ctx_len = azure_detect.lookup_context_length(\n         effective_model,\n         effective_url,\n-        effective_key,\n+        api_key=[redacted:secret-assignment]\n+        token_provider=[redacted:secret-assignment]\n     )\n \n-    # ── Step 6: persist ──────────────────────────────────────────────\n-    save_env_value(\"AZURE_FOUNDRY_API_KEY\", effective_key)\n+    # ── Step 7: persist ──────────────────────────────────────────────\n+    if not use_entra:\n+        save_env_value(\"AZURE_FOUNDRY_API_KEY\", effective_key)\n \n     cfg = load_config()\n     model = cfg.get(\"model\")","@@ -3724,6 +3854,22 @@ def _model_flow_azure_foundry(config, current_model=\"\"):\n     model[\"base_url\"] = effective_url\n     model[\"api_mode\"] = api_mode\n     model[\"default\"] = effective_model\n+    model[\"auth_mode\"] = auth_mode_label\n+    if use_entra:\n+        # Persist only the non-default Entra scope so config.yaml stays tidy.\n+        # Azure identity selection stays in standard AZURE_* env vars.\n+        clean_entra: dict = {}\n+        for key in (\"scope\",):\n+            val = entra_overrides.get(key)\n+            if val:\n+                clean_entra[key] = val\n+        if clean_entra:\n+            model[\"entra\"] = clean_entra\n+        elif \"entra\" in model:\n+            del model[\"entra\"]\n+    else:\n+        if \"entra\" in model:\n+            del model[\"entra\"]\n     if ctx_len:\n         model[\"context_length\"] = ctx_len\n ","@@ -3739,10 +3885,14 @@ def _model_flow_azure_foundry(config, current_model=\"\"):\n         save_env_value(\"OPENAI_API_KEY\", \"\")\n \n     mode_label = \"OpenAI-style\" if api_mode == \"chat_completions\" else \"Anthropic-style\"\n+    auth_label = (\n+        \"Microsoft Entra ID (keyless)\" if use_entra else \"API key\"\n+    )\n     print()\n     print(\"✓ Azure Foundry configured:\")\n     print(f\"    Endpoint:       {effective_url}\")\n     print(f\"    API mode:       {mode_label}\")\n+    print(f\"    Auth:           {auth_label}\")\n     print(f\"    Model:          {effective_model}\")\n     if ctx_len:\n         print(f\"    Context length: {ctx_len:,} tokens\")"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -744,6 +744,15 @@ def _resolve_azure_foundry_runtime(\n     strips a trailing ``/v1`` for Anthropic-style endpoints because the\n     Anthropic SDK appends ``/v1/messages`` internally.\n \n+    When ``model.auth_mode == \"entra_id\"`` (and the model is OpenAI-style),\n+    the returned ``api_key`` is a zero-arg callable produced by\n+    :func:`agent.azure_identity_adapter.build_token_provider` rather than\n+    a string. Downstream code that constructs an OpenAI SDK client passes\n+    this through unchanged (the SDK accepts ``Callable[[], str]`` for\n+    ``api_key`` and calls it before every request). Code paths that need\n+    a string (logging, manual HTTP probes, header injection) must use the\n+    helpers in ``agent.azure_identity_adapter``.\n+\n     Raises :class:`AuthError` when required values are missing.\n     \"\"\"\n     explicit_api_key = [redacted:secret-assignment] or \"\").strip()","@@ -752,9 +761,15 @@ def _resolve_azure_foundry_runtime(\n     cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n     cfg_base_url = \"\"\n     cfg_api_mode = \"chat_completions\"\n+    cfg_auth_mode = \"api_key\"\n+    cfg_entra: Dict[str, Any] = {}\n     if cfg_provider == \"azure-foundry\":\n         cfg_base_url = str(model_cfg.get(\"base_url\") or \"\").strip().rstrip(\"/\")\n         cfg_api_mode = _parse_api_mode(model_cfg.get(\"api_mode\")) or \"chat_completions\"\n+        cfg_auth_mode = str(model_cfg.get(\"auth_mode\") or \"api_key\").strip().lower() or \"api_key\"\n+        _entra = model_cfg.get(\"entra\")\n+        if isinstance(_entra, dict):\n+            cfg_entra = _entra\n \n     # Model-family inference: Azure Foundry deploys GPT-5.x / codex / o1-o4\n     # reasoning models as Responses-API-only.  Calling /chat/completions","@@ -780,6 +795,79 @@ def _resolve_azure_foundry_runtime(\n             \"the AZURE_FOUNDRY_BASE_URL environment variable.\"\n         )\n \n+    # Anthropic SDK appends /v1/messages itself, so strip any trailing /v1\n+    # we inherited from the configured base_url to avoid double-/v1 paths.\n+    if cfg_api_mode == \"anthropic_messages\":\n+        base_url = re.sub(r\"/v1/?$\", \"\", base_url)\n+\n+    # ── Entra ID (Microsoft Foundry recommended path) ──────────────────\n+    #\n+    # OpenAI-style endpoints use the OpenAI SDK's native callable\n+    # ``api_key=`` contract — the SDK mints a fresh JWT per request\n+    # automatically.\n+    #\n+    # Anthropic-style endpoints (Claude on Foundry) take the callable\n+    # too: :func:`agent.anthropic_adapter.build_anthropic_client`\n+    # detects the callable and constructs an ``httpx.Client`` with a\n+    # request event hook that injects a fresh ``Authorization: Bearer``\n+    # header per request (the Anthropic SDK does not accept callables\n+    # natively). From the runtime resolver's perspective both modes\n+    # are identical — return the callable api_key and let the\n+    # downstream SDK wrapper handle the contract difference.\n+    if cfg_auth_mode == \"entra_id\":\n+        if explicit_api_key:\n+            # User passed --api-key on the CLI while config says entra_id —\n+            # honour the explicit string (escape hatch for one-off testing).\n+            api_key: Any = explicit_api_key\n+            source = \"explicit\"\n+            auth_mode = \"api_key\"\n+        else:\n+            try:\n+                from agent.azure_identity_adapter import (\n+                    EntraIdentityConfig,\n+                    SCOPE_AI_AZURE_DEFAULT,\n+                    build_token_provider,\n+                )\n+            except Exception as exc:\n+                raise AuthError(\n+                    \"Azure Foundry Entra ID auth requires the 'azure-identity' \"\n+                    \"package. Install it with: pip install azure-identity \"\n+                    f\"(import failed: {exc})\"\n+                ) from exc\n+\n+            scope = (\n+                str(cfg_entra.get(\"scope\") or \"\").strip()\n+                or SCOPE_AI_AZURE_DEFAULT\n+            )\n+            try:\n+                entra_config = EntraIdentityConfig(\n+                    scope=scope,\n+                )\n+                token_provider = [redacted:secret-assignment]\n+            except ImportError as exc:\n+                raise AuthError(str(exc)) from exc\n+            api_key = [redacted:secret-assignment]\n+            source = \"entra_id\"\n+            auth_mode = \"entra_id\"\n+\n+        clean_entra = {}\n+        if auth_mode == \"entra_id\":\n+            configured_scope = str(cfg_entra.get(\"scope\") or \"\").strip()\n+            if configured_scope:\n+                clean_entra[\"scope\"] = configured_scope\n+\n+        return {\n+            \"provider\": \"azure-foundry\",\n+            \"api_mode\": cfg_api_mode,\n+            \"base_url\": base_url,\n+            \"api_key\": [redacted:secret-assignment]\n+            \"auth_mode\": auth_mode,\n+            \"entra\": clean_entra,\n+            \"source\": source,\n+            \"requested_provider\": requested_provider,\n+        }\n+\n+    # ── Static API key (legacy / default) ──────────────────────────────\n     api_key = [redacted:secret-assignment]\n     if not api_key:\n         try:","@@ -792,20 +880,19 @@ def _resolve_azure_foundry_runtime(\n     if not api_key:\n         raise AuthError(\n             \"Azure Foundry requires an API key. Set AZURE_FOUNDRY_API_KEY in \"\n-            \"~/.hermes/.env or run 'hermes model' to configure.\"\n+            \"~/.hermes/.env or run 'hermes model' to configure. To use \"\n+            \"keyless Microsoft Entra ID auth instead, set \"\n+            \"model.auth_mode: entra_id in config.yaml (or pick \"\n+            \"'Microsoft Entra ID' in 'hermes model').\"\n         )\n \n-    # Anthropic SDK appends /v1/messages itself, so strip any trailing /v1\n-    # we inherited from the configured base_url to avoid double-/v1 paths.\n-    if cfg_api_mode == \"anthropic_messages\":\n-        base_url = re.sub(r\"/v1/?$\", \"\", base_url)\n-\n     source = \"explicit\" if (explicit_api_key or explicit_base_url) else \"config\"\n     return {\n         \"provider\": \"azure-foundry\",\n         \"api_mode\": cfg_api_mode,\n         \"base_url\": base_url,\n         \"api_key\": [redacted:secret-assignment]\n+        \"auth_mode\": \"api_key\",\n         \"source\": source,\n         \"requested_provider\": requested_provider,\n     }","@@ -1232,7 +1319,7 @@ def resolve_runtime_provider(\n             cfg_base_url = (model_cfg.get(\"base_url\") or \"\").strip().rstrip(\"/\")\n         base_url = cfg_base_url or \"https://api.anthropic.com\"\n \n-        # For Azure AI Foundry endpoints, use ANTHROPIC_API_KEY directly —\n+        # For Microsoft Foundry endpoints, use ANTHROPIC_API_KEY directly —\n         # Claude Code OAuth tokens (sk-ant-oat01) are not accepted by Azure.\n         # Azure keys don't start with \"sk-ant-\" so resolve_anthropic_token()\n         # would find the Claude Code OAuth token first (priority 3) and return"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -1288,9 +1288,15 @@ def _truncate_token(value: Optional[str], visible: int = 6) -> str:\n     OAuth access token. JWT prefixes (the part before the first dot) are\n     stripped first when present so the visible suffix is always part of\n     the signing region rather than a meaningless header chunk.\n+\n+    Returns the Entra-ID placeholder when handed a callable (Azure Foundry\n+    bearer provider) — the callable is NEVER invoked here.\n     \"\"\"\n     if not value:\n         return \"\"\n+    if callable(value) and not isinstance(value, str):\n+        # Entra ID bearer provider — never reveal a minted token in the UI.\n+        return \"<entra-id-bearer>\"\n     s = str(value)\n     if \".\" in s and s.count(\".\") >= 2:\n         # Looks like a JWT — show the trailing piece of the signature only."]},{"file":"plugins/model-providers/azure-foundry/__init__.py","hunks":["@@ -1,4 +1,4 @@\n-\"\"\"Azure AI Foundry provider profile.\n+\"\"\"Microsoft Foundry provider profile.\n \n Azure Foundry exposes an OpenAI-compatible endpoint; users supply their own\n base URL at setup since endpoints are per-resource.","@@ -11,7 +11,7 @@\n     name=\"azure-foundry\",\n     aliases=(\"azure\", \"azure-ai-foundry\", \"azure-ai\"),\n     display_name=\"Azure Foundry\",\n-    description=\"Azure AI Foundry — OpenAI-compatible endpoint (user-supplied base URL)\",\n+    description=\"Microsoft Foundry - OpenAI-compatible endpoint (user-supplied base URL)\",\n     signup_url=\"https://ai.azure.com/\",\n     env_vars=(\"AZURE_FOUNDRY_API_KEY\", \"AZURE_FOUNDRY_BASE_URL\"),\n     base_url=\"\",  # per-resource; user provides at setup"]},{"file":"plugins/model-providers/azure-foundry/plugin.yaml","hunks":["@@ -1,5 +1,5 @@\n name: azure-foundry-provider\n kind: model-provider\n version: 1.0.0\n-description: Azure AI Foundry\n+description: Microsoft Foundry\n author: Nous Research"]},{"file":"pyproject.toml","hunks":["@@ -125,6 +125,7 @@ acp = [\"agent-client-protocol==0.9.0\"]\n #   4. Run `uv lock` to regenerate transitives.\n #   5. Optionally re-add to [all] only after a few days of clean operation.\n bedrock = [\"boto3==1.42.89\"]\n+azure-identity = [\"azure-identity==1.25.3\"]\n termux = [\n   # Baseline Android / Termux path for reliable fresh installs.\n   \"python-telegram-bot[webhooks]==22.6\","]},{"file":"run_agent.py","hunks":["@@ -1428,7 +1428,11 @@ def _summarize_api_error(error: Exception) -> str:\n         prefix = f\"HTTP {status_code}: \" if status_code else \"\"\n         return f\"{prefix}{raw[:500]}\"\n \n-    def _mask_api_key_for_logs(self, key: Optional[str]) -> Optional[str]:\n+    def _mask_api_key_for_logs(self, key: Any) -> Optional[str]:\n+        # Azure Foundry Entra ID bearer providers are callables — never\n+        # invoke them in log paths; identify the auth surface instead.\n+        if callable(key) and not isinstance(key, str):\n+            return \"<entra-id-bearer>\"\n         if not key:\n             return None\n         if len(key) <= 12:"]},{"file":"scripts/release.py","hunks":["@@ -640,6 +640,7 @@\n     \"[redacted:email]\": \"geoffwellman\",\n     \"[redacted:email]\": \"jamesarch\",\n     \"[redacted:email]\": \"LongOddCode\",\n+    \"[redacted:email]\": \"glennc\",\n     \"[redacted:email]\": \"hata1234\",\n     \"[redacted:email]\": \"Hmbown\",\n     \"[redacted:email]\": \"m0n5t3r\","]},{"file":"tests/acp_adapter/test_detect_provider_entra.py","hunks":["@@ -0,0 +1,87 @@\n+\"\"\"Regression tests for ACP adapter detection under Azure Foundry Entra ID.\n+\n+The ACP adapter's ``detect_provider`` previously gated on\n+``isinstance(api_key, str)`` and returned ``None`` for any runtime that\n+returned a callable ``api_key`` — i.e. Azure Foundry with\n+``auth_mode=entra_id``. Downstream, ACP would default to\n+``\"openrouter\"`` and reject the legitimate provider in its auth handshake.\n+This test pins the callable-aware fix so it never regresses.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import patch\n+\n+\n+class TestDetectProviderEntra:\n+    def test_callable_api_key_is_a_valid_credential(self):\n+        \"\"\"A runtime returning a callable ``api_key`` (Entra bearer token\n+        provider) must be detected as a configured provider, not\n+        ``None``.\"\"\"\n+        from acp_adapter import auth as _acp_auth\n+\n+        def _fake_runtime(**_kwargs):\n+            return {\n+                \"provider\": \"azure-foundry\",\n+                \"api_mode\": \"chat_completions\",\n+                \"auth_mode\": \"entra_id\",\n+                \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                \"api_key\": [redacted:secret-assignment] \"jwt-fresh\",\n+            }\n+\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            side_effect=_fake_runtime,\n+        ):\n+            assert _acp_auth.detect_provider() == \"azure-foundry\"\n+            assert _acp_auth.has_provider() is True\n+\n+    def test_string_api_key_still_works(self):\n+        from acp_adapter import auth as _acp_auth\n+\n+        def _fake_runtime(**_kwargs):\n+            return {\n+                \"provider\": \"openrouter\",\n+                \"api_key\": \"[redacted:secret-assignment]\",\n+            }\n+\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            side_effect=_fake_runtime,\n+        ):\n+            assert _acp_auth.detect_provider() == \"openrouter\"\n+\n+    def test_empty_string_api_key_returns_none(self):\n+        from acp_adapter import auth as _acp_auth\n+\n+        def _fake_runtime(**_kwargs):\n+            return {\"provider\": \"openrouter\", \"api_key\": \"\"}\n+\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            side_effect=_fake_runtime,\n+        ):\n+            assert _acp_auth.detect_provider() is None\n+\n+    def test_missing_provider_returns_none(self):\n+        \"\"\"A callable api_key without a provider is still ``None`` —\n+        we don't synthesize a provider name from the credential shape.\"\"\"\n+        from acp_adapter import auth as _acp_auth\n+\n+        def _fake_runtime(**_kwargs):\n+            return {\"api_key\": [redacted:secret-assignment] \"jwt-fresh\", \"provider\": \"\"}\n+\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            side_effect=_fake_runtime,\n+        ):\n+            assert _acp_auth.detect_provider() is None\n+\n+    def test_resolver_exception_returns_none(self):\n+        from acp_adapter import auth as _acp_auth\n+\n+        with patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            side_effect=RuntimeError(\"simulated\"),\n+        ):\n+            assert _acp_auth.detect_provider() is None"]},{"file":"tests/agent/test_anthropic_adapter.py","hunks":["@@ -9,6 +9,7 @@\n \n from agent.prompt_caching import apply_anthropic_cache_control\n from agent.anthropic_adapter import (\n+    _is_azure_anthropic_endpoint,\n     _is_oauth_token,\n     _refresh_oauth_token,\n     _to_plain_data,","@@ -121,6 +122,20 @@ def test_azure_anthropic_endpoint_keeps_context_1m_beta(self):\n             betas = kwargs[\"default_headers\"][\"anthropic-beta\"]\n             assert \"context-1m-2025-08-07\" in betas\n \n+    def test_azure_anthropic_endpoint_detection_is_host_and_path_scoped(self):\n+        assert _is_azure_anthropic_endpoint(\n+            \"https://example.services.ai.azure.com/models/anthropic\"\n+        ) is True\n+        assert _is_azure_anthropic_endpoint(\n+            \"https://example.services.ai.azure.us/anthropic\"\n+        ) is True\n+        assert _is_azure_anthropic_endpoint(\n+            \"https://example.openai.azure.com/openai/v1\"\n+        ) is False\n+        assert _is_azure_anthropic_endpoint(\n+            \"https://management.azure.com/anthropic\"\n+        ) is False\n+\n     def test_bedrock_client_keeps_context_1m_beta(self):\n         with patch(\"agent.anthropic_adapter._anthropic_sdk\") as mock_sdk:\n             mock_sdk.AnthropicBedrock = MagicMock()"]},{"file":"tests/agent/test_auxiliary_client_azure_foundry.py","hunks":["@@ -0,0 +1,350 @@\n+\"\"\"Tests for auxiliary client routing of the ``azure-foundry`` provider.\n+\n+Covers the dedicated branch in ``agent.auxiliary_client.resolve_provider_client``\n+that delegates to :func:`hermes_cli.runtime_provider._resolve_azure_foundry_runtime`\n+instead of falling into the generic ``resolve_api_key_provider_credentials``\n+path (which only knows about ``AZURE_FOUNDRY_API_KEY`` and would 401 for\n+Entra ID users and miss ``model.base_url`` overrides for api-key users\n+with non-standard Foundry-projects endpoints).\n+\n+Pinned scenarios:\n+\n+  * ``auth_mode: api_key`` → plain OpenAI client with the static string\n+    key for ``chat_completions``.\n+  * ``auth_mode: entra_id`` + ``chat_completions`` → plain OpenAI\n+    client with a callable ``api_key`` (the bearer-token provider) —\n+    confirms the callable survives the auxiliary path end-to-end.\n+  * ``auth_mode: entra_id`` + GPT-5.x model → CodexAuxiliaryClient\n+    wrapping the OpenAI client (api_mode auto-upgrades to\n+    codex_responses).\n+  * Anthropic-style + entra_id → rejected at the runtime resolver,\n+    so the aux path returns ``(None, None)``.\n+  * Failure path when no model is configured returns ``(None, None)``\n+    cleanly so the auto chain falls through.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import sys\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n[redacted:email](autouse=True)\n+def _reset_credential_cache():\n+    from agent.azure_identity_adapter import reset_credential_cache\n+    reset_credential_cache()\n+    yield\n+    reset_credential_cache()\n+\n+\n[redacted:email]\n+def fake_azure_identity(monkeypatch):\n+    \"\"\"Stand-in for azure.identity (keeps CI hermetic when the SDK is\n+    not installed).\"\"\"\n+    from agent import azure_identity_adapter as _adapter\n+\n+    last = {\"scope\": None}\n+\n+    def _provider(scope):\n+        return lambda: f\"jwt-for-{scope}\"\n+\n+    fake_module = SimpleNamespace(\n+        DefaultAzureCredential=lambda **kw: SimpleNamespace(\n+            kwargs=kw,\n+            get_token=lambda scope: SimpleNamespace(token=\"fake\", expires_on=9999999999),\n+        ),\n+        get_bearer_token_provider=lambda credential, scope: (\n+            last.__setitem__(\"scope\", scope),\n+            _provider(scope),\n+        )[-1],\n+    )\n+    monkeypatch.setattr(_adapter, \"_require_azure_identity\", lambda: fake_module)\n+    monkeypatch.setitem(sys.modules, \"azure.identity\", fake_module)\n+    return last\n+\n+\n[redacted:email]\n+def patch_load_config(monkeypatch):\n+    \"\"\"Helper to set model_cfg seen by _try_azure_foundry.\"\"\"\n+    def _apply(model_cfg):\n+        monkeypatch.setattr(\n+            \"hermes_cli.config.load_config\",\n+            lambda: {\"model\": model_cfg},\n+        )\n+    return _apply\n+\n+\n+# ---------------------------------------------------------------------------\n+# auth_mode: api_key (default) — regression for the legacy path\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestAuxAzureFoundryApiKey:\n+    def test_chat_completions_returns_plain_openai_client(self, monkeypatch, patch_load_config):\n+        from agent.auxiliary_client import _try_azure_foundry\n+        from openai import OpenAI as _OpenAI\n+\n+        monkeypatch.setenv(\"AZURE_FOUNDRY_API_KEY\", \"sk-azure-static-key\")\n+        patch_load_config({\n+            \"provider\": \"azure-foundry\",\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+            \"api_mode\": \"chat_completions\",\n+            \"default\": \"gpt-4o\",\n+        })\n+        client, resolved = _try_azure_foundry(model=\"gpt-4o\")\n+        assert client is not None\n+        assert resolved == \"gpt-4o\"\n+        assert isinstance(client, _OpenAI)\n+        assert client.api_key == \"sk-azure-static-key\"\n+\n+    def test_codex_responses_wraps_in_codex_aux_client(self, monkeypatch, patch_load_config):\n+        from agent.auxiliary_client import _try_azure_foundry, CodexAuxiliaryClient\n+\n+        monkeypatch.setenv(\"AZURE_FOUNDRY_API_KEY\", \"sk-azure-static-key\")\n+        patch_load_config({\n+            \"provider\": \"azure-foundry\",\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+            \"api_mode\": \"chat_completions\",\n+            \"default\": \"gpt-5.4-mini\",\n+        })\n+        # GPT-5.x → runtime auto-upgrades to codex_responses\n+        client, resolved = _try_azure_foundry(model=\"gpt-5.4-mini\")\n+        assert resolved == \"gpt-5.4-mini\"\n+        assert isinstance(client, CodexAuxiliaryClient)\n+        assert client.api_key == \"sk-azure-static-key\"\n+\n+    def test_no_key_returns_none(self, monkeypatch, patch_load_config):\n+        from agent.auxiliary_client import _try_azure_foundry\n+\n+        monkeypatch.delenv(\"AZURE_FOUNDRY_API_KEY\", raising=False)\n+        patch_load_config({\n+            \"provider\": \"azure-foundry\",\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+            \"api_mode\": \"chat_completions\",\n+            \"default\": \"gpt-4o\",\n+        })\n+        client, resolved = _try_azure_foundry(model=\"gpt-4o\")\n+        assert client is None\n+        assert resolved is None\n+\n+    def test_no_model_returns_none(self, monkeypatch, patch_load_config):\n+        \"\"\"Azure has no fallback aux model — fail soft so the auto chain\n+        can try other providers.\"\"\"\n+        from agent.auxiliary_client import _try_azure_foundry\n+\n+        monkeypatch.setenv(\"AZURE_FOUNDRY_API_KEY\", \"sk-azure-static-key\")\n+        patch_load_config({\n+            \"provider\": \"azure-foundry\",\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+            \"api_mode\": \"chat_completions\",\n+            # No default model\n+        })\n+        client, resolved = _try_azure_foundry()\n+        assert client is None\n+        assert resolved is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# auth_mode: entra_id — callable api_key survives end-to-end\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestAuxAzureFoundryEntra:\n+    def test_callable_api_key_reaches_openai_constructor(\n+        self, monkeypatch, fake_azure_identity, patch_load_config,\n+    ):\n+        \"\"\"The token provider callable must arrive at ``OpenAI(api_key=[redacted:secret-assignment]\n+        intact — never stringified to ``\"no-key-required\"`` or to the\n+        SDK-internal empty-string representation BEFORE we hand it off.\n+\n+        We assert on the public SDK contract (constructor receives the\n+        callable) rather than ``client.api_key``, because OpenAI 2.24.0\n+        stores callable api_keys in a private attribute and exposes\n+        ``client.api_key`` as ``\"\"``. The SDK still calls the callable\n+        per request to mint ``Authorization: Bearer <token>``; that\n+        behaviour is the documented Microsoft/OpenAI contract we rely on.\n+        \"\"\"\n+        from agent import auxiliary_client as _aux\n+\n+        received = {}\n+\n+        class _FakeOpenAI:\n+            def __init__(self, **kwargs):\n+                received.update(kwargs)\n+                # Mirror the fields downstream callers read.\n+                self.api_key = kwargs.get(\"api_key\", \"\")\n+                self.base_url = kwargs.get(\"base_url\", \"\")\n+\n+        monkeypatch.setattr(_aux, \"OpenAI\", _FakeOpenAI)\n+        patch_load_config({\n+            \"provider\": \"azure-foundry\",\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+            \"api_mode\": \"chat_completions\",\n+            \"auth_mode\": \"entra_id\",\n+            \"default\": \"gpt-4o\",\n+        })\n+        client, resolved = _aux._try_azure_foundry(model=\"gpt-4o\")\n+        assert client is not None\n+        assert resolved == \"gpt-4o\"\n+        # Public-contract assertion: the OpenAI SDK constructor saw the\n+        # callable, exactly as Microsoft's Foundry sample requires.\n+        assert callable(received[\"api_key\"])\n+        assert not isinstance(received[\"api_key\"], str)\n+        assert received[\"api_key\"]().startswith(\"jwt-for-\")\n+        # Base URL forwarded verbatim (no /responses suffix stripping\n+        # in this path — that's a separate concern handled by the\n+        # runtime resolver only when the user re-saves config).\n+        assert received[\"base_url\"] == \"https://r.openai.azure.com/openai/v1\"\n+\n+    def test_codex_responses_with_entra_wraps_correctly(\n+        self, monkeypatch, fake_azure_identity, patch_load_config,\n+    ):\n+        \"\"\"GPT-5.x deployment on Entra ID — auto-upgraded to\n+        codex_responses, wrapped in CodexAuxiliaryClient, callable\n+        api_key handed to the underlying OpenAI SDK.\"\"\"\n+        from agent import auxiliary_client as _aux\n+\n+        received = {}\n+\n+        class _FakeOpenAI:\n+            def __init__(self, **kwargs):\n+                received.update(kwargs)\n+                self.api_key = kwargs.get(\"api_key\", \"\")\n+                self.base_url = kwargs.get(\"base_url\", \"\")\n+\n+        monkeypatch.setattr(_aux, \"OpenAI\", _FakeOpenAI)\n+        patch_load_config({\n+            \"provider\": \"azure-foundry\",\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+            \"api_mode\": \"chat_completions\",\n+            \"auth_mode\": \"entra_id\",\n+            \"default\": \"gpt-5.4-mini\",\n+        })\n+        client, resolved = _aux._try_azure_foundry(model=\"gpt-5.4-mini\")\n+        assert resolved == \"gpt-5.4-mini\"\n+        assert isinstance(client, _aux.CodexAuxiliaryClient)\n+        # The Codex wrapper received an OpenAI client built with the\n+        # callable api_key — verify against the SDK constructor record,\n+        # not the wrapper attribute (which mirrors the SDK's empty-\n+        # string representation).\n+        assert callable(received[\"api_key\"])\n+        assert received[\"api_key\"]().startswith(\"jwt-for-\")\n+\n+    def test_entra_anthropic_messages_uses_bearer_hook(\n+        self, monkeypatch, fake_azure_identity, patch_load_config,\n+    ):\n+        \"\"\"Entra ID + anthropic_messages: runtime returns a callable\n+        api_key; ``_maybe_wrap_anthropic`` → ``build_anthropic_client``\n+        detects the callable and installs the bearer-injecting httpx\n+        event hook on a custom ``httpx.Client`` passed to the\n+        Anthropic SDK via ``http_client=``.\"\"\"\n+        from agent import auxiliary_client as _aux\n+        from agent import anthropic_adapter as _anthropic\n+\n+        received = {}\n+\n+        class _FakeOpenAI:\n+            def __init__(self, **kwargs):\n+                received[\"openai\"] = kwargs\n+                self.api_key = kwargs.get(\"api_key\", \"\")\n+                self.base_url = kwargs.get(\"base_url\", \"\")\n+\n+        class _FakeAnthropicSDK:\n+            class Anthropic:\n+                def __init__(self, **kwargs):\n+                    received[\"anthropic\"] = kwargs\n+\n+        monkeypatch.setattr(_aux, \"OpenAI\", _FakeOpenAI)\n+        monkeypatch.setattr(_anthropic, \"_get_anthropic_sdk\", lambda: _FakeAnthropicSDK)\n+\n+        patch_load_config({\n+            \"provider\": \"azure-foundry\",\n+            \"base_url\": \"https://r.services.ai.azure.com/anthropic\",\n+            \"api_mode\": \"anthropic_messages\",\n+            \"auth_mode\": \"entra_id\",\n+            \"default\": \"claude-sonnet-4-5\",\n+        })\n+        client, resolved = _aux._try_azure_foundry(model=\"claude-sonnet-4-5\")\n+        assert client is not None\n+        assert resolved == \"claude-sonnet-4-5\"\n+        # The Anthropic SDK constructor received a custom http_client\n+        # (the bearer-injecting hook) and a placeholder auth_token.\n+        anthropic_kwargs = received.get(\"anthropic\") or {}\n+        assert \"http_client\" in anthropic_kwargs, (\n+            \"build_anthropic_client must pass a custom http_client when \"\n+            \"given a callable api_key, otherwise the SDK cannot mint \"\n+            \"fresh tokens per request\"\n+        )\n+        assert anthropic_kwargs.get(\"auth_token\") == \"entra-id-bearer-via-http-hook\"\n+        # Verify the http_client actually has our event hook installed.\n+        http_client = anthropic_kwargs[\"http_client\"]\n+        hooks = getattr(http_client, \"event_hooks\", {})\n+        assert \"request\" in hooks and len(hooks[\"request\"]) >= 1\n+\n+\n+# ---------------------------------------------------------------------------\n+# resolve_provider_client → azure-foundry dispatch\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestResolveProviderClientAzureFoundry:\n+    def test_dispatches_to_azure_branch_not_generic_api_key_path(\n+        self, monkeypatch, fake_azure_identity, patch_load_config,\n+    ):\n+        \"\"\"End-to-end: the public ``resolve_provider_client`` entry\n+        point must take the dedicated azure-foundry branch, NOT the\n+        generic api-key registry path that would call\n+        ``resolve_api_key_provider_credentials`` and return None for\n+        Entra users.\"\"\"\n+        from agent import auxiliary_client as _aux\n+\n+        received = {}\n+\n+        class _FakeOpenAI:\n+            def __init__(self, **kwargs):\n+                received.update(kwargs)\n+                self.api_key = kwargs.get(\"api_key\", \"\")\n+                self.base_url = kwargs.get(\"base_url\", \"\")\n+\n+        monkeypatch.setattr(_aux, \"OpenAI\", _FakeOpenAI)\n+        patch_load_config({\n+            \"provider\": \"azure-foundry\",\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+            \"api_mode\": \"chat_completions\",\n+            \"auth_mode\": \"entra_id\",\n+            \"default\": \"gpt-4o\",\n+        })\n+        client, resolved = _aux.resolve_provider_client(\"azure-foundry\", \"gpt-4o\")\n+        assert client is not None\n+        assert resolved == \"gpt-4o\"\n+        # The callable made it through resolve_provider_client → _try_azure_foundry\n+        # → OpenAI(api_key=...).\n+        assert callable(received[\"api_key\"])\n+\n+    def test_warns_and_returns_none_on_failure(\n+        self, monkeypatch, patch_load_config, caplog,\n+    ):\n+        \"\"\"When azure-foundry is requested but cannot be resolved\n+        (e.g. no model + no key), we return (None, None) and log a\n+        clear warning pointing at ``hermes doctor``.\"\"\"\n+        import logging\n+        from agent.auxiliary_client import resolve_provider_client\n+\n+        monkeypatch.delenv(\"AZURE_FOUNDRY_API_KEY\", raising=False)\n+        patch_load_config({\n+            \"provider\": \"azure-foundry\",\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+            \"api_mode\": \"chat_completions\",\n+            # No default → resolver yields no model → bail\n+        })\n+        with caplog.at_level(logging.WARNING, logger=\"agent.auxiliary_client\"):\n+            client, resolved = resolve_provider_client(\"azure-foundry\")\n+        assert client is None\n+        assert resolved is None\n+        assert any(\n+            \"azure-foundry\" in rec.message and \"hermes doctor\" in rec.message\n+            for rec in caplog.records\n+        )"]},{"file":"tests/agent/test_azure_identity_adapter.py","hunks":["@@ -0,0 +1,662 @@\n+\"\"\"Tests for the Microsoft Entra ID adapter (agent/azure_identity_adapter.py).\n+\n+Covers:\n+  - Scope resolution per Azure host shape\n+  - Display masking for callable + string + None inputs\n+  - Cache-fingerprint stability under callable refresh\n+  - is_token_provider truthiness on callables vs strings\n+  - EntraIdentityConfig serialization round-trip\n+  - Token provider construction with mocked azure-identity\n+  - Credential cache reuse + reset\n+  - has_azure_identity_credentials timeout / failure paths\n+  - describe_active_credential structural reporting\n+  - Lazy-install error path when azure-identity absent + lazy installs\n+    disabled\n+\n+We mock azure.identity at the import boundary rather than hitting any\n+real Azure endpoint. Tests must remain hermetic per AGENTS.md.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import sys\n+from collections.abc import Callable\n+from types import SimpleNamespace\n+from typing import cast\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+# Ensure we always import a fresh adapter module — credential caches in\n+# the adapter persist across tests otherwise, polluting assertions\n+# about cache invalidation.\n[redacted:email](autouse=True)\n+def _reset_adapter_cache():\n+    from agent.azure_identity_adapter import reset_credential_cache\n+    reset_credential_cache()\n+    yield\n+    reset_credential_cache()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Scope constant\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestEntraScopeConstant:\n+    \"\"\"Pin the Microsoft-documented Foundry inference scope.\n+\n+    Microsoft's official samples for both ``*.openai.azure.com`` and\n+    ``*.services.ai.azure.com`` use ``https://ai.azure.com/.default``.\n+    The older ``cognitiveservices.azure.com/.default`` is the\n+    control-plane scope and is rejected for inference by newer\n+    Azure OpenAI / Foundry resources.\n+\n+    Users with sovereign-cloud or unusual-tenant requirements pass the\n+    scope explicitly via ``model.entra.scope`` in ``config.yaml``.\n+\n+    Refs:\n+      * https://learn.microsoft.com/azure/ai-foundry/openai/how-to/managed-identity\n+      * https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id\n+    \"\"\"\n+\n+    def test_default_scope_matches_microsoft_documentation(self):\n+        from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT\n+        assert SCOPE_AI_AZURE_DEFAULT == \"https://ai.azure.com/.default\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Cache fingerprint + http-bearer helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestMaterializeBearerForHttp:\n+    \"\"\"The only helper that mints a real bearer JWT — must call the\n+    callable exactly once and never fall through to display masking.\"\"\"\n+\n+    def test_callable_is_invoked_and_returns_token(self):\n+        from agent.azure_identity_adapter import materialize_bearer_for_http\n+\n+        invoked = {\"count\": 0}\n+\n+        def provider():\n+            invoked[\"count\"] += 1\n+            return \"fresh-jwt\"\n+\n+        assert materialize_bearer_for_http(provider) == \"fresh-jwt\"\n+        assert invoked[\"count\"] == 1\n+\n+    def test_string_passes_through(self):\n+        from agent.azure_identity_adapter import materialize_bearer_for_http\n+        assert materialize_bearer_for_http(\"plain-key\") == \"plain-key\"\n+\n+    def test_callable_returning_empty_raises(self):\n+        from agent.azure_identity_adapter import materialize_bearer_for_http\n+        with pytest.raises(ValueError):\n+            materialize_bearer_for_http(lambda: \"\")\n+\n+    def test_empty_string_raises(self):\n+        from agent.azure_identity_adapter import materialize_bearer_for_http\n+        with pytest.raises(ValueError):\n+            materialize_bearer_for_http(\"\")\n+        with pytest.raises(ValueError):\n+            materialize_bearer_for_http(None)\n+\n+\n+# ---------------------------------------------------------------------------\n+# build_bearer_http_client — the Anthropic-on-Foundry bridge\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBuildBearerHttpClient:\n+    \"\"\"``build_bearer_http_client`` returns an ``httpx.Client`` whose\n+    request event hook mints a fresh JWT per outbound request. This is\n+    how Entra ID auth reaches the Anthropic SDK (which does not accept\n+    callable ``auth_token``).\"\"\"\n+\n+    def test_returns_httpx_client_with_request_hook(self):\n+        import httpx\n+        from agent.azure_identity_adapter import build_bearer_http_client\n+\n+        client = build_bearer_http_client(lambda: \"jwt\")\n+        try:\n+            assert isinstance(client, httpx.Client)\n+            hooks = client.event_hooks.get(\"request\", [])\n+            assert len(hooks) >= 1\n+        finally:\n+            client.close()\n+\n+    def test_hook_overrides_authorization_header(self):\n+        import httpx\n+        from agent.azure_identity_adapter import build_bearer_http_client\n+\n+        minted_tokens = []\n+\n+        def provider():\n+            minted_tokens.append(f\"jwt-{len(minted_tokens) + 1}\")\n+            return minted_tokens[-1]\n+\n+        client = build_bearer_http_client(provider)\n+        try:\n+            hook = client.event_hooks[\"request\"][0]\n+            # Build a request with conflicting pre-set headers and verify\n+            # the hook strips them and installs the fresh bearer.\n+            req = httpx.Request(\n+                \"POST\", \"https://example.com/v1/messages\",\n+                headers={\n+                    \"[redacted:auth-header]\",\n+                    \"api-key\": \"[redacted:secret-assignment]\",\n+                    \"x-api-key\": \"[redacted:secret-assignment]\",\n+                },\n+                json={\"hello\": \"world\"},\n+            )\n+            hook(req)\n+            assert req.headers[\"Authorization\"] == \"Bearer jwt-1\"\n+            # The static-key headers must be stripped — sending both\n+            # auth values would be ambiguous on Azure.\n+            assert \"api-key\" not in req.headers\n+            assert \"x-api-key\" not in req.headers\n+\n+            # Second invocation mints a fresh token.\n+            req2 = httpx.Request(\"GET\", \"https://example.com/v1/models\")\n+            hook(req2)\n+            assert req2.headers[\"Authorization\"] == \"Bearer jwt-2\"\n+            assert len(minted_tokens) == 2\n+        finally:\n+            client.close()\n+\n+    def test_hook_strips_auth_headers_and_warns_when_token_provider_fails(self, caplog):\n+        \"\"\"When the token provider fails (chain exhausted, IMDS down, az\n+        login expired), the hook must:\n+          1. Log at WARNING level so the misconfiguration is visible at\n+             default log level (not buried at DEBUG).\n+          2. Strip any pre-set Authorization headers — including the\n+             placeholder ``entra-id-bearer-via-http-hook`` sentinel that\n+             :func:`_build_anthropic_client_with_bearer_hook` sets on the\n+             Anthropic SDK constructor. This produces a clean\n+             \"missing auth\" 401 from Azure rather than a sentinel-bearing\n+             401 that's harder to diagnose AND avoids leaking the\n+             sentinel string into upstream access logs.\n+        \"\"\"\n+        import logging\n+        import httpx\n+        from agent.azure_identity_adapter import build_bearer_http_client\n+\n+        def bad_provider():\n+            return \"\"  # empty token → materialize_bearer_for_http raises\n+\n+        client = build_bearer_http_client(bad_provider)\n+        try:\n+            hook = client.event_hooks[\"request\"][0]\n+            req = httpx.Request(\n+                \"POST\", \"https://example.com/v1/messages\",\n+                headers={\n+                    \"Authorization\": \"[redacted:bearer-token]\",\n+                    \"api-key\": \"[redacted:secret-assignment]\",\n+                },\n+            )\n+            with caplog.at_level(logging.WARNING, logger=\"agent.azure_identity_adapter\"):\n+                hook(req)  # Must not raise.\n+            # Pre-set auth headers stripped — no sentinel makes it to Azure.\n+            assert \"Authorization\" not in req.headers\n+            assert \"api-key\" not in req.headers\n+            # WARNING was logged so the user sees the misconfiguration.\n+            assert any(\n+                rec.levelno == logging.WARNING and \"Entra ID token provider\" in rec.message\n+                for rec in caplog.records\n+            )\n+        finally:\n+            client.close()\n+\n+    def test_rejects_non_callable_provider(self):\n+        from agent.azure_identity_adapter import build_bearer_http_client\n+        with pytest.raises(ValueError):\n+            build_bearer_http_client(cast(Callable[[], str], \"plain-string-not-callable\"))\n+        with pytest.raises(ValueError):\n+            build_bearer_http_client(cast(Callable[[], str], None))\n+\n+    def test_forwards_httpx_kwargs(self):\n+        import httpx\n+        from agent.azure_identity_adapter import build_bearer_http_client\n+\n+        timeout = httpx.Timeout(60.0, connect=5.0)\n+        client = build_bearer_http_client(lambda: \"jwt\", timeout=timeout)\n+        try:\n+            # httpx stores the timeout per-pool; just sanity-check it was\n+            # accepted without TypeError.\n+            assert client is not None\n+        finally:\n+            client.close()\n+\n+\n+class TestIsTokenProvider:\n+    def test_callable_is_token_provider(self):\n+        from agent.azure_identity_adapter import is_token_provider\n+        assert is_token_provider(lambda: \"x\") is True\n+\n+    def test_string_is_not_token_provider(self):\n+        from agent.azure_identity_adapter import is_token_provider\n+        assert is_token_provider(\"static-key\") is False\n+        # ``str`` instances are technically callable in some edge cases\n+        # — confirm they're never classified as token providers.\n+        assert is_token_provider(\"\") is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# EntraIdentityConfig\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestEntraIdentityConfig:\n+    \"\"\"The serializable config that crosses multiprocessing boundaries —\n+    must round-trip through dict cleanly and never lose fields.\"\"\"\n+\n+    def test_to_dict_round_trip(self):\n+        from agent.azure_identity_adapter import EntraIdentityConfig\n+        cfg = EntraIdentityConfig(\n+            scope=\"https://ai.azure.com/.default\",\n+            exclude_interactive_browser=False,\n+        )\n+        rebuilt = EntraIdentityConfig.from_dict(cfg.to_dict())\n+        assert rebuilt == cfg\n+\n+    def test_from_dict_handles_empty_strings(self):\n+        from agent.azure_identity_adapter import EntraIdentityConfig\n+        cfg = EntraIdentityConfig.from_dict({\n+            \"scope\": \"\",\n+            \"client_id\": None,\n+        })\n+        # Empty scope falls back to default\n+        assert cfg.scope.endswith(\"/.default\")\n+\n+    def test_from_dict_ignores_legacy_identity_keys(self):\n+        \"\"\"Old config.yaml that still has model.entra.client_id /\n+        tenant_id / authority should not crash from_dict — those values\n+        are now read from AZURE_* env vars by azure-identity directly.\"\"\"\n+        from agent.azure_identity_adapter import EntraIdentityConfig\n+        cfg = EntraIdentityConfig.from_dict({\n+            \"tenant_id\": \"legacy-tenant\",\n+            \"authority\": \"https://login.partner.microsoftonline.cn\",\n+            \"client_id\": \"user-mi-client\",\n+        })\n+        # Legacy keys silently ignored — no crash, no surprise field on the dataclass.\n+        assert not hasattr(cfg, \"client_id\")\n+        assert not hasattr(cfg, \"tenant_id\")\n+        assert not hasattr(cfg, \"authority\")\n+\n+    def test_constructor_normalizes_empty_scope(self):\n+        from agent.azure_identity_adapter import EntraIdentityConfig\n+        cfg = EntraIdentityConfig(scope=\"\")\n+        assert cfg.scope.endswith(\"/.default\")\n+\n+    def test_from_dict_default_scope_override(self):\n+        from agent.azure_identity_adapter import EntraIdentityConfig\n+        cfg = EntraIdentityConfig.from_dict(\n+            {\"scope\": \"\"},\n+            default_scope=\"https://custom.example/.default\",\n+        )\n+        assert cfg.scope == \"https://custom.example/.default\"\n+\n+    def test_dataclass_is_frozen(self):\n+        # Frozen dataclasses are hashable / safe to pass through caches.\n+        from agent.azure_identity_adapter import EntraIdentityConfig\n+        cfg = EntraIdentityConfig()\n+        with pytest.raises((AttributeError, Exception)):\n+            setattr(cfg, \"scope\", \"mutated\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Credential / token provider construction\n+# ---------------------------------------------------------------------------\n+\n+\n+class _FakeAzureIdentity:\n+    \"\"\"Stand-in for the ``azure.identity`` module.\n+\n+    Captures kwargs passed to ``DefaultAzureCredential`` so tests can\n+    assert how config flows into the SDK.\n+    \"\"\"\n+\n+    def __init__(self):\n+        self.last_credential_kwargs = None\n+        self.last_scope = None\n+        self.credential_count = 0\n+\n+    def DefaultAzureCredential(self, **kwargs):  # noqa: N802 — match SDK\n+        self.last_credential_kwargs = kwargs\n+        self.credential_count += 1\n+        return SimpleNamespace(\n+            get_token=lambda scope: SimpleNamespace(token=\"[redacted:secret-assignment]\", expires_on=9999999999),\n+            kwargs=kwargs,\n+        )\n+\n+    def get_bearer_token_provider(self, credential, scope):\n+        self.last_scope = scope\n+        # Return a callable that mints a token when invoked.\n+        return lambda: f\"jwt-for-{scope}\"\n+\n+\n[redacted:email]\n+def fake_azure_identity(monkeypatch):\n+    \"\"\"Install a fake azure.identity into sys.modules and stub the\n+    adapter's `_require_azure_identity` so all tests use the fake.\"\"\"\n+    fake = _FakeAzureIdentity()\n+\n+    fake_module = SimpleNamespace(\n+        DefaultAzureCredential=[redacted:secret-assignment]\n+        get_bearer_token_provider=[redacted:secret-assignment]\n+    )\n+    monkeypatch.setitem(sys.modules, \"azure\", SimpleNamespace(identity=fake_module))\n+    monkeypatch.setitem(sys.modules, \"azure.identity\", fake_module)\n+\n+    # The adapter's `_require_azure_identity` does its own import, so\n+    # patch that too to make sure tests never hit the real package's\n+    # singleton state.\n+    from agent import azure_identity_adapter as _adapter\n+    monkeypatch.setattr(_adapter, \"_require_azure_identity\", lambda: fake_module)\n+\n+    return fake\n+\n+\n+class TestBuildCredential:\n+    def test_default_kwargs_are_minimal(self, fake_azure_identity):\n+        \"\"\"SDK default for ``exclude_interactive_browser_credential`` is\n+        True; we only pass it when the user opts IN to interactive\n+        browser auth. Tenant / authority / service principal config\n+        flow through the standard ``AZURE_*`` env vars (read by\n+        azure-identity directly), not Hermes config kwargs.\"\"\"\n+        from agent.azure_identity_adapter import EntraIdentityConfig, build_credential\n+        cred = build_credential(EntraIdentityConfig())\n+        kwargs = fake_azure_identity.last_credential_kwargs\n+        # Default config should produce empty kwargs — SDK uses its own\n+        # defaults plus env-var-driven settings.\n+        assert kwargs == {}\n+        assert cred is not None\n+\n+    def test_interactive_browser_opt_in(self, fake_azure_identity):\n+        \"\"\"When the user explicitly sets\n+        ``exclude_interactive_browser=False``, the SDK kwarg is set to\n+        False. Without the opt-in we don't pass the kwarg at all (SDK\n+        default is True / browser excluded).\"\"\"\n+        from agent.azure_identity_adapter import EntraIdentityConfig, build_credential\n+        build_credential(EntraIdentityConfig(exclude_interactive_browser=False))\n+        kwargs = fake_azure_identity.last_credential_kwargs\n+        assert kwargs[\"exclude_interactive_browser_credential\"] is False\n+\n+    def test_credential_is_cached_per_config(self, fake_azure_identity):\n+        from agent.azure_identity_adapter import EntraIdentityConfig, build_credential\n+        cfg = EntraIdentityConfig(scope=\"s1\")\n+        c1 = build_credential(cfg)\n+        c2 = build_credential(cfg)\n+        assert c1 is c2\n+        assert fake_azure_identity.credential_count == 1\n+\n+    def test_distinct_configs_get_distinct_credentials(self, fake_azure_identity):\n+        from agent.azure_identity_adapter import EntraIdentityConfig, build_credential\n+        c1 = build_credential(EntraIdentityConfig(scope=\"s1\"))\n+        c2 = build_credential(EntraIdentityConfig(scope=\"s2\"))\n+        assert c1 is not c2\n+        assert fake_azure_identity.credential_count == 2\n+\n+    def test_reset_cache_invalidates(self, fake_azure_identity):\n+        from agent.azure_identity_adapter import (\n+            EntraIdentityConfig,\n+            build_credential,\n+            reset_credential_cache,\n+        )\n+        cfg = EntraIdentityConfig(scope=\"x\")\n+        c1 = build_credential(cfg)\n+        reset_credential_cache()\n+        c2 = build_credential(cfg)\n+        assert c1 is not c2\n+\n+\n+class TestBuildTokenProvider:\n+    def test_returns_callable_for_scope(self, fake_azure_identity):\n+        from agent.azure_identity_adapter import build_token_provider\n+        provider = build_token_provider(scope=\"https://ai.azure.com/.default\")\n+        assert callable(provider)\n+        assert provider() == \"jwt-for-https://ai.azure.com/.default\"\n+        assert fake_azure_identity.last_scope == \"https://ai.azure.com/.default\"\n+\n+    def test_falls_back_to_default_scope_when_unspecified(self, fake_azure_identity):\n+        \"\"\"When neither ``scope`` nor ``config`` is provided,\n+        ``build_token_provider`` uses ``SCOPE_AI_AZURE_DEFAULT`` —\n+        Microsoft's documented Foundry inference scope. ``base_url`` is\n+        accepted for back-compat but ignored.\"\"\"\n+        from agent.azure_identity_adapter import (\n+            SCOPE_AI_AZURE_DEFAULT,\n+            build_token_provider,\n+        )\n+        build_token_provider(base_url=\"https://r.openai.azure.com/openai/v1\")\n+        assert fake_azure_identity.last_scope == SCOPE_AI_AZURE_DEFAULT\n+\n+    def test_explicit_scope_wins_over_base_url(self, fake_azure_identity):\n+        from agent.azure_identity_adapter import build_token_provider\n+        build_token_provider(\n+            scope=\"https://override.example/.default\",\n+            base_url=\"https://r.openai.azure.com/openai/v1\",\n+        )\n+        assert fake_azure_identity.last_scope == \"https://override.example/.default\"\n+\n+    def test_config_object_wins_over_kwargs(self, fake_azure_identity):\n+        from agent.azure_identity_adapter import (\n+            EntraIdentityConfig,\n+            build_token_provider,\n+        )\n+        cfg = EntraIdentityConfig(scope=\"cfg-scope\")\n+        build_token_provider(scope=\"ignored\", config=cfg)\n+        assert fake_azure_identity.last_scope == \"cfg-scope\"\n+        assert fake_azure_identity.last_credential_kwargs == {}\n+\n+\n+# ---------------------------------------------------------------------------\n+# Lazy-install / missing-package surface\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestRequireAzureIdentityMissing:\n+    def test_clear_error_when_lazy_install_disabled(self, monkeypatch):\n+        \"\"\"When azure-identity isn't importable AND lazy installs are\n+        off, the adapter must raise ImportError with an actionable\n+        message, not propagate FeatureUnavailable.\"\"\"\n+        from agent import azure_identity_adapter as _adapter\n+\n+        # Force the import path to fail.\n+        original_import = __builtins__[\"__import__\"] if isinstance(__builtins__, dict) else __import__\n+        def _fake_import(name, *args, **kwargs):\n+            if name == \"azure.identity\" or name.startswith(\"azure.identity.\"):\n+                raise ImportError(\"simulated missing azure-identity\")\n+            return original_import(name, *args, **kwargs)\n+\n+        monkeypatch.setattr(\"builtins.__import__\", _fake_import)\n+\n+        # Simulate lazy installs disabled.\n+        from tools.lazy_deps import FeatureUnavailable\n+\n+        def _fake_ensure(*args, **kwargs):\n+            raise FeatureUnavailable(\n+                \"provider.azure_identity\",\n+                (\"azure-identity==1.25.3\",),\n+                \"lazy installs disabled (test simulation)\",\n+            )\n+\n+        # The adapter calls ``ensure`` from ``tools.lazy_deps``; intercept\n+        # it by patching the actual symbol path.\n+        monkeypatch.setattr(\"tools.lazy_deps.ensure\", _fake_ensure)\n+\n+        with pytest.raises(ImportError) as exc_info:\n+            _adapter._require_azure_identity()\n+        msg = str(exc_info.value)\n+        assert \"azure-identity\" in msg\n+        assert \"Foundry\" in msg or \"foundry\" in msg.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# has_azure_identity_credentials probe (timeout-bounded)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestHasAzureIdentityCredentials:\n+    def test_returns_false_when_package_missing_and_install_disabled(self, monkeypatch):\n+        from agent import azure_identity_adapter as _adapter\n+        monkeypatch.setattr(_adapter, \"has_azure_identity_installed\", lambda: False)\n+        assert _adapter.has_azure_identity_credentials(\n+            \"https://x/.default\", allow_install=False,\n+        ) is False\n+\n+    def test_lazy_install_triggered_when_package_missing(self, monkeypatch):\n+        \"\"\"With allow_install=True (default), the probe must trigger the\n+        lazy-install path before bailing — otherwise the wizard's\n+        ``preflight`` would silently fail for fresh installs that haven't\n+        run ``pip install azure-identity`` yet.\"\"\"\n+        from agent import azure_identity_adapter as _adapter\n+\n+        installed = {\"called\": False}\n+\n+        def _fake_install():\n+            installed[\"called\"] = True\n+            # After install, pretend the package is now importable.\n+            monkeypatch.setattr(_adapter, \"has_azure_identity_installed\", lambda: True)\n+            return SimpleNamespace(\n+                DefaultAzureCredential=lambda **kw: SimpleNamespace(\n+                    kwargs=kw,\n+                    get_token=lambda scope: SimpleNamespace(token=\"[redacted:secret-assignment]\", expires_on=0),\n+                ),\n+                get_bearer_token_provider=lambda c, s: lambda: \"x\",\n+            )\n+\n+        monkeypatch.setattr(_adapter, \"has_azure_identity_installed\", lambda: False)\n+        monkeypatch.setattr(_adapter, \"_require_azure_identity\", _fake_install)\n+\n+        # Provide a credential factory so the probe proceeds after install.\n+        monkeypatch.setattr(\n+            _adapter, \"build_credential\",\n+            lambda config: SimpleNamespace(\n+                get_token=lambda scope: SimpleNamespace(token=\"[redacted:secret-assignment]\", expires_on=0),\n+            ),\n+        )\n+\n+        result = _adapter.has_azure_identity_credentials(\n+            \"https://x/.default\", timeout_seconds=0.5,\n+        )\n+        assert installed[\"called\"] is True, (\n+            \"has_azure_identity_credentials must trigger lazy install \"\n+            \"before bailing\"\n+        )\n+        assert result is True\n+\n+    def test_returns_true_on_successful_token_mint(self, fake_azure_identity):\n+        from agent.azure_identity_adapter import has_azure_identity_credentials\n+        assert has_azure_identity_credentials(\"https://x/.default\", timeout_seconds=0.5) is True\n+\n+    def test_returns_false_when_get_token_raises(self, monkeypatch):\n+        from agent import azure_identity_adapter as _adapter\n+\n+        def _failing_credential(_config):\n+            class _Cred:\n+                def get_token(self, scope):\n+                    raise RuntimeError(\"simulated chain exhaustion\")\n+            return _Cred()\n+\n+        monkeypatch.setattr(_adapter, \"build_credential\", _failing_credential)\n+        monkeypatch.setattr(_adapter, \"has_azure_identity_installed\", lambda: True)\n+        assert _adapter.has_azure_identity_credentials(\"https://x/.default\", timeout_seconds=0.5) is False\n+\n+    def test_returns_false_on_timeout(self, monkeypatch):\n+        \"\"\"Slow IMDS / network must time out, not hang the caller.\"\"\"\n+        import threading\n+        from agent import azure_identity_adapter as _adapter\n+\n+        slow_release = threading.Event()\n+\n+        def _slow_credential(_config):\n+            class _Cred:\n+                def get_token(self, scope):\n+                    # Block forever from the test's perspective; the\n+                    # adapter must give up via its thread-bounded probe.\n+                    slow_release.wait(timeout=10)\n+                    return SimpleNamespace(token=\"[redacted:secret-assignment]\", expires_on=0)\n+            return _Cred()\n+\n+        monkeypatch.setattr(_adapter, \"build_credential\", _slow_credential)\n+        monkeypatch.setattr(_adapter, \"has_azure_identity_installed\", lambda: True)\n+        try:\n+            assert _adapter.has_azure_identity_credentials(\n+                \"https://x/.default\", timeout_seconds=0.1\n+            ) is False\n+        finally:\n+            slow_release.set()\n+\n+\n+# ---------------------------------------------------------------------------\n+# describe_active_credential — used by hermes doctor + hermes auth\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestDescribeActiveCredential:\n+    def test_reports_not_installed(self, monkeypatch):\n+        from agent import azure_identity_adapter as _adapter\n+        monkeypatch.setattr(_adapter, \"has_azure_identity_installed\", lambda: False)\n+        info = _adapter.describe_active_credential(\n+            scope=\"https://x/.default\", allow_install=False,\n+        )\n+        assert info[\"ok\"] is False\n+        assert \"not installed\" in info[\"error\"].lower()\n+        assert \"pip install\" in info[\"hint\"].lower()\n+\n+    def test_reports_install_failure(self, monkeypatch):\n+        \"\"\"When lazy install is allowed but fails (e.g. lazy installs\n+        disabled), the diagnostic surfaces the failure as the error.\"\"\"\n+        from agent import azure_identity_adapter as _adapter\n+        monkeypatch.setattr(_adapter, \"has_azure_identity_installed\", lambda: False)\n+\n+        def _fail_install():\n+            raise ImportError(\"simulated: lazy installs disabled\")\n+\n+        monkeypatch.setattr(_adapter, \"_require_azure_identity\", _fail_install)\n+        info = _adapter.describe_active_credential(\n+            scope=\"https://x/.default\", allow_install=True,\n+        )\n+        assert info[\"ok\"] is False\n+        assert \"lazy installs disabled\" in info[\"error\"]\n+        assert \"lazy\" in info[\"hint\"].lower()\n+\n+    def test_reports_env_sources_for_managed_identity(self, fake_azure_identity, monkeypatch):\n+        from agent.azure_identity_adapter import describe_active_credential\n+        monkeypatch.setenv(\"IDENTITY_ENDPOINT\", \"http://169.254.169.254\")\n+        info = describe_active_credential(scope=\"https://x/.default\", timeout_seconds=0.5)\n+        assert info[\"ok\"] is True\n+        sources = info.get(\"env_sources\") or []\n+        assert any(\"ManagedIdentity\" in s for s in sources)\n+\n+    def test_reports_env_sources_for_workload_identity(self, fake_azure_identity, monkeypatch):\n+        from agent.azure_identity_adapter import describe_active_credential\n+        monkeypatch.setenv(\"AZURE_FEDERATED_TOKEN_FILE\", \"/var/secrets/azure/federated-token\")\n+        info = describe_active_credential(scope=\"https://x/.default\", timeout_seconds=0.5)\n+        sources = info.get(\"env_sources\") or []\n+        assert any(\"WorkloadIdentity\" in s for s in sources)\n+\n+    def test_reports_env_sources_for_service_principal(self, fake_azure_identity, monkeypatch):\n+        from agent.azure_identity_adapter import describe_active_credential\n+        monkeypatch.setenv(\"AZURE_TENANT_ID\", \"t\")\n+        monkeypatch.setenv(\"AZURE_CLIENT_ID\", \"c\")\n+        monkeypatch.setenv(\"AZURE_CLIENT_SECRET\", \"s\")\n+        info = describe_active_credential(scope=\"https://x/.default\", timeout_seconds=0.5)\n+        sources = info.get(\"env_sources\") or []\n+        assert any(\"EnvironmentCredential\" in s for s in sources)\n+\n+    def test_reports_error_on_chain_failure(self, monkeypatch):\n+        from agent import azure_identity_adapter as _adapter\n+\n+        def _failing_credential(_config):\n+            class _Cred:\n+                def get_token(self, scope):\n+                    raise RuntimeError(\"auth failed\")\n+            return _Cred()\n+\n+        monkeypatch.setattr(_adapter, \"build_credential\", _failing_credential)\n+        monkeypatch.setattr(_adapter, \"has_azure_identity_installed\", lambda: True)\n+        info = _adapter.describe_active_credential(scope=\"https://x/.default\", timeout_seconds=0.5)\n+        assert info[\"ok\"] is False\n+        assert \"auth failed\" in info.get(\"error\", \"\")"]},{"file":"tests/agent/test_bedrock_1m_context.py","hunks":["@@ -1,7 +1,7 @@\n \"\"\"Tests for the 1M-context beta header on AWS Bedrock Claude models.\n \n Claude Opus 4.6/4.7 and Sonnet 4.6 support a 1M context window, but on AWS\n-Bedrock (and Azure AI Foundry) that window is still gated behind the\n+Bedrock (and Microsoft Foundry) that window is still gated behind the\n ``context-1m-2025-08-07`` beta header as of 2026-04. Without it, Bedrock\n caps these models at 200K even though ``model_metadata.py`` advertises 1M.\n ","@@ -61,4 +61,3 @@ def test_build_anthropic_bedrock_client_sends_1m_beta(self):\n         # Other common betas still present — no regression.\n         assert \"interleaved-thinking-2025-05-14\" in beta_header\n         assert \"fine-grained-tool-streaming-2025-05-14\" in beta_header\n-"]},{"file":"tests/hermes_cli/test_azure_detect.py","hunks":["@@ -102,7 +102,7 @@ def test_detect_anthropic_path_wins_without_http():\n \n def test_detect_openai_models_probe_success():\n     \"\"\"/models probe returning a model list → chat_completions.\"\"\"\n-    def _fake_get(url, api_key, timeout=6.0):\n+    def _fake_get(url, api_key, timeout=6.0, **kwargs):\n         assert \"key-abc\" == api_key\n         return 200, json.loads(_openai_models_body(\"gpt-5.4\", \"claude-opus-4-6\"))\n ","@@ -118,7 +118,7 @@ def _fake_get(url, api_key, timeout=6.0):\n \n def test_detect_openai_models_probe_empty_list_still_counts():\n     \"\"\"Endpoint returned OpenAI shape but no models → still chat_completions.\"\"\"\n-    def _fake_get(url, api_key, timeout=6.0):\n+    def _fake_get(url, api_key, timeout=6.0, **kwargs):\n         return 200, {\"object\": \"list\", \"data\": []}\n \n     with patch.object(azure_detect, \"_http_get_json\", side_effect=_fake_get):","@@ -132,7 +132,7 @@ def _fake_get(url, api_key, timeout=6.0):\n \n def test_detect_falls_back_to_anthropic_probe():\n     \"\"\"/models fails but Anthropic Messages probe succeeds.\"\"\"\n-    def _fake_get(url, api_key, timeout=6.0):\n+    def _fake_get(url, api_key, timeout=6.0, **kwargs):\n         return 401, None  # /models forbidden\n \n     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():\n     \"\"\"First call (no api-version) fails, api-version fallback succeeds.\"\"\"\n     calls = []\n \n-    def _fake_get(url, api_key, timeout=6.0):\n+    def _fake_get(url, api_key, timeout=6.0, **kwargs):\n         calls.append(url)\n         if \"api-version\" not in url:\n             return 404, None"]},{"file":"tests/hermes_cli/test_azure_foundry_entra.py","hunks":["@@ -0,0 +1,404 @@\n+\"\"\"Tests for Azure Foundry Entra ID runtime resolution.\n+\n+Covers the contract introduced in PR for Microsoft Entra ID auth on\n+``azure-foundry``:\n+\n+  * ``_resolve_azure_foundry_runtime`` returns a callable ``api_key`` for\n+    ``model.auth_mode = entra_id`` (OpenAI-style only).\n+  * Anthropic-style endpoints with ``auth_mode = entra_id`` return the same\n+    callable runtime credential as OpenAI-style endpoints.\n+  * The legacy ``api_key`` path is unchanged when ``auth_mode`` is absent\n+    or set to ``api_key``.\n+  * Explicit ``--api-key`` overrides at runtime still work in entra mode\n+    (escape hatch for one-off testing).\n+  * ``model.entra.scope`` propagates to the token-provider config; Azure\n+    identity selection stays in standard AZURE_* env vars.\n+  * ``_get_azure_foundry_auth_status`` is structural — never mints a\n+    token (verified by checking the credential cache untouched).\n+  * ``has_usable_secret`` for ``AZURE_FOUNDRY_API_KEY`` is irrelevant\n+    when ``auth_mode == entra_id``.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import sys\n+from types import SimpleNamespace\n+from typing import cast\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n[redacted:email](autouse=True)\n+def _reset_credential_cache():\n+    from agent.azure_identity_adapter import reset_credential_cache\n+    reset_credential_cache()\n+    yield\n+    reset_credential_cache()\n+\n+\n[redacted:email]\n+def fake_azure_identity(monkeypatch):\n+    \"\"\"Identical fake to test_azure_identity_adapter — keeps Azure SDK\n+    out of these tests so they run in CI without the package installed.\"\"\"\n+    from agent import azure_identity_adapter as _adapter\n+\n+    last = {\"scope\": None, \"kwargs\": None, \"credential_count\": 0}\n+\n+    def _provider(scope):\n+        return lambda: f\"jwt-for-{scope}\"\n+\n+    fake_module = SimpleNamespace(\n+        DefaultAzureCredential=lambda **kw: SimpleNamespace(\n+            kwargs=kw,\n+            get_token=lambda scope: SimpleNamespace(token=\"fake\", expires_on=9999999999),\n+        ),\n+        get_bearer_token_provider=lambda credential, scope: (\n+            last.__setitem__(\"scope\", scope),\n+            last.__setitem__(\"kwargs\", credential.kwargs),\n+            last.__setitem__(\"credential_count\", cast(int, last[\"credential_count\"]) + 1),\n+            _provider(scope),\n+        )[-1],\n+    )\n+    monkeypatch.setattr(_adapter, \"_require_azure_identity\", lambda: fake_module)\n+    monkeypatch.setitem(sys.modules, \"azure.identity\", fake_module)\n+    return last\n+\n+\n+# ---------------------------------------------------------------------------\n+# _resolve_azure_foundry_runtime: entra_id branch\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestResolveAzureFoundryRuntimeEntra:\n+    def test_returns_callable_api_key_for_entra(self, fake_azure_identity):\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        runtime = _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://my-resource.openai.azure.com/openai/v1\",\n+                \"api_mode\": \"chat_completions\",\n+                \"auth_mode\": \"entra_id\",\n+                \"default\": \"gpt-4o\",  # stays on chat_completions (no codex auto-upgrade)\n+            },\n+        )\n+        assert runtime[\"provider\"] == \"azure-foundry\"\n+        assert runtime[\"auth_mode\"] == \"entra_id\"\n+        assert runtime[\"api_mode\"] == \"chat_completions\"\n+        assert callable(runtime[\"api_key\"])\n+        assert runtime[\"source\"] == \"entra_id\"\n+\n+    def test_entra_inherits_codex_responses_for_gpt5_family(self, fake_azure_identity):\n+        \"\"\"GPT-5.x / o-series / codex models on Azure are Responses-API-only.\n+        The runtime auto-upgrades api_mode regardless of auth mode — this is\n+        the same behaviour as the static-key path (see\n+        ``hermes_cli/models.py::azure_foundry_model_api_mode``).\"\"\"\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        runtime = _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://my-resource.openai.azure.com/openai/v1\",\n+                \"api_mode\": \"chat_completions\",\n+                \"auth_mode\": \"entra_id\",\n+                \"default\": \"gpt-5.4\",\n+            },\n+        )\n+        # GPT-5.x is upgraded to codex_responses — Entra path inherits.\n+        assert runtime[\"api_mode\"] == \"codex_responses\"\n+        assert callable(runtime[\"api_key\"])\n+        assert runtime[\"auth_mode\"] == \"entra_id\"\n+\n+    def test_entra_propagates_scope_only(self, fake_azure_identity):\n+        \"\"\"``model.entra.scope`` is the only Hermes-managed Azure SDK\n+        setting. Identity selection (client ID, tenant, authority,\n+        service principal secret, federated token file) flows through\n+        standard ``AZURE_*`` env vars read by azure-identity directly.\n+        Legacy ``model.entra.client_id`` / ``tenant_id`` / ``authority``\n+        keys in config.yaml are silently ignored.\"\"\"\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://my-resource.services.ai.azure.com/v1\",\n+                \"api_mode\": \"chat_completions\",\n+                \"auth_mode\": \"entra_id\",\n+                \"entra\": {\n+                    \"scope\": \"https://custom.example/.default\",\n+                    \"client_id\": \"client-uuid\",\n+                    # Legacy keys must not crash — they are accepted in\n+                    # from_dict but never propagated to the SDK.\n+                    \"tenant_id\": \"legacy-tenant\",\n+                    \"authority\": \"https://login.microsoftonline.us\",\n+                },\n+            },\n+        )\n+        assert fake_azure_identity[\"scope\"] == \"https://custom.example/.default\"\n+        kw = fake_azure_identity[\"kwargs\"]\n+        assert \"managed_identity_client_id\" not in kw\n+        assert \"workload_identity_client_id\" not in kw\n+        assert \"interactive_browser_tenant_id\" not in kw\n+        assert \"authority\" not in kw\n+\n+    def test_entra_default_scope_when_unset(self, fake_azure_identity):\n+        \"\"\"When ``model.entra.scope`` is not set, the runtime resolves\n+        Microsoft's documented inference scope —\n+        ``https://ai.azure.com/.default`` — regardless of whether the\n+        endpoint is ``*.openai.azure.com`` or ``*.services.ai.azure.com``.\n+        Both shapes use the SAME scope per Microsoft's docs; the\n+        ``cognitiveservices.azure.com`` scope is the control-plane\n+        audience and is rejected for inference by newer resources.\"\"\"\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT\n+        _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                \"api_mode\": \"chat_completions\",\n+                \"auth_mode\": \"entra_id\",\n+            },\n+        )\n+        assert fake_azure_identity[\"scope\"] == SCOPE_AI_AZURE_DEFAULT\n+\n+    def test_entra_scope_override_wins(self, fake_azure_identity):\n+        \"\"\"Users on sovereign clouds / unusual tenants can set\n+        ``model.entra.scope`` to override the default.\"\"\"\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                \"api_mode\": \"chat_completions\",\n+                \"auth_mode\": \"entra_id\",\n+                \"entra\": {\n+                    \"scope\": \"https://cognitiveservices.azure.com/.default\",\n+                },\n+            },\n+        )\n+        assert (\n+            fake_azure_identity[\"scope\"]\n+            == \"https://cognitiveservices.azure.com/.default\"\n+        )\n+\n+    def test_entra_with_anthropic_messages_is_supported(self, fake_azure_identity):\n+        \"\"\"Entra ID now works for both OpenAI-style and Anthropic-style\n+        Azure Foundry endpoints. The runtime returns a callable\n+        ``api_key``; downstream\n+        :func:`agent.anthropic_adapter.build_anthropic_client` detects\n+        the callable and installs an httpx event hook that mints a\n+        fresh bearer JWT per request (the Anthropic SDK does not\n+        accept callable auth_token natively).\"\"\"\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        runtime = _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://r.services.ai.azure.com/anthropic\",\n+                \"api_mode\": \"anthropic_messages\",\n+                \"auth_mode\": \"entra_id\",\n+                \"default\": \"claude-sonnet-4-5\",\n+            },\n+        )\n+        assert runtime[\"provider\"] == \"azure-foundry\"\n+        assert runtime[\"auth_mode\"] == \"entra_id\"\n+        assert runtime[\"api_mode\"] == \"anthropic_messages\"\n+        # Callable api_key — the anthropic_adapter detects this and\n+        # plumbs through an httpx event hook.\n+        assert callable(runtime[\"api_key\"])\n+        assert not isinstance(runtime[\"api_key\"], str)\n+\n+    def test_entra_with_explicit_api_key_uses_string_escape_hatch(self, fake_azure_identity):\n+        \"\"\"Passing --api-key on the CLI overrides the entra path so a\n+        user can debug a single request with a static key without\n+        editing config.yaml.\"\"\"\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        runtime = _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                \"api_mode\": \"chat_completions\",\n+                \"auth_mode\": \"entra_id\",\n+            },\n+            explicit_api_key=\"[redacted:secret-assignment]\",\n+        )\n+        assert runtime[\"api_key\"] == \"explicit-string-key\"\n+        assert runtime[\"auth_mode\"] == \"api_key\"\n+        assert runtime[\"source\"] == \"explicit\"\n+\n+    def test_entra_runtime_dict_keeps_only_scope_override(self, fake_azure_identity):\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        runtime = _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                \"api_mode\": \"chat_completions\",\n+                \"auth_mode\": \"entra_id\",\n+                \"entra\": {\n+                    \"scope\": \"https://custom.example/.default\",\n+                    \"client_id\": \"legacy-client\",\n+                },\n+            },\n+        )\n+        assert runtime[\"entra\"] == {\"scope\": \"https://custom.example/.default\"}\n+\n+\n+# ---------------------------------------------------------------------------\n+# _resolve_azure_foundry_runtime: legacy api_key branch (regression)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestResolveAzureFoundryRuntimeApiKey:\n+    def test_default_auth_mode_uses_static_key(self, monkeypatch):\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        monkeypatch.setenv(\"AZURE_FOUNDRY_API_KEY\", \"sk-azure-static-key\")\n+        runtime = _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                \"api_mode\": \"chat_completions\",\n+            },\n+        )\n+        assert runtime[\"api_key\"] == \"sk-azure-static-key\"\n+        assert runtime[\"auth_mode\"] == \"api_key\"\n+        assert \"entra\" not in runtime  # only present in entra mode\n+\n+    def test_explicit_auth_mode_api_key(self, monkeypatch):\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        monkeypatch.setenv(\"AZURE_FOUNDRY_API_KEY\", \"sk-static\")\n+        runtime = _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                \"api_mode\": \"chat_completions\",\n+                \"auth_mode\": \"api_key\",\n+            },\n+        )\n+        assert runtime[\"api_key\"] == \"sk-static\"\n+        assert runtime[\"auth_mode\"] == \"api_key\"\n+\n+    def test_anthropic_messages_strips_v1_suffix(self, monkeypatch):\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        monkeypatch.setenv(\"AZURE_FOUNDRY_API_KEY\", \"k\")\n+        runtime = _resolve_azure_foundry_runtime(\n+            requested_provider=\"azure-foundry\",\n+            model_cfg={\n+                \"provider\": \"azure-foundry\",\n+                \"base_url\": \"https://r.services.ai.azure.com/anthropic/v1\",\n+                \"api_mode\": \"anthropic_messages\",\n+            },\n+        )\n+        assert runtime[\"base_url\"] == \"https://r.services.ai.azure.com/anthropic\"\n+\n+    def test_missing_api_key_raises_with_entra_hint(self, monkeypatch):\n+        from hermes_cli.auth import AuthError\n+        from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime\n+        monkeypatch.delenv(\"AZURE_FOUNDRY_API_KEY\", raising=False)\n+        with pytest.raises(AuthError) as exc_info:\n+            _resolve_azure_foundry_runtime(\n+                requested_provider=\"azure-foundry\",\n+                model_cfg={\n+                    \"provider\": \"azure-foundry\",\n+                    \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                    \"api_mode\": \"chat_completions\",\n+                },\n+            )\n+        msg = str(exc_info.value)\n+        assert \"AZURE_FOUNDRY_API_KEY\" in msg\n+        # Surface the Entra alternative so users discover the keyless path.\n+        assert \"entra_id\" in msg\n+\n+\n+# ---------------------------------------------------------------------------\n+# _get_azure_foundry_auth_status (auth.py) — never mints a token\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestAzureFoundryAuthStatus:\n+    def test_entra_status_does_not_mint_token(self, monkeypatch, tmp_path):\n+        \"\"\"Structural check — must return logged_in=True based on\n+        importable + config, never call get_bearer_token_provider.\"\"\"\n+        from hermes_cli import auth as _auth\n+        # Force load_config to return our entra config.\n+        monkeypatch.setattr(\n+            \"hermes_cli.config.load_config\",\n+            lambda: {\n+                \"model\": {\n+                    \"provider\": \"azure-foundry\",\n+                    \"auth_mode\": \"entra_id\",\n+                    \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                },\n+            },\n+        )\n+        # Patch has_azure_identity_installed to True; do NOT patch the\n+        # token provider — if the code path tried to mint, the SDK\n+        # missing would raise.\n+        monkeypatch.setattr(\n+            \"agent.azure_identity_adapter.has_azure_identity_installed\",\n+            lambda: True,\n+        )\n+        info = _auth._get_azure_foundry_auth_status()\n+        assert info[\"logged_in\"] is True\n+        assert info[\"auth_mode\"] == \"entra_id\"\n+        assert info[\"azure_identity_installed\"] is True\n+        assert info[\"scope\"].endswith(\"/.default\")\n+\n+    def test_entra_status_reports_missing_package(self, monkeypatch):\n+        from hermes_cli import auth as _auth\n+        monkeypatch.setattr(\n+            \"hermes_cli.config.load_config\",\n+            lambda: {\n+                \"model\": {\n+                    \"provider\": \"azure-foundry\",\n+                    \"auth_mode\": \"entra_id\",\n+                    \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                },\n+            },\n+        )\n+        monkeypatch.setattr(\n+            \"agent.azure_identity_adapter.has_azure_identity_installed\",\n+            lambda: False,\n+        )\n+        info = _auth._get_azure_foundry_auth_status()\n+        assert info[\"logged_in\"] is False\n+        assert info[\"azure_identity_installed\"] is False\n+        assert \"azure-identity\" in info[\"hint\"]\n+\n+    def test_api_key_status_uses_env_var(self, monkeypatch):\n+        from hermes_cli import auth as _auth\n+        monkeypatch.setattr(\n+            \"hermes_cli.config.load_config\",\n+            lambda: {\n+                \"model\": {\n+                    \"provider\": \"azure-foundry\",\n+                    \"auth_mode\": \"api_key\",\n+                    \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+                },\n+            },\n+        )\n+        monkeypatch.setenv(\"AZURE_FOUNDRY_API_KEY\", \"sk-real-key-xxx\")\n+        info = _auth._get_azure_foundry_auth_status()\n+        assert info[\"auth_mode\"] == \"api_key\"\n+        assert info[\"logged_in\"] is True\n+\n+    def test_api_key_status_false_when_missing(self, monkeypatch):\n+        from hermes_cli import auth as _auth\n+        monkeypatch.setattr(\n+            \"hermes_cli.config.load_config\",\n+            lambda: {\n+                \"model\": {\n+                    \"provider\": \"azure-foundry\",\n+                    \"auth_mode\": \"api_key\",\n+                },\n+            },\n+        )\n+        monkeypatch.delenv(\"AZURE_FOUNDRY_API_KEY\", raising=False)\n+        info = _auth._get_azure_foundry_auth_status()\n+        assert info[\"logged_in\"] is False"]},{"file":"tests/run_agent/test_callable_api_key.py","hunks":["@@ -0,0 +1,375 @@\n+\"\"\"Tests that callable api_key (Entra ID bearer provider) flows through\n+the agent stack without coercion.\n+\n+The OpenAI Python SDK accepts ``api_key: str | None | Callable[[], str]``,\n+and ``azure-identity``'s ``get_bearer_token_provider`` returns a callable.\n+Hermes preserves the callable end-to-end so the SDK refreshes tokens\n+transparently. This file pins the contract at the high-risk seams the\n+rubber-duck audit identified.\n+\n+Covered:\n+  * ``_create_openai_client`` passes a callable ``api_key`` straight\n+    through to ``openai.OpenAI(...)``.\n+  * ``_normalize_main_runtime`` preserves the callable so auxiliary\n+    clients inherit Entra auth.\n+  * ``_truncate_token`` (dashboard preview) renders ``\"<entra-id-bearer>\"``\n+    instead of ``\"<function ...>\"`` and never invokes the callable.\n+  * ``run_agent.py`` masked-banner path renders the Entra placeholder\n+    and never tries to slice/len the callable.\n+  * Serialization scrub: dumping a runtime dict via ``json.dumps`` with\n+    a callable api_key raises (default behaviour) — guards against\n+    silently leaking ``\"<function ...>\"`` strings into event logs.\n+  * ``batch_runner`` strips the callable from the worker config dict\n+    so multiprocessing.Pool can pickle the rest.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+from types import SimpleNamespace\n+from typing import cast\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# OpenAI SDK construction preserves the callable\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestCreateOpenAIClientCallable:\n+    \"\"\"``AIAgent._create_openai_client`` must pass the callable through\n+    to ``openai.OpenAI(...)`` without coercion.\"\"\"\n+\n+    def test_callable_api_key_passed_to_openai_constructor(self, monkeypatch):\n+        \"\"\"Construct the smallest possible AIAgent surface and verify\n+        the OpenAI client receives the callable unchanged.\"\"\"\n+        captured = {}\n+\n+        def fake_openai(**kwargs):\n+            captured[\"kwargs\"] = kwargs\n+            return MagicMock(api_key=kwargs.get(\"api_key\"))\n+\n+        # Patch the module-level OpenAI proxy used by ``_create_openai_client``.\n+        monkeypatch.setattr(\"run_agent.OpenAI\", fake_openai)\n+\n+        # Build a minimal stand-in for AIAgent so we can call the bound\n+        # method directly without paying the full __init__ cost.\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent.__new__(AIAgent)\n+        # Attributes consulted by _create_openai_client / _client_log_context.\n+        agent.provider = \"azure-foundry\"\n+        agent.model = \"gpt-4o\"\n+        agent.base_url = \"https://r.openai.azure.com/openai/v1\"\n+        agent._client_kwargs = {}\n+\n+        def token_provider():\n+            return \"fresh-jwt\"\n+\n+        client_kwargs = {\n+            \"api_key\": [redacted:secret-assignment]\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+        }\n+        client = agent._create_openai_client(client_kwargs, reason=\"test\", shared=False)\n+\n+        # The OpenAI constructor must receive the *callable*, not a string.\n+        forwarded = captured[\"kwargs\"][\"api_key\"]\n+        assert callable(forwarded)\n+        assert not isinstance(forwarded, str)\n+        assert forwarded is token_provider, (\n+            \"_create_openai_client must not wrap or coerce the callable\"\n+        )\n+        assert client is not None\n+\n+\n+# ---------------------------------------------------------------------------\n+# Auxiliary runtime preserves the callable\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestNormalizeMainRuntimePreservesCallable:\n+    \"\"\"The aux client orchestrator must keep the callable on the\n+    runtime dict so compression / vision / embedding / title-gen clients\n+    inherit Entra ID auth from the main agent.\"\"\"\n+\n+    def test_callable_api_key_survives_normalization(self):\n+        from agent.auxiliary_client import _normalize_main_runtime\n+\n+        def provider():\n+            return \"jwt\"\n+\n+        normalized = _normalize_main_runtime({\n+            \"provider\": \"azure-foundry\",\n+            \"model\": \"gpt-4o\",\n+            \"base_url\": \"https://r.openai.azure.com/openai/v1\",\n+            \"api_key\": [redacted:secret-assignment]\n+            \"api_mode\": \"chat_completions\",\n+            \"auth_mode\": \"entra_id\",\n+        })\n+        assert normalized[\"api_key\"] is provider\n+        assert normalized[\"auth_mode\"] == \"entra_id\"\n+\n+    def test_string_api_key_still_works(self):\n+        from agent.auxiliary_client import _normalize_main_runtime\n+        normalized = _normalize_main_runtime({\n+            \"provider\": \"azure-foundry\",\n+            \"api_key\": \"[redacted:secret-assignment]\",\n+        })\n+        assert normalized[\"api_key\"] == \"sk-static\"\n+\n+    def test_normalization_drops_empty_string_but_preserves_callable(self):\n+        from agent.auxiliary_client import _normalize_main_runtime\n+\n+        def provider():\n+            return \"\"\n+\n+        # Empty string fields are dropped, but a callable is preserved\n+        # even if it would mint an empty token (we don't invoke during\n+        # normalization).\n+        normalized = _normalize_main_runtime({\n+            \"provider\": \"azure-foundry\",\n+            \"api_key\": [redacted:secret-assignment]\n+            \"model\": \"\",\n+        })\n+        assert normalized[\"api_key\"] is provider\n+        assert \"model\" not in normalized\n+\n+    def test_unknown_field_dropped(self):\n+        from agent.auxiliary_client import _normalize_main_runtime, _MAIN_RUNTIME_FIELDS\n+        normalized = _normalize_main_runtime({\n+            \"provider\": \"azure-foundry\",\n+            \"api_key\": \"k\",\n+            \"secret_field_we_dont_want\": \"leak\",\n+        })\n+        assert \"secret_field_we_dont_want\" not in normalized\n+        # auth_mode IS in the field allowlist (rubber-duck blocker fix).\n+        assert \"auth_mode\" in _MAIN_RUNTIME_FIELDS\n+\n+\n+# ---------------------------------------------------------------------------\n+# Display surfaces never invoke the callable\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestTruncateTokenCallable:\n+    def test_callable_returns_placeholder(self):\n+        \"\"\"Dashboard preview must render the Entra placeholder, NOT\n+        ``\"<function ...>\"``.\"\"\"\n+        from hermes_cli.web_server import _truncate_token\n+\n+        invoked = {\"count\": 0}\n+\n+        def provider():\n+            invoked[\"count\"] += 1\n+            return \"should-not-appear-in-ui\"\n+\n+        token_provider = [redacted:secret-assignment] | None, provider)\n+        rendered = _truncate_token(token_provider)\n+        assert rendered == \"<entra-id-bearer>\"\n+        assert invoked[\"count\"] == 0\n+\n+    def test_string_jwt_still_truncated_to_signature_tail(self):\n+        from hermes_cli.web_server import _truncate_token\n+        # JWT shape: header.payload.signature → only signature tail shown.\n+        out = _truncate_token(\"aaaa.bbbb.cccccccsig\", visible=4)\n+        assert out == \"…csig\"\n+\n+    def test_empty_returns_empty(self):\n+        from hermes_cli.web_server import _truncate_token\n+        assert _truncate_token(None) == \"\"\n+        assert _truncate_token(\"\") == \"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Serialization scrub — runtime dicts with callables must NOT silently\n+# JSON-encode as ``\"<function ...>\"`` (would leak garbage into events).\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestRuntimeDictSerializationGuard:\n+    def test_json_dumps_default_str_does_not_silently_stringify_callable(self):\n+        \"\"\"Sanity check: a runtime dict with a callable api_key must\n+        either raise on plain ``json.dumps`` (good — fail loud) or be\n+        sanitized BEFORE serialization. This test pins the loud-fail\n+        behaviour so future changes that introduce\n+        ``json.dumps(..., default=str)`` over a runtime dict are caught\n+        by a regression here.\"\"\"\n+\n+        def provider():\n+            return \"jwt\"\n+\n+        runtime = {\n+            \"provider\": \"azure-foundry\",\n+            \"api_key\": [redacted:secret-assignment]\n+            \"auth_mode\": \"entra_id\",\n+        }\n+        # Plain json.dumps — must raise, not silently produce\n+        # ``\"<function provider at 0x...>\"``.\n+        with pytest.raises(TypeError):\n+            json.dumps(runtime)\n+\n+\n+# ---------------------------------------------------------------------------\n+# batch_runner strips callables from the worker config dict\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBatchRunnerCallableHandling:\n+    def test_callable_api_key_stripped_from_worker_config(self, capsys, monkeypatch, tmp_path):\n+        \"\"\"``BatchRunner._run_batches`` (or the equivalent code path)\n+        must replace a callable api_key with None before pickling the\n+        worker config dict — otherwise multiprocessing.Pool fails.\"\"\"\n+        # We can't easily run BatchRunner end-to-end in a unit test\n+        # (it spawns subprocesses), but we CAN inline the same logic:\n+        # the production code uses ``callable(self.api_key) and not\n+        # isinstance(self.api_key, str)`` to gate the substitution.\n+        # Re-execute the same predicate here as a contract guard.\n+\n+        def provider():\n+            return \"jwt\"\n+\n+        api_key = provider\n+        worker_api_key = None if (callable(api_key) and not isinstance(api_key, str)) else api_key\n+        assert worker_api_key is None, (\n+            \"BatchRunner must replace callable api_key with None so \"\n+            \"multiprocessing.Pool can pickle the worker config\"\n+        )\n+\n+        # And a string passes through unchanged.\n+        api_key_str = \"[redacted:secret-assignment]\"\n+        worker_api_key_str = None if (callable(api_key_str) and not isinstance(api_key_str, str)) else api_key_str\n+        assert worker_api_key_str == \"sk-static\"\n+\n+    def test_batch_runner_source_uses_the_correct_predicate(self):\n+        \"\"\"Pin the predicate string in batch_runner so refactors that\n+        change it are caught here. Reading the source rather than\n+        importing avoids spinning up the full BatchRunner.\"\"\"\n+        from pathlib import Path\n+        src = (Path(__file__).resolve().parent.parent.parent\n+               / \"batch_runner.py\").read_text()\n+        assert \"callable(self.api_key) and not isinstance(self.api_key, str)\" in src, (\n+            \"BatchRunner.api_key callable check changed — update test or \"\n+            \"verify the new predicate still routes Entra token providers \"\n+            \"to the worker-rebuild path.\"\n+        )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Inline masked-banner / display sites (callable-aware)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestCliEnsureRuntimeCredentialsCallable:\n+    \"\"\"Regression: ``cli.py:_ensure_runtime_credentials`` previously\n+    treated a callable ``api_key`` as \"not a string\" and overwrote it\n+    with the ``\"no-key-required\"`` placeholder, which then got sent as\n+    ``[redacted:auth-header]`` and rejected by Azure\n+    with a 401. This is the most subtle of the callable-api_key audit\n+    sites — gated by ``not isinstance(api_key, str)`` rather than the\n+    cleaner ``callable(...)`` check used elsewhere.\n+\n+    We verify the source pattern (rather than spinning up a real\n+    ``HermesCLI`` instance) — the predicate change is the load-bearing\n+    fix and is invariant under the surrounding orchestration code.\"\"\"\n+\n+    def test_callable_predicate_present_in_cli_runtime_validation(self):\n+        from pathlib import Path\n+        src = (Path(__file__).resolve().parent.parent.parent\n+               / \"cli.py\").read_text()\n+        # The fix introduces ``_is_callable_provider`` which gates the\n+        # string-only check so callable token providers survive.\n+        assert \"_is_callable_provider = callable(api_key)\" in src, (\n+            \"cli.py:_ensure_runtime_credentials must preserve a callable \"\n+            \"api_key (Entra ID bearer provider). Without the guard, the \"\n+            \"callable is stringified to 'no-key-required' and Azure 401s.\"\n+        )\n+\n+\n+class TestInlinedDisplayMasks:\n+    \"\"\"The masked-credential display sites are now inlined per-site (no\n+    shared helper). Each site uses the ``is_token_provider`` predicate\n+    to short-circuit on callables and print a static\n+    ``\"Microsoft Entra ID\"`` label, then falls through to its own\n+    context-appropriate string mask. This replaces a unified helper\n+    that would have forced one mask shape across sites with legitimately\n+    different display needs (banner vs diagnostic vs UI vs preview).\"\"\"\n+\n+    def test_run_agent_banner_uses_is_token_provider_guard(self):\n+        \"\"\"The masked-banner sites live in ``agent/agent_init.py``\n+        (the ``__init__`` body was extracted into ``init_agent`` after\n+        this feature was first written). Both the OpenAI and Anthropic\n+        client init paths must guard their banner prints with\n+        ``is_token_provider`` so a callable Entra ID provider doesn't\n+        crash ``len(api_key)``.\"\"\"\n+        from pathlib import Path\n+        src = (Path(__file__).resolve().parent.parent.parent\n+               / \"agent\" / \"agent_init.py\").read_text()\n+        assert src.count(\"is_token_provider(\") >= 2, (\n+            \"agent/agent_init.py must guard BOTH masked-banner paths \"\n+            \"(chat_completions and anthropic_messages) with \"\n+            \"is_token_provider().\"\n+        )\n+        assert src.count('\"🔑 Using credentials: Microsoft Entra ID\"') >= 2, (\n+            \"agent/agent_init.py banner blocks should print a static \"\n+            \"'Microsoft Entra ID' label for callable api_keys — no \"\n+            \"placeholder plumbing, no describe-mask fallback.\"\n+        )\n+\n+    def test_cli_show_config_handles_callable(self):\n+        \"\"\"``cli.HermesCLI.show_config`` previously did\n+        ``self.api_key[-4:]`` / ``len(self.api_key)`` which crashes on\n+        callable Entra ID providers. The inlined version uses\n+        ``is_token_provider`` and prints the same static label as the\n+        run_agent banners.\"\"\"\n+        from pathlib import Path\n+        src = (Path(__file__).resolve().parent.parent.parent\n+               / \"cli.py\").read_text()\n+        assert \"is_token_provider(self.api_key)\" in src, (\n+            \"cli.HermesCLI.show_config must guard self.api_key via \"\n+            \"is_token_provider so callable Entra ID providers don't \"\n+            \"crash /config.\"\n+        )\n+        assert '\"Microsoft Entra ID\"' in src, (\n+            \"cli.HermesCLI.show_config must print the static \"\n+            \"'Microsoft Entra ID' label (matching run_agent banners) \"\n+            \"instead of attempting to slice the callable.\"\n+        )\n+\n+    def test_mask_api_key_for_logs_handles_callable(self):\n+        \"\"\"``run_agent._mask_api_key_for_logs`` is called from the\n+        request-dump JSON path. For Entra users, ``self.client.api_key``\n+        is the SDK's empty string (callable stashed privately) — but\n+        defensively the helper must also accept a callable directly\n+        and return the placeholder rather than crashing on\n+        ``len(callable)``.\"\"\"\n+        from pathlib import Path\n+        src = (Path(__file__).resolve().parent.parent.parent\n+               / \"run_agent.py\").read_text()\n+        # The function now starts with a callable check.\n+        assert (\n+            \"if callable(key) and not isinstance(key, str):\" in src\n+            and '\"<entra-id-bearer>\"' in src\n+        ), (\n+            \"run_agent._mask_api_key_for_logs must short-circuit for \"\n+            \"callable api_keys to avoid len(callable) crashes in \"\n+            \"request-dump paths.\"\n+        )\n+\n+    def test_anthropic_401_diagnostic_handles_callable(self):\n+        \"\"\"The Anthropic 401 diagnostic path lives in\n+        ``agent/conversation_loop.py`` (the ``run_conversation`` body\n+        was extracted after this feature was first written). It used\n+        to do ``key[:12]`` on ``self._anthropic_api_key``. For Entra ID +\n+        Anthropic-style mode that's a callable; slicing crashes.\"\"\"\n+        from pathlib import Path\n+        src = (Path(__file__).resolve().parent.parent.parent\n+               / \"agent\" / \"conversation_loop.py\").read_text()\n+        # The Anthropic 401 block now branches on is_token_provider\n+        # before slicing the key.\n+        assert \"Microsoft Entra ID (httpx event hook)\" in src, (\n+            \"agent/conversation_loop.py Anthropic 401 diagnostic must \"\n+            \"surface a Microsoft Entra ID branch before slicing the \"\n+            \"key prefix.\"\n+        )"]},{"file":"tools/lazy_deps.py","hunks":["@@ -81,6 +81,11 @@\n     \"provider.anthropic\": (\"anthropic==0.87.0\",),  # CVE-2026-34450, CVE-2026-34452\n     # AWS Bedrock provider\n     \"provider.bedrock\": (\"boto3==1.42.89\",),\n+    # Microsoft Foundry — Entra ID auth (managed identity, workload identity,\n+    # service principal, az login, VS Code, azd, PowerShell). Only loaded\n+    # when model.auth_mode=entra_id is selected; key-based azure-foundry\n+    # users never pay this import.\n+    \"provider.azure_identity\": (\"azure-identity==1.25.3\",),\n \n     # ─── Web search backends ───────────────────────────────────────────────\n     \"search.exa\": (\"exa-py==2.10.2\",),"]},{"file":"tui_gateway/server.py","hunks":["@@ -1087,7 +1087,16 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:\n         current_provider = str(runtime.get(\"provider\", \"\") or \"\")\n         current_model = _resolve_model()\n         current_base_url = str(runtime.get(\"base_url\", \"\") or \"\")\n-        current_api_key = [redacted:secret-assignment]\"api_key\", \"\") or \"\")\n+        # Preserve a callable api_key (Azure Foundry Entra ID bearer\n+        # provider) unchanged — ``str(...)`` would produce\n+        # ``\"<function ...>\"`` and poison downstream switch_model\n+        # validation. Match the agent-present branch's behavior at the\n+        # top of this block.\n+        _runtime_key = runtime.get(\"api_key\", \"\")\n+        if callable(_runtime_key) and not isinstance(_runtime_key, str):\n+            current_api_key = [redacted:secret-assignment]\n+        else:\n+            current_api_key = [redacted:secret-assignment] or \"\")\n \n     # Load user-defined providers so switch_model can resolve named custom\n     # endpoints (e.g. \"ollama-launch\") and validate against saved model lists."]},{"file":"uv.lock","hunks":["@@ -500,6 +500,35 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl\", hash = \"sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e\", size = 22071058, upload-time = \"2026-03-14T14:38:43.663Z\" },\n ]\n \n+[[package]]\n+name = \"azure-core\"\n+version = \"1.41.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"requests\" },\n+    { name = \"typing-extensions\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz\", hash = \"sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a\", size = 381042, upload-time = \"2026-05-07T23:30:54.302Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl\", hash = \"sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d\", size = 220920, upload-time = \"2026-05-07T23:30:56.357Z\" },\n+]\n+\n+[[package]]\n+name = \"azure-identity\"\n+version = \"1.25.3\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"azure-core\" },\n+    { name = \"cryptography\" },\n+    { name = \"msal\" },\n+    { name = \"msal-extensions\" },\n+    { name = \"typing-extensions\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz\", hash = \"sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6\", size = 286304, upload-time = \"2026-03-13T01:12:20.892Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl\", hash = \"sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c\", size = 192138, upload-time = \"2026-03-13T01:12:22.951Z\" },\n+]\n+\n [[package]]\n name = \"base58\"\n version = \"2.1.1\"","@@ -1619,6 +1648,9 @@ all = [\n anthropic = [\n     { name = \"anthropic\" },\n ]\n+azure-identity = [\n+    { name = \"azure-identity\" },\n+]\n bedrock = [\n     { name = \"boto3\" },\n ]","@@ -1768,6 +1800,7 @@ requires-dist = [\n     { name = \"alibabacloud-dingtalk\", marker = \"extra == 'dingtalk'\", specifier = \"==2.2.42\" },\n     { name = \"anthropic\", marker = \"extra == 'anthropic'\", specifier = \"==0.87.0\" },\n     { name = \"asyncpg\", marker = \"extra == 'matrix'\", specifier = \"==0.31.0\" },\n+    { name = \"azure-identity\", marker = \"extra == 'azure-identity'\", specifier = \"==1.25.3\" },\n     { name = \"boto3\", marker = \"extra == 'bedrock'\", specifier = \"==1.42.89\" },\n     { name = \"brotlicffi\", marker = \"extra == 'messaging'\", specifier = \"==1.2.0.1\" },\n     { name = \"croniter\", specifier = \"==6.0.0\" },","@@ -1857,7 +1890,7 @@ requires-dist = [\n     { name = \"vercel\", marker = \"extra == 'vercel'\", specifier = \"==0.5.7\" },\n     { name = \"youtube-transcript-api\", marker = \"extra == 'youtube'\", specifier = \"==1.2.4\" },\n ]\n-provides-extras = [\"anthropic\", \"exa\", \"firecrawl\", \"parallel-web\", \"fal\", \"edge-tts\", \"modal\", \"daytona\", \"vercel\", \"hindsight\", \"dev\", \"messaging\", \"cron\", \"slack\", \"matrix\", \"cli\", \"tts-premium\", \"voice\", \"pty\", \"honcho\", \"mcp\", \"homeassistant\", \"sms\", \"computer-use\", \"acp\", \"bedrock\", \"termux\", \"termux-all\", \"dingtalk\", \"feishu\", \"google\", \"youtube\", \"web\", \"all\"]\n+provides-extras = [\"anthropic\", \"exa\", \"firecrawl\", \"parallel-web\", \"fal\", \"edge-tts\", \"modal\", \"daytona\", \"vercel\", \"hindsight\", \"dev\", \"messaging\", \"cron\", \"slack\", \"matrix\", \"cli\", \"tts-premium\", \"voice\", \"pty\", \"honcho\", \"mcp\", \"homeassistant\", \"sms\", \"computer-use\", \"acp\", \"bedrock\", \"azure-identity\", \"termux\", \"termux-all\", \"dingtalk\", \"feishu\", \"google\", \"youtube\", \"web\", \"all\"]\n \n [[package]]\n name = \"hf-xet\"","@@ -2423,6 +2456,32 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl\", hash = \"sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c\", size = 536198, upload-time = \"2023-03-07T16:47:09.197Z\" },\n ]\n \n+[[package]]\n+name = \"msal\"\n+version = \"1.36.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"cryptography\" },\n+    { name = \"pyjwt\", extra = [\"crypto\"] },\n+    { name = \"requests\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz\", hash = \"sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b\", size = 174217, upload-time = \"2026-04-09T10:20:33.525Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl\", hash = \"sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4\", size = 121547, upload-time = \"2026-04-09T10:20:32.336Z\" },\n+]\n+\n+[[package]]\n+name = \"msal-extensions\"\n+version = \"1.3.1\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"msal\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz\", hash = \"sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4\", size = 23315, upload-time = \"2025-03-14T23:51:03.902Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl\", hash = \"sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca\", size = 20583, upload-time = \"2025-03-14T23:51:03.016Z\" },\n+]\n+\n [[package]]\n name = \"msgpack\"\n version = \"1.1.2\""]},{"file":"website/docs/guides/azure-foundry.md","hunks":["@@ -1,23 +1,23 @@\n ---\n sidebar_position: 15\n-title: \"Azure AI Foundry\"\n-description: \"Use Hermes Agent with Azure AI Foundry — OpenAI-style and Anthropic-style endpoints, auto-detection of transport and deployed models\"\n+title: \"Microsoft Foundry\"\n+description: \"Use Hermes Agent with Microsoft Foundry — OpenAI-style and Anthropic-style endpoints, auto-detection of transport and deployed models\"\n ---\n \n-# Azure AI Foundry\n+# Microsoft Foundry\n \n-Hermes Agent supports Azure AI Foundry (and Azure OpenAI) as a first-class provider. A single Azure resource can host models with two different wire formats:\n+Hermes Agent's `azure-foundry` provider supports Microsoft Foundry (formerly Azure AI Foundry) and Azure OpenAI. A single Foundry resource can host models with two different wire formats:\n \n - **OpenAI-style** — `POST /v1/chat/completions` on endpoints like `https://<resource>.openai.azure.com/openai/v1`. Used for GPT-4.x, GPT-5.x, Llama, Mistral, and most open-weight models.\n-- **Anthropic-style** — `POST /v1/messages` on endpoints like `https://<resource>.services.ai.azure.com/anthropic`. Used when Azure Foundry serves Claude models via the Anthropic Messages API format.\n+- **Anthropic-style** — `POST /v1/messages` on endpoints like `https://<resource>.services.ai.azure.com/anthropic`. Used when Microsoft Foundry serves Claude models via the Anthropic Messages API format.\n \n The setup wizard probes your endpoint and auto-detects which transport it uses, which deployments are available, and each model's context length.\n \n ## Prerequisites\n \n-- An Azure AI Foundry or Azure OpenAI resource with at least one deployment\n-- An API key for that resource (available in the Azure Portal under \"Keys and Endpoint\")\n+- A Microsoft Foundry or Azure OpenAI resource with at least one deployment\n - The deployment's endpoint URL\n+- **Either** an API key (from the Azure Portal under \"Keys and Endpoint\") **or** the **Azure AI User** RBAC role on the Foundry resource if you plan to use Microsoft Entra ID (the keyless path Microsoft recommends). Some tenants may show the role as **Foundry User** during Microsoft's rename rollout.\n \n ## Quick Start\n ","@@ -25,20 +25,172 @@ The setup wizard probes your endpoint and auto-detects which transport it uses,\n hermes model\n # → Select \"Azure Foundry\"\n # → Enter your endpoint URL\n-# → Enter your API key\n+# → Choose Authentication:\n+#     1. API key\n+#     2. Microsoft Entra ID  (managed identity / workload identity / az login)\n+# → (Entra) Hermes probes DefaultAzureCredential; on success it never asks for a key\n+# → (API key) Enter your API key\n # Hermes probes the endpoint and auto-detects transport + models\n # → Pick a model from the list (or type a deployment name manually)\n ```\n \n The wizard will:\n \n-1. **Sniff the URL path** — URLs ending in `/anthropic` are recognised as Azure Foundry Claude routes.\n+1. **Sniff the URL path** — URLs ending in `/anthropic` are recognised as Microsoft Foundry Claude routes.\n 2. **Probe `GET <base>/models`** — if the endpoint returns an OpenAI-shaped model list, Hermes switches to `chat_completions` and prefills a picker with the returned deployment IDs.\n 3. **Probe Anthropic Messages shape** — fallback for endpoints that do not expose `/models` but do accept the Anthropic Messages format.\n 4. **Fall back to manual entry** — private/gated endpoints that reject every probe still work; you pick the API mode and type a deployment name by hand.\n \n Context length for the chosen model is resolved via Hermes' standard metadata chain (`models.dev`, provider metadata, and hardcoded family fallbacks) and stored in `config.yaml` so the model can size its own context window correctly.\n \n+## Microsoft Entra ID (keyless, RBAC) — recommended\n+\n+Microsoft recommends [keyless authentication with Microsoft Entra ID](https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id) for production Foundry workloads. Hermes supports Entra ID for **both** API surfaces:\n+\n+- **OpenAI-style** (`api_mode: chat_completions` / `codex_responses`) — GPT-4/5, Llama, Mistral, DeepSeek, etc.\n+- **Anthropic-style** (`api_mode: anthropic_messages`) — Claude models on Microsoft Foundry.\n+\n+Foundry's RBAC is per-resource (`Azure AI User` grants both surfaces; some tenants may display `Foundry User`) and Microsoft documents the same inference scope (`https://ai.azure.com/.default`) for both. Under the hood:\n+\n+- OpenAI-style uses the OpenAI Python SDK's native callable `api_key=` contract — the SDK mints a fresh JWT per request automatically.\n+- Anthropic-style uses an `httpx.Client` with a request event hook installed by `agent.azure_identity_adapter.build_bearer_http_client`, because the Anthropic SDK does not accept callable `auth_token` natively. The hook rewrites `Authorization: Bearer <fresh-jwt>` per outbound request. Same Microsoft RBAC, same Foundry scope — the SDK contract is the only difference.\n+\n+### Why use Entra ID?\n+\n+- No long-lived API keys to rotate or revoke.\n+- RBAC-driven access — grant or remove `Azure AI User` on the Foundry resource, no config rewrite needed.\n+- Access and audit logs are segmented by assignee instead of all callers sharing one static key.\n+- Single auth surface for Azure VMs, AKS pods, App Service, Functions, Container Apps, and Foundry Agent Service via managed identity.\n+- Workload identity and service-principal flows for CI/CD pipelines.\n+\n+### One-time setup (Azure side)\n+\n+1. In the Azure Portal, open your Foundry resource → **Access control (IAM)** → **Add → Add role assignment**.\n+2. Pick the **Azure AI User** role (or **Foundry User** if your tenant has the renamed role).\n+3. Assign it to:\n+   - **Your user account** for local development with `az login`.\n+   - **A managed identity or workload identity** for Azure-hosted compute (recommended for production).\n+   - **A Foundry Agent Service hosted agent's agent identity** when Hermes runs inside a hosted agent.\n+   - **A service principal** for CI/CD pipelines when workload identity is not available.\n+4. Wait ~5 minutes for the role to propagate.\n+\n+Azure CLI equivalent:\n+\n+```bash\n+az role assignment create \\\n+  --assignee <principal-or-agent-identity-client-id> \\\n+  --role \"Azure AI User\" \\\n+  --scope <foundry-resource-id>\n+```\n+\n+### One-time setup (Hermes side)\n+\n+```bash\n+hermes model\n+# → Select \"Azure Foundry\"\n+# → Enter your endpoint URL\n+# → Authentication: 2 (Microsoft Entra ID)\n+# → (optional) user-assigned managed identity client ID\n+# → (optional) Azure tenant ID\n+# → Hermes probes DefaultAzureCredential() and reports which inner\n+#    credential succeeded (e.g. AzureCliCredential, ManagedIdentityCredential)\n+```\n+\n+The wizard runs a bounded preflight probe (10 s timeout). On failure it offers to \"save anyway, validate later\" — useful when configuring on a machine that doesn't yet have credentials but will at runtime (e.g. preparing config for a managed-identity deployment).\n+\n+`azure-identity` is installed automatically on first use via Hermes' lazy-install path. To pre-install:\n+\n+```bash\n+pip install azure-identity\n+```\n+\n+### Configuration written to `config.yaml`\n+\n+```yaml\n+model:\n+  provider: azure-foundry\n+  base_url: https://my-resource.openai.azure.com/openai/v1\n+  api_mode: chat_completions\n+  auth_mode: entra_id\n+  default: gpt-4o\n+  context_length: 128000\n+  entra:\n+    scope: https://ai.azure.com/.default        # only when overriding the default\n+```\n+\n+Hermes only manages one Entra-specific knob in `config.yaml`:\n+\n+- **`scope`** — the OAuth resource scope. Defaults to Microsoft's documented inference scope (`https://ai.azure.com/.default`). Override only if your resource was provisioned against a non-standard audience.\n+\n+Everything else (tenant, service principal secret, federated token file, sovereign cloud authority, broker preferences) is read by `azure-identity` directly from the standard `AZURE_*` environment variables — see the [credential resolution order](#credential-resolution-order) below. Set those in `~/.hermes/.env` or your deployment environment, exactly as Microsoft's SDK reference describes.\n+\n+No secrets land in `~/.hermes/.env` for Entra mode — `azure-identity` caches tokens in-process (and where available, in your OS keychain / `~/.IdentityService`).\n+\n+### Credential resolution order\n+\n+`azure-identity`'s `DefaultAzureCredential` walks this chain on each token request, stopping at the first credential that returns a token:\n+\n+1. **Environment credential** — `AZURE_TENANT_ID` + `AZURE_CLIENT_ID` + `AZURE_CLIENT_SECRET` (or `AZURE_CLIENT_CERTIFICATE_PATH` / `AZURE_FEDERATED_TOKEN_FILE`).\n+2. **Workload Identity** — `AZURE_FEDERATED_TOKEN_FILE` (AKS federated tokens / OIDC).\n+3. **Managed Identity** — IMDS endpoint (`169.254.169.254`) for virtual machines; `IDENTITY_ENDPOINT` for App Service / Functions / Container Apps. Foundry Agent Service hosted agents use the hosted agent's agent identity.\n+4. **Visual Studio Code** — Azure account extension.\n+5. **Azure CLI** — `az login` session.\n+6. **Azure Developer CLI** — `azd auth login`.\n+7. **Azure PowerShell** — `Connect-AzAccount`.\n+8. **Broker** (Windows / WSL only) — Web Account Manager.\n+\n+Interactive browser credential is excluded by default for unattended Hermes runs; use Azure CLI, Azure Developer CLI, managed identity, workload identity, or service principal credentials instead.\n+\n+### Deployment patterns\n+\n+**Local development:**\n+```bash\n+az login\n+hermes model   # pick Azure Foundry → Entra ID\n+hermes         # uses your az login token\n+```\n+\n+**Azure VM / Functions / App Service / Container Apps (system-assigned managed identity):**\n+1. Enable system-assigned identity on the compute resource.\n+2. Grant the identity `Azure AI User` (or `Foundry User`) on the Foundry resource.\n+3. Set `model.auth_mode: entra_id` in config.yaml — no env vars needed.\n+\n+**Azure VM / Functions / App Service / Container Apps (user-assigned managed identity):**\n+- Set `AZURE_CLIENT_ID` to the user-assigned identity's client ID so `DefaultAzureCredential` picks the right one.\n+\n+**Foundry Agent Service hosted agent:**\n+- Create the hosted agent and grant that agent's identity `Azure AI User` (or `Foundry User`) on the Foundry resource. Hermes uses `ManagedIdentityCredential` from inside the hosted agent; role assignment belongs on the agent identity, not just the parent project or your user.\n+\n+**AKS Workload Identity (replaces AAD Pod Identity):**\n+- Annotate the pod's service account with the workload identity client ID.\n+- The pod's federated token file is auto-detected via `AZURE_FEDERATED_TOKEN_FILE`.\n+- `model.auth_mode: entra_id` works without further config changes.\n+\n+**Service principal in CI:**\n+- Set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` in the runner env.\n+\n+**Sovereign clouds (Government, China):**\n+- Export `AZURE_AUTHORITY_HOST` (e.g. `https://login.microsoftonline.us` for Azure Government, `https://login.partner.microsoftonline.cn` for Azure China). `azure-identity` reads it directly.\n+\n+### Health checks\n+\n+`hermes doctor` runs a 10 s probe against `DefaultAzureCredential` when `model.auth_mode: entra_id`, reporting which inner credential won (env vars present, managed identity endpoint reachable, etc.).\n+\n+`hermes auth` shows a structured status block:\n+\n+```\n+azure-foundry (Microsoft Entra ID):\n+  Endpoint: https://my-resource.openai.azure.com/openai/v1\n+  Scope: https://ai.azure.com/.default\n+  Status: configured; live token probe is skipped here\n+```\n+\n+### Limitations\n+\n+- **Anthropic-style endpoints use an httpx event hook.** The Anthropic Python SDK does not accept a callable `auth_token` natively (≤ 0.86.0). Hermes installs a request event hook on a custom `httpx.Client` that mints a fresh JWT per outbound request and rewrites `Authorization: Bearer <jwt>`. This is functionally equivalent to the OpenAI SDK's native `Callable[[], str]` contract but adds one indirection layer. If the Anthropic SDK adds first-class callable-auth support in a future release, Hermes will switch to it transparently.\n+- **Batch jobs and `multiprocessing.Pool`.** The Entra token provider is a closure that cannot be pickled across process boundaries. `batch_runner.py` automatically drops the callable from the worker config and lets each worker process rebuild its own provider from `config.yaml` — no user action required, but each worker pays one chain walk at startup.\n+- **No bearer JWT persistence in `auth.json`.** Hermes does not duplicate `azure-identity`'s internal token cache; cold starts walk the credential chain on first inference.\n+\n ## Configuration (written to `config.yaml`)\n \n After running the wizard you'll see something like this:","@@ -72,11 +224,11 @@ model:\n \n Important behaviour:\n \n-- **GPT-5.x, codex, and o-series auto-route to the Responses API.** Azure Foundry deploys GPT-5 / codex / o1 / o3 / o4 models as Responses-API-only — calling `/chat/completions` against them returns `400 \"The requested operation is unsupported.\"`. Hermes detects these model families by name and upgrades `api_mode` to `codex_responses` transparently, even when `config.yaml` still reads `api_mode: chat_completions`. GPT-4, GPT-4o, Llama, Mistral, and other deployments stay on `/chat/completions`.\n+- **GPT-5.x, codex, and o-series auto-route to the Responses API.** Microsoft Foundry deploys GPT-5 / codex / o1 / o3 / o4 models as Responses-API-only — calling `/chat/completions` against them returns `400 \"The requested operation is unsupported.\"`. Hermes detects these model families by name and upgrades `api_mode` to `codex_responses` transparently, even when `config.yaml` still reads `api_mode: chat_completions`. GPT-4, GPT-4o, Llama, Mistral, and other deployments stay on `/chat/completions`.\n - **`max_completion_tokens` is used automatically.** Azure OpenAI (like direct OpenAI) requires `max_completion_tokens` for gpt-4o, o-series, and gpt-5.x models. Hermes sends the right parameter based on the endpoint.\n - **Pre-v1 endpoints that require `api-version`.** If you have a legacy base URL like `https://<resource>.openai.azure.com/openai?api-version=2025-04-01-preview`, Hermes extracts the query string and forwards it via `default_query` on every request (the OpenAI SDK otherwise drops it when joining paths).\n \n-## Anthropic-style endpoints (Claude via Azure Foundry)\n+## Anthropic-style endpoints (Claude via Microsoft Foundry)\n \n For Claude deployments, use the Anthropic-style route:\n ","@@ -96,7 +248,7 @@ Important behaviour:\n \n ## Alternative: `provider: anthropic` + Azure base URL\n \n-If you already have `provider: anthropic` configured and just want to point it at Azure AI Foundry for Claude, you can skip the `azure-foundry` provider entirely:\n+If you already have `provider: anthropic` configured and just want to point it at Microsoft Foundry for Claude, you can skip the `azure-foundry` provider entirely:\n \n ```yaml\n model:","@@ -117,7 +269,7 @@ Azure does **not** expose a pure-API-key endpoint to list your *deployed* model\n What Hermes can do:\n \n - Azure OpenAI v1 endpoints (`<resource>.openai.azure.com/openai/v1`) expose `GET /models` with the resource's **available** model catalog. Hermes uses this list to prefill the model picker.\n-- Azure Foundry `/anthropic` routes: detected via URL path, model name entered manually.\n+- Microsoft Foundry `/anthropic` routes: detected via URL path, model name entered manually.\n - Private / firewalled endpoints: manual entry with a friendly \"couldn't probe\" message.\n \n You can always type a deployment name directly — Hermes does not validate against the returned list.","@@ -126,9 +278,18 @@ You can always type a deployment name directly — Hermes does not validate agai\n \n | Variable | Purpose |\n |----------|---------|\n-| `AZURE_FOUNDRY_API_KEY` | Primary API key for Azure AI Foundry / Azure OpenAI |\n+| `AZURE_FOUNDRY_API_KEY` | Primary API key for Microsoft Foundry / Azure OpenAI (api_key mode) |\n | `AZURE_FOUNDRY_BASE_URL` | Endpoint URL (set via `hermes model`; env var is used as a fallback) |\n | `AZURE_ANTHROPIC_KEY` | Used by `provider: anthropic` + Azure base URL (alternative to `ANTHROPIC_API_KEY`) |\n+| `AZURE_TENANT_ID` | Entra ID tenant for service-principal flows |\n+| `AZURE_CLIENT_ID` | Entra ID client ID (service principal, workload identity, or user-assigned managed identity) |\n+| `AZURE_CLIENT_SECRET` | Service principal secret |\n+| `AZURE_CLIENT_CERTIFICATE_PATH` | Service principal cert (alternative to secret) |\n+| `AZURE_FEDERATED_TOKEN_FILE` | Workload Identity federated token path (AKS) |\n+| `AZURE_AUTHORITY_HOST` | Sovereign cloud authority host override |\n+| `IDENTITY_ENDPOINT` / `MSI_ENDPOINT` | Managed Identity endpoint for App Service, Functions, and Container Apps; VMs usually use IMDS instead |\n+\n+The Azure SDK reads the `AZURE_*` env vars directly. Hermes never inspects them other than to report which sources are present in `hermes doctor` output.\n \n ## Troubleshooting\n ","@@ -150,8 +311,21 @@ model:\n   api_mode: anthropic_messages   # or chat_completions\n ```\n \n+**Entra ID: \"credential chain exhausted\" or 401 Unauthorized after switching to `auth_mode: entra_id`.**\n+- Run `az login` to refresh your developer session (the cached token may have expired).\n+- Verify the `Azure AI User` (or `Foundry User`) role assignment took effect: `az role assignment list --assignee <user-or-identity-id>` should list it on your Foundry resource. Role propagation can take up to 5 minutes.\n+- For user-assigned managed identities, double-check `AZURE_CLIENT_ID` matches the identity attached to the compute resource.\n+- Run `hermes doctor` — the Azure Entra probe reports whether token acquisition succeeded and includes a remediation hint.\n+\n+**Entra ID: wizard preflight hangs or times out.**\n+The 10 s preflight is a soft check. Choose \"Save anyway and validate later\" and run `hermes doctor` after deploying to the target environment. Common causes include an unreachable token service or stale local login state — prefer workload identity in CI, set `AZURE_TENANT_ID`+`AZURE_CLIENT_ID`+`AZURE_CLIENT_SECRET` when using a service principal, or run `az login` for local development.\n+\n+**401 on Anthropic-style endpoint with Entra ID.**\n+Verify the same `Azure AI User` (or `Foundry User`) role is assigned on the Foundry resource (it covers both `/openai/v1` and `/anthropic` paths). If the OpenAI-style probe works during the wizard but `claude-*` requests fail at runtime, the most common cause is a stale `model.entra.scope` left over from an earlier wizard run — delete the `entra.scope` line from `config.yaml` so the runtime falls back to the default `https://ai.azure.com/.default` scope.\n+\n ## Related\n \n - [Environment variables](/docs/reference/environment-variables)\n - [Configuration](/docs/user-guide/configuration)\n - [AWS Bedrock](/docs/guides/aws-bedrock) — the other major cloud provider integration\n+- [Microsoft: Configure Entra ID for Foundry](https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id) — upstream documentation for the keyless path"]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -50,9 +50,16 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config\n | `XIAOMI_BASE_URL` | Override Xiaomi MiMo base URL (default: `https://api.xiaomimimo.com/v1`) |\n | `TOKENHUB_API_KEY` | Tencent TokenHub API key ([tokenhub.tencentmaas.com](https://tokenhub.tencentmaas.com)) |\n | `TOKENHUB_BASE_URL` | Override Tencent TokenHub base URL (default: `https://tokenhub.tencentmaas.com/v1`) |\n-| `AZURE_FOUNDRY_API_KEY` | Azure AI Foundry / Azure OpenAI API key ([ai.azure.com](https://ai.azure.com/)) |\n-| `AZURE_FOUNDRY_BASE_URL` | Azure AI Foundry endpoint URL (e.g. `https://<resource>.openai.azure.com/openai/v1` for OpenAI-style, or `https://<resource>.services.ai.azure.com/anthropic` for Anthropic-style) |\n-| `AZURE_ANTHROPIC_KEY` | Azure Anthropic API key for `provider: anthropic` + `base_url` pointing at an Azure Foundry Claude deployment (alternative to `ANTHROPIC_API_KEY` when both Anthropic and Azure Anthropic are configured) |\n+| `AZURE_FOUNDRY_API_KEY` | Microsoft Foundry / Azure OpenAI API key ([ai.azure.com](https://ai.azure.com/)). Not needed when `model.auth_mode: entra_id` |\n+| `AZURE_FOUNDRY_BASE_URL` | Microsoft Foundry endpoint URL (e.g. `https://<resource>.openai.azure.com/openai/v1` for OpenAI-style, or `https://<resource>.services.ai.azure.com/anthropic` for Anthropic-style) |\n+| `AZURE_ANTHROPIC_KEY` | Azure Anthropic API key for `provider: anthropic` + `base_url` pointing at a Microsoft Foundry Claude deployment (alternative to `ANTHROPIC_API_KEY` when both Anthropic and Azure Anthropic are configured) |\n+| `AZURE_TENANT_ID` | Entra ID tenant ID (service-principal flows; honored by `azure-identity` when `model.auth_mode: entra_id`) |\n+| `AZURE_CLIENT_ID` | Entra ID client ID (service principal, workload identity, or user-assigned managed identity) |\n+| `AZURE_CLIENT_SECRET` | Service principal secret used by `EnvironmentCredential` |\n+| `AZURE_CLIENT_CERTIFICATE_PATH` | Service principal certificate (alternative to `AZURE_CLIENT_SECRET`) |\n+| `AZURE_FEDERATED_TOKEN_FILE` | Federated token file path for AKS Workload Identity / OIDC flows |\n+| `AZURE_AUTHORITY_HOST` | Sovereign-cloud authority override (e.g. `https://login.microsoftonline.us` for Azure Government). See [Azure Foundry guide](/docs/guides/azure-foundry#sovereign-clouds-government-china) |\n+| `IDENTITY_ENDPOINT` / `MSI_ENDPOINT` | Managed Identity endpoint for App Service, Functions, and Container Apps; VMs usually use IMDS instead and do not set these |\n | `HF_TOKEN` | Hugging Face token for Inference Providers ([huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)) |\n | `HF_BASE_URL` | Override Hugging Face base URL (default: `https://router.huggingface.co/v1`) |\n | `GOOGLE_API_KEY` | Google AI Studio API key ([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) |"]},{"file":"website/docs/user-guide/features/fallback-providers.md","hunks":["@@ -81,7 +81,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback\n | Kimi / Moonshot (China) | `kimi-coding-cn` | `KIMI_CN_API_KEY` |\n | StepFun | `stepfun` | `STEPFUN_API_KEY` |\n | Tencent TokenHub | `tencent-tokenhub` | `TOKENHUB_API_KEY` |\n-| Azure AI Foundry | `azure-foundry` | `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` |\n+| Microsoft Foundry | `azure-foundry` | `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` |\n | LM Studio (local) | `lmstudio` | `LM_API_KEY` (or none for local) + `LM_BASE_URL` |\n | Hugging Face | `huggingface` | `HF_TOKEN` |\n | Custom endpoint | `custom` | `base_url` + `key_env` (see below) |\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}