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:
committed by
Teknium
co-authored by
Copilot
parent
457fa913b8
commit
9df9816dab
+126
-20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user