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:
@@ -3535,11 +3535,27 @@ def _save_custom_provider(
|
||||
|
||||
|
||||
def _model_flow_azure_foundry(config, current_model=""):
|
||||
"""Azure Foundry provider: configure endpoint, API mode, API key, and model.
|
||||
"""Azure Foundry provider: configure endpoint, auth mode, API mode, and model.
|
||||
|
||||
Azure Foundry supports both OpenAI-style (``/v1/chat/completions``) and
|
||||
Anthropic-style (``/v1/messages``) endpoints. The wizard auto-detects
|
||||
the transport and available models when possible:
|
||||
Anthropic-style (``/v1/messages``) endpoints, and two authentication
|
||||
modes:
|
||||
|
||||
* **API key** (default) — uses ``AZURE_FOUNDRY_API_KEY`` from .env.
|
||||
* **Microsoft Entra ID** — keyless, RBAC-based auth via the
|
||||
``azure-identity`` SDK (Managed Identity / Workload Identity / az
|
||||
login / VS Code / azd / service principal env vars). Works on both
|
||||
OpenAI-style and Anthropic-style endpoints — Microsoft RBAC is
|
||||
per-resource and the same ``Azure AI User`` role grants
|
||||
both. For OpenAI-style the OpenAI SDK's native callable
|
||||
``api_key=`` contract is used; for Anthropic-style an
|
||||
``httpx.Client`` with a request event hook (built by
|
||||
:func:`agent.azure_identity_adapter.build_bearer_http_client`)
|
||||
mints a fresh JWT per request because the Anthropic SDK does not
|
||||
accept a callable ``auth_token`` natively.
|
||||
|
||||
The wizard auto-detects the transport and available models when
|
||||
possible:
|
||||
|
||||
* URLs ending in ``/anthropic`` → Anthropic Messages API.
|
||||
* Successful ``GET <base>/models`` probe → OpenAI-style + populates
|
||||
@@ -3566,9 +3582,14 @@ def _model_flow_azure_foundry(config, current_model=""):
|
||||
if isinstance(model_cfg, dict) and model_cfg.get("provider") == "azure-foundry":
|
||||
current_base_url = str(model_cfg.get("base_url", "") or "")
|
||||
current_api_mode = str(model_cfg.get("api_mode", "") or "")
|
||||
current_auth_mode = str(model_cfg.get("auth_mode") or "api_key").strip().lower() or "api_key"
|
||||
_cur_entra = model_cfg.get("entra") or {}
|
||||
current_entra = _cur_entra if isinstance(_cur_entra, dict) else {}
|
||||
else:
|
||||
current_base_url = ""
|
||||
current_api_mode = ""
|
||||
current_auth_mode = "api_key"
|
||||
current_entra = {}
|
||||
|
||||
current_api_key = get_env_value("AZURE_FOUNDRY_API_KEY") or ""
|
||||
|
||||
@@ -3583,22 +3604,29 @@ def _model_flow_azure_foundry(config, current_model=""):
|
||||
print()
|
||||
|
||||
if current_base_url:
|
||||
print(f" Current endpoint: {current_base_url}")
|
||||
print(f" Current endpoint: {current_base_url}")
|
||||
if current_api_mode:
|
||||
_lbl = (
|
||||
"OpenAI-style"
|
||||
if current_api_mode == "chat_completions"
|
||||
else "Anthropic-style"
|
||||
)
|
||||
print(f" Current API mode: {_lbl}")
|
||||
if current_api_key:
|
||||
print(f" Current API key: {current_api_key[:8]}...")
|
||||
print(f" Current API mode: {_lbl}")
|
||||
if current_auth_mode == "entra_id":
|
||||
print(f" Current auth mode: Microsoft Entra ID (keyless)")
|
||||
elif current_api_key:
|
||||
print(f" Current auth mode: API key ({current_api_key[:8]}...)")
|
||||
print()
|
||||
|
||||
# ── Step 1: endpoint URL ─────────────────────────────────────────
|
||||
try:
|
||||
_placeholder = (
|
||||
current_base_url
|
||||
or "e.g. https://<resource>.openai.azure.com/openai/v1 "
|
||||
"or https://<resource>.services.ai.azure.com/anthropic"
|
||||
)
|
||||
base_url = input(
|
||||
f"API endpoint URL [{current_base_url or 'e.g. https://your-resource.openai.azure.com/openai/v1'}]: "
|
||||
f"API endpoint URL [{_placeholder}]: "
|
||||
).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled.")
|
||||
@@ -3612,25 +3640,125 @@ def _model_flow_azure_foundry(config, current_model=""):
|
||||
print(f"Invalid URL: {effective_url} (must start with http:// or https://)")
|
||||
return
|
||||
|
||||
# ── Step 2: API key ──────────────────────────────────────────────
|
||||
# ── Step 2: authentication mode ──────────────────────────────────
|
||||
print()
|
||||
print("Authentication:")
|
||||
print(" 1. API key (AZURE_FOUNDRY_API_KEY in .env)")
|
||||
print(" 2. Microsoft Entra ID (managed identity / workload identity / az login)")
|
||||
print(" Recommended by Microsoft. Works for both OpenAI-style and Anthropic-style endpoints.")
|
||||
print(" Requires the 'Azure AI User' role on the Foundry resource.")
|
||||
try:
|
||||
api_key = getpass.getpass(
|
||||
f"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: "
|
||||
).strip()
|
||||
_auth_default = "2" if current_auth_mode == "entra_id" else "1"
|
||||
auth_choice = (
|
||||
input(f"Authentication mode [1/2] ({_auth_default}): ").strip()
|
||||
or _auth_default
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled.")
|
||||
return
|
||||
use_entra = auth_choice == "2"
|
||||
auth_mode_label = "entra_id" if use_entra else "api_key"
|
||||
|
||||
effective_key = api_key or current_api_key
|
||||
if not effective_key:
|
||||
print("No API key provided. Cancelled.")
|
||||
return
|
||||
# ── Step 3: credentials (key OR Entra preflight) ─────────────────
|
||||
effective_key: str = ""
|
||||
entra_overrides: dict = {}
|
||||
token_provider = None # callable when entra
|
||||
entra_scope = ""
|
||||
|
||||
# ── Step 3: auto-detect transport + models ───────────────────────
|
||||
if use_entra:
|
||||
try:
|
||||
from agent.azure_identity_adapter import (
|
||||
EntraIdentityConfig,
|
||||
SCOPE_AI_AZURE_DEFAULT,
|
||||
build_token_provider,
|
||||
describe_active_credential,
|
||||
has_azure_identity_installed,
|
||||
)
|
||||
except ImportError as exc:
|
||||
print()
|
||||
print(f"⚠ Could not import azure-identity adapter: {exc}")
|
||||
print(" Falling back to API key auth.")
|
||||
use_entra = False
|
||||
auth_mode_label = "api_key"
|
||||
|
||||
if use_entra:
|
||||
print()
|
||||
if not has_azure_identity_installed():
|
||||
print("◐ The 'azure-identity' package is not installed yet.")
|
||||
print(
|
||||
" Hermes will install it now (the preflight below "
|
||||
"triggers the lazy-install). To skip lazy installs, "
|
||||
"run: pip install azure-identity"
|
||||
)
|
||||
|
||||
# Preserve only the optional scope override. Identity selection
|
||||
# (tenant, user-assigned MI, workload identity, service principal)
|
||||
# stays in Azure SDK env vars such as AZURE_CLIENT_ID.
|
||||
_persisted_scope_override = str(current_entra.get("scope") or "").strip()
|
||||
entra_scope = _persisted_scope_override or SCOPE_AI_AZURE_DEFAULT
|
||||
|
||||
entra_overrides = {}
|
||||
if _persisted_scope_override:
|
||||
entra_overrides["scope"] = _persisted_scope_override
|
||||
|
||||
print()
|
||||
print("◐ Probing Microsoft Entra ID credential chain (up to 10s)...")
|
||||
_config = EntraIdentityConfig(
|
||||
scope=entra_scope,
|
||||
)
|
||||
info = describe_active_credential(config=_config, timeout_seconds=10.0)
|
||||
if info.get("ok"):
|
||||
env_sources = info.get("env_sources") or []
|
||||
tag = ", ".join(env_sources) if env_sources else "default chain"
|
||||
print(f"✓ Entra ID token acquired ({tag}, scope={entra_scope})")
|
||||
else:
|
||||
err = info.get("error") or "credential chain exhausted"
|
||||
hint = info.get("hint") or (
|
||||
"Run `az login`, attach a managed identity to this VM, or "
|
||||
"set AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET."
|
||||
)
|
||||
print(f"⚠ {err}")
|
||||
print(f" Hint: {hint}")
|
||||
try:
|
||||
ans = input("Save Entra config anyway and validate later? [Y/n]: ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled.")
|
||||
return
|
||||
if ans and ans not in ("y", "yes"):
|
||||
print("Cancelled.")
|
||||
return
|
||||
|
||||
# Build the token provider for the detection probe (best-effort —
|
||||
# if the credential chain failed above, this will silently return
|
||||
# None inside azure_detect and the probe falls back to manual).
|
||||
try:
|
||||
token_provider = build_token_provider(config=_config)
|
||||
except Exception as exc:
|
||||
print(f"⚠ Could not build token provider for probing: {exc}")
|
||||
token_provider = None
|
||||
else:
|
||||
print()
|
||||
try:
|
||||
api_key = getpass.getpass(
|
||||
f"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: "
|
||||
).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled.")
|
||||
return
|
||||
|
||||
effective_key = api_key or current_api_key
|
||||
if not effective_key:
|
||||
print("No API key provided. Cancelled.")
|
||||
return
|
||||
|
||||
# ── Step 4: auto-detect transport + models ───────────────────────
|
||||
print()
|
||||
print("◐ Probing endpoint to auto-detect transport and models...")
|
||||
detection = azure_detect.detect(effective_url, effective_key)
|
||||
detection = azure_detect.detect(
|
||||
effective_url,
|
||||
api_key=effective_key,
|
||||
token_provider=token_provider,
|
||||
)
|
||||
|
||||
discovered_models: list[str] = list(detection.models)
|
||||
api_mode: str = detection.api_mode or ""
|
||||
@@ -3665,7 +3793,7 @@ def _model_flow_azure_foundry(config, current_model=""):
|
||||
return
|
||||
api_mode = "anthropic_messages" if mode_choice == "2" else "chat_completions"
|
||||
|
||||
# ── Step 4: model name ───────────────────────────────────────────
|
||||
# ── Step 5: model name ───────────────────────────────────────────
|
||||
print()
|
||||
effective_model = ""
|
||||
if discovered_models:
|
||||
@@ -3704,15 +3832,17 @@ def _model_flow_azure_foundry(config, current_model=""):
|
||||
print("No model name provided. Cancelled.")
|
||||
return
|
||||
|
||||
# ── Step 5: context-length lookup ────────────────────────────────
|
||||
# ── Step 6: context-length lookup ────────────────────────────────
|
||||
ctx_len = azure_detect.lookup_context_length(
|
||||
effective_model,
|
||||
effective_url,
|
||||
effective_key,
|
||||
api_key=effective_key,
|
||||
token_provider=token_provider,
|
||||
)
|
||||
|
||||
# ── Step 6: persist ──────────────────────────────────────────────
|
||||
save_env_value("AZURE_FOUNDRY_API_KEY", effective_key)
|
||||
# ── Step 7: persist ──────────────────────────────────────────────
|
||||
if not use_entra:
|
||||
save_env_value("AZURE_FOUNDRY_API_KEY", effective_key)
|
||||
|
||||
cfg = load_config()
|
||||
model = cfg.get("model")
|
||||
@@ -3724,6 +3854,22 @@ def _model_flow_azure_foundry(config, current_model=""):
|
||||
model["base_url"] = effective_url
|
||||
model["api_mode"] = api_mode
|
||||
model["default"] = effective_model
|
||||
model["auth_mode"] = auth_mode_label
|
||||
if use_entra:
|
||||
# Persist only the non-default Entra scope so config.yaml stays tidy.
|
||||
# Azure identity selection stays in standard AZURE_* env vars.
|
||||
clean_entra: dict = {}
|
||||
for key in ("scope",):
|
||||
val = entra_overrides.get(key)
|
||||
if val:
|
||||
clean_entra[key] = val
|
||||
if clean_entra:
|
||||
model["entra"] = clean_entra
|
||||
elif "entra" in model:
|
||||
del model["entra"]
|
||||
else:
|
||||
if "entra" in model:
|
||||
del model["entra"]
|
||||
if ctx_len:
|
||||
model["context_length"] = ctx_len
|
||||
|
||||
@@ -3739,10 +3885,14 @@ def _model_flow_azure_foundry(config, current_model=""):
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
|
||||
mode_label = "OpenAI-style" if api_mode == "chat_completions" else "Anthropic-style"
|
||||
auth_label = (
|
||||
"Microsoft Entra ID (keyless)" if use_entra else "API key"
|
||||
)
|
||||
print()
|
||||
print("✓ Azure Foundry configured:")
|
||||
print(f" Endpoint: {effective_url}")
|
||||
print(f" API mode: {mode_label}")
|
||||
print(f" Auth: {auth_label}")
|
||||
print(f" Model: {effective_model}")
|
||||
if ctx_len:
|
||||
print(f" Context length: {ctx_len:,} tokens")
|
||||
|
||||
Reference in New Issue
Block a user