mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +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>
|
||||||
|
|
||||||
|
<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>
|
<details>
|
||||||
<summary><b>Skywork / APIFree</b></summary>
|
<summary><b>Skywork / APIFree</b></summary>
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,18 @@
|
|||||||
Uses ``AsyncOpenAI`` pointed at ``https://{endpoint}/openai/v1/`` which
|
Uses ``AsyncOpenAI`` pointed at ``https://{endpoint}/openai/v1/`` which
|
||||||
routes to the Responses API (``/responses``). Reuses shared conversion
|
routes to the Responses API (``/responses``). Reuses shared conversion
|
||||||
helpers from :mod:`nanobot.providers.openai_responses`.
|
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
|
from __future__ import annotations
|
||||||
@ -21,6 +33,48 @@ from nanobot.providers.openai_responses import (
|
|||||||
parse_response_output,
|
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):
|
class AzureOpenAIProvider(LLMProvider):
|
||||||
"""Azure OpenAI provider backed by the Responses API.
|
"""Azure OpenAI provider backed by the Responses API.
|
||||||
@ -31,6 +85,8 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
- Calls ``client.responses.create()`` (Responses API)
|
- Calls ``client.responses.create()`` (Responses API)
|
||||||
- Reuses shared message/tool/SSE conversion from
|
- Reuses shared message/tool/SSE conversion from
|
||||||
``openai_responses``
|
``openai_responses``
|
||||||
|
- Falls back to :class:`DefaultAzureCredential` (AAD) when ``api_key``
|
||||||
|
is empty. See module docstring for details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -42,8 +98,6 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
super().__init__(api_key, api_base)
|
super().__init__(api_key, api_base)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
|
|
||||||
if not api_key:
|
|
||||||
raise ValueError("Azure OpenAI api_key is required")
|
|
||||||
if not api_base:
|
if not api_base:
|
||||||
raise ValueError("Azure OpenAI api_base is required")
|
raise ValueError("Azure OpenAI api_base is required")
|
||||||
|
|
||||||
@ -52,10 +106,22 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
api_base += "/"
|
api_base += "/"
|
||||||
self.api_base = 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
|
# SDK client targeting the Azure Responses API endpoint
|
||||||
base_url = f"{api_base.rstrip('/')}/openai/v1/"
|
base_url = f"{api_base.rstrip('/')}/openai/v1/"
|
||||||
self._client = AsyncOpenAI(
|
self._client = AsyncOpenAI(
|
||||||
api_key=api_key,
|
api_key=client_api_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
default_headers={"x-session-affinity": uuid.uuid4().hex},
|
default_headers={"x-session-affinity": uuid.uuid4().hex},
|
||||||
max_retries=0,
|
max_retries=0,
|
||||||
|
|||||||
@ -44,8 +44,8 @@ def _make_provider_core(
|
|||||||
backend = spec.backend if spec else "openai_compat"
|
backend = spec.backend if spec else "openai_compat"
|
||||||
|
|
||||||
if backend == "azure_openai":
|
if backend == "azure_openai":
|
||||||
if not p or not p.api_key or not p.api_base:
|
if not p or not p.api_base:
|
||||||
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
|
raise ValueError("Azure OpenAI requires api_base in config.")
|
||||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||||
needs_key = not (p and p.api_key)
|
needs_key = not (p and p.api_key)
|
||||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
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
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||||
|
|
||||||
provider = AzureOpenAIProvider(
|
provider = AzureOpenAIProvider(
|
||||||
api_key=p.api_key,
|
api_key=p.api_key or "",
|
||||||
api_base=p.api_base,
|
api_base=p.api_base,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -68,6 +68,9 @@ dependencies = [
|
|||||||
api = [
|
api = [
|
||||||
"aiohttp>=3.9.0,<4.0.0",
|
"aiohttp>=3.9.0,<4.0.0",
|
||||||
]
|
]
|
||||||
|
azure = [
|
||||||
|
"azure-identity>=1.19.0,<2.0.0",
|
||||||
|
]
|
||||||
wecom = [
|
wecom = [
|
||||||
"wecom-aibot-sdk-python>=0.1.5",
|
"wecom-aibot-sdk-python>=0.1.5",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
"""Test Azure OpenAI provider (Responses API via OpenAI SDK)."""
|
"""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
|
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
|
from nanobot.providers.base import LLMResponse
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Init & validation
|
# Init & validation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -25,6 +30,8 @@ def test_init_creates_sdk_client():
|
|||||||
assert provider.default_model == "gpt-4o-deployment"
|
assert provider.default_model == "gpt-4o-deployment"
|
||||||
# SDK client base_url ends with /openai/v1/
|
# SDK client base_url ends with /openai/v1/
|
||||||
assert str(provider._client.base_url).rstrip("/").endswith("/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():
|
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")
|
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():
|
def test_init_validation_missing_base():
|
||||||
with pytest.raises(ValueError, match="Azure OpenAI api_base is required"):
|
with pytest.raises(ValueError, match="Azure OpenAI api_base is required"):
|
||||||
AzureOpenAIProvider(api_key="test", api_base="")
|
AzureOpenAIProvider(api_key="test", api_base="")
|
||||||
@ -59,6 +61,96 @@ def test_no_api_version_in_base_url():
|
|||||||
assert "api-version" not in base
|
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
|
# _supports_temperature
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user