From c7f01048b77b2600298ab2f776334034736c4d81 Mon Sep 17 00:00:00 2001 From: Matt Aitchison Date: Wed, 29 Apr 2026 16:52:29 -0500 Subject: [PATCH] feat(azure): forward credential_scopes to Azure AI Inference client (#5661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../crewai/llms/providers/azure/completion.py | 21 ++++ lib/crewai/tests/llms/azure/test_azure.py | 117 ++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/lib/crewai/src/crewai/llms/providers/azure/completion.py b/lib/crewai/src/crewai/llms/providers/azure/completion.py index b5cb30fb1..59fd5994e 100644 --- a/lib/crewai/src/crewai/llms/providers/azure/completion.py +++ b/lib/crewai/src/crewai/llms/providers/azure/completion.py @@ -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 diff --git a/lib/crewai/tests/llms/azure/test_azure.py b/lib/crewai/tests/llms/azure/test_azure.py index 774d23f20..9a08ff40f 100644 --- a/lib/crewai/tests/llms/azure/test_azure.py +++ b/lib/crewai/tests/llms/azure/test_azure.py @@ -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