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

Use azure-identity DefaultAzureCredential for keyless Foundry auth.

Preserve refreshable callable credentials through OpenAI and Anthropic client paths.

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
glennc
2026-05-18 10:14:38 -07:00
committed by Teknium
co-authored by Copilot
parent 457fa913b8
commit 9df9816dab
38 changed files with 3772 additions and 122 deletions
+126 -20
View File
@@ -1,6 +1,6 @@
"""Azure Foundry endpoint auto-detection.
Inspect an Azure AI Foundry / Azure OpenAI endpoint to determine:
Inspect a Microsoft Foundry / Azure OpenAI endpoint to determine:
- API transport (OpenAI-style ``chat_completions`` vs
Anthropic-style ``anthropic_messages``)
- Available models (best effort — Azure does not expose a deployment
@@ -19,6 +19,16 @@ rather than the user's *deployed* deployment names. In practice it is
still a useful hint — the user picks a familiar model name and we look
up its context length from the catalog.
Authentication modes:
- ``api_key`` (default): the wizard passes an ``api_key`` string; the
probe sends both ``api-key:`` and ``Authorization: Bearer`` headers
so we hit any Azure deployment regardless of which header it expects.
- ``entra_id``: the wizard passes a ``token_provider`` callable from
:mod:`agent.azure_identity_adapter`. The probe mints exactly one
bearer JWT, sends **only** ``Authorization: Bearer <jwt>`` (never
``api-key:``), and never persists the token. This matches Microsoft's
documented contract for keyless inference.
The detector never crashes on errors (every HTTP call is wrapped in a
broad try/except). Callers get a :class:`DetectionResult` with whatever
information could be gathered, and fall back to manual entry for the
@@ -31,7 +41,7 @@ import json
import logging
import re
from dataclasses import dataclass, field
from typing import Optional
from typing import Any, Callable, Optional
from urllib import request as urllib_request
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
@@ -79,15 +89,73 @@ class DetectionResult:
is_anthropic: bool = False
def _http_get_json(url: str, api_key: str, timeout: float = 6.0) -> tuple[int, Optional[dict]]:
"""GET a URL with ``api-key`` + ``Authorization`` headers. Return
def _resolve_credential(api_key: Any,
token_provider: Optional[Callable[[], str]] = None,
) -> tuple[Optional[str], str]:
"""Coerce wizard inputs into a (token, mode) pair.
Returns ``(token_or_None, mode)`` where ``mode`` is:
- ``"entra_id"`` when a callable token provider was supplied — the
returned token is a freshly minted bearer JWT, sent ONLY in
``Authorization: Bearer``.
- ``"api_key"`` when a string key was supplied — the returned token
is the raw API key, sent in BOTH ``api-key:`` and
``Authorization: Bearer`` headers (preserves the original
broad-compat probe behaviour).
- ``("", "api_key")`` when neither yields a value.
Bearer minting failures degrade to ``("", "entra_id")`` so the caller
can still report "detection incomplete" rather than crashing.
"""
# Token-provider path (callable wins when both supplied).
if token_provider is not None and callable(token_provider):
try:
token = token_provider()
return (str(token) if token else None), "entra_id"
except Exception as exc:
logger.debug("azure_detect: token_provider failed: %s", exc)
return None, "entra_id"
if callable(api_key) and not isinstance(api_key, str):
try:
token = api_key()
return (str(token) if token else None), "entra_id"
except Exception as exc:
logger.debug("azure_detect: api_key callable failed: %s", exc)
return None, "entra_id"
# API-key path.
if isinstance(api_key, str) and api_key:
return api_key, "api_key"
return None, "api_key"
def _apply_auth_headers(req: urllib_request.Request,
token: Optional[str],
mode: str) -> None:
"""Attach the right auth headers to ``req`` based on credential mode."""
if not token:
return
if mode == "entra_id":
# Bearer-only: do NOT also set api-key, which would log a JWT in
# a header slot intended for static keys.
req.add_header("Authorization", f"Bearer {token}")
else:
# Legacy broad-compat behaviour: send both headers so we land on
# any Azure resource regardless of which it accepts.
req.add_header("api-key", token)
req.add_header("Authorization", f"Bearer {token}")
def _http_get_json(url: str,
api_key: Any,
timeout: float = 6.0,
*,
token_provider: Optional[Callable[[], str]] = None,
) -> tuple[int, Optional[dict]]:
"""GET a URL with the appropriate auth headers. Return
``(status_code, parsed_json_or_None)``. Never raises."""
token, mode = _resolve_credential(api_key, token_provider)
req = urllib_request.Request(url, method="GET")
# Azure OpenAI uses ``api-key``. Some Azure deployments (and
# Anthropic-style routes) use ``Authorization: Bearer``. Send both
# so we probe once per URL rather than twice.
req.add_header("api-key", api_key)
req.add_header("Authorization", f"Bearer {api_key}")
_apply_auth_headers(req, token, mode)
req.add_header("User-Agent", "hermes-agent/azure-detect")
try:
with urllib_request.urlopen(req, timeout=timeout) as resp:
@@ -140,7 +208,11 @@ def _extract_model_ids(payload: dict) -> list[str]:
return ids
def _probe_openai_models(base_url: str, api_key: str) -> tuple[bool, list[str]]:
def _probe_openai_models(base_url: str,
api_key: Any,
*,
token_provider: Optional[Callable[[], str]] = None,
) -> tuple[bool, list[str]]:
"""Probe ``<base>/models`` for an OpenAI-shaped response.
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]]:
candidates.append(f"{base_url}/models?api-version={v}")
for url in candidates:
status, body = _http_get_json(url, api_key)
status, body = _http_get_json(url, api_key, token_provider=token_provider)
if status == 200 and body is not None:
ids = _extract_model_ids(body)
if ids:
@@ -172,7 +244,11 @@ def _probe_openai_models(base_url: str, api_key: str) -> tuple[bool, list[str]]:
return False, []
def _probe_anthropic_messages(base_url: str, api_key: str) -> bool:
def _probe_anthropic_messages(base_url: str,
api_key: Any,
*,
token_provider: Optional[Callable[[], str]] = None,
) -> bool:
"""Send a zero-token request to ``<base>/v1/messages`` and check
whether the endpoint at least *recognises* the Anthropic Messages
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:
"messages": [{"role": "user", "content": "ping"}],
}).encode("utf-8")
req = urllib_request.Request(url, method="POST", data=payload)
req.add_header("api-key", api_key)
req.add_header("Authorization", f"Bearer {api_key}")
token, mode = _resolve_credential(api_key, token_provider)
_apply_auth_headers(req, token, mode)
req.add_header("anthropic-version", "2023-06-01")
req.add_header("content-type", "application/json")
req.add_header("User-Agent", "hermes-agent/azure-detect")
@@ -218,13 +294,23 @@ def _probe_anthropic_messages(base_url: str, api_key: str) -> bool:
return False
def detect(base_url: str, api_key: str) -> DetectionResult:
def detect(base_url: str,
api_key: Any = "",
*,
token_provider: Optional[Callable[[], str]] = None,
) -> DetectionResult:
"""Inspect an Azure endpoint and describe its transport + models.
Call this from the wizard before asking the user to pick an API
mode manually. The caller should treat the returned
:class:`DetectionResult` as *advisory* — if ``api_mode`` is None,
fall back to asking the user.
``api_key`` may be a string (legacy API-key auth — sends both
``api-key:`` and ``Authorization: Bearer``) or a callable returning
a bearer JWT (Entra ID auth — sends ONLY ``Authorization: Bearer``).
``token_provider`` is an alternative explicit name for the callable
form; if both are supplied the callable wins.
"""
result = DetectionResult()
@@ -244,7 +330,7 @@ def detect(base_url: str, api_key: str) -> DetectionResult:
# 2. Try the OpenAI-style /models probe. If this works, the
# endpoint definitely speaks OpenAI wire.
ok, models = _probe_openai_models(base_url, api_key)
ok, models = _probe_openai_models(base_url, api_key, token_provider=token_provider)
if ok:
result.models_probe_ok = True
result.models = models
@@ -259,7 +345,7 @@ def detect(base_url: str, api_key: str) -> DetectionResult:
# 3. Fallback: probe the Anthropic Messages shape. Slower and more
# intrusive than /models, so only run it when the OpenAI probe
# failed.
if _probe_anthropic_messages(base_url, api_key):
if _probe_anthropic_messages(base_url, api_key, token_provider=token_provider):
result.is_anthropic = True
result.api_mode = "anthropic_messages"
result.reason = "Endpoint accepts Anthropic Messages shape"
@@ -273,11 +359,26 @@ def detect(base_url: str, api_key: str) -> DetectionResult:
return result
def lookup_context_length(model: str, base_url: str, api_key: str) -> Optional[int]:
def lookup_context_length(model: str,
base_url: str,
api_key: Any = "",
*,
token_provider: Optional[Callable[[], str]] = None,
) -> Optional[int]:
"""Thin wrapper around :func:`agent.model_metadata.get_model_context_length`
that returns ``None`` when only the fallback default (128k) would
fire, so the wizard can distinguish "we actually know this" from
"we guessed."""
"we guessed.
For Entra-ID mode pass a callable as ``api_key`` (or via
``token_provider=``); the wrapped resolver expects a string, so we
mint one bearer JWT here for the single lookup. The resolver itself
only reads catalog metadata over HTTP — no SDK client is built — so
the minted token is consumed for at most one /models probe.
"""
model_id = str(model or "").strip()
if not model_id:
return None
try:
from agent.model_metadata import (
DEFAULT_FALLBACK_CONTEXT,
@@ -286,8 +387,13 @@ def lookup_context_length(model: str, base_url: str, api_key: str) -> Optional[i
except Exception:
return None
# Resolve the credential once. For Entra mode this calls the token
# provider; for legacy api_key this is a no-op string pass-through.
token, mode = _resolve_credential(api_key, token_provider)
effective_key = token or ""
try:
n = get_model_context_length(model, base_url=base_url, api_key=api_key)
n = get_model_context_length(model_id, base_url=base_url, api_key=effective_key)
except Exception as exc:
logger.debug("azure_detect: context length lookup failed: %s", exc)
return None