Skip to content

Python: OpenAIChatCompletionClient(credential=...) hardcodes token scope to cognitiveservices.azure.com, breaking Foundry project endpoints that require the ai.azure.com audience #7201

Description

@nomhiro

Description

High-level problem

When OpenAIChatCompletionClient is constructed with credential= (keyless / Entra auth) against a Microsoft Foundry project OpenAI-compatible surface — https://<account>.services.ai.azure.com/api/projects/<project>/openai/v1 — every request fails with HTTP 401. The SDK always requests a token for the https://cognitiveservices.azure.com/.default scope, but that endpoint requires the https://ai.azure.com/.default audience. There is no public way to override the scope.

Expected behavior

Keyless auth against a Foundry project endpoint succeeds, or there is a supported parameter to set the token scope/audience.

Actual behavior

Every call 401s. The response body itself states the expected audience is https://ai.azure.com.

Minimal reproduction (steps)

  1. Deploy or target a Foundry project endpoint (.../api/projects/<project>/openai/v1).
  2. Build the client with credential=DefaultAzureCredential() (see Code Sample).
  3. Call get_response(...) → 401.

Root cause

In agent_framework_openai/_shared.py the scope is a module-level constant with no override path:

  • line 33: AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default"
  • _resolve_azure_credential_to_token_provider() lines 350 & 352 pass this constant to get_(async_)bearer_token_provider(credential, AZURE_OPENAI_TOKEN_SCOPE) for every credential path.

Foundry account endpoints accept cognitiveservices.azure.com, but the project-scoped surface requires ai.azure.com; the two are not interchangeable (verified: ai.azure.com token → 200, cognitiveservices token → 401 on the same project URL). This hits Foundry Hosted Agents directly, since the platform injects FOUNDRY_PROJECT_ENDPOINT (a project endpoint) and the recommended keyless pattern is DefaultAzureCredential.

Requested fix

Add a public credential_scopes: Sequence[str] | None = None parameter to OpenAIChatCompletionClient (and the Azure client family), falling back to AZURE_OPENAI_TOKEN_SCOPE when unset:

scope = credential_scopes or (AZURE_OPENAI_TOKEN_SCOPE,)
return get_bearer_token_provider(credential, *scope)

For reference, openai's AsyncAzureOpenAI and azure-ai-inference both let callers choose the token scope; the hardcoding here is the outlier.


Related sub-issue (same file): a synchronous callable passed as api_key is not awaited

The natural workaround for the scope problem is to pass a bearer-token provider as a callable api_key. A synchronous provider fails with object str can't be used in 'await' expression; only the async variant (azure.identity.aio) works.

Cause: _shared.py already has _ensure_async_token_provider() (lines 314–329) that normalizes a possibly-sync provider into an awaitable, but it is applied only to the azure_ad_token_provider path (line 302), not to a user-supplied callable api_key. A sync callable api_key therefore reaches AsyncOpenAI unwrapped and gets awaited as a bare str.

Requested fix: route callable api_key values through _ensure_async_token_provider() too, so both sync and async token providers work. At minimum, document that api_key callables must return Awaitable[str].

Code Sample

# --- fails: 401, scope hardcoded to cognitiveservices ---
from agent_framework.openai import OpenAIChatCompletionClient
from azure.identity import DefaultAzureCredential

client = OpenAIChatCompletionClient(
    model="gpt-5.4-mini",
    base_url="https://<account>.services.ai.azure.com/api/projects/<project>/openai/v1",
    credential=DefaultAzureCredential(),
)
# await client.get_response(...) -> 401

# --- workaround that WORKS today (async provider as callable api_key) ---
from azure.identity.aio import DefaultAzureCredential, get_bearer_token_provider
token_provider = get_bearer_token_provider(
    DefaultAzureCredential(), "https://ai.azure.com/.default"
)
client = OpenAIChatCompletionClient(
    model="gpt-5.4-mini",
    base_url="https://<account>.services.ai.azure.com/api/projects/<project>/openai/v1",
    api_key=token_provider,   # async callable -> OK
)

# --- same workaround but SYNC provider -> raises ---
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
token_provider = get_bearer_token_provider(
    DefaultAzureCredential(), "https://ai.azure.com/.default"
)
# api_key=token_provider  ->  object str can't be used in 'await' expression

Error Messages / Stack Traces

# 401 from the project endpoint (scope mismatch)
openai.AuthenticationError: Error code: 401 - {'statusCode': 401,
 'message': 'Unauthorized. Access token is missing, invalid,
 audience is incorrect (https://ai.azure.com), or have expired.'}

# sync callable api_key
agent_framework.exceptions.ChatClientException:
 (... service failed to complete the prompt: object str can't be used in 'await' expression,
  TypeError("object str can't be used in 'await' expression"))

Package Versions

agent-framework-openai: 1.10.1   (also reproduced on 1.4.0)
agent-framework-core:   1.11.0
azure-identity:         1.26.0b2

Python Version

3.12

Additional Context

  • Sanity check for an endpoint's audience: az account get-access-token --resource https://ai.azure.com vs --resource https://cognitiveservices.azure.com, then curl the /openai/v1/chat/completions path with each bearer.
  • agent_framework.foundry.FoundryChatClient (from agent-framework-foundry) uses the correct audience internally and is a working alternative. This issue is specifically about keeping the plain Chat Completions path via OpenAIChatCompletionClient usable with keyless auth against a project endpoint.
  • Endpoint host/project names are masked (<account> / <project>).
  • Possibly-adjacent (different root cause): Python: [Bug]: Multi-turn tool calls fail with both Responses API and Chat Completions API when used via agent-framework-ag-ui (Foundry project endpoint) #5941 hit the same Foundry project-endpoint environment but is about multi-turn tool-result replay, not auth.

Metadata

Metadata

Assignees

Labels

agentsUsage: [Issues, PRs], Target: Single agentpythonUsage: [Issues, PRs], Target: Python

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions