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
64 changes: 64 additions & 0 deletions .github/workflows/python-openai-dependency-bounds.yml
Original file line number Diff line number Diff line change
@@ -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 }}
2 changes: 1 addition & 1 deletion python/packages/devui/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion python/packages/hosting-responses/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
28 changes: 11 additions & 17 deletions python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]``.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]``.
Expand Down
25 changes: 17 additions & 8 deletions python/packages/openai/agent_framework_openai/_feature_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""

Expand All @@ -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(
Expand All @@ -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)
)

Expand Down
15 changes: 9 additions & 6 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, _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
Expand All @@ -34,6 +34,7 @@


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


RESPONSE_TYPE = Union[
Expand All @@ -46,7 +47,7 @@
Response,
AsyncStream[ResponseStreamEvent],
Transcription,
_legacy_response.HttpxBinaryResponseContent,
HttpxBinaryResponseContent,
]

AzureTokenProvider = Callable[[], str | Awaitable[str]]
Expand Down Expand Up @@ -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)
Comment thread
eavanvalkenburg marked this conversation as resolved.
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."
Expand Down
2 changes: 1 addition & 1 deletion python/packages/openai/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.15.0,<2",
"openai>=2.25.0,<3",
"openai>=2.25.0,<4",
Comment thread
eavanvalkenburg marked this conversation as resolved.
]

[tool.uv]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")])],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")])],
Expand Down
5 changes: 2 additions & 3 deletions python/packages/openai/tests/openai/test_openai_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "openai>=1.50,<3",
# "openai>=1.50,<4",
# "azure-identity>=1.19,<2",
# ]
# ///
Expand Down
Loading
Loading