diff --git a/.github/workflows/python-openai-dependency-bounds.yml b/.github/workflows/python-openai-dependency-bounds.yml new file mode 100644 index 00000000000..eddc22a5468 --- /dev/null +++ b/.github/workflows/python-openai-dependency-bounds.yml @@ -0,0 +1,64 @@ +name: Python - OpenAI Dependency Bounds + +on: + pull_request: + branches: ["main", "feature*"] + paths: + - ".github/actions/python-setup/**" + - ".github/workflows/python-openai-dependency-bounds.yml" + - "python/packages/devui/**" + - "python/packages/hosting-responses/**" + - "python/packages/openai/**" + - "python/pyproject.toml" + - "python/scripts/dependencies/**" + - "python/samples/04-hosting/container/hyperlight_codeact/call_server.py" + - "python/shared_tasks.toml" + +permissions: + contents: read + +env: + UV_CACHE_DIR: /tmp/.uv-cache + UV_PYTHON: "3.13" + +jobs: + openai-dependency-bounds: + name: OpenAI ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - name: integration 2.x floor + package: openai + module: agent_framework_openai + requirement: openai==2.25.0 + - name: integration 3.x floor + package: openai + module: agent_framework_openai + requirement: openai==3.0.0 + - name: DevUI 3.x floor + package: devui + module: agent_framework_devui + requirement: openai==3.0.0 + - name: hosting responses 3.x floor + package: hosting-responses + module: agent_framework_hosting_responses + requirement: openai==3.0.0 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python and install the project + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + - name: Install ${{ matrix.name }} + run: uv pip install "${{ matrix.requirement }}" + working-directory: ./python + - name: Test and type-check ${{ matrix.name }} + run: | + uv run --no-sync python -c "import openai; print(f'OpenAI SDK {openai.__version__}')" + uv run --no-sync python -m pytest -m "not integration" --cov=${{ matrix.module }} --cov-report=term-missing:skip-covered tests + uv run --no-sync pyright + working-directory: ./python/packages/${{ matrix.package }} diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 382d75fb47a..4b0022e37a0 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.15.0,<2", - "openai>=2.45.0,<3", + "openai>=2.45.0,<4", "opentelemetry-sdk>=1.39.0,<2", "fastapi>=0.115.0,<0.138.1", "uvicorn[standard]>=0.30.0,<1" diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml index 6e0b0049060..f9472164403 100644 --- a/python/packages/hosting-responses/pyproject.toml +++ b/python/packages/hosting-responses/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.13.0,<2", "agent-framework-hosting==1.0.0a260730", - "openai>=1.99.0,<3", + "openai>=1.99.0,<4", ] [dependency-groups] diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 55ff20950ad..772d19566f9 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -73,7 +73,12 @@ ) from agent_framework.observability import ChatTelemetryLayer from openai import AsyncAzureOpenAI, AsyncOpenAI, BadRequestError -from openai.types.responses import FunctionShellToolParam, ResponseCustomToolCall, ResponseToolSearchCall +from openai.types.responses import ( + FunctionShellToolParam, + ResponseCustomToolCall, + ResponseToolSearchCall, + response_create_params, +) from openai.types.responses.file_search_tool_param import FileSearchToolParam from openai.types.responses.function_tool_param import FunctionToolParam from openai.types.responses.parsed_response import ( @@ -115,23 +120,12 @@ else: from typing_extensions import TypedDict # pragma: no cover -try: - from openai.types.responses.response_create_params import PromptCacheOptions - - _prompt_cache_options_supported = True -except ImportError: # pragma: no cover - _prompt_cache_options_supported = False +_prompt_cache_options_supported = hasattr(response_create_params, "PromptCacheOptions") - class PromptCacheOptions(TypedDict, total=False): - """Fallback for openai versions that predate prompt cache options. - - Mirrors the SDK's shape so ``prompt_cache_options`` type-checks the same on - every supported openai version; a runtime guard rejects the option when the - installed openai is too old to send it. - """ - mode: Literal["implicit", "explicit"] - ttl: Literal["30m"] +class _PromptCacheOptions(TypedDict, total=False): + mode: Literal["implicit", "explicit"] + ttl: Literal["30m"] if TYPE_CHECKING: @@ -233,7 +227,7 @@ class OpenAIChatOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT], prompt_cache_retention: Literal["24h"] """Retention policy for prompt cache. Set to '24h' for extended caching.""" - prompt_cache_options: PromptCacheOptions + prompt_cache_options: _PromptCacheOptions """Request-wide prompt cache policy for GPT-5.6 and later models. Set mode to 'explicit' to use only the breakpoints set on content parts via ``Content.additional_properties["prompt_cache_breakpoint"]``. diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index dda04913208..6639422fc3f 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -49,6 +49,7 @@ from openai import AsyncAzureOpenAI, AsyncOpenAI, BadRequestError from openai.lib._parsing._completions import type_to_response_format_param from openai.types import CompletionUsage +from openai.types.chat import completion_create_params from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, ChoiceDelta from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice @@ -82,23 +83,12 @@ else: from typing_extensions import TypedDict # pragma: no cover -try: - from openai.types.chat.completion_create_params import PromptCacheOptions +_prompt_cache_options_supported = hasattr(completion_create_params, "PromptCacheOptions") - _prompt_cache_options_supported = True -except ImportError: # pragma: no cover - _prompt_cache_options_supported = False - class PromptCacheOptions(TypedDict, total=False): - """Fallback for openai versions that predate prompt cache options. - - Mirrors the SDK's shape so ``prompt_cache_options`` type-checks the same on - every supported openai version; a runtime guard rejects the option when the - installed openai is too old to send it. - """ - - mode: Literal["implicit", "explicit"] - ttl: Literal["30m"] +class _PromptCacheOptions(TypedDict, total=False): + mode: Literal["implicit", "explicit"] + ttl: Literal["30m"] if TYPE_CHECKING: @@ -229,7 +219,7 @@ class OpenAIChatCompletionOptions(ChatOptions[ResponseModelT], Generic[ResponseM """Output verbosity for GPT-5 family models. Lower values yield shorter responses. See: https://developers.openai.com/cookbook/examples/gpt-5/gpt-5_new_params_and_tools#1-verbosity-parameter""" - prompt_cache_options: PromptCacheOptions + prompt_cache_options: _PromptCacheOptions """Request-wide prompt cache policy for GPT-5.6 and later models. Set mode to 'explicit' to use only the breakpoints set on content parts via ``Content.additional_properties["prompt_cache_breakpoint"]``. diff --git a/python/packages/openai/agent_framework_openai/_feature_usage.py b/python/packages/openai/agent_framework_openai/_feature_usage.py index 7b92d1aa3dc..bf350e11a0a 100644 --- a/python/packages/openai/agent_framework_openai/_feature_usage.py +++ b/python/packages/openai/agent_framework_openai/_feature_usage.py @@ -2,9 +2,11 @@ import asyncio import contextlib +from collections.abc import MutableMapping from enum import IntEnum +from typing import Protocol +from urllib.parse import urlsplit -import httpx from agent_framework._telemetry import USER_AGENT_KEY, apply_feature_token, remove_feature_token from openai import DefaultAsyncHttpxClient @@ -22,6 +24,14 @@ class FeatureIndex(IntEnum): ) +class _HttpRequest(Protocol): + @property + def headers(self) -> MutableMapping[str, str]: ... + + @property + def url(self) -> object: ... + + class _FeatureUsageAsyncHttpxClient(DefaultAsyncHttpxClient): """OpenAI-default HTTP client that preserves the SDK's GC cleanup behavior.""" @@ -32,11 +42,10 @@ def __del__(self) -> None: asyncio.get_running_loop().create_task(self.aclose()) -def _is_approved_origin(url: httpx.URL | str, suffixes: tuple[str, ...]) -> bool: - if isinstance(url, str): - url = httpx.URL(url) - host = (url.host or "").rstrip(".").lower() - return url.scheme == "https" and any(host == suffix or host.endswith(f".{suffix}") for suffix in suffixes) +def _is_approved_origin(url: str, suffixes: tuple[str, ...]) -> bool: + parsed_url = urlsplit(url) + host = (parsed_url.hostname or "").rstrip(".").lower() + return parsed_url.scheme == "https" and any(host == suffix or host.endswith(f".{suffix}") for suffix in suffixes) def create_feature_usage_http_client( @@ -45,11 +54,11 @@ def create_feature_usage_http_client( ) -> DefaultAsyncHttpxClient: """Create the OpenAI SDK default client with destination-aware feature stamping.""" - async def stamp_feature_usage(request: httpx.Request) -> None: # ruff:ignore[unused-async] + async def stamp_feature_usage(request: _HttpRequest) -> None: # ruff:ignore[unused-async] user_agent = request.headers.get(USER_AGENT_KEY, "") request.headers[USER_AGENT_KEY] = ( apply_feature_token(user_agent) - if _is_approved_origin(request.url, approved_origin_suffixes) + if _is_approved_origin(str(request.url), approved_origin_suffixes) else remove_feature_token(user_agent) ) diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index b2ee84aacfc..2541f20b512 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, _legacy_response # type: ignore +from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, HttpxBinaryResponseContent from openai.types import Completion from openai.types.audio import Transcription from openai.types.chat import ChatCompletion, ChatCompletionChunk @@ -34,6 +34,7 @@ AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default" # ruff:ignore[hardcoded-password-string] # nosec B105 +_SUPPRESSED_AZURE_API_KEY = "" RESPONSE_TYPE = Union[ @@ -46,7 +47,7 @@ Response, AsyncStream[ResponseStreamEvent], Transcription, - _legacy_response.HttpxBinaryResponseContent, + HttpxBinaryResponseContent, ] AzureTokenProvider = Callable[[], str | Awaitable[str]] @@ -295,14 +296,16 @@ def load_openai_service_settings( client_args["azure_endpoint"] = endpoint if base_url := azure_settings.get("base_url"): client_args["base_url"] = base_url - if api_key := azure_settings.get("api_key"): - client_args["api_key"] = api_key.get_secret_value() - if api_key_callable: - client_args["api_key"] = api_key_callable if api_version := azure_settings.get("api_version"): client_args["api_version"] = api_version if credential: + # Both supported SDK majors recognize this sentinel and skip API-key auth without consulting the environment. + client_args["api_key"] = _SUPPRESSED_AZURE_API_KEY client_args["azure_ad_token_provider"] = _resolve_azure_credential_to_token_provider(credential) + elif api_key_callable: + client_args["api_key"] = api_key_callable + elif api_key := azure_settings.get("api_key"): + client_args["api_key"] = api_key.get_secret_value() if "api_key" not in client_args and "azure_ad_token_provider" not in client_args: raise SettingNotFoundError( "Azure OpenAI client requires either an API key or an Azure AD token provider." diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index 4fb486e1a4a..676c8f867fd 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.15.0,<2", - "openai>=2.25.0,<3", + "openai>=2.25.0,<4", ] [tool.uv] diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index aab0fc1fe24..ce13e6e610d 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -6819,6 +6819,9 @@ def get_round_trip_marker() -> str: ) first_message = first_response.messages[0] + raw_response = cast(Any, first_response.raw_representation) + if not any(getattr(item, "type", None) == "reasoning" for item in raw_response.output): + pytest.skip("OpenAI omitted the optional reasoning item for the forced function call.") reasoning_contents = [content for content in first_message.contents if content.type == "text_reasoning"] assert reasoning_contents assert any(content.protected_data for content in reasoning_contents) @@ -8218,8 +8221,11 @@ def test_prepare_content_for_openai_no_prompt_cache_breakpoint_by_default() -> N assert part == {"type": "input_text", "text": "hello"} -async def test_prepare_options_prompt_cache_options_passthrough() -> None: +async def test_prepare_options_prompt_cache_options_passthrough(monkeypatch: pytest.MonkeyPatch) -> None: """Request-level prompt_cache_options reaches the Responses API run options.""" + import agent_framework_openai._chat_client as chat_client_module + + monkeypatch.setattr(chat_client_module, "_prompt_cache_options_supported", True) client = OpenAIChatClient(api_key="test-api-key", model="test-model") run_options = await client._prepare_options( [Message(role="user", contents=[Content.from_text("hi")])], diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index fe532322c33..f828a18660e 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -16,6 +16,7 @@ from azure.core.credentials_async import AsyncTokenCredential from azure.identity.aio import AzureCliCredential from openai import AsyncAzureOpenAI +from openai._models import FinalRequestOptions from pydantic import BaseModel from pytest import param @@ -145,15 +146,22 @@ def test_api_version_alone_does_not_override_openai_api_key( assert client.azure_endpoint is None -def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: +async def test_explicit_credential_wins_over_openai_api_key( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-azure-key") client = OpenAIChatClient(credential=lambda: "token") + options = FinalRequestOptions.construct(method="GET", url="/models") + request = client.client._build_request(await client.client._prepare_options(options)) assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + assert request.headers["Authorization"] == "Bearer token" + assert "api-key" not in request.headers def test_init_falls_back_to_generic_azure_deployment_env( diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index d63f3a3e100..d6338d39783 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -2442,8 +2442,11 @@ def test_prepare_content_for_openai_image_prompt_cache_breakpoint() -> None: assert part["prompt_cache_breakpoint"] == {"mode": "explicit"} -def test_prepare_options_prompt_cache_options_passthrough() -> None: +def test_prepare_options_prompt_cache_options_passthrough(monkeypatch: pytest.MonkeyPatch) -> None: """Request-level prompt_cache_options reaches the Chat Completions run options.""" + import agent_framework_openai._chat_completion_client as chat_completion_module + + monkeypatch.setattr(chat_completion_module, "_prompt_cache_options_supported", True) client = OpenAIChatCompletionClient(api_key="test-api-key", model="test-model") run_options = client._prepare_options( [Message(role="user", contents=[Content.from_text("hi")])], diff --git a/python/packages/openai/tests/openai/test_openai_shared.py b/python/packages/openai/tests/openai/test_openai_shared.py index f844f3f90d0..f1036949b39 100644 --- a/python/packages/openai/tests/openai/test_openai_shared.py +++ b/python/packages/openai/tests/openai/test_openai_shared.py @@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import agent_framework._telemetry as telemetry -import httpx import pytest from agent_framework import AGENT_FRAMEWORK_USER_AGENT from agent_framework._telemetry import FeatureIndex as CoreFeatureIndex @@ -84,12 +83,12 @@ async def test_feature_usage_hook_stamps_approved_origin_and_strips_custom_origi mark_feature_used(CoreFeatureIndex.CORE_AGENT) client = create_feature_usage_http_client() hook = client.event_hooks["request"][0] - approved = httpx.Request( + approved = client.build_request( "POST", "https://resource.openai.azure.com/openai/v1/responses", headers={"User-Agent": f"{AGENT_FRAMEWORK_USER_AGENT} sdk/1.0"}, ) - custom = httpx.Request( + custom = client.build_request( "POST", "https://customer-gateway.example.com/v1/responses", headers={"User-Agent": f"{AGENT_FRAMEWORK_USER_AGENT} (feat=v1.1)"}, diff --git a/python/samples/04-hosting/container/hyperlight_codeact/call_server.py b/python/samples/04-hosting/container/hyperlight_codeact/call_server.py index 90ea113b1f2..6566b7977dd 100644 --- a/python/samples/04-hosting/container/hyperlight_codeact/call_server.py +++ b/python/samples/04-hosting/container/hyperlight_codeact/call_server.py @@ -1,7 +1,7 @@ # /// script # requires-python = ">=3.10" # dependencies = [ -# "openai>=1.50,<3", +# "openai>=1.50,<4", # "azure-identity>=1.19,<2", # ] # /// diff --git a/python/uv.lock b/python/uv.lock index 5aa816032c6..d16194c3574 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -581,7 +581,7 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-orchestrations", marker = "extra == 'dev'", editable = "packages/orchestrations" }, { name = "fastapi", specifier = ">=0.115.0,<0.138.1" }, - { name = "openai", specifier = ">=2.45.0,<3" }, + { name = "openai", specifier = ">=2.45.0,<4" }, { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, { name = "pytest", marker = "extra == 'all'", specifier = "==9.1.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, @@ -764,7 +764,7 @@ test = [ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-hosting", editable = "packages/hosting" }, - { name = "openai", specifier = ">=1.99.0,<3" }, + { name = "openai", specifier = ">=1.99.0,<4" }, ] [package.metadata.requires-dev] @@ -962,7 +962,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "openai", specifier = ">=2.25.0,<3" }, + { name = "openai", specifier = ">=2.25.0,<4" }, ] [[package]]