mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-06 15:39:24 +00:00
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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user