Compare commits

..

1 Commits

Author SHA1 Message Date
Greyson LaLonde
5b6f89fe64 docs: update changelog and version for v1.14.2a4
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
2026-04-15 02:34:32 +08:00
5 changed files with 12 additions and 399 deletions

View File

@@ -94,7 +94,6 @@ google-genai = [
]
azure-ai-inference = [
"azure-ai-inference~=1.0.0b9",
"azure-identity>=1.17.0,<2",
]
anthropic = [
"anthropic~=0.73.0",

View File

@@ -35,7 +35,6 @@ try:
)
from azure.core.credentials import (
AzureKeyCredential,
TokenCredential,
)
from azure.core.exceptions import (
HttpResponseError,
@@ -89,8 +88,6 @@ class AzureCompletion(BaseLLM):
response_format: type[BaseModel] | None = None
is_openai_model: bool = False
is_azure_openai_endpoint: bool = False
azure_tenant_id: str | None = None
azure_client_id: str | None = None
_client: Any = PrivateAttr(default=None)
_async_client: Any = PrivateAttr(default=None)
@@ -118,12 +115,6 @@ class AzureCompletion(BaseLLM):
data["api_version"] = (
data.get("api_version") or os.getenv("AZURE_API_VERSION") or "2024-06-01"
)
data["azure_tenant_id"] = data.get("azure_tenant_id") or os.getenv(
"AZURE_TENANT_ID"
)
data["azure_client_id"] = data.get("azure_client_id") or os.getenv(
"AZURE_CLIENT_ID"
)
# Credentials and endpoint are validated lazily in `_init_clients`
# so the LLM can be constructed before deployment env vars are set.
@@ -158,7 +149,7 @@ class AzureCompletion(BaseLLM):
try:
self._client = self._build_sync_client()
self._async_client = self._build_async_client()
except (ValueError, ImportError):
except ValueError:
pass
return self
@@ -192,89 +183,24 @@ class AzureCompletion(BaseLLM):
AzureCompletion._is_azure_openai_endpoint(self.endpoint)
)
# Re-read identity env vars for deferred builds
if not self.azure_tenant_id:
self.azure_tenant_id = os.getenv("AZURE_TENANT_ID")
if not self.azure_client_id:
self.azure_client_id = os.getenv("AZURE_CLIENT_ID")
if not self.api_key:
raise ValueError(
"Azure API key is required. Set AZURE_API_KEY environment "
"variable or pass api_key parameter."
)
if not self.endpoint:
raise ValueError(
"Azure endpoint is required. Set AZURE_ENDPOINT environment "
"variable or pass endpoint parameter."
)
credential = self._resolve_credential()
client_kwargs: dict[str, Any] = {
"endpoint": self.endpoint,
"credential": credential,
"credential": AzureKeyCredential(self.api_key),
}
if self.api_version:
client_kwargs["api_version"] = self.api_version
return client_kwargs
def _resolve_credential(self) -> AzureKeyCredential | TokenCredential:
"""Resolve the Azure credential using a priority chain.
1. OIDC federation (WorkloadIdentityCredential) — auto-discovered
from AZURE_FEDERATED_TOKEN_FILE + AZURE_TENANT_ID + AZURE_CLIENT_ID
2. Client secret (ClientSecretCredential) — explicit SP credentials
3. Default chain (DefaultAzureCredential) — Managed Identity et al.
4. API key fallback (AzureKeyCredential) — existing path
"""
federated_token_file = os.getenv("AZURE_FEDERATED_TOKEN_FILE")
client_secret = os.getenv("AZURE_CLIENT_SECRET")
# Path 1: OIDC Workload Identity Federation
if federated_token_file and self.azure_tenant_id and self.azure_client_id:
try:
from azure.identity import WorkloadIdentityCredential
return WorkloadIdentityCredential(
tenant_id=self.azure_tenant_id,
client_id=self.azure_client_id,
token_file_path=federated_token_file,
)
except ImportError:
raise ImportError(
"azure-identity is required for workload identity federation. "
'Install with: uv add "crewai[azure-ai-inference]"'
) from None
# Path 2: Client Secret (Service Principal)
if client_secret and self.azure_tenant_id and self.azure_client_id:
try:
from azure.identity import ClientSecretCredential
return ClientSecretCredential(
tenant_id=self.azure_tenant_id,
client_id=self.azure_client_id,
client_secret=client_secret,
)
except ImportError:
raise ImportError(
"azure-identity is required for service principal authentication. "
'Install with: uv add "crewai[azure-ai-inference]"'
) from None
# Path 3: DefaultAzureCredential (Managed Identity, Azure CLI, etc.)
# Only attempt if azure-identity is installed and no API key is available
if not self.api_key:
try:
from azure.identity import DefaultAzureCredential
return DefaultAzureCredential()
except ImportError:
raise ValueError(
"Azure API key is required when azure-identity is not installed. "
"Set AZURE_API_KEY environment variable, pass api_key parameter, "
'or install azure-identity: uv add "crewai[azure-ai-inference]"'
) from None
# Path 4: API Key (existing path)
return AzureKeyCredential(self.api_key)
def _get_sync_client(self) -> Any:
if self._client is None:
self._client = self._build_sync_client()

