Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**: Apply MCP security labels before dynamically discovered and progressively disclosed tools become callable ([#8972](https://github.com/microsoft/agent-framework/pull/8972))
- **agent-framework-core**: Reduce response-stream hot-path overhead and avoid loading tool-approval middleware for agent runs without middleware ([#8971](https://github.com/microsoft/agent-framework/pull/8971))
- **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))
Expand Down
33 changes: 30 additions & 3 deletions python/packages/openai/agent_framework_openai/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +35,7 @@

AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default" # ruff:ignore[hardcoded-password-string] # nosec B105
_SUPPRESSED_AZURE_API_KEY = "<missing API key>"
_OPENAI_METADATA_HEADERS = ("OpenAI-Organization", "OpenAI-Project")


RESPONSE_TYPE = Union[
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions python/packages/openai/tests/openai/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
171 changes: 170 additions & 1 deletion python/packages/openai/tests/openai/test_openai_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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()
Expand Down
Loading