feat(azure): fall back to DefaultAzureCredential when no API key

Enables keyless Azure auth (OIDC Workload Identity Federation, Managed
Identity, Azure CLI, env-configured Service Principal) without any
crewAI-specific configuration. Customers whose deployment environment
already sets the standard azure-identity env vars get keyless auth for
free; the existing API-key path is unchanged.

Linear: FAC-40
This commit is contained in:
Matt Aitchison
2026-04-22 15:21:35 -05:00
committed by GitHub
parent c94f2e8f28
commit fdf3101b39
3 changed files with 60 additions and 13 deletions

View File

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

View File

@@ -183,11 +183,6 @@ class AzureCompletion(BaseLLM):
AzureCompletion._is_azure_openai_endpoint(self.endpoint)
)
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 "
@@ -195,12 +190,39 @@ class AzureCompletion(BaseLLM):
)
client_kwargs: dict[str, Any] = {
"endpoint": self.endpoint,
"credential": AzureKeyCredential(self.api_key),
"credential": self._resolve_credential(),
}
if self.api_version:
client_kwargs["api_version"] = self.api_version
return client_kwargs
def _resolve_credential(self) -> Any:
"""Return an Azure credential, preferring the API key when set.
Without an API key, fall back to ``DefaultAzureCredential`` from
``azure-identity``. That chain auto-detects the standard keyless
paths the customer's environment may provide — OIDC Workload
Identity Federation (``AZURE_FEDERATED_TOKEN_FILE`` +
``AZURE_TENANT_ID`` + ``AZURE_CLIENT_ID``), Managed Identity on
AKS/Azure VMs, environment-configured service principals, and
developer tools like the Azure CLI. Installing ``azure-identity``
is what enables these paths; without it we raise the existing
API-key error.
"""
if self.api_key:
return AzureKeyCredential(self.api_key)
try:
from azure.identity import DefaultAzureCredential
except ImportError:
raise ValueError(
"Azure API key is required when azure-identity is not "
"installed. Set AZURE_API_KEY, or install azure-identity "
'for keyless auth: uv add "crewai[azure-ai-inference]"'
) from None
return DefaultAzureCredential()
def _get_sync_client(self) -> Any:
if self._client is None:
self._client = self._build_sync_client()

View File

@@ -389,17 +389,41 @@ def test_azure_raises_error_when_endpoint_missing():
llm._get_sync_client()
def test_azure_raises_error_when_api_key_missing():
"""Credentials are validated lazily: construction succeeds, first
def test_azure_raises_error_when_api_key_missing_without_azure_identity():
"""Without an API key AND without ``azure-identity`` installed,
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 pytest.raises(ValueError, match="Azure API key is required"):
llm._get_sync_client()
with patch.dict("sys.modules", {"azure.identity": None}):
llm = AzureCompletion(
model="gpt-4", endpoint="https://test.openai.azure.com"
)
with pytest.raises(ValueError, match="Azure API key is required"):
llm._get_sync_client()
def test_azure_uses_default_credential_when_api_key_missing():
"""With ``azure-identity`` installed, a missing API key falls back to
``DefaultAzureCredential`` instead of raising. This is the path that
enables keyless auth (OIDC WIF on EKS/AKS, Managed Identity, Azure
CLI) without any crewAI-specific config."""
from unittest.mock import MagicMock
from crewai.llms.providers.azure.completion import AzureCompletion
sentinel = MagicMock(name="DefaultAzureCredential()")
with patch.dict(os.environ, {}, clear=True):
with patch(
"azure.identity.DefaultAzureCredential", return_value=sentinel
) as mock_cls:
llm = AzureCompletion(
model="gpt-4",
endpoint="https://test-ai.services.example.com",
)
kwargs = llm._make_client_kwargs()
assert kwargs["credential"] is sentinel
mock_cls.assert_called()
@pytest.mark.asyncio