View File

@@ -390,26 +390,16 @@ def test_azure_raises_error_when_endpoint_missing():
def test_azure_raises_error_when_api_key_missing():
"""When no API key AND azure-identity is not installed, credentials
are validated lazily: construction succeeds, first client build raises.
With azure-identity installed, DefaultAzureCredential is used instead."""
"""Credentials are validated lazily: construction succeeds, first
client build raises the descriptive error."""
from crewai.llms.providers.azure.completion import AzureCompletion
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4", endpoint="https://test.openai.azure.com"
)
# With azure-identity installed, DefaultAzureCredential is used as
# fallback instead of raising. Only raises when azure-identity is
# not available.
try:
import azure.identity # noqa: F401
# azure-identity is installed — DefaultAzureCredential will be used
client = llm._get_sync_client()
assert client is not None
except ImportError:
with pytest.raises(ValueError, match="Azure API key is required"):
llm._get_sync_client()
with pytest.raises(ValueError, match="Azure API key is required"):
llm._get_sync_client()
@pytest.mark.asyncio

View File

@@ -1,258 +0,0 @@
"""Tests for Azure credential resolution chain in AzureCompletion.
Covers the four credential paths:
1. WorkloadIdentityCredential (OIDC federation)
2. ClientSecretCredential (Service Principal)
3. DefaultAzureCredential (Managed Identity / CLI fallback)
4. AzureKeyCredential (API key - existing path)
"""
from unittest.mock import patch, MagicMock
import pytest
# Use a non-Azure-OpenAI endpoint to avoid _validate_and_fix_endpoint suffixing
ENDPOINT = "https://test-ai.services.example.com"
@pytest.fixture
def _clear_azure_env(monkeypatch):
"""Remove all Azure env vars to start clean."""
for key in [
"AZURE_API_KEY", "AZURE_ENDPOINT", "AZURE_OPENAI_ENDPOINT",
"AZURE_API_BASE", "AZURE_API_VERSION", "AZURE_TENANT_ID",
"AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", "AZURE_FEDERATED_TOKEN_FILE",
]:
monkeypatch.delenv(key, raising=False)
@pytest.mark.usefixtures("_clear_azure_env")
class TestCredentialResolution:
"""Tests for AzureCompletion._resolve_credential."""
def test_api_key_credential_when_api_key_set(self):
"""Path 4: API key produces AzureKeyCredential."""
from crewai.llms.providers.azure.completion import AzureCompletion
from azure.core.credentials import AzureKeyCredential
completion = AzureCompletion(
model="gpt-4",
api_key="test-key",
endpoint=ENDPOINT,
)
cred = completion._resolve_credential()
assert isinstance(cred, AzureKeyCredential)
def test_api_key_from_env(self, monkeypatch):
"""Path 4: api_key picked up from AZURE_API_KEY env var."""
from crewai.llms.providers.azure.completion import AzureCompletion
from azure.core.credentials import AzureKeyCredential
monkeypatch.setenv("AZURE_API_KEY", "env-key")
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
completion = AzureCompletion(model="gpt-4")
cred = completion._resolve_credential()
assert isinstance(cred, AzureKeyCredential)
def test_workload_identity_credential(self, monkeypatch, tmp_path):
"""Path 1: OIDC federation via WorkloadIdentityCredential."""
from crewai.llms.providers.azure.completion import AzureCompletion
token_file = tmp_path / "token.txt"
token_file.write_text("eyJhbGciOiJSUzI1NiJ9.test")
monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", str(token_file))
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
mock_wi_cred = MagicMock()
with patch(
"azure.identity.WorkloadIdentityCredential",
return_value=mock_wi_cred,
) as mock_cls:
completion = AzureCompletion(
model="gpt-4",
azure_tenant_id="tenant-123",
azure_client_id="client-456",
)
cred = completion._resolve_credential()
assert cred is mock_wi_cred
# Called at least once with the right args (init may also call it)
mock_cls.assert_any_call(
tenant_id="tenant-123",
client_id="client-456",
token_file_path=str(token_file),
)
def test_workload_identity_from_env_vars(self, monkeypatch, tmp_path):
"""Path 1: All WI fields discovered from environment."""
from crewai.llms.providers.azure.completion import AzureCompletion
token_file = tmp_path / "token.txt"
token_file.write_text("eyJhbGciOiJSUzI1NiJ9.test")
monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", str(token_file))
monkeypatch.setenv("AZURE_TENANT_ID", "env-tenant")
monkeypatch.setenv("AZURE_CLIENT_ID", "env-client")
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
mock_wi_cred = MagicMock()
with patch(
"azure.identity.WorkloadIdentityCredential",
return_value=mock_wi_cred,
) as mock_cls:
completion = AzureCompletion(model="gpt-4")
cred = completion._resolve_credential()
assert cred is mock_wi_cred
mock_cls.assert_any_call(
tenant_id="env-tenant",
client_id="env-client",
token_file_path=str(token_file),
)
def test_client_secret_credential(self, monkeypatch):
"""Path 2: Service Principal with client secret."""
from crewai.llms.providers.azure.completion import AzureCompletion
monkeypatch.setenv("AZURE_CLIENT_SECRET", "sp-secret")
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
mock_cs_cred = MagicMock()
with patch(
"azure.identity.ClientSecretCredential",
return_value=mock_cs_cred,
) as mock_cls:
completion = AzureCompletion(
model="gpt-4",
azure_tenant_id="tenant-123",
azure_client_id="client-456",
)
cred = completion._resolve_credential()
assert cred is mock_cs_cred
mock_cls.assert_any_call(
tenant_id="tenant-123",
client_id="client-456",
client_secret="sp-secret",
)
def test_default_azure_credential_when_no_api_key(self, monkeypatch):
"""Path 3: DefaultAzureCredential when no api_key and no SP/WI vars."""
from crewai.llms.providers.azure.completion import AzureCompletion
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
mock_default_cred = MagicMock()
with patch(
"azure.identity.DefaultAzureCredential",
return_value=mock_default_cred,
):
completion = AzureCompletion(model="gpt-4")
cred = completion._resolve_credential()
assert cred is mock_default_cred
def test_workload_identity_takes_priority_over_api_key(self, monkeypatch, tmp_path):
"""WI credential should take priority even when api_key is also set."""
from crewai.llms.providers.azure.completion import AzureCompletion
token_file = tmp_path / "token.txt"
token_file.write_text("eyJhbGciOiJSUzI1NiJ9.test")
monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", str(token_file))
monkeypatch.setenv("AZURE_API_KEY", "should-not-use-this")
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
mock_wi_cred = MagicMock()
with patch(
"azure.identity.WorkloadIdentityCredential",
return_value=mock_wi_cred,
):
completion = AzureCompletion(
model="gpt-4",
azure_tenant_id="tenant-123",
azure_client_id="client-456",
)
cred = completion._resolve_credential()
assert cred is mock_wi_cred
def test_client_secret_takes_priority_over_api_key(self, monkeypatch):
"""SP credential should take priority over API key."""
from crewai.llms.providers.azure.completion import AzureCompletion
monkeypatch.setenv("AZURE_CLIENT_SECRET", "sp-secret")
monkeypatch.setenv("AZURE_API_KEY", "should-not-use-this")
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
mock_cs_cred = MagicMock()
with patch(
"azure.identity.ClientSecretCredential",
return_value=mock_cs_cred,
):
completion = AzureCompletion(
model="gpt-4",
azure_tenant_id="tenant-123",
azure_client_id="client-456",
)
cred = completion._resolve_credential()
assert cred is mock_cs_cred
def test_raises_when_no_api_key_and_no_azure_identity(self, monkeypatch):
"""ValueError when no api_key and azure-identity not installed."""
from crewai.llms.providers.azure.completion import AzureCompletion
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
with patch.dict("sys.modules", {"azure.identity": None}):
completion = AzureCompletion(model="gpt-4")
with pytest.raises(ValueError, match="Azure API key is required"):
completion._resolve_credential()
def test_endpoint_still_required(self, monkeypatch, tmp_path):
"""Endpoint is always required regardless of credential type."""
from crewai.llms.providers.azure.completion import AzureCompletion
token_file = tmp_path / "token.txt"
token_file.write_text("test-jwt")
monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", str(token_file))
monkeypatch.setenv("AZURE_TENANT_ID", "tenant-123")
monkeypatch.setenv("AZURE_CLIENT_ID", "client-456")
completion = AzureCompletion(model="gpt-4")
with pytest.raises(ValueError, match="Azure endpoint is required"):
completion._make_client_kwargs()
def test_deferred_build_picks_up_wi_env_vars(self, monkeypatch, tmp_path):
"""Env vars set after construction are picked up on deferred build."""
from crewai.llms.providers.azure.completion import AzureCompletion
# Construct with endpoint only — no credentials yet
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
completion = AzureCompletion(model="gpt-4")
# Now set WI env vars (simulating WI manager setting them before crew run)
token_file = tmp_path / "token.txt"
token_file.write_text("eyJhbGciOiJSUzI1NiJ9.deferred")
monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", str(token_file))
monkeypatch.setenv("AZURE_TENANT_ID", "deferred-tenant")
monkeypatch.setenv("AZURE_CLIENT_ID", "deferred-client")
mock_wi_cred = MagicMock()
with patch(
"azure.identity.WorkloadIdentityCredential",
return_value=mock_wi_cred,
):
kwargs = completion._make_client_kwargs()
assert kwargs["credential"] is mock_wi_cred
def test_make_client_kwargs_includes_api_version(self, monkeypatch):
"""api_version is included in client kwargs."""
from crewai.llms.providers.azure.completion import AzureCompletion
monkeypatch.setenv("AZURE_API_KEY", "test-key")
monkeypatch.setenv("AZURE_ENDPOINT", ENDPOINT)
completion = AzureCompletion(model="gpt-4", api_version="2025-01-01")
kwargs = completion._make_client_kwargs()
assert kwargs["api_version"] == "2025-01-01"
assert kwargs["endpoint"] == ENDPOINT

46
uv.lock generated
View File

@@ -13,7 +13,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-04-12T13:29:14.108221482Z"
exclude-newer = "2026-04-10T18:30:59.748668Z"
exclude-newer-span = "P3D"
[manifest]
@@ -495,22 +495,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" },
]
[[package]]
name = "azure-identity"
version = "1.25.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core" },
{ name = "cryptography" },
{ name = "msal" },
{ name = "msal-extensions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" },
]
[[package]]
name = "backoff"
version = "2.2.1"
@@ -1297,7 +1281,6 @@ aws = [
]
azure-ai-inference = [
{ name = "azure-ai-inference" },
{ name = "azure-identity" },
]
bedrock = [
{ name = "boto3" },
@@ -1352,7 +1335,6 @@ requires-dist = [
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = "~=0.73.0" },
{ name = "appdirs", specifier = "~=1.4.4" },
{ name = "azure-ai-inference", marker = "extra == 'azure-ai-inference'", specifier = "~=1.0.0b9" },
{ name = "azure-identity", marker = "extra == 'azure-ai-inference'", specifier = ">=1.17.0,<2" },
{ name = "boto3", marker = "extra == 'aws'", specifier = "~=1.42.79" },
{ name = "boto3", marker = "extra == 'bedrock'", specifier = "~=1.42.79" },
{ name = "chromadb", specifier = "~=1.1.0" },
@@ -4322,32 +4304,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "msal"
version = "1.36.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" },
]
[[package]]
name = "msal-extensions"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "msal" },
]
sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" },
]
[[package]]
name = "msgpack"
version = "1.1.2"