mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 14:23:58 +00:00
Add support for Azure AAD based Auth
This commit is contained in:
parent
39454534d4
commit
ba3fa38e97
@ -205,6 +205,72 @@ Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Azure OpenAI</b></summary>
|
||||
|
||||
The `azure_openai` provider talks to your Azure OpenAI resource via the OpenAI **Responses API** (`/openai/v1/responses`). Model names map to **deployment names**, not OpenAI model IDs. Two authentication modes are supported.
|
||||
|
||||
**Mode 1: Static API key** (simplest)
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"azure_openai": {
|
||||
"apiKey": "${AZURE_OPENAI_API_KEY}",
|
||||
"apiBase": "https://my-resource.openai.azure.com"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "azure_openai",
|
||||
"model": "my-gpt-5-deployment"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Mode 2: Microsoft Entra ID (Azure AD) via `DefaultAzureCredential`**
|
||||
|
||||
Omit `apiKey` (or leave it empty / unset). The provider falls back to [`DefaultAzureCredential`](https://learn.microsoft.com/azure/developer/python/sdk/authentication/credential-chains#defaultazurecredential-overview) and acquires a bearer token scoped to `https://cognitiveservices.azure.com/.default` for every request. The Azure SDK's own MSAL-backed cache returns valid tokens without a network round-trip.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"azure_openai": {
|
||||
"apiBase": "https://my-resource.openai.azure.com"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "azure_openai",
|
||||
"model": "my-gpt-5-deployment"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Install the optional dependency:
|
||||
|
||||
```bash
|
||||
pip install 'nanobot-ai[azure]'
|
||||
```
|
||||
|
||||
`DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds:
|
||||
|
||||
1. **EnvironmentCredential** — reads `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and one of `AZURE_CLIENT_SECRET` / `AZURE_CLIENT_CERTIFICATE_PATH` / `AZURE_USERNAME` + `AZURE_PASSWORD`.
|
||||
2. **WorkloadIdentityCredential** — for AKS workload-identity / federated tokens (`AZURE_FEDERATED_TOKEN_FILE`).
|
||||
3. **ManagedIdentityCredential** — for Azure VMs, App Service, Functions, Container Apps, etc.
|
||||
4. **AzureCliCredential** — uses the token from `az login` on your dev machine.
|
||||
5. **AzurePowerShellCredential** — uses the token from `Connect-AzAccount`.
|
||||
6. **AzureDeveloperCliCredential** — uses the token from `azd auth login`.
|
||||
7. **InteractiveBrowserCredential** *(disabled by default)*.
|
||||
|
||||
The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request.
|
||||
|
||||
> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `pip install 'nanobot-ai[azure]'`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Skywork / APIFree</b></summary>
|
||||
|
||||
|
||||
@ -3,6 +3,18 @@
|
||||
Uses ``AsyncOpenAI`` pointed at ``https://{endpoint}/openai/v1/`` which
|
||||
routes to the Responses API (``/responses``). Reuses shared conversion
|
||||
helpers from :mod:`nanobot.providers.openai_responses`.
|
||||
|
||||
Authentication
|
||||
--------------
|
||||
Two modes are supported, selected automatically:
|
||||
|
||||
1. **Static API key** — when ``api_key`` is non-empty it is sent as the
|
||||
``api-key`` / ``Authorization: Bearer`` header (existing behavior).
|
||||
2. **Microsoft Entra ID (AAD)** — when ``api_key`` is empty the provider
|
||||
falls back to :class:`azure.identity.aio.DefaultAzureCredential` and
|
||||
acquires a bearer token scoped to
|
||||
``https://cognitiveservices.azure.com/.default``. ``azure-identity``
|
||||
is an optional dependency installed via ``pip install nanobot-ai[azure]``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -21,6 +33,48 @@ from nanobot.providers.openai_responses import (
|
||||
parse_response_output,
|
||||
)
|
||||
|
||||
_AZURE_OPENAI_SCOPE = "https://cognitiveservices.azure.com/.default"
|
||||
|
||||
|
||||
class _AzureTokenProvider:
|
||||
"""Async bearer-token callback for AAD authentication.
|
||||
|
||||
Thin wrapper around :class:`azure.identity.aio.DefaultAzureCredential`
|
||||
that exposes itself as an async callable returning a fresh bearer
|
||||
token. The Azure SDK's own MSAL-backed token cache already returns
|
||||
valid tokens without network calls, so no extra caching is layered on
|
||||
top here.
|
||||
|
||||
Raises ``RuntimeError`` with a clear install hint if
|
||||
``azure-identity`` is not installed.
|
||||
"""
|
||||
|
||||
def __init__(self, scope: str = _AZURE_OPENAI_SCOPE) -> None:
|
||||
try:
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Azure OpenAI AAD authentication requires the 'azure-identity' package. "
|
||||
"Install it with: pip install 'nanobot-ai[azure]'"
|
||||
) from exc
|
||||
|
||||
self._scope = scope
|
||||
self._credential = DefaultAzureCredential()
|
||||
|
||||
async def __call__(self) -> str:
|
||||
"""Return a bearer token for the configured scope."""
|
||||
access_token = await self._credential.get_token(self._scope)
|
||||
return access_token.token
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Release credential resources. Safe to call multiple times."""
|
||||
close = getattr(self._credential, "close", None)
|
||||
if close is not None:
|
||||
try:
|
||||
await close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class AzureOpenAIProvider(LLMProvider):
|
||||
"""Azure OpenAI provider backed by the Responses API.
|
||||
@ -31,6 +85,8 @@ class AzureOpenAIProvider(LLMProvider):
|
||||
- Calls ``client.responses.create()`` (Responses API)
|
||||
- Reuses shared message/tool/SSE conversion from
|
||||
``openai_responses``
|
||||
- Falls back to :class:`DefaultAzureCredential` (AAD) when ``api_key``
|
||||
is empty. See module docstring for details.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@ -42,8 +98,6 @@ class AzureOpenAIProvider(LLMProvider):
|
||||
super().__init__(api_key, api_base)
|
||||
self.default_model = default_model
|
||||
|
||||
if not api_key:
|
||||
raise ValueError("Azure OpenAI api_key is required")
|
||||
if not api_base:
|
||||
raise ValueError("Azure OpenAI api_base is required")
|
||||
|
||||
@ -52,10 +106,22 @@ class AzureOpenAIProvider(LLMProvider):
|
||||
api_base += "/"
|
||||
self.api_base = api_base
|
||||
|
||||
# Select auth mode. A truthy api_key wins; otherwise fall back to
|
||||
# AAD via DefaultAzureCredential. The OpenAI SDK accepts an async
|
||||
# callable as ``api_key`` and invokes it per request, using the
|
||||
# returned string as the bearer token.
|
||||
self._token_provider: _AzureTokenProvider | None = None
|
||||
client_api_key: str | Callable[[], Awaitable[str]]
|
||||
if api_key:
|
||||
client_api_key = api_key
|
||||
else:
|
||||
self._token_provider = _AzureTokenProvider()
|
||||
client_api_key = self._token_provider
|
||||
|
||||
# SDK client targeting the Azure Responses API endpoint
|
||||
base_url = f"{api_base.rstrip('/')}/openai/v1/"
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
api_key=client_api_key,
|
||||
base_url=base_url,
|
||||
default_headers={"x-session-affinity": uuid.uuid4().hex},
|
||||
max_retries=0,
|
||||
|
||||
@ -44,8 +44,8 @@ def _make_provider_core(
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
|
||||
if backend == "azure_openai":
|
||||
if not p or not p.api_key or not p.api_base:
|
||||
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
|
||||
if not p or not p.api_base:
|
||||
raise ValueError("Azure OpenAI requires api_base in config.")
|
||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||
needs_key = not (p and p.api_key)
|
||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
||||
@ -60,7 +60,7 @@ def _make_provider_core(
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key=p.api_key,
|
||||
api_key=p.api_key or "",
|
||||
api_base=p.api_base,
|
||||
default_model=model,
|
||||
)
|
||||
|
||||
@ -68,6 +68,9 @@ dependencies = [
|
||||
api = [
|
||||
"aiohttp>=3.9.0,<4.0.0",
|
||||
]
|
||||
azure = [
|
||||
"azure-identity>=1.19.0,<2.0.0",
|
||||
]
|
||||
wecom = [
|
||||
"wecom-aibot-sdk-python>=0.1.5",
|
||||
]
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
"""Test Azure OpenAI provider (Responses API via OpenAI SDK)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
from nanobot.providers.azure_openai_provider import (
|
||||
AzureOpenAIProvider,
|
||||
_AzureTokenProvider,
|
||||
)
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Init & validation
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -25,6 +30,8 @@ def test_init_creates_sdk_client():
|
||||
assert provider.default_model == "gpt-4o-deployment"
|
||||
# SDK client base_url ends with /openai/v1/
|
||||
assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1")
|
||||
# Static-key path must NOT construct an AAD token provider
|
||||
assert provider._token_provider is None
|
||||
|
||||
|
||||
def test_init_base_url_no_trailing_slash():
|
||||
@ -42,11 +49,6 @@ def test_init_base_url_with_trailing_slash():
|
||||
assert str(provider._client.base_url).rstrip("/").endswith("/openai/v1")
|
||||
|
||||
|
||||
def test_init_validation_missing_key():
|
||||
with pytest.raises(ValueError, match="Azure OpenAI api_key is required"):
|
||||
AzureOpenAIProvider(api_key="", api_base="https://test.com")
|
||||
|
||||
|
||||
def test_init_validation_missing_base():
|
||||
with pytest.raises(ValueError, match="Azure OpenAI api_base is required"):
|
||||
AzureOpenAIProvider(api_key="test", api_base="")
|
||||
@ -59,6 +61,96 @@ def test_no_api_version_in_base_url():
|
||||
assert "api-version" not in base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AAD / DefaultAzureCredential fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_fake_azure_identity(monkeypatch, credential_factory):
|
||||
"""Install a fake ``azure.identity.aio`` module exposing ``DefaultAzureCredential``."""
|
||||
azure_mod = sys.modules.get("azure") or SimpleNamespace()
|
||||
identity_mod = SimpleNamespace()
|
||||
aio_mod = SimpleNamespace(DefaultAzureCredential=credential_factory)
|
||||
identity_mod.aio = aio_mod
|
||||
azure_mod.identity = identity_mod # type: ignore[attr-defined]
|
||||
|
||||
monkeypatch.setitem(sys.modules, "azure", azure_mod)
|
||||
monkeypatch.setitem(sys.modules, "azure.identity", identity_mod)
|
||||
monkeypatch.setitem(sys.modules, "azure.identity.aio", aio_mod)
|
||||
|
||||
|
||||
def test_init_missing_key_uses_aad_token_provider(monkeypatch):
|
||||
"""Empty api_key falls back to DefaultAzureCredential via _AzureTokenProvider."""
|
||||
credential_instance = MagicMock()
|
||||
credential_factory = MagicMock(return_value=credential_instance)
|
||||
_install_fake_azure_identity(monkeypatch, credential_factory)
|
||||
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="", api_base="https://res.openai.azure.com",
|
||||
)
|
||||
|
||||
assert provider._token_provider is not None
|
||||
assert isinstance(provider._token_provider, _AzureTokenProvider)
|
||||
# DefaultAzureCredential must have been instantiated exactly once
|
||||
credential_factory.assert_called_once_with()
|
||||
# The SDK client must have received the token provider as its api_key
|
||||
# (the SDK stores it on the auth wrapper, not directly accessible — so
|
||||
# we assert the callable was wired in via the provider attribute).
|
||||
assert provider._token_provider._credential is credential_instance
|
||||
|
||||
|
||||
def test_init_explicit_key_does_not_construct_credential(monkeypatch):
|
||||
"""Explicit api_key wins; DefaultAzureCredential must never be touched."""
|
||||
credential_factory = MagicMock(side_effect=AssertionError(
|
||||
"DefaultAzureCredential must not be constructed when api_key is set"
|
||||
))
|
||||
_install_fake_azure_identity(monkeypatch, credential_factory)
|
||||
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key="real-key", api_base="https://res.openai.azure.com",
|
||||
)
|
||||
|
||||
assert provider._token_provider is None
|
||||
credential_factory.assert_not_called()
|
||||
|
||||
|
||||
def test_init_missing_key_without_azure_identity_raises(monkeypatch):
|
||||
"""Clear RuntimeError with pip-install hint when azure-identity is missing."""
|
||||
# Force the import inside _AzureTokenProvider to fail.
|
||||
real_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "azure.identity.aio":
|
||||
raise ImportError("No module named 'azure.identity.aio'")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=fake_import):
|
||||
with pytest.raises(RuntimeError, match=r"pip install 'nanobot-ai\[azure\]'"):
|
||||
AzureOpenAIProvider(api_key="", api_base="https://res.openai.azure.com")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_provider_returns_credential_token(monkeypatch):
|
||||
"""Token callback delegates to DefaultAzureCredential.get_token with the AOAI scope."""
|
||||
access_token = SimpleNamespace(token="token-A", expires_on=time.time() + 3600)
|
||||
credential_instance = MagicMock()
|
||||
credential_instance.get_token = AsyncMock(return_value=access_token)
|
||||
|
||||
credential_factory = MagicMock(return_value=credential_instance)
|
||||
_install_fake_azure_identity(monkeypatch, credential_factory)
|
||||
|
||||
tp = _AzureTokenProvider()
|
||||
|
||||
assert await tp() == "token-A"
|
||||
credential_instance.get_token.assert_awaited_with(
|
||||
"https://cognitiveservices.azure.com/.default"
|
||||
)
|
||||
# No client-side caching layer — every call delegates to the Azure SDK,
|
||||
# which has its own MSAL-backed token cache.
|
||||
assert await tp() == "token-A"
|
||||
assert credential_instance.get_token.await_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _supports_temperature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user