feat(azure): forward credential_scopes to Azure AI Inference client (#5661)
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
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled

* feat(azure): forward credential_scopes to Azure AI Inference client

Adds a credential_scopes field to the native Azure AI Inference
provider and a matching AZURE_CREDENTIAL_SCOPES env var
(comma-separated). The value is forwarded to ChatCompletionsClient /
AsyncChatCompletionsClient when set, letting keyless / Entra-based
callers target a specific Azure AD audience (e.g.
https://cognitiveservices.azure.com/.default) without subclassing the
provider. Matches the upstream azure.ai.inference SDK kwarg of the
same name.

Lazy build re-reads the env var so an LLM constructed at module
import (before deployment env vars are set) still picks up scopes —
same pattern as the existing AZURE_API_KEY / AZURE_ENDPOINT lazy
reads. to_config_dict round-trips the field.

* refactor(azure): tighten credential_scopes env handling

Address review feedback:
- Move os.getenv into the helper so AZURE_CREDENTIAL_SCOPES appears once
- Match the surrounding api_key/endpoint `or` style in the validator
- Drop the list() defensive copy in to_config_dict — every other field
  in that method (and the base class's `stop`) is assigned by reference
This commit is contained in:
Matt Aitchison
2026-04-29 16:52:29 -05:00
committed by GitHub
parent 14c3963d2c
commit c7f01048b7
2 changed files with 138 additions and 0 deletions

View File

@@ -88,6 +88,7 @@ class AzureCompletion(BaseLLM):
response_format: type[BaseModel] | None = None
is_openai_model: bool = False
is_azure_openai_endpoint: bool = False
credential_scopes: list[str] | None = None
# Responses API settings
api: Literal["completions", "responses"] = "completions"
@@ -129,6 +130,10 @@ class AzureCompletion(BaseLLM):
data["api_version"] = (
data.get("api_version") or os.getenv("AZURE_API_VERSION") or "2024-06-01"
)
data["credential_scopes"] = (
data.get("credential_scopes")
or AzureCompletion._credential_scopes_from_env()
)
# Credentials and endpoint are validated lazily in `_init_clients`
# so the LLM can be constructed before deployment env vars are set.
@@ -154,6 +159,15 @@ class AzureCompletion(BaseLLM):
hostname == "openai.azure.com" or hostname.endswith(".openai.azure.com")
) and "/openai/deployments/" in endpoint
@staticmethod
def _credential_scopes_from_env() -> list[str] | None:
"""Read ``AZURE_CREDENTIAL_SCOPES`` (comma-separated) into a list."""
raw = os.getenv("AZURE_CREDENTIAL_SCOPES")
if not raw:
return None
scopes = [s.strip() for s in raw.split(",") if s.strip()]
return scopes or None
@model_validator(mode="after")
def _init_clients(self) -> AzureCompletion:
"""Eagerly build clients when credentials are available, otherwise
@@ -279,12 +293,17 @@ class AzureCompletion(BaseLLM):
"Azure endpoint is required. Set AZURE_ENDPOINT environment "
"variable or pass endpoint parameter."
)
if self.credential_scopes is None:
self.credential_scopes = AzureCompletion._credential_scopes_from_env()
client_kwargs: dict[str, Any] = {
"endpoint": self.endpoint,
"credential": self._resolve_credential(),
}
if self.api_version:
client_kwargs["api_version"] = self.api_version
if self.credential_scopes:
client_kwargs["credential_scopes"] = self.credential_scopes
return client_kwargs
def _resolve_credential(self) -> Any:
@@ -353,6 +372,8 @@ class AzureCompletion(BaseLLM):
config["store"] = self.store
if self.max_completion_tokens is not None:
config["max_completion_tokens"] = self.max_completion_tokens
if self.credential_scopes:
config["credential_scopes"] = self.credential_scopes
return config
@staticmethod

View File

@@ -1518,3 +1518,120 @@ def test_azure_no_detail_fields():
assert usage["completion_tokens"] == 30
assert usage["cached_prompt_tokens"] == 0
assert usage["reasoning_tokens"] == 0
def test_azure_credential_scopes_passed_to_client():
"""`credential_scopes` constructor arg flows through `_make_client_kwargs`
so the underlying ChatCompletionsClient requests tokens for the requested
audience (e.g. ``cognitiveservices.azure.com/.default``)."""
from crewai.llms.providers.azure.completion import AzureCompletion
scopes = ["https://cognitiveservices.azure.com/.default"]
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4",
api_key="test-key",
endpoint="https://test.openai.azure.com",
credential_scopes=scopes,
)
kwargs = llm._make_client_kwargs()
assert kwargs["credential_scopes"] == scopes
def test_azure_credential_scopes_omitted_by_default():
"""Without explicit scopes or env var, the kwarg must not be set so the
Azure SDK chooses its own default audience."""
from crewai.llms.providers.azure.completion import AzureCompletion
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4",
api_key="test-key",
endpoint="https://test.openai.azure.com",
)
kwargs = llm._make_client_kwargs()
assert "credential_scopes" not in kwargs
def test_azure_credential_scopes_from_env_comma_separated():
"""``AZURE_CREDENTIAL_SCOPES`` accepts a comma-separated list. Whitespace
around entries is stripped; empty entries are dropped."""
from crewai.llms.providers.azure.completion import AzureCompletion
with patch.dict(
os.environ,
{
"AZURE_API_KEY": "test-key",
"AZURE_ENDPOINT": "https://test.openai.azure.com",
"AZURE_CREDENTIAL_SCOPES": " https://cognitiveservices.azure.com/.default , https://other/.default ",
},
clear=True,
):
llm = AzureCompletion(model="gpt-4")
assert llm.credential_scopes == [
"https://cognitiveservices.azure.com/.default",
"https://other/.default",
]
kwargs = llm._make_client_kwargs()
assert kwargs["credential_scopes"] == llm.credential_scopes
def test_azure_credential_scopes_constructor_overrides_env():
"""A constructor-provided ``credential_scopes`` must win over the env var,
matching how endpoint/api_key precedence works elsewhere in this provider."""
from crewai.llms.providers.azure.completion import AzureCompletion
explicit = ["https://explicit/.default"]
with patch.dict(
os.environ,
{
"AZURE_API_KEY": "test-key",
"AZURE_ENDPOINT": "https://test.openai.azure.com",
"AZURE_CREDENTIAL_SCOPES": "https://env/.default",
},
clear=True,
):
llm = AzureCompletion(model="gpt-4", credential_scopes=explicit)
assert llm.credential_scopes == explicit
def test_azure_credential_scopes_lazy_env_read():
"""When the LLM is built before ``AZURE_CREDENTIAL_SCOPES`` is exported
(e.g. constructed at module import), the lazy client builder must still
pick up the env value — same pattern as the existing api_key/endpoint
lazy reads."""
from crewai.llms.providers.azure.completion import AzureCompletion
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4",
api_key="test-key",
endpoint="https://test.openai.azure.com",
)
assert llm.credential_scopes is None
with patch.dict(
os.environ,
{"AZURE_CREDENTIAL_SCOPES": "https://late/.default"},
clear=True,
):
kwargs = llm._make_client_kwargs()
assert kwargs["credential_scopes"] == ["https://late/.default"]
assert llm.credential_scopes == ["https://late/.default"]
def test_azure_credential_scopes_in_to_config_dict():
"""Config round-trips the scopes so an LLM rebuilt from `to_config_dict`
keeps the same audience."""
from crewai.llms.providers.azure.completion import AzureCompletion
scopes = ["https://cognitiveservices.azure.com/.default"]
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4",
api_key="test-key",
endpoint="https://test.openai.azure.com",
credential_scopes=scopes,
)
config = llm.to_config_dict()
assert config["credential_scopes"] == scopes