From 28006a1d259ace99b65438b6051d45b147399cd3 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 2 Oct 2026 13:01:41 +0200 Subject: [PATCH 1/2] Python: isolate OpenAI metadata on Azure routes --- .../openai/agent_framework_openai/_shared.py | 33 +++- .../packages/openai/tests/openai/conftest.py | 2 + .../openai/tests/openai/test_openai_shared.py | 171 +++++++++++++++++- 3 files changed, 202 insertions(+), 4 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index 77e7dac867b..16bb2effbbd 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -10,7 +10,7 @@ from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import APP_INFO, prepend_agent_framework_to_user_agent from agent_framework.exceptions import SettingNotFoundError -from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, HttpxBinaryResponseContent +from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, HttpxBinaryResponseContent, Omit from openai.types import Completion from openai.types.audio import Transcription from openai.types.chat import ChatCompletion, ChatCompletionChunk @@ -35,6 +35,7 @@ AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default" # ruff:ignore[hardcoded-password-string] # nosec B105 _SUPPRESSED_AZURE_API_KEY = "" +_OPENAI_METADATA_HEADERS = ("OpenAI-Organization", "OpenAI-Project") RESPONSE_TYPE = Union[ @@ -191,6 +192,26 @@ def _join_env_names(env_names: Sequence[str]) -> str: return ", ".join(f"'{env_name}'" for env_name in env_names) +def _prepare_azure_openai_default_headers( + headers: Mapping[str, str], + *, + suppress_authorization: bool, +) -> dict[str, str | Omit]: + """Prevent OpenAI SDK defaults from crossing into Azure routes.""" + azure_headers: dict[str, str | Omit] = dict(headers) + controlled_headers = ( + (*_OPENAI_METADATA_HEADERS, "Authorization") if suppress_authorization else _OPENAI_METADATA_HEADERS + ) + for header in controlled_headers: + normalized_header = header.lower() + explicit_value: str | None = None + for candidate in list(azure_headers): + if candidate.lower() == normalized_header: + explicit_value = cast(str, azure_headers.pop(candidate)) + azure_headers[header] = explicit_value if explicit_value is not None else Omit() + return azure_headers + + def load_openai_service_settings( *, model: str | None, @@ -310,7 +331,6 @@ def load_openai_service_settings( ) if client: return azure_settings, client, True # type: ignore[return-value] - client_args["default_headers"] = merged_headers client_args["http_client"] = create_feature_usage_http_client() if endpoint := azure_settings.get("endpoint"): if responses_mode: @@ -342,7 +362,14 @@ def load_openai_service_settings( # sent as-is. responses_mode is excluded because the Responses API path # (/responses) is not rewritten by the Azure SDK. resolved_base_url = client_args.get("base_url", "") - if not responses_mode and resolved_base_url and resolved_base_url.rstrip("/").endswith("/openai/v1"): + use_openai_v1_bridge = ( + not responses_mode and resolved_base_url and resolved_base_url.rstrip("/").endswith("/openai/v1") + ) + client_args["default_headers"] = _prepare_azure_openai_default_headers( + merged_headers, + suppress_authorization=not use_openai_v1_bridge and "azure_ad_token_provider" not in client_args, + ) + if use_openai_v1_bridge: openai_args: dict[str, Any] = { "base_url": resolved_base_url, "default_headers": client_args.get("default_headers"), diff --git a/python/packages/openai/tests/openai/conftest.py b/python/packages/openai/tests/openai/conftest.py index 8f97be47c38..2181e1d67de 100644 --- a/python/packages/openai/tests/openai/conftest.py +++ b/python/packages/openai/tests/openai/conftest.py @@ -59,6 +59,7 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # [ "OPENAI_API_KEY", "OPENAI_ORG_ID", + "OPENAI_PROJECT_ID", "OPENAI_MODEL", "OPENAI_EMBEDDING_MODEL", "OPENAI_CHAT_COMPLETION_MODEL", @@ -108,6 +109,7 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic [ "OPENAI_API_KEY", "OPENAI_ORG_ID", + "OPENAI_PROJECT_ID", "OPENAI_MODEL", "OPENAI_EMBEDDING_MODEL", "OPENAI_CHAT_COMPLETION_MODEL", diff --git a/python/packages/openai/tests/openai/test_openai_shared.py b/python/packages/openai/tests/openai/test_openai_shared.py index 7f62cc491a0..e33cf3b5133 100644 --- a/python/packages/openai/tests/openai/test_openai_shared.py +++ b/python/packages/openai/tests/openai/test_openai_shared.py @@ -15,7 +15,8 @@ from agent_framework._telemetry import mark_feature_used from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from openai import AsyncAzureOpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI +from openai._models import FinalRequestOptions from agent_framework_openai._feature_usage import create_feature_usage_http_client from agent_framework_openai._shared import ( @@ -47,6 +48,11 @@ def get_token(self, *scopes: str, **kwargs: object): raise NotImplementedError +async def _build_request(client: AsyncOpenAI): + options = FinalRequestOptions.construct(method="GET", url="/models") + return client._build_request(await client._prepare_options(options)) + + @pytest.mark.usefixtures("openai_unit_test_env") @pytest.mark.parametrize("api_key", ["test-secret-key", SecretString("test-secret-key")], ids=["str", "secret"]) @pytest.mark.parametrize("route", ["openai", "azure"]) @@ -76,6 +82,169 @@ async def test_service_settings_unwrap_api_key_at_sdk_boundary(api_key: str | Se await sdk_client.close() +@pytest.mark.parametrize("auth_mode", ["api_key", "credential"]) +async def test_azure_route_suppresses_openai_metadata_environment(monkeypatch, auth_mode: str) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "ambient-openai-key") + monkeypatch.setenv("OPENAI_ORG_ID", "ambient-org") + monkeypatch.setenv("OPENAI_PROJECT_ID", "ambient-project") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "ambient-azure-key") + + _, sdk_client, use_azure = load_openai_service_settings( + model="test-model", + api_key="explicit-azure-key" if auth_mode == "api_key" else None, + credential=(lambda: "explicit-azure-token") if auth_mode == "credential" else None, + org_id=None, + base_url=None, + endpoint="https://test.openai.azure.com", + api_version="2024-12-01-preview", + default_azure_api_version="2024-12-01-preview", + env_file_path=None, + env_file_encoding=None, + ) + try: + request = await _build_request(sdk_client) + + assert use_azure + if auth_mode == "api_key": + assert request.headers["api-key"] == "explicit-azure-key" + assert "Authorization" not in request.headers + else: + assert request.headers["Authorization"] == "Bearer explicit-azure-token" + assert "api-key" not in request.headers + assert "OpenAI-Organization" not in request.headers + assert "OpenAI-Project" not in request.headers + finally: + await sdk_client.close() + + +async def test_azure_openai_v1_bridge_suppresses_openai_metadata_environment(monkeypatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "ambient-openai-key") + monkeypatch.setenv("OPENAI_ORG_ID", "ambient-org") + monkeypatch.setenv("OPENAI_PROJECT_ID", "ambient-project") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "ambient-azure-key") + + _, sdk_client, use_azure = load_openai_service_settings( + model="test-model", + api_key=None, + credential=lambda: "explicit-azure-token", + org_id=None, + base_url="https://test.openai.azure.com/openai/v1/", + endpoint=None, + api_version=None, + default_azure_api_version="2024-12-01-preview", + env_file_path=None, + env_file_encoding=None, + ) + try: + request = await _build_request(sdk_client) + + assert use_azure + assert not isinstance(sdk_client, AsyncAzureOpenAI) + assert request.headers["Authorization"] == "Bearer explicit-azure-token" + assert "api-key" not in request.headers + assert "OpenAI-Organization" not in request.headers + assert "OpenAI-Project" not in request.headers + finally: + await sdk_client.close() + + +async def test_azure_route_preserves_explicit_metadata_headers(monkeypatch) -> None: + monkeypatch.setenv("OPENAI_ORG_ID", "ambient-org") + monkeypatch.setenv("OPENAI_PROJECT_ID", "ambient-project") + + _, sdk_client, use_azure = load_openai_service_settings( + model="test-model", + api_key="explicit-azure-key", + credential=None, + org_id=None, + base_url=None, + endpoint="https://test.openai.azure.com", + api_version="2024-12-01-preview", + default_azure_api_version="2024-12-01-preview", + default_headers={ + "authorization": "Bearer explicit-authorization", + "OpenAI-Organization": "explicit-org", + "openai-project": "explicit-project", + "X-Gateway-Token": "explicit-gateway-token", + }, + env_file_path=None, + env_file_encoding=None, + ) + try: + request = await _build_request(sdk_client) + + assert use_azure + assert request.headers["Authorization"] == "Bearer explicit-authorization" + assert request.headers["OpenAI-Organization"] == "explicit-org" + assert request.headers["OpenAI-Project"] == "explicit-project" + assert request.headers["X-Gateway-Token"] == "explicit-gateway-token" + finally: + await sdk_client.close() + + +async def test_openai_route_retains_metadata_environment_fallback(monkeypatch) -> None: + monkeypatch.setenv("OPENAI_ORG_ID", "ambient-org") + monkeypatch.setenv("OPENAI_PROJECT_ID", "ambient-project") + + _, sdk_client, use_azure = load_openai_service_settings( + model="test-model", + api_key="explicit-openai-key", + credential=None, + org_id=None, + base_url="https://example.test/v1/", + endpoint=None, + api_version=None, + default_azure_api_version="2024-12-01-preview", + env_file_path=None, + env_file_encoding=None, + ) + try: + request = await _build_request(sdk_client) + + assert not use_azure + assert request.headers["Authorization"] == "Bearer explicit-openai-key" + assert request.headers["OpenAI-Organization"] == "ambient-org" + assert request.headers["OpenAI-Project"] == "ambient-project" + finally: + await sdk_client.close() + + +async def test_prebuilt_client_preserves_metadata_configuration(monkeypatch) -> None: + monkeypatch.setenv("OPENAI_ORG_ID", "ambient-org") + monkeypatch.setenv("OPENAI_PROJECT_ID", "ambient-project") + prebuilt_client = AsyncOpenAI( + api_key="prebuilt-key", + organization="prebuilt-org", + project="prebuilt-project", + base_url="https://example.test/v1/", + default_headers={"X-Gateway-Token": "prebuilt-gateway-token"}, + ) + + _, sdk_client, use_azure = load_openai_service_settings( + model="test-model", + api_key=None, + credential=None, + org_id=None, + base_url=None, + endpoint=None, + api_version=None, + default_azure_api_version="2024-12-01-preview", + client=prebuilt_client, + env_file_path=None, + env_file_encoding=None, + ) + try: + request = await _build_request(sdk_client) + + assert not use_azure + assert sdk_client is prebuilt_client + assert request.headers["OpenAI-Organization"] == "prebuilt-org" + assert request.headers["OpenAI-Project"] == "prebuilt-project" + assert request.headers["X-Gateway-Token"] == "prebuilt-gateway-token" + finally: + await sdk_client.close() + + def test_resolve_azure_async_credential_wraps_provider() -> None: credential = _AsyncTokenCredentialStub() token_provider = MagicMock() From f66f5ddf5464ed779cd0d18ab67ff189f22ab065 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 2 Oct 2026 13:44:03 +0200 Subject: [PATCH 2/2] Python: add OpenAI routing changelog entry --- python/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 33c1b85262a..9407f17f69f 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **docs**, **samples**: Cross-reference security patterns, clarify file-search retrieval scope, improve local-shell filtering guidance, and correct README instructions ([#8726](https://github.com/microsoft/agent-framework/pull/8726), [#8591](https://github.com/microsoft/agent-framework/pull/8591), [#8766](https://github.com/microsoft/agent-framework/pull/8766), [#8822](https://github.com/microsoft/agent-framework/pull/8822)) ### Fixed +- **agent-framework-openai**: Isolate OpenAI metadata defaults from Azure-routed clients ([#8969](https://github.com/microsoft/agent-framework/pull/8969)) - **agent-framework-core**: Preserve functional-workflow replay identity, scope invocation arguments and activity IDs to workflow runs, deduplicate fan-out targets, propagate nested cancellation arguments, and support orchestration checkpoint deserialization ([#8887](https://github.com/microsoft/agent-framework/pull/8887), [#8596](https://github.com/microsoft/agent-framework/pull/8596), [#8549](https://github.com/microsoft/agent-framework/pull/8549), [#8739](https://github.com/microsoft/agent-framework/pull/8739), [#8602](https://github.com/microsoft/agent-framework/pull/8602), [#8723](https://github.com/microsoft/agent-framework/pull/8723)) - **agent-framework-core**: Preserve repeated history turns, compaction through middleware rewrites, checkpoint dictionary subclasses, response metadata, annotations, native vector keys, integer precision, and boolean set values ([#8800](https://github.com/microsoft/agent-framework/pull/8800), [#8671](https://github.com/microsoft/agent-framework/pull/8671), [#8519](https://github.com/microsoft/agent-framework/pull/8519), [#8464](https://github.com/microsoft/agent-framework/pull/8464), [#8701](https://github.com/microsoft/agent-framework/pull/8701), [#8535](https://github.com/microsoft/agent-framework/pull/8535), [#8637](https://github.com/microsoft/agent-framework/pull/8637), [#8681](https://github.com/microsoft/agent-framework/pull/8681)) - **agent-framework-core**: Improve MCP skill archive and resource validation, stop failed MCP lifecycle owners, distinguish missing tool arguments from parse failures, settle fatal unknown calls, and omit absent MCP prompt-argument descriptions ([#8937](https://github.com/microsoft/agent-framework/pull/8937), [#8690](https://github.com/microsoft/agent-framework/pull/8690), [#8755](https://github.com/microsoft/agent-framework/pull/8755), [#8609](https://github.com/microsoft/agent-framework/pull/8609), [#8640](https://github.com/microsoft/agent-framework/pull/8640), [#8733](https://github.com/microsoft/agent-framework/pull/8733))