From 01c45ef4102406e5aea390382f8485e848a6333b Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Wed, 10 Jun 2026 03:10:07 -0400 Subject: [PATCH 1/5] Add OCI Generative AI model support via request-signing client Adds optional-dependency support for Oracle Cloud Infrastructure Generative AI under agents.extensions.models: an httpx auth hook that performs OCI request signing (API key, session token, instance and resource principals, with transparent refresh) plus thin OCIChatCompletionsModel / OCIResponsesModel subclasses and an OCIProvider that route through the service's OpenAI-compatible chat completions and Responses endpoints. Includes unit tests and end-to-end runner tests against mocked transports. --- pyproject.toml | 5 + src/agents/extensions/models/oci_model.py | 146 +++++++++++ src/agents/extensions/models/oci_provider.py | 97 +++++++ src/agents/extensions/models/oci_signer.py | 254 +++++++++++++++++++ tests/models/test_oci_integration.py | 219 ++++++++++++++++ tests/models/test_oci_model.py | 135 ++++++++++ uv.lock | 138 +++++++++- 7 files changed, 990 insertions(+), 4 deletions(-) create mode 100644 src/agents/extensions/models/oci_model.py create mode 100644 src/agents/extensions/models/oci_provider.py create mode 100644 src/agents/extensions/models/oci_signer.py create mode 100644 tests/models/test_oci_integration.py create mode 100644 tests/models/test_oci_model.py diff --git a/pyproject.toml b/pyproject.toml index 1d4f6b4584..05e0b3c681 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ Repository = "https://github.com/openai/openai-agents-python" voice = ["numpy>=2.2.0, <3; python_version>='3.10'", "websockets>=15.0, <17"] viz = ["graphviz>=0.17"] litellm = ["litellm>=1.83.0"] +oci = ["oci>=2.150.0"] any-llm = ["any-llm-sdk>=1.11.0, <2; python_version >= '3.11'"] realtime = ["websockets>=15.0, <17"] sqlalchemy = ["SQLAlchemy>=2.0", "asyncpg>=0.29.0"] @@ -140,6 +141,10 @@ disallow_untyped_calls = false module = "sounddevice.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["oci", "oci.*", "requests", "requests.*"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = ["modal", "modal.*"] ignore_missing_imports = true diff --git a/src/agents/extensions/models/oci_model.py b/src/agents/extensions/models/oci_model.py new file mode 100644 index 0000000000..fa6a8866e6 --- /dev/null +++ b/src/agents/extensions/models/oci_model.py @@ -0,0 +1,146 @@ +"""Models for the OCI Generative AI OpenAI-compatible transports. + +OCI Generative AI exposes most of its hosted catalog (including the `openai.*` +model IDs) on OpenAI-compatible `chat/completions` and `responses` endpoints, +authenticated with OCI request signing instead of bearer tokens. The classes +here reuse the SDK's OpenAI model implementations against those endpoints by +injecting a signing HTTP client. +""" + +from __future__ import annotations + +import httpx +from openai import AsyncOpenAI + +from ...models.openai_chatcompletions import OpenAIChatCompletionsModel +from ...models.openai_responses import OpenAIResponsesModel +from .oci_signer import ( + OCIAuthType, + OCIClientConfig, + OCIRequestSigner, + oci_openai_base_url, + resolve_client_config, +) + +# Reasoning models can take minutes before the first byte; use a generous default. +DEFAULT_REQUEST_TIMEOUT = 300.0 + + +def build_signed_openai_client( + client_config: OCIClientConfig, + *, + request_timeout: float = DEFAULT_REQUEST_TIMEOUT, +) -> AsyncOpenAI: + """Build an `AsyncOpenAI` client wired to an OCI Generative AI regional endpoint. + + The returned client signs every request with the resolved OCI credentials and + routes it to the region's OpenAI-compatible base URL. The `api_key` placeholder is + never sent; the signer strips bearer auth before signing. + """ + http_client = httpx.AsyncClient( + auth=OCIRequestSigner( + client_config.signer, + compartment_id=client_config.compartment_id, + refresh_signer=client_config.refresh_signer, + ), + timeout=httpx.Timeout(request_timeout), + ) + return AsyncOpenAI( + base_url=oci_openai_base_url(client_config.region), + api_key="oci-request-signing", + http_client=http_client, + ) + + +class OCIChatCompletionsModel(OpenAIChatCompletionsModel): + """OCI Generative AI model served over the OpenAI-compatible chat completions API. + + This is the right transport for `openai.*` model IDs and most of the rest of the + on-demand catalog. + + Example: + ```python + model = OCIChatCompletionsModel( + "openai.gpt-4o", + compartment_id="ocid1.compartment.oc1..example", + ) + agent = Agent(name="Assistant", model=model) + ``` + """ + + def __init__( + self, + model: str, + *, + auth_type: OCIAuthType | None = None, + profile: str | None = None, + config_file: str | None = None, + region: str | None = None, + compartment_id: str | None = None, + request_timeout: float = DEFAULT_REQUEST_TIMEOUT, + openai_client: AsyncOpenAI | None = None, + ) -> None: + if openai_client is None: + client_config = resolve_client_config( + auth_type=auth_type, + profile=profile, + config_file=config_file, + region=region, + compartment_id=compartment_id, + ) + openai_client = build_signed_openai_client( + client_config, request_timeout=request_timeout + ) + super().__init__(model, openai_client) + + +class OCIResponsesModel(OpenAIResponsesModel): + """OCI Generative AI model served over the OpenAI-compatible Responses API. + + Required for Responses-only reasoning models in the OCI catalog. The transport is + server-stateful: multi-turn continuation uses `previous_response_id`, which the + runner manages. For tenancies with Zero Data Retention enabled, pass + `ModelSettings(store=False)` so the full history is sent each turn instead. + + Example: + ```python + model = OCIResponsesModel( + "openai.gpt-5", + compartment_id="ocid1.compartment.oc1..example", + ) + agent = Agent(name="Assistant", model=model) + ``` + """ + + def __init__( + self, + model: str, + *, + auth_type: OCIAuthType | None = None, + profile: str | None = None, + config_file: str | None = None, + region: str | None = None, + compartment_id: str | None = None, + request_timeout: float = DEFAULT_REQUEST_TIMEOUT, + openai_client: AsyncOpenAI | None = None, + ) -> None: + if openai_client is None: + client_config = resolve_client_config( + auth_type=auth_type, + profile=profile, + config_file=config_file, + region=region, + compartment_id=compartment_id, + ) + openai_client = build_signed_openai_client( + client_config, request_timeout=request_timeout + ) + super().__init__(model, openai_client) + + +__all__ = [ + "DEFAULT_REQUEST_TIMEOUT", + "OCIChatCompletionsModel", + "OCIResponsesModel", + "build_signed_openai_client", +] diff --git a/src/agents/extensions/models/oci_provider.py b/src/agents/extensions/models/oci_provider.py new file mode 100644 index 0000000000..37dbb7c326 --- /dev/null +++ b/src/agents/extensions/models/oci_provider.py @@ -0,0 +1,97 @@ +"""ModelProvider that routes OCI Generative AI model names to the right endpoint. + +OCI Generative AI serves its catalog over two OpenAI-compatible endpoints: + +- Chat completions: the default for the on-demand catalog (`openai.*` and most + other model IDs). +- Responses: required for Responses-only reasoning models. These cannot be + detected from the model name, so select them with the `responses:` prefix + (e.g. `"responses:openai.gpt-5"`). +""" + +from __future__ import annotations + +from openai import AsyncOpenAI + +from ...exceptions import UserError +from ...models.interface import Model, ModelProvider +from .oci_model import ( + DEFAULT_REQUEST_TIMEOUT, + OCIChatCompletionsModel, + OCIResponsesModel, + build_signed_openai_client, +) +from .oci_signer import OCIAuthType, OCIClientConfig, resolve_client_config + +_RESPONSES_PREFIX = "responses:" + + +class OCIProvider(ModelProvider): + """A ModelProvider for the OCI Generative AI service. You can use it via: + + ```python + Runner.run(agent, input, run_config=RunConfig(model_provider=OCIProvider())) + ``` + + Credentials are resolved from the standard OCI configuration sources: an + `~/.oci/config` profile (API key or session token) or, when requested explicitly, + instance/resource principals. The compartment used for inference is taken from the + `compartment_id` argument or the `OCI_COMPARTMENT_ID` environment variable. + """ + + def __init__( + self, + *, + auth_type: OCIAuthType | None = None, + profile: str | None = None, + config_file: str | None = None, + region: str | None = None, + compartment_id: str | None = None, + request_timeout: float = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + self._auth_type = auth_type + self._profile = profile + self._config_file = config_file + self._region = region + self._compartment_id = compartment_id + self._request_timeout = request_timeout + self._client_config: OCIClientConfig | None = None + self._openai_client: AsyncOpenAI | None = None + + def _get_openai_client(self) -> AsyncOpenAI: + # The signed client is shared by every model handed out by this provider. + if self._openai_client is None: + if self._client_config is None: + self._client_config = resolve_client_config( + auth_type=self._auth_type, + profile=self._profile, + config_file=self._config_file, + region=self._region, + compartment_id=self._compartment_id, + ) + self._openai_client = build_signed_openai_client( + self._client_config, request_timeout=self._request_timeout + ) + return self._openai_client + + def get_model(self, model_name: str | None) -> Model: + if not model_name: + raise UserError( + "OCIProvider requires an explicit model name (e.g. 'openai.gpt-4o' or " + "'openai.gpt-5')." + ) + + if model_name.startswith(_RESPONSES_PREFIX): + return OCIResponsesModel( + model_name.removeprefix(_RESPONSES_PREFIX), + openai_client=self._get_openai_client(), + ) + return OCIChatCompletionsModel(model_name, openai_client=self._get_openai_client()) + + async def aclose(self) -> None: + if self._openai_client is not None: + await self._openai_client.close() + self._openai_client = None + + +__all__ = ["OCIProvider"] diff --git a/src/agents/extensions/models/oci_signer.py b/src/agents/extensions/models/oci_signer.py new file mode 100644 index 0000000000..a3705a52ab --- /dev/null +++ b/src/agents/extensions/models/oci_signer.py @@ -0,0 +1,254 @@ +"""OCI request-signing support shared by the OCI Generative AI model classes. + +Oracle Cloud Infrastructure (OCI) authenticates HTTP requests with per-request +signatures derived from IAM credentials rather than bearer tokens. This module +resolves those credentials (API key, session token, instance principal, or +resource principal) and exposes an `httpx.Auth` hook that signs each outgoing +request, so the OpenAI client can talk to the OCI Generative AI +OpenAI-compatible endpoints. +""" + +from __future__ import annotations + +import os +import threading +import time +from collections.abc import Callable, Generator +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +import httpx + +try: + import oci +except ImportError as _e: + raise ImportError( + "`oci` is required to use the OCI model classes. You can install it via the optional " + "dependency group: `pip install 'openai-agents[oci]'`." + ) from _e + +import requests + +from ...exceptions import UserError + +DEFAULT_OCI_REGION = "us-chicago-1" +"""Fallback region used when none is configured anywhere else.""" + +OCIAuthType = Literal["api_key", "security_token", "instance_principal", "resource_principal"] + + +def oci_openai_base_url(region: str) -> str: + """Return the OCI Generative AI OpenAI-compatible base URL for a region.""" + return f"https://inference.generativeai.{region}.oci.oraclecloud.com/openai/v1" + + +@dataclass +class OCIClientConfig: + """Resolved OCI credentials and routing information. + + Attributes: + signer: The OCI signer used to sign requests. + config: The OCI SDK config dict (empty for principal-based auth). + region: The region whose Generative AI endpoint should be called. + compartment_id: The compartment all inference requests are billed against. + refresh_signer: Optional zero-arg callable that rebuilds the signer when its + credentials expire (session tokens, principal tokens). `None` for API keys, + which do not expire. + """ + + signer: Any + config: dict[str, Any] + region: str + compartment_id: str | None + refresh_signer: Callable[[], Any] | None = None + + +def _load_file_config(profile: str | None, config_file: str | None) -> dict[str, Any]: + file_location = config_file or oci.config.DEFAULT_LOCATION + profile_name = profile or os.environ.get("OCI_CLI_PROFILE") or oci.config.DEFAULT_PROFILE + config: dict[str, Any] = oci.config.from_file( + file_location=file_location, profile_name=profile_name + ) + return config + + +def _build_api_key_signer(config: dict[str, Any]) -> Any: + return oci.signer.Signer( + tenancy=config["tenancy"], + user=config["user"], + fingerprint=config["fingerprint"], + private_key_file_location=config["key_file"], + pass_phrase=config.get("pass_phrase"), + ) + + +def _build_security_token_signer(config: dict[str, Any]) -> Any: + token = Path(config["security_token_file"]).expanduser().read_text().strip() + private_key = oci.signer.load_private_key_from_file( + config["key_file"], config.get("pass_phrase") + ) + return oci.auth.signers.SecurityTokenSigner(token=token, private_key=private_key) + + +def _build_instance_principal_signer() -> Any: + return oci.auth.signers.InstancePrincipalsSecurityTokenSigner() + + +def _build_resource_principal_signer() -> Any: + return oci.auth.signers.get_resource_principals_signer() + + +def resolve_client_config( + *, + auth_type: OCIAuthType | None = None, + profile: str | None = None, + config_file: str | None = None, + region: str | None = None, + compartment_id: str | None = None, +) -> OCIClientConfig: + """Resolve OCI credentials into a signer plus routing information. + + When `auth_type` is omitted, file-based configuration is used and the auth mode is + inferred: profiles carrying a `security_token_file` use session-token signing, + everything else uses API-key signing. Principal-based modes must be requested + explicitly because they cannot be detected from a config file. + + Resolution order for the region: explicit argument, `OCI_REGION` env var, the config + file profile's `region`, then the service default. For the compartment: explicit + argument, `OCI_COMPARTMENT_ID` env var, then the profile's tenancy as a best-effort + fallback. + """ + refresh: Callable[[], Any] | None = None + + if auth_type == "instance_principal": + signer = _build_instance_principal_signer() + config: dict[str, Any] = {} + refresh = _build_instance_principal_signer + elif auth_type == "resource_principal": + signer = _build_resource_principal_signer() + config = {} + refresh = _build_resource_principal_signer + else: + config = _load_file_config(profile, config_file) + use_session_token = auth_type == "security_token" or ( + auth_type is None and config.get("security_token_file") + ) + if use_session_token: + if not config.get("security_token_file"): + raise UserError( + "auth_type='security_token' requires a `security_token_file` entry in the " + "selected OCI config profile." + ) + signer = _build_security_token_signer(config) + + def _refresh_from_disk(cfg: dict[str, Any] = config) -> Any: + return _build_security_token_signer(cfg) + + refresh = _refresh_from_disk + else: + signer = _build_api_key_signer(config) + + resolved_region = ( + region + or os.environ.get("OCI_REGION") + or config.get("region") + or getattr(signer, "region", None) + or DEFAULT_OCI_REGION + ) + resolved_compartment = ( + compartment_id or os.environ.get("OCI_COMPARTMENT_ID") or config.get("tenancy") + ) + + return OCIClientConfig( + signer=signer, + config=config, + region=str(resolved_region), + compartment_id=resolved_compartment, + refresh_signer=refresh, + ) + + +class OCIRequestSigner(httpx.Auth): + """`httpx.Auth` hook that applies OCI request signing to every request. + + The hook strips any bearer auth injected by the OpenAI client, attaches the + `opc-compartment-id` header expected by the OCI OpenAI-compatible endpoints, signs + the request with the configured signer, and transparently rebuilds expiring signers + both on a timed interval and on a 401 response. + """ + + requires_request_body = True + + def __init__( + self, + signer: Any, + *, + compartment_id: str | None = None, + refresh_signer: Callable[[], Any] | None = None, + refresh_interval: float = 600.0, + ) -> None: + self._signer = signer + self._compartment_id = compartment_id + self._refresh_signer = refresh_signer + self._refresh_interval = refresh_interval + self._last_refresh = time.monotonic() + self._lock = threading.Lock() + + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + self._maybe_refresh() + self._sign(request) + response = yield request + if response.status_code == 401 and self._refresh_signer is not None: + self._refresh(force=True) + self._sign(request) + yield request + + def _maybe_refresh(self) -> None: + if self._refresh_signer is None: + return + if time.monotonic() - self._last_refresh >= self._refresh_interval: + self._refresh(force=False) + + def _refresh(self, *, force: bool) -> None: + if self._refresh_signer is None: + return + with self._lock: + if not force and time.monotonic() - self._last_refresh < self._refresh_interval: + return + self._signer = self._refresh_signer() + self._last_refresh = time.monotonic() + + def _sign(self, request: httpx.Request) -> None: + # The OpenAI client always sends a bearer token; OCI uses signature auth instead. + request.headers.pop("Authorization", None) + if self._compartment_id is not None: + request.headers["opc-compartment-id"] = self._compartment_id + + try: + content = request.content + except httpx.RequestNotRead: + content = request.read() + + # OCI signers operate on `requests.PreparedRequest`; rebuild the request in that + # shape, sign it, and copy the signature headers (authorization, date, host, + # x-content-sha256, ...) back onto the httpx request. + prepared = requests.Request( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + data=content, + ).prepare() + self._signer.do_request_sign(prepared) + for key, value in prepared.headers.items(): + request.headers[key] = value + + +__all__ = [ + "DEFAULT_OCI_REGION", + "OCIAuthType", + "OCIClientConfig", + "OCIRequestSigner", + "oci_openai_base_url", + "resolve_client_config", +] diff --git a/tests/models/test_oci_integration.py b/tests/models/test_oci_integration.py new file mode 100644 index 0000000000..dc3d6cdaa0 --- /dev/null +++ b/tests/models/test_oci_integration.py @@ -0,0 +1,219 @@ +"""End-to-end agent runs over the OCI Generative AI OpenAI-compatible endpoints. + +These tests drive the full `Runner` loop (including tool round-trips) against an +`httpx.MockTransport`, so request signing and wire shapes are exercised through +the real OpenAI client for both the chat completions and Responses endpoints. +""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest +from openai import AsyncOpenAI + +from agents import Agent, Runner, function_tool +from agents.extensions.models.oci_model import ( + OCIChatCompletionsModel, + OCIResponsesModel, +) +from agents.extensions.models.oci_signer import OCIRequestSigner, oci_openai_base_url + +COMPARTMENT_ID = "ocid1.compartment.oc1..testcompartment" +REGION = "us-chicago-1" + +# These tests intentionally exercise the real model classes against mocked transports. +pytestmark = pytest.mark.allow_call_model_methods + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather for a city. + + Args: + city: The city to look up. + """ + return f"The weather in {city} is sunny." + + +class FakeSigner: + def do_request_sign(self, prepared: Any) -> None: + prepared.headers["authorization"] = "Signature integration-test" + + +def _signed_openai_client(replies: list[dict[str, Any]], seen: list[httpx.Request]) -> AsyncOpenAI: + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=replies[len(seen) - 1]) + + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + auth=OCIRequestSigner(FakeSigner(), compartment_id=COMPARTMENT_ID), + ) + return AsyncOpenAI( + base_url=oci_openai_base_url(REGION), + api_key="oci-request-signing", + http_client=http_client, + ) + + +def _chat_completion_reply( + *, content: str | None = None, tool_call: dict[str, Any] | None = None +) -> dict[str, Any]: + message: dict[str, Any] = {"role": "assistant", "content": content} + finish_reason = "stop" + if tool_call is not None: + message["tool_calls"] = [tool_call] + finish_reason = "tool_calls" + return { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1, + "model": "openai.gpt-4o", + "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + +async def test_agent_tool_round_trip_over_chat_completions_transport() -> None: + seen: list[httpx.Request] = [] + replies = [ + _chat_completion_reply( + tool_call={ + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "SF"}'}, + } + ), + _chat_completion_reply(content="It is sunny in SF."), + ] + model = OCIChatCompletionsModel( + "openai.gpt-4o", openai_client=_signed_openai_client(replies, seen) + ) + agent = Agent( + name="weather-agent", + instructions="Use the weather tool.", + model=model, + tools=[get_weather], + ) + + result = await Runner.run(agent, "What's the weather in SF?") + + assert result.final_output == "It is sunny in SF." + assert len(seen) == 2 + for request in seen: + assert request.url.path.endswith("/openai/v1/chat/completions") + assert request.headers["authorization"] == "Signature integration-test" + assert request.headers["opc-compartment-id"] == COMPARTMENT_ID + + # The second request must replay the tool call and carry the tool output. + second_body = json.loads(seen[1].content) + roles = [message["role"] for message in second_body["messages"]] + assert "tool" in roles + tool_message = next(m for m in second_body["messages"] if m["role"] == "tool") + assert tool_message["tool_call_id"] == "call_1" + assert "sunny" in str(tool_message["content"]) + + +def _responses_reply(*, text: str, response_id: str) -> dict[str, Any]: + return { + "id": response_id, + "object": "response", + "created_at": 1.0, + "model": "openai.gpt-5", + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": text, "annotations": []}], + } + ], + "tool_choice": "auto", + "tools": [], + "parallel_tool_calls": False, + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}, + }, + } + + +async def test_agent_run_over_responses_transport() -> None: + seen: list[httpx.Request] = [] + replies = [_responses_reply(text="It is sunny in SF.", response_id="resp_1")] + model = OCIResponsesModel("openai.gpt-5", openai_client=_signed_openai_client(replies, seen)) + agent = Agent(name="weather-agent", instructions="Answer briefly.", model=model) + + result = await Runner.run(agent, "What's the weather in SF?") + + assert result.final_output == "It is sunny in SF." + assert len(seen) == 1 + request = seen[0] + assert request.url.path.endswith("/openai/v1/responses") + assert request.headers["authorization"] == "Signature integration-test" + assert request.headers["opc-compartment-id"] == COMPARTMENT_ID + + +async def test_chat_completions_transport_streams_signed_requests() -> None: + sse_body = ( + "data: " + + json.dumps( + { + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 1, + "model": "openai.gpt-4o", + "choices": [ + {"index": 0, "delta": {"role": "assistant", "content": "It is sunny."}} + ], + } + ) + + "\n\ndata: " + + json.dumps( + { + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 1, + "model": "openai.gpt-4o", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ) + + "\n\ndata: [DONE]\n\n" + ) + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, content=sse_body, headers={"content-type": "text/event-stream"}) + + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + auth=OCIRequestSigner(FakeSigner(), compartment_id=COMPARTMENT_ID), + ) + model = OCIChatCompletionsModel( + "openai.gpt-4o", + openai_client=AsyncOpenAI( + base_url=oci_openai_base_url(REGION), + api_key="oci-request-signing", + http_client=http_client, + ), + ) + agent = Agent(name="weather-agent", instructions="Answer briefly.", model=model) + + result = Runner.run_streamed(agent, "What's the weather in SF?") + deltas: list[str] = [] + async for event in result.stream_events(): + if event.type == "raw_response_event" and event.data.type == "response.output_text.delta": + deltas.append(event.data.delta) + + assert "".join(deltas) == "It is sunny." + assert seen[0].headers["authorization"] == "Signature integration-test" + assert seen[0].headers["opc-compartment-id"] == COMPARTMENT_ID diff --git a/tests/models/test_oci_model.py b/tests/models/test_oci_model.py new file mode 100644 index 0000000000..1d166d91cb --- /dev/null +++ b/tests/models/test_oci_model.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +from agents.exceptions import UserError +from agents.extensions.models.oci_provider import OCIProvider +from agents.extensions.models.oci_signer import ( + OCIClientConfig, + OCIRequestSigner, + oci_openai_base_url, +) + +COMPARTMENT_ID = "ocid1.compartment.oc1..testcompartment" + + +class FakeSigner: + """Stands in for an OCI signer; records what it signed.""" + + def __init__(self, signature: str = "Signature test") -> None: + self.signature = signature + self.signed_bodies: list[Any] = [] + + def do_request_sign(self, prepared: Any) -> None: + self.signed_bodies.append(prepared.body) + prepared.headers["authorization"] = self.signature + prepared.headers["date"] = "Mon, 01 Jan 2026 00:00:00 GMT" + + +def _drive_auth_flow(signer: OCIRequestSigner, request: httpx.Request) -> httpx.Request: + flow = signer.auth_flow(request) + return next(flow) + + +def test_endpoint_construction() -> None: + assert ( + oci_openai_base_url("us-chicago-1") + == "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1" + ) + + +def test_signer_replaces_bearer_auth_and_adds_compartment() -> None: + fake_signer = FakeSigner() + signer = OCIRequestSigner(fake_signer, compartment_id=COMPARTMENT_ID) + request = httpx.Request( + "POST", + "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1/chat/completions", + json={"model": "openai.gpt-4o"}, + headers={"Authorization": "Bearer should-be-removed"}, + ) + + signed = _drive_auth_flow(signer, request) + + assert signed.headers["authorization"] == "Signature test" + assert signed.headers["opc-compartment-id"] == COMPARTMENT_ID + assert fake_signer.signed_bodies == [request.content] + + +def test_signer_rebuilds_signer_on_401() -> None: + rebuilt: list[FakeSigner] = [] + + def refresh() -> FakeSigner: + new_signer = FakeSigner(signature=f"Signature refreshed-{len(rebuilt)}") + rebuilt.append(new_signer) + return new_signer + + signer = OCIRequestSigner(FakeSigner(), refresh_signer=refresh) + request = httpx.Request("POST", "https://example.com/openai/v1/chat/completions", json={}) + + flow = signer.auth_flow(request) + first = next(flow) + assert first.headers["authorization"] == "Signature test" + + retried = flow.send(httpx.Response(401, request=request)) + assert len(rebuilt) == 1 + assert retried.headers["authorization"] == "Signature refreshed-0" + + +def test_signer_does_not_retry_without_refresh() -> None: + signer = OCIRequestSigner(FakeSigner()) + request = httpx.Request("POST", "https://example.com/openai/v1/chat/completions", json={}) + + flow = signer.auth_flow(request) + next(flow) + with pytest.raises(StopIteration): + flow.send(httpx.Response(401, request=request)) + + +def test_provider_requires_model_name() -> None: + provider = OCIProvider(compartment_id=COMPARTMENT_ID) + with pytest.raises(UserError): + provider.get_model(None) + + +def test_provider_routes_to_model_classes(monkeypatch: pytest.MonkeyPatch) -> None: + import agents.extensions.models.oci_provider as oci_provider_module + + client_config = OCIClientConfig( + signer=FakeSigner(), + config={}, + region="us-chicago-1", + compartment_id=COMPARTMENT_ID, + ) + monkeypatch.setattr( + oci_provider_module, "resolve_client_config", lambda **kwargs: client_config + ) + + created: list[tuple[str, str]] = [] + + class StubChatModel: + def __init__(self, model: str, **kwargs: Any) -> None: + created.append(("chat", model)) + + class StubResponsesModel: + def __init__(self, model: str, **kwargs: Any) -> None: + created.append(("responses", model)) + + monkeypatch.setattr(oci_provider_module, "OCIChatCompletionsModel", StubChatModel) + monkeypatch.setattr(oci_provider_module, "OCIResponsesModel", StubResponsesModel) + monkeypatch.setattr( + oci_provider_module, + "build_signed_openai_client", + lambda config, request_timeout: object(), + ) + + provider = OCIProvider(compartment_id=COMPARTMENT_ID) + provider.get_model("openai.gpt-4o") + provider.get_model("responses:openai.gpt-5") + + assert created == [ + ("chat", "openai.gpt-4o"), + ("responses", "openai.gpt-5"), + ] diff --git a/uv.lock b/uv.lock index 685cfbac6d..24c4fbecc0 100644 --- a/uv.lock +++ b/uv.lock @@ -563,6 +563,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] +[[package]] +name = "circuitbreaker" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/ac/de7a92c4ed39cba31fe5ad9203b76a25ca67c530797f6bb420fff5f65ccb/circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084", size = 10787, upload-time = "2025-03-31T08:12:08.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/34/15f08edd4628f65217de1fc3c1a27c82e46fe357d60c217fc9881e12ebcc/circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1", size = 7737, upload-time = "2025-03-31T08:12:07.802Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -669,6 +678,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/19/e67f4ae24e232c7f713337f3f4f7c9c58afd0c02866fb07c7b9255a19ed7/coverage-7.10.3-py3-none-any.whl", hash = "sha256:416a8d74dc0adfd33944ba2f405897bab87b7e9e84a391e09d241956bd953ce1", size = 207921, upload-time = "2025-08-10T21:27:38.254Z" }, ] +[[package]] +name = "crc32c" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/66/7e97aa77af7cf6afbff26e3651b564fe41932599bc2d3dce0b2f73d4829a/crc32c-2.8.tar.gz", hash = "sha256:578728964e59c47c356aeeedee6220e021e124b9d3e8631d95d9a5e5f06e261c", size = 48179, upload-time = "2025-10-17T06:20:13.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/a0/28b4686a8db0bb0f77970f4c6ccede90d1d5740a1d4b4703bd54c3e75655/crc32c-2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2c0f4eb01fe7c0a3e3f973a418e04d52101bb077dd77626fd80c658ec60aaf95", size = 66321, upload-time = "2025-10-17T06:18:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/76/1f/1697f5b8b770f715ed9b264d79e36b4f77ae0527f81f3c749ef08937a32e/crc32c-2.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6baefcfbca82b1a9678455416da24f18629769a76920c640d5a538620a7d12bb", size = 62985, upload-time = "2025-10-17T06:18:54.97Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/333cfa5ffa8d5779733aced2b984b5e5139b4a8ceaa2c6bc563e9a1092f3/crc32c-2.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d7f959fcf6c5aad1c4a653ee1a50f05760dab1d1c35d98ec4d7f0f68643f7612", size = 61517, upload-time = "2025-10-17T06:18:55.795Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d8/362a009e8140dd926a153b44d56753e3aa7cb50aca243779a84adadbff11/crc32c-2.8-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bb678507a4e4cf3f0506607b046ecc4ed1c58a19e08a3fb3c2d25441c480bf1", size = 79385, upload-time = "2025-10-17T06:18:56.598Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0d4ea3aa71ffb15f1285669d23024cc40779388ce32157d339dc2584491c/crc32c-2.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a16f7ffa4c242a909558565567cbba95148603717b53538ea299c98da68e7a9", size = 80965, upload-time = "2025-10-17T06:18:57.384Z" }, + { url = "https://files.pythonhosted.org/packages/20/44/d77657aaca4a2c0283f2356a3da6f8e91b003567bb8f09daaf540cbf192f/crc32c-2.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0184369aad562d801f91f454c81f56b9ecb966f6b96684c4d6cf82fc8741d2ad", size = 79993, upload-time = "2025-10-17T06:18:58.503Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c0/07017a93ebf85d9408028b7e03ef96d5c6bfb14cb77cfe90d35eedcc1501/crc32c-2.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:86d2eeb5f0189bd803720abe7387019328ea34c4acde62999e5723f789bc316b", size = 79243, upload-time = "2025-10-17T06:18:59.273Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/b3c5ac4cf2fd1f82395173d0bd8e1a15d09f0bc1eccdf10ea7f8caaccd67/crc32c-2.8-cp310-cp310-win32.whl", hash = "sha256:51da61904a9e753780a2e6011885677d601db1fa840be4b68799643a113e6f08", size = 64888, upload-time = "2025-10-17T06:19:00.089Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f2/60c45fc7bb2221d3c93c7a872e921be591f40d45228fe46f879b1d8c0424/crc32c-2.8-cp310-cp310-win_amd64.whl", hash = "sha256:b2d6a1f2500daaf2e4b08f97ad0349aa2eff5faaaa5fd3350314a26eade334cd", size = 66639, upload-time = "2025-10-17T06:19:00.974Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0b/5e03b22d913698e9cc563f39b9f6bbd508606bf6b8e9122cd6bf196b87ea/crc32c-2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e560a97fbb96c9897cb1d9b5076ef12fc12e2e25622530a1afd0de4240f17e1f", size = 66329, upload-time = "2025-10-17T06:19:01.771Z" }, + { url = "https://files.pythonhosted.org/packages/6b/38/2fe0051ffe8c6a650c8b1ac0da31b8802d1dbe5fa40a84e4b6b6f5583db5/crc32c-2.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6762d276d90331a490ef7e71ffee53b9c0eb053bd75a272d786f3b08d3fe3671", size = 62988, upload-time = "2025-10-17T06:19:02.953Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/5837a71c014be83aba1469c58820d287fc836512a0cad6b8fdd43868accd/crc32c-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:60670569f5ede91e39f48fb0cb4060e05b8d8704dd9e17ede930bf441b2f73ef", size = 61522, upload-time = "2025-10-17T06:19:03.796Z" }, + { url = "https://files.pythonhosted.org/packages/ca/29/63972fc1452778e2092ae998c50cbfc2fc93e3fa9798a0278650cd6169c5/crc32c-2.8-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:711743da6ccc70b3c6718c328947b0b6f34a1fe6a6c27cc6c1d69cc226bf70e9", size = 80200, upload-time = "2025-10-17T06:19:04.617Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3a/60eb49d7bdada4122b3ffd45b0df54bdc1b8dd092cda4b069a287bdfcff4/crc32c-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eb4094a2054774f13b26f21bf56792bb44fa1fcee6c6ad099387a43ffbfb4fa", size = 81757, upload-time = "2025-10-17T06:19:05.496Z" }, + { url = "https://files.pythonhosted.org/packages/f5/63/6efc1b64429ef7d23bd58b75b7ac24d15df327e3ebbe9c247a0f7b1c2ed1/crc32c-2.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fff15bf2bd3e95780516baae935ed12be88deaa5ebe6143c53eb0d26a7bdc7b7", size = 80830, upload-time = "2025-10-17T06:19:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/e1/eb/0ae9f436f8004f1c88f7429e659a7218a3879bd11a6b18ed1257aad7e98b/crc32c-2.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c0e11e3826668121fa53e0745635baf5e4f0ded437e8ff63ea56f38fc4f970a", size = 80095, upload-time = "2025-10-17T06:19:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/9e/81/4afc9d468977a4cd94a2eb62908553345009a7c0d30e74463a15d4b48ec3/crc32c-2.8-cp311-cp311-win32.whl", hash = "sha256:38f915336715d1f1353ab07d7d786f8a789b119e273aea106ba55355dfc9101d", size = 64886, upload-time = "2025-10-17T06:19:08.497Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e8/94e839c9f7e767bf8479046a207afd440a08f5c59b52586e1af5e64fa4a0/crc32c-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:60e0a765b1caab8d31b2ea80840639253906a9351d4b861551c8c8625ea20f86", size = 66639, upload-time = "2025-10-17T06:19:09.338Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/fd18ef23c42926b79c7003e16cb0f79043b5b179c633521343d3b499e996/crc32c-2.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:572ffb1b78cce3d88e8d4143e154d31044a44be42cb3f6fbbf77f1e7a941c5ab", size = 66379, upload-time = "2025-10-17T06:19:10.115Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b8/c584958e53f7798dd358f5bdb1bbfc97483134f053ee399d3eeb26cca075/crc32c-2.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cf827b3758ee0c4aacd21ceca0e2da83681f10295c38a10bfeb105f7d98f7a68", size = 63042, upload-time = "2025-10-17T06:19:10.946Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/6f2af0ec64a668a46c861e5bc778ea3ee42171fedfc5440f791f470fd783/crc32c-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:106fbd79013e06fa92bc3b51031694fcc1249811ed4364ef1554ee3dd2c7f5a2", size = 61528, upload-time = "2025-10-17T06:19:11.768Z" }, + { url = "https://files.pythonhosted.org/packages/17/8b/4a04bd80a024f1a23978f19ae99407783e06549e361ab56e9c08bba3c1d3/crc32c-2.8-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6dde035f91ffbfe23163e68605ee5a4bb8ceebd71ed54bb1fb1d0526cdd125a2", size = 80028, upload-time = "2025-10-17T06:19:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/8f/01c7afdc76ac2007d0e6a98e7300b4470b170480f8188475b597d1f4b4c6/crc32c-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e41ebe7c2f0fdcd9f3a3fd206989a36b460b4d3f24816d53e5be6c7dba72c5e1", size = 81531, upload-time = "2025-10-17T06:19:13.406Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/8f78c5a8cc66486be5f51b6f038fc347c3ba748d3ea68be17a014283c331/crc32c-2.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecf66cf90266d9c15cea597d5cc86c01917cd1a238dc3c51420c7886fa750d7e", size = 80608, upload-time = "2025-10-17T06:19:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/db/86/fad1a94cdeeeb6b6e2323c87f970186e74bfd6fbfbc247bf5c88ad0873d5/crc32c-2.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:59eee5f3a69ad0793d5fa9cdc9b9d743b0cd50edf7fccc0a3988a821fef0208c", size = 79886, upload-time = "2025-10-17T06:19:15.345Z" }, + { url = "https://files.pythonhosted.org/packages/d5/db/1a7cb6757a1e32376fa2dfce00c815ea4ee614a94f9bff8228e37420c183/crc32c-2.8-cp312-cp312-win32.whl", hash = "sha256:a73d03ce3604aa5d7a2698e9057a0eef69f529c46497b27ee1c38158e90ceb76", size = 64896, upload-time = "2025-10-17T06:19:16.457Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8e/2024de34399b2e401a37dcb54b224b56c747b0dc46de4966886827b4d370/crc32c-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:56b3b7d015247962cf58186e06d18c3d75a1a63d709d3233509e1c50a2d36aa2", size = 66645, upload-time = "2025-10-17T06:19:17.235Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d8/3ae227890b3be40955a7144106ef4dd97d6123a82c2a5310cdab58ca49d8/crc32c-2.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36f1e03ee9e9c6938e67d3bcb60e36f260170aa5f37da1185e04ef37b56af395", size = 66380, upload-time = "2025-10-17T06:19:18.009Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8b/178d3f987cd0e049b484615512d3f91f3d2caeeb8ff336bb5896ae317438/crc32c-2.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2f3226b94b85a8dd9b3533601d7a63e9e3e8edf03a8a169830ee8303a199aeb", size = 63048, upload-time = "2025-10-17T06:19:18.853Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a1/48145ae2545ebc0169d3283ebe882da580ea4606bfb67cf4ca922ac3cfc3/crc32c-2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6e08628bc72d5b6bc8e0730e8f142194b610e780a98c58cb6698e665cb885a5b", size = 61530, upload-time = "2025-10-17T06:19:19.974Z" }, + { url = "https://files.pythonhosted.org/packages/06/4b/cf05ed9d934cc30e5ae22f97c8272face420a476090e736615d9a6b53de0/crc32c-2.8-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:086f64793c5ec856d1ab31a026d52ad2b895ac83d7a38fce557d74eb857f0a82", size = 80001, upload-time = "2025-10-17T06:19:20.784Z" }, + { url = "https://files.pythonhosted.org/packages/15/ab/4b04801739faf36345f6ba1920be5b1c70282fec52f8280afd3613fb13e2/crc32c-2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcf72ee7e0135b3d941c34bb2c26c3fc6bc207106b49fd89aaafaeae223ae209", size = 81543, upload-time = "2025-10-17T06:19:21.557Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1b/6e38dde5bfd2ea69b7f2ab6ec229fcd972a53d39e2db4efe75c0ac0382ce/crc32c-2.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a717dd9c3fd777d9bc6603717eae172887d402c4ab589d124ebd0184a83f89e", size = 80644, upload-time = "2025-10-17T06:19:22.325Z" }, + { url = "https://files.pythonhosted.org/packages/ce/45/012176ffee90059ae8ec7131019c71724ea472aa63e72c0c8edbd1fad1d7/crc32c-2.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0450bb845b3c3c7b9bdc0b4e95620ec9a40824abdc8c86d6285c919a90743c1a", size = 79919, upload-time = "2025-10-17T06:19:23.101Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/f557629842f9dec2b3461cb3a0d854bb586ec45b814cea58b082c32f0dde/crc32c-2.8-cp313-cp313-win32.whl", hash = "sha256:765d220bfcbcffa6598ac11eb1e10af0ee4802b49fe126aa6bf79f8ddb9931d1", size = 64896, upload-time = "2025-10-17T06:19:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/d0/db/fd0f698c15d1e21d47c64181a98290665a08fcbb3940cd559e9c15bda57e/crc32c-2.8-cp313-cp313-win_amd64.whl", hash = "sha256:171ff0260d112c62abcce29332986950a57bddee514e0a2418bfde493ea06bb3", size = 66646, upload-time = "2025-10-17T06:19:24.702Z" }, + { url = "https://files.pythonhosted.org/packages/db/b9/8e5d7054fe8e7eecab10fd0c8e7ffb01439417bdb6de1d66a81c38fc4a20/crc32c-2.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b977a32a3708d6f51703c8557008f190aaa434d7347431efb0e86fcbe78c2a50", size = 66203, upload-time = "2025-10-17T06:19:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/cc926c70057a63cc0c98a3c8a896eb15fc7e74d3034eadd53c94917c6cc3/crc32c-2.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7399b01db4adaf41da2fb36fe2408e75a8d82a179a9564ed7619412e427b26d6", size = 62956, upload-time = "2025-10-17T06:19:26.652Z" }, + { url = "https://files.pythonhosted.org/packages/a1/8a/0660c44a2dd2cb6ccbb529eb363b9280f5c766f1017bc8355ed8d695bd94/crc32c-2.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4379f73f9cdad31958a673d11a332ec725ca71572401ca865867229f5f15e853", size = 61442, upload-time = "2025-10-17T06:19:27.74Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/6108d2dfc0fe33522ce83ba07aed4b22014911b387afa228808a278e27cd/crc32c-2.8-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e68264555fab19bab08331550dab58573e351a63ed79c869d455edd3b0aa417", size = 79109, upload-time = "2025-10-17T06:19:28.535Z" }, + { url = "https://files.pythonhosted.org/packages/84/1e/c054f9e390090c197abf3d2936f4f9effaf0c6ee14569ae03d6ddf86958a/crc32c-2.8-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b48f2486727b8d0e7ccbae4a34cb0300498433d2a9d6b49cb13cb57c2e3f19cb", size = 80987, upload-time = "2025-10-17T06:19:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ad/1650e5c3341e4a485f800ea83116d72965030c5d48ccc168fcc685756e4d/crc32c-2.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ecf123348934a086df8c8fde7f9f2d716d523ca0707c5a1367b8bb00d8134823", size = 79994, upload-time = "2025-10-17T06:19:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3b/f2ed924b177729cbb2ab30ca2902abff653c31d48c95e7b66717a9ca9fcc/crc32c-2.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e636ac60f76de538f7a2c0d0f3abf43104ee83a8f5e516f6345dc283ed1a4df7", size = 79046, upload-time = "2025-10-17T06:19:30.894Z" }, + { url = "https://files.pythonhosted.org/packages/4b/80/413b05ee6ace613208b31b3670c3135ee1cf451f0e72a9c839b4946acc04/crc32c-2.8-cp313-cp313t-win32.whl", hash = "sha256:8dd4a19505e0253892e1b2f1425cc3bd47f79ae5a04cb8800315d00aad7197f2", size = 64837, upload-time = "2025-10-17T06:19:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/3b/1b/85eddb6ac5b38496c4e35c20298aae627970c88c3c624a22ab33e84f16c7/crc32c-2.8-cp313-cp313t-win_amd64.whl", hash = "sha256:4bb18e4bd98fb266596523ffc6be9c5b2387b2fa4e505ec56ca36336f49cb639", size = 66574, upload-time = "2025-10-17T06:19:33.143Z" }, + { url = "https://files.pythonhosted.org/packages/aa/df/50e9079b532ff53dbfc0e66eed781374bd455af02ed5df8b56ad538de4ff/crc32c-2.8-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3a3b2e4bcf7b3ee333050e7d3ff38e2ba46ea205f1d73d8949b248aaffe937ac", size = 66399, upload-time = "2025-10-17T06:19:34.279Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2e/67e3b0bc3d30e46ea5d16365cc81203286387671e22f2307eb41f19abb9c/crc32c-2.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:445e559e66dff16be54f8a4ef95aa6b01db799a639956d995c5498ba513fccc2", size = 63044, upload-time = "2025-10-17T06:19:35.062Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/1723b17437e4344ed8d067456382ecb1f5b535d83fdc5aaebab676c6d273/crc32c-2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bf3040919e17afa5782e01b1875d6a05f44b8f19c05f211d8b9f8a1deb8bbd9c", size = 61541, upload-time = "2025-10-17T06:19:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6a/cbec8a235c5b46a01f319939b538958662159aec0ed3a74944e3a6de21f1/crc32c-2.8-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5607ab8221e1ffd411f64aa40dbb6850cf06dd2908c9debd05d371e1acf62ff3", size = 80139, upload-time = "2025-10-17T06:19:37.351Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/d096722fe74b692d6e8206c27da1ea5f6b2a12ff92c54a62a6ba2f376254/crc32c-2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f5db4f16816926986d3c94253314920689706ae13a9bf4888b47336c6735ce", size = 81736, upload-time = "2025-10-17T06:19:38.16Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a2/f75ef716ff7e3c22f385ba6ef30c5de80c19a21ebe699dc90824a1903275/crc32c-2.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:70b0153c4d418b673309d3529334d117e1074c4a3b2d7f676e430d72c14de67b", size = 80795, upload-time = "2025-10-17T06:19:38.948Z" }, + { url = "https://files.pythonhosted.org/packages/d8/94/6d647a12d96ab087d9b8eacee3da073f981987827d57c7072f89ffc7b6cd/crc32c-2.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c8933531442042438753755a5c8a9034e4d88b01da9eb796f7e151b31a7256c", size = 80042, upload-time = "2025-10-17T06:19:39.725Z" }, + { url = "https://files.pythonhosted.org/packages/cd/dc/32b8896b40a0afee7a3c040536d0da5a73e68df2be9fadd21770fd158e16/crc32c-2.8-cp314-cp314-win32.whl", hash = "sha256:cdc83a3fe6c4e5df9457294cfd643de7d95bd4e9382c1dd6ed1e0f0f9169172c", size = 64914, upload-time = "2025-10-17T06:19:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b4/4308b27d307e8ecaf8dd1dcc63bbb0e47ae1826d93faa3e62d1ee00ee2d5/crc32c-2.8-cp314-cp314-win_amd64.whl", hash = "sha256:509e10035106df66770fe24b9eb8d9e32b6fb967df17744402fb67772d8b2bc7", size = 66723, upload-time = "2025-10-17T06:19:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/d5/a19d2489fa997a143bfbbf971a5c9a43f8b1ba9e775b1fb362d8fb15260c/crc32c-2.8-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:864359a39777a07b09b28eb31337c0cc603d5c1bf0fc328c3af736a8da624ec0", size = 66201, upload-time = "2025-10-17T06:19:43.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/c2/5f82f22d2c1242cb6f6fe92aa9a42991ebea86de994b8f9974d9c1d128e2/crc32c-2.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:14511d7cfc5d9f5e1a6c6b64caa6225c2bdc1ed00d725e9a374a3e84073ce180", size = 62956, upload-time = "2025-10-17T06:19:44.099Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/3d43d33489cf974fb78bfb3500845770e139ae6d1d83473b660bd8f79a6c/crc32c-2.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:918b7999b52b5dcbcea34081e9a02d46917d571921a3f209956a9a429b2e06e5", size = 61443, upload-time = "2025-10-17T06:19:44.89Z" }, + { url = "https://files.pythonhosted.org/packages/52/6d/f306ce64a352a3002f76b0fc88a1373f4541f9d34fad3668688610bab14b/crc32c-2.8-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cc445da03fc012a5a03b71da1df1b40139729e6a5571fd4215ab40bfb39689c7", size = 79106, upload-time = "2025-10-17T06:19:45.688Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b7/1f74965dd7ea762954a69d172dfb3a706049c84ffa45d31401d010a4a126/crc32c-2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e3dde2ec59a8a830511d72a086ead95c0b0b7f0d418f93ea106244c5e77e350", size = 80983, upload-time = "2025-10-17T06:19:46.792Z" }, + { url = "https://files.pythonhosted.org/packages/1b/50/af93f0d91ccd61833ce77374ebfbd16f5805f5c17d18c6470976d9866d76/crc32c-2.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:61d51681a08b6a2a2e771b7f0cd1947fb87cb28f38ed55a01cb7c40b2ac4cdd8", size = 80009, upload-time = "2025-10-17T06:19:47.619Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fa/94f394beb68a88258af694dab2f1284f55a406b615d7900bdd6235283bc4/crc32c-2.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:67c0716c3b1a02d5235be649487b637eed21f2d070f2b3f63f709dcd2fefb4c7", size = 79066, upload-time = "2025-10-17T06:19:48.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/c6/a6050e0c64fd73c67a97da96cb59f08b05111e00b958fb87ecdce99f17ac/crc32c-2.8-cp314-cp314t-win32.whl", hash = "sha256:2e8fe863fbbd8bdb6b414a2090f1b0f52106e76e9a9c96a413495dbe5ebe492a", size = 64869, upload-time = "2025-10-17T06:19:49.197Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/c7735034e401cb1ea14f996a224518e3a3fa9987cb13680e707328a7d779/crc32c-2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:20a9cfb897693eb6da19e52e2a7be2026fd4d9fc8ae318f086c0d71d5dd2d8e0", size = 66633, upload-time = "2025-10-17T06:19:50.003Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/dd926c68eb8aac8b142a1a10b8eb62d95212c1cf81775644373fe7cceac2/crc32c-2.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5833f4071da7ea182c514ba17d1eee8aec3c5be927d798222fbfbbd0f5eea02c", size = 62345, upload-time = "2025-10-17T06:20:09.39Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/803404e5abea2ef2c15042edca04bbb7f625044cca879e47f186b43887c2/crc32c-2.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1dc4da036126ac07b39dd9d03e93e585ec615a2ad28ff12757aef7de175295a8", size = 61229, upload-time = "2025-10-17T06:20:10.236Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3a/00cc578cd27ed0b22c9be25cef2c24539d92df9fa80ebd67a3fc5419724c/crc32c-2.8-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15905fa78344654e241371c47e6ed2411f9eeb2b8095311c68c88eccf541e8b4", size = 64108, upload-time = "2025-10-17T06:20:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bc/0587ef99a1c7629f95dd0c9d4f3d894de383a0df85831eb16c48a6afdae4/crc32c-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c596f918688821f796434e89b431b1698396c38bf0b56de873621528fe3ecb1e", size = 64815, upload-time = "2025-10-17T06:20:11.919Z" }, + { url = "https://files.pythonhosted.org/packages/73/42/94f2b8b92eae9064fcfb8deef2b971514065bd606231f8857ff8ae02bebd/crc32c-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8d23c4fe01b3844cb6e091044bc1cebdef7d16472e058ce12d9fadf10d2614af", size = 66659, upload-time = "2025-10-17T06:20:12.766Z" }, +] + [[package]] name = "cryptography" version = "45.0.7" @@ -2401,6 +2486,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/12/08547e63edf2239ec6660af434602208ab6f394955ef660a6edda13a0bee/obstore-0.8.2-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:4eec1fb32ffa4fb9fe9ad584611ff031927a5c22732b56075ee7204f0e35ebdf", size = 3944069, upload-time = "2025-09-16T15:34:54.108Z" }, ] +[[package]] +name = "oci" +version = "2.177.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "circuitbreaker" }, + { name = "crc32c" }, + { name = "cryptography" }, + { name = "pyopenssl" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/eb/f4e9a840c2c703bf78f1ca8506514bb1195792715dcf52fbb92cab4a6cec/oci-2.177.0.tar.gz", hash = "sha256:941c15283677ec5ca65d82a4bc71bae28692d73e79abbaf5eccb305a0ddb1251", size = 17454232, upload-time = "2026-06-02T01:55:16.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a6/260b0e2ef9de7356030ea82dec56b61c40ac2907590f6aecaa9ce7b11059/oci-2.177.0-py3-none-any.whl", hash = "sha256:fd45a4a4d81764315123283f66cf33cc536ede3e3008dd37c871c89103012a81", size = 35629529, upload-time = "2026-06-02T01:55:07.57Z" }, +] + [[package]] name = "openai" version = "2.36.0" @@ -2472,6 +2576,9 @@ modal = [ mongodb = [ { name = "pymongo" }, ] +oci = [ + { name = "oci" }, +] realtime = [ { name = "websockets" }, ] @@ -2559,6 +2666,7 @@ requires-dist = [ { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<2" }, { name = "modal", marker = "extra == 'modal'", specifier = "==1.4.3" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, + { name = "oci", marker = "extra == 'oci'", specifier = ">=2.150.0" }, { name = "openai", specifier = ">=2.36.0,<3" }, { name = "pydantic", specifier = ">=2.12.2,<3" }, { name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.14" }, @@ -2575,7 +2683,7 @@ requires-dist = [ { name = "websockets", marker = "extra == 'realtime'", specifier = ">=15.0,<17" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<17" }, ] -provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "s3", "temporal"] +provides-extras = ["voice", "viz", "litellm", "oci", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "s3", "temporal"] [package.metadata.requires-dev] dev = [ @@ -3292,6 +3400,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/ef/8c08d4f255bb3efe8806609d1f0b1ddd29684ab0f9ffb5e26d3ad7957b29/pyobjc_framework_quartz-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:39d02a3df4b5e3eee1e0da0fb150259476910d2a9aa638ab94153c24317a9561", size = 226353, upload-time = "2025-06-14T20:53:40.655Z" }, ] +[[package]] +name = "pyopenssl" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, +] + [[package]] name = "pyright" version = "1.1.408" @@ -3403,6 +3524,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, ] +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -4207,11 +4337,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From 6018d72057f3017b2676d08a4e70b4c442bbffbc Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Wed, 10 Jun 2026 04:27:26 -0400 Subject: [PATCH 2/5] Close internally created signing clients from Model.close() The OCI model classes own the AsyncOpenAI client they build when the caller does not pass one; release its connection pool when the model is closed, while leaving caller-provided clients untouched. --- src/agents/extensions/models/oci_model.py | 16 ++++++++ tests/models/test_oci_model.py | 47 +++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/agents/extensions/models/oci_model.py b/src/agents/extensions/models/oci_model.py index fa6a8866e6..7966c04478 100644 --- a/src/agents/extensions/models/oci_model.py +++ b/src/agents/extensions/models/oci_model.py @@ -80,6 +80,7 @@ def __init__( request_timeout: float = DEFAULT_REQUEST_TIMEOUT, openai_client: AsyncOpenAI | None = None, ) -> None: + owns_openai_client = openai_client is None if openai_client is None: client_config = resolve_client_config( auth_type=auth_type, @@ -92,6 +93,13 @@ def __init__( client_config, request_timeout=request_timeout ) super().__init__(model, openai_client) + self._owns_openai_client = owns_openai_client + + async def close(self) -> None: + """Release the internally created signing client, if this model owns it.""" + await super().close() + if self._owns_openai_client: + await self._client.close() class OCIResponsesModel(OpenAIResponsesModel): @@ -124,6 +132,7 @@ def __init__( request_timeout: float = DEFAULT_REQUEST_TIMEOUT, openai_client: AsyncOpenAI | None = None, ) -> None: + owns_openai_client = openai_client is None if openai_client is None: client_config = resolve_client_config( auth_type=auth_type, @@ -136,6 +145,13 @@ def __init__( client_config, request_timeout=request_timeout ) super().__init__(model, openai_client) + self._owns_openai_client = owns_openai_client + + async def close(self) -> None: + """Release the internally created signing client, if this model owns it.""" + await super().close() + if self._owns_openai_client: + await self._client.close() __all__ = [ diff --git a/tests/models/test_oci_model.py b/tests/models/test_oci_model.py index 1d166d91cb..8b94f46060 100644 --- a/tests/models/test_oci_model.py +++ b/tests/models/test_oci_model.py @@ -94,6 +94,53 @@ def test_provider_requires_model_name() -> None: provider.get_model(None) +async def test_close_releases_internally_created_client(monkeypatch: pytest.MonkeyPatch) -> None: + import agents.extensions.models.oci_model as oci_model_module + + class FakeAsyncOpenAI: + def __init__(self) -> None: + self.closed = False + + async def close(self) -> None: + self.closed = True + + fake_client = FakeAsyncOpenAI() + client_config = OCIClientConfig( + signer=FakeSigner(), config={}, region="us-chicago-1", compartment_id=COMPARTMENT_ID + ) + monkeypatch.setattr(oci_model_module, "resolve_client_config", lambda **kwargs: client_config) + monkeypatch.setattr( + oci_model_module, "build_signed_openai_client", lambda config, request_timeout: fake_client + ) + + from agents.extensions.models.oci_model import OCIChatCompletionsModel, OCIResponsesModel + + model = OCIChatCompletionsModel("openai.gpt-4o") + await model.close() + assert fake_client.closed + + fake_client = FakeAsyncOpenAI() + responses_model = OCIResponsesModel("openai.gpt-5") + await responses_model.close() + assert fake_client.closed + + +async def test_close_leaves_caller_provided_client_open() -> None: + from agents.extensions.models.oci_model import OCIChatCompletionsModel + + class FakeAsyncOpenAI: + def __init__(self) -> None: + self.closed = False + + async def close(self) -> None: + self.closed = True + + caller_client = FakeAsyncOpenAI() + model = OCIChatCompletionsModel("openai.gpt-4o", openai_client=caller_client) # type: ignore[arg-type] + await model.close() + assert not caller_client.closed + + def test_provider_routes_to_model_classes(monkeypatch: pytest.MonkeyPatch) -> None: import agents.extensions.models.oci_provider as oci_provider_module From 7586e879633b874c4b7ec03caebfdfac41aaf514 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Wed, 10 Jun 2026 04:56:00 -0400 Subject: [PATCH 3/5] Support OCI Generative AI project scoping via OpenAI-Project header Adds an optional project_id (ocid1.generativeaiproject...) to the signed-client builder, both model classes, and OCIProvider, forwarded as the OpenAI client's project so the OpenAI-Project header reaches the service. Projects scope response/conversation retention and memory settings on the Responses endpoint; the parameter stays optional since requests succeed without it on tenancies that do not use projects. --- src/agents/extensions/models/oci_model.py | 16 +++++++- src/agents/extensions/models/oci_provider.py | 6 ++- tests/models/test_oci_integration.py | 41 ++++++++++++++++++++ tests/models/test_oci_model.py | 4 +- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/agents/extensions/models/oci_model.py b/src/agents/extensions/models/oci_model.py index 7966c04478..44a5349117 100644 --- a/src/agents/extensions/models/oci_model.py +++ b/src/agents/extensions/models/oci_model.py @@ -30,12 +30,21 @@ def build_signed_openai_client( client_config: OCIClientConfig, *, request_timeout: float = DEFAULT_REQUEST_TIMEOUT, + project_id: str | None = None, ) -> AsyncOpenAI: """Build an `AsyncOpenAI` client wired to an OCI Generative AI regional endpoint. The returned client signs every request with the resolved OCI credentials and routes it to the region's OpenAI-compatible base URL. The `api_key` placeholder is never sent; the signer strips bearer auth before signing. + + Args: + client_config: Resolved OCI credentials and routing information. + request_timeout: Per-request timeout in seconds. + project_id: Optional OCI Generative AI project OCID + (`ocid1.generativeaiproject...`), sent as the `OpenAI-Project` header. + Projects scope response/conversation retention and memory settings on the + Responses endpoint. """ http_client = httpx.AsyncClient( auth=OCIRequestSigner( @@ -48,6 +57,7 @@ def build_signed_openai_client( return AsyncOpenAI( base_url=oci_openai_base_url(client_config.region), api_key="oci-request-signing", + project=project_id, http_client=http_client, ) @@ -79,6 +89,7 @@ def __init__( compartment_id: str | None = None, request_timeout: float = DEFAULT_REQUEST_TIMEOUT, openai_client: AsyncOpenAI | None = None, + project_id: str | None = None, ) -> None: owns_openai_client = openai_client is None if openai_client is None: @@ -90,7 +101,7 @@ def __init__( compartment_id=compartment_id, ) openai_client = build_signed_openai_client( - client_config, request_timeout=request_timeout + client_config, request_timeout=request_timeout, project_id=project_id ) super().__init__(model, openai_client) self._owns_openai_client = owns_openai_client @@ -131,6 +142,7 @@ def __init__( compartment_id: str | None = None, request_timeout: float = DEFAULT_REQUEST_TIMEOUT, openai_client: AsyncOpenAI | None = None, + project_id: str | None = None, ) -> None: owns_openai_client = openai_client is None if openai_client is None: @@ -142,7 +154,7 @@ def __init__( compartment_id=compartment_id, ) openai_client = build_signed_openai_client( - client_config, request_timeout=request_timeout + client_config, request_timeout=request_timeout, project_id=project_id ) super().__init__(model, openai_client) self._owns_openai_client = owns_openai_client diff --git a/src/agents/extensions/models/oci_provider.py b/src/agents/extensions/models/oci_provider.py index 37dbb7c326..9c5adfb382 100644 --- a/src/agents/extensions/models/oci_provider.py +++ b/src/agents/extensions/models/oci_provider.py @@ -48,6 +48,7 @@ def __init__( region: str | None = None, compartment_id: str | None = None, request_timeout: float = DEFAULT_REQUEST_TIMEOUT, + project_id: str | None = None, ) -> None: self._auth_type = auth_type self._profile = profile @@ -55,6 +56,7 @@ def __init__( self._region = region self._compartment_id = compartment_id self._request_timeout = request_timeout + self._project_id = project_id self._client_config: OCIClientConfig | None = None self._openai_client: AsyncOpenAI | None = None @@ -70,7 +72,9 @@ def _get_openai_client(self) -> AsyncOpenAI: compartment_id=self._compartment_id, ) self._openai_client = build_signed_openai_client( - self._client_config, request_timeout=self._request_timeout + self._client_config, + request_timeout=self._request_timeout, + project_id=self._project_id, ) return self._openai_client diff --git a/tests/models/test_oci_integration.py b/tests/models/test_oci_integration.py index dc3d6cdaa0..980b43b800 100644 --- a/tests/models/test_oci_integration.py +++ b/tests/models/test_oci_integration.py @@ -162,6 +162,47 @@ async def test_agent_run_over_responses_transport() -> None: assert request.headers["opc-compartment-id"] == COMPARTMENT_ID +async def test_project_id_is_sent_as_openai_project_header() -> None: + from agents.extensions.models.oci_model import build_signed_openai_client + from agents.extensions.models.oci_signer import OCIClientConfig + + project_id = "ocid1.generativeaiproject.oc1..testproject" + + # The builder propagates project_id onto the OpenAI client. + client_config = OCIClientConfig( + signer=FakeSigner(), config={}, region=REGION, compartment_id=COMPARTMENT_ID + ) + built = build_signed_openai_client(client_config, project_id=project_id) + assert built.project == project_id + await built.close() + + # On the wire, the project lands as the OpenAI-Project header next to the + # signature and compartment headers. + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=_responses_reply(text="Hello.", response_id="resp_1")) + + openai_client = AsyncOpenAI( + base_url=oci_openai_base_url(REGION), + api_key="oci-request-signing", + project=project_id, + http_client=httpx.AsyncClient( + transport=httpx.MockTransport(handler), + auth=OCIRequestSigner(FakeSigner(), compartment_id=COMPARTMENT_ID), + ), + ) + model = OCIResponsesModel("openai.gpt-5", openai_client=openai_client) + agent = Agent(name="weather-agent", instructions="Answer briefly.", model=model) + result = await Runner.run(agent, "Say hello.") + + assert result.final_output == "Hello." + assert seen[0].headers["openai-project"] == project_id + assert seen[0].headers["opc-compartment-id"] == COMPARTMENT_ID + assert seen[0].headers["authorization"] == "Signature integration-test" + + async def test_chat_completions_transport_streams_signed_requests() -> None: sse_body = ( "data: " diff --git a/tests/models/test_oci_model.py b/tests/models/test_oci_model.py index 8b94f46060..55160e59ba 100644 --- a/tests/models/test_oci_model.py +++ b/tests/models/test_oci_model.py @@ -110,7 +110,7 @@ async def close(self) -> None: ) monkeypatch.setattr(oci_model_module, "resolve_client_config", lambda **kwargs: client_config) monkeypatch.setattr( - oci_model_module, "build_signed_openai_client", lambda config, request_timeout: fake_client + oci_model_module, "build_signed_openai_client", lambda config, **kwargs: fake_client ) from agents.extensions.models.oci_model import OCIChatCompletionsModel, OCIResponsesModel @@ -169,7 +169,7 @@ def __init__(self, model: str, **kwargs: Any) -> None: monkeypatch.setattr( oci_provider_module, "build_signed_openai_client", - lambda config, request_timeout: object(), + lambda config, **kwargs: object(), ) provider = OCIProvider(compartment_id=COMPARTMENT_ID) From e6131f11bedb610786a6d8e91b5c210fa17257b6 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Wed, 10 Jun 2026 05:36:06 -0400 Subject: [PATCH 4/5] Use Oracle's official oci-openai client for signing and transport Replaces the hand-rolled request signer with Oracle's oci-openai package, which provides the AsyncOpenAI-compatible client, all four IAM auth modes with refresh, and compartment routing. The extension now only contributes the thin model subclasses, credential/profile resolution, and the provider. The oci extra becomes oci-openai>=1.1.0. --- pyproject.toml | 4 +- src/agents/extensions/models/oci_model.py | 164 +++++++++--- src/agents/extensions/models/oci_provider.py | 19 +- src/agents/extensions/models/oci_signer.py | 254 ------------------- tests/models/test_oci_integration.py | 37 ++- tests/models/test_oci_model.py | 155 +++++------ uv.lock | 19 +- 7 files changed, 231 insertions(+), 421 deletions(-) delete mode 100644 src/agents/extensions/models/oci_signer.py diff --git a/pyproject.toml b/pyproject.toml index 05e0b3c681..0247a5391e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ Repository = "https://github.com/openai/openai-agents-python" voice = ["numpy>=2.2.0, <3; python_version>='3.10'", "websockets>=15.0, <17"] viz = ["graphviz>=0.17"] litellm = ["litellm>=1.83.0"] -oci = ["oci>=2.150.0"] +oci = ["oci-openai>=1.1.0"] any-llm = ["any-llm-sdk>=1.11.0, <2; python_version >= '3.11'"] realtime = ["websockets>=15.0, <17"] sqlalchemy = ["SQLAlchemy>=2.0", "asyncpg>=0.29.0"] @@ -142,7 +142,7 @@ module = "sounddevice.*" ignore_missing_imports = true [[tool.mypy.overrides]] -module = ["oci", "oci.*", "requests", "requests.*"] +module = ["oci", "oci.*", "oci_openai", "oci_openai.*"] ignore_missing_imports = true [[tool.mypy.overrides]] diff --git a/src/agents/extensions/models/oci_model.py b/src/agents/extensions/models/oci_model.py index 44a5349117..8f868d1185 100644 --- a/src/agents/extensions/models/oci_model.py +++ b/src/agents/extensions/models/oci_model.py @@ -1,64 +1,152 @@ -"""Models for the OCI Generative AI OpenAI-compatible transports. +"""Models for the OCI Generative AI OpenAI-compatible endpoints. OCI Generative AI exposes most of its hosted catalog (including the `openai.*` model IDs) on OpenAI-compatible `chat/completions` and `responses` endpoints, authenticated with OCI request signing instead of bearer tokens. The classes -here reuse the SDK's OpenAI model implementations against those endpoints by -injecting a signing HTTP client. +here reuse the SDK's OpenAI model implementations against those endpoints, +connecting through Oracle's official `oci-openai` client, which performs the +request signing and compartment routing. """ from __future__ import annotations +import os +from typing import Any, Literal, cast + import httpx from openai import AsyncOpenAI +from ...exceptions import UserError from ...models.openai_chatcompletions import OpenAIChatCompletionsModel from ...models.openai_responses import OpenAIResponsesModel -from .oci_signer import ( - OCIAuthType, - OCIClientConfig, - OCIRequestSigner, - oci_openai_base_url, - resolve_client_config, -) + +try: + from oci_openai import ( + AsyncOciOpenAI, + OciInstancePrincipalAuth, + OciResourcePrincipalAuth, + OciSessionAuth, + OciUserPrincipalAuth, + ) +except ImportError as _e: + raise ImportError( + "`oci-openai` is required to use the OCI model classes. You can install it via the " + "optional dependency group: `pip install 'openai-agents[oci]'`." + ) from _e + +DEFAULT_OCI_REGION = "us-chicago-1" +"""Fallback region used when none is configured anywhere else.""" # Reasoning models can take minutes before the first byte; use a generous default. DEFAULT_REQUEST_TIMEOUT = 300.0 +OCIAuthType = Literal["api_key", "security_token", "instance_principal", "resource_principal"] + +_DEFAULT_CONFIG_FILE = "~/.oci/config" +_DEFAULT_PROFILE = "DEFAULT" + + +def _load_profile(profile: str | None, config_file: str | None) -> dict[str, Any]: + import oci + + config: dict[str, Any] = oci.config.from_file( + file_location=config_file or _DEFAULT_CONFIG_FILE, + profile_name=profile or os.environ.get("OCI_CLI_PROFILE") or _DEFAULT_PROFILE, + ) + return config + + +def _build_auth( + auth_type: OCIAuthType | None, + profile: str | None, + config_file: str | None, + profile_config: dict[str, Any], +) -> httpx.Auth: + """Select the `oci-openai` auth implementation for the requested auth mode. + + When `auth_type` is omitted, profiles carrying a `security_token_file` use + session-token auth and everything else uses API-key auth. Principal-based modes + must be requested explicitly because they cannot be detected from a config file. + """ + if auth_type == "instance_principal": + return cast(httpx.Auth, OciInstancePrincipalAuth()) + if auth_type == "resource_principal": + return cast(httpx.Auth, OciResourcePrincipalAuth()) + + resolved_config_file = config_file or _DEFAULT_CONFIG_FILE + resolved_profile = profile or os.environ.get("OCI_CLI_PROFILE") or _DEFAULT_PROFILE + use_session_token = auth_type == "security_token" or ( + auth_type is None and profile_config.get("security_token_file") + ) + if use_session_token: + return cast( + httpx.Auth, + OciSessionAuth(config_file=resolved_config_file, profile_name=resolved_profile), + ) + return cast( + httpx.Auth, + OciUserPrincipalAuth(config_file=resolved_config_file, profile_name=resolved_profile), + ) + def build_signed_openai_client( - client_config: OCIClientConfig, *, - request_timeout: float = DEFAULT_REQUEST_TIMEOUT, + auth_type: OCIAuthType | None = None, + profile: str | None = None, + config_file: str | None = None, + region: str | None = None, + compartment_id: str | None = None, project_id: str | None = None, + request_timeout: float = DEFAULT_REQUEST_TIMEOUT, ) -> AsyncOpenAI: - """Build an `AsyncOpenAI` client wired to an OCI Generative AI regional endpoint. + """Build an `AsyncOciOpenAI` client wired to an OCI Generative AI regional endpoint. + + The returned client (a drop-in `AsyncOpenAI` subclass from Oracle's `oci-openai` + package) signs every request with the resolved OCI credentials and attaches the + compartment header the service requires. - The returned client signs every request with the resolved OCI credentials and - routes it to the region's OpenAI-compatible base URL. The `api_key` placeholder is - never sent; the signer strips bearer auth before signing. + Resolution order for the region: explicit argument, `OCI_REGION` env var, the + config file profile's `region`, then the service default. For the compartment: + explicit argument, `OCI_COMPARTMENT_ID` env var, then the profile's tenancy as a + best-effort fallback. Args: - client_config: Resolved OCI credentials and routing information. - request_timeout: Per-request timeout in seconds. + auth_type: OCI auth mode; inferred from the config profile when omitted. + profile: OCI config profile name (defaults to `OCI_CLI_PROFILE` or `DEFAULT`). + config_file: OCI config file location (defaults to `~/.oci/config`). + region: OCI region whose Generative AI endpoint should be called. + compartment_id: Compartment all inference requests are billed against. project_id: Optional OCI Generative AI project OCID (`ocid1.generativeaiproject...`), sent as the `OpenAI-Project` header. Projects scope response/conversation retention and memory settings on the Responses endpoint. + request_timeout: Per-request timeout in seconds. """ - http_client = httpx.AsyncClient( - auth=OCIRequestSigner( - client_config.signer, - compartment_id=client_config.compartment_id, - refresh_signer=client_config.refresh_signer, - ), - timeout=httpx.Timeout(request_timeout), + uses_file_config = auth_type not in ("instance_principal", "resource_principal") + profile_config = _load_profile(profile, config_file) if uses_file_config else {} + + auth = _build_auth(auth_type, profile, config_file, profile_config) + resolved_region = ( + region or os.environ.get("OCI_REGION") or profile_config.get("region") or DEFAULT_OCI_REGION ) - return AsyncOpenAI( - base_url=oci_openai_base_url(client_config.region), - api_key="oci-request-signing", - project=project_id, - http_client=http_client, + resolved_compartment = ( + compartment_id or os.environ.get("OCI_COMPARTMENT_ID") or profile_config.get("tenancy") + ) + if not resolved_compartment: + raise UserError( + "A compartment_id is required for OCI Generative AI. Pass it explicitly or set " + "the OCI_COMPARTMENT_ID environment variable." + ) + + return cast( + AsyncOpenAI, + AsyncOciOpenAI( + auth=auth, + region=str(resolved_region), + compartment_id=resolved_compartment, + timeout=request_timeout, + project=project_id, + ), ) @@ -93,15 +181,14 @@ def __init__( ) -> None: owns_openai_client = openai_client is None if openai_client is None: - client_config = resolve_client_config( + openai_client = build_signed_openai_client( auth_type=auth_type, profile=profile, config_file=config_file, region=region, compartment_id=compartment_id, - ) - openai_client = build_signed_openai_client( - client_config, request_timeout=request_timeout, project_id=project_id + project_id=project_id, + request_timeout=request_timeout, ) super().__init__(model, openai_client) self._owns_openai_client = owns_openai_client @@ -146,15 +233,14 @@ def __init__( ) -> None: owns_openai_client = openai_client is None if openai_client is None: - client_config = resolve_client_config( + openai_client = build_signed_openai_client( auth_type=auth_type, profile=profile, config_file=config_file, region=region, compartment_id=compartment_id, - ) - openai_client = build_signed_openai_client( - client_config, request_timeout=request_timeout, project_id=project_id + project_id=project_id, + request_timeout=request_timeout, ) super().__init__(model, openai_client) self._owns_openai_client = owns_openai_client @@ -167,7 +253,9 @@ async def close(self) -> None: __all__ = [ + "DEFAULT_OCI_REGION", "DEFAULT_REQUEST_TIMEOUT", + "OCIAuthType", "OCIChatCompletionsModel", "OCIResponsesModel", "build_signed_openai_client", diff --git a/src/agents/extensions/models/oci_provider.py b/src/agents/extensions/models/oci_provider.py index 9c5adfb382..025e5992bf 100644 --- a/src/agents/extensions/models/oci_provider.py +++ b/src/agents/extensions/models/oci_provider.py @@ -17,11 +17,11 @@ from ...models.interface import Model, ModelProvider from .oci_model import ( DEFAULT_REQUEST_TIMEOUT, + OCIAuthType, OCIChatCompletionsModel, OCIResponsesModel, build_signed_openai_client, ) -from .oci_signer import OCIAuthType, OCIClientConfig, resolve_client_config _RESPONSES_PREFIX = "responses:" @@ -57,24 +57,19 @@ def __init__( self._compartment_id = compartment_id self._request_timeout = request_timeout self._project_id = project_id - self._client_config: OCIClientConfig | None = None self._openai_client: AsyncOpenAI | None = None def _get_openai_client(self) -> AsyncOpenAI: # The signed client is shared by every model handed out by this provider. if self._openai_client is None: - if self._client_config is None: - self._client_config = resolve_client_config( - auth_type=self._auth_type, - profile=self._profile, - config_file=self._config_file, - region=self._region, - compartment_id=self._compartment_id, - ) self._openai_client = build_signed_openai_client( - self._client_config, - request_timeout=self._request_timeout, + auth_type=self._auth_type, + profile=self._profile, + config_file=self._config_file, + region=self._region, + compartment_id=self._compartment_id, project_id=self._project_id, + request_timeout=self._request_timeout, ) return self._openai_client diff --git a/src/agents/extensions/models/oci_signer.py b/src/agents/extensions/models/oci_signer.py deleted file mode 100644 index a3705a52ab..0000000000 --- a/src/agents/extensions/models/oci_signer.py +++ /dev/null @@ -1,254 +0,0 @@ -"""OCI request-signing support shared by the OCI Generative AI model classes. - -Oracle Cloud Infrastructure (OCI) authenticates HTTP requests with per-request -signatures derived from IAM credentials rather than bearer tokens. This module -resolves those credentials (API key, session token, instance principal, or -resource principal) and exposes an `httpx.Auth` hook that signs each outgoing -request, so the OpenAI client can talk to the OCI Generative AI -OpenAI-compatible endpoints. -""" - -from __future__ import annotations - -import os -import threading -import time -from collections.abc import Callable, Generator -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Literal - -import httpx - -try: - import oci -except ImportError as _e: - raise ImportError( - "`oci` is required to use the OCI model classes. You can install it via the optional " - "dependency group: `pip install 'openai-agents[oci]'`." - ) from _e - -import requests - -from ...exceptions import UserError - -DEFAULT_OCI_REGION = "us-chicago-1" -"""Fallback region used when none is configured anywhere else.""" - -OCIAuthType = Literal["api_key", "security_token", "instance_principal", "resource_principal"] - - -def oci_openai_base_url(region: str) -> str: - """Return the OCI Generative AI OpenAI-compatible base URL for a region.""" - return f"https://inference.generativeai.{region}.oci.oraclecloud.com/openai/v1" - - -@dataclass -class OCIClientConfig: - """Resolved OCI credentials and routing information. - - Attributes: - signer: The OCI signer used to sign requests. - config: The OCI SDK config dict (empty for principal-based auth). - region: The region whose Generative AI endpoint should be called. - compartment_id: The compartment all inference requests are billed against. - refresh_signer: Optional zero-arg callable that rebuilds the signer when its - credentials expire (session tokens, principal tokens). `None` for API keys, - which do not expire. - """ - - signer: Any - config: dict[str, Any] - region: str - compartment_id: str | None - refresh_signer: Callable[[], Any] | None = None - - -def _load_file_config(profile: str | None, config_file: str | None) -> dict[str, Any]: - file_location = config_file or oci.config.DEFAULT_LOCATION - profile_name = profile or os.environ.get("OCI_CLI_PROFILE") or oci.config.DEFAULT_PROFILE - config: dict[str, Any] = oci.config.from_file( - file_location=file_location, profile_name=profile_name - ) - return config - - -def _build_api_key_signer(config: dict[str, Any]) -> Any: - return oci.signer.Signer( - tenancy=config["tenancy"], - user=config["user"], - fingerprint=config["fingerprint"], - private_key_file_location=config["key_file"], - pass_phrase=config.get("pass_phrase"), - ) - - -def _build_security_token_signer(config: dict[str, Any]) -> Any: - token = Path(config["security_token_file"]).expanduser().read_text().strip() - private_key = oci.signer.load_private_key_from_file( - config["key_file"], config.get("pass_phrase") - ) - return oci.auth.signers.SecurityTokenSigner(token=token, private_key=private_key) - - -def _build_instance_principal_signer() -> Any: - return oci.auth.signers.InstancePrincipalsSecurityTokenSigner() - - -def _build_resource_principal_signer() -> Any: - return oci.auth.signers.get_resource_principals_signer() - - -def resolve_client_config( - *, - auth_type: OCIAuthType | None = None, - profile: str | None = None, - config_file: str | None = None, - region: str | None = None, - compartment_id: str | None = None, -) -> OCIClientConfig: - """Resolve OCI credentials into a signer plus routing information. - - When `auth_type` is omitted, file-based configuration is used and the auth mode is - inferred: profiles carrying a `security_token_file` use session-token signing, - everything else uses API-key signing. Principal-based modes must be requested - explicitly because they cannot be detected from a config file. - - Resolution order for the region: explicit argument, `OCI_REGION` env var, the config - file profile's `region`, then the service default. For the compartment: explicit - argument, `OCI_COMPARTMENT_ID` env var, then the profile's tenancy as a best-effort - fallback. - """ - refresh: Callable[[], Any] | None = None - - if auth_type == "instance_principal": - signer = _build_instance_principal_signer() - config: dict[str, Any] = {} - refresh = _build_instance_principal_signer - elif auth_type == "resource_principal": - signer = _build_resource_principal_signer() - config = {} - refresh = _build_resource_principal_signer - else: - config = _load_file_config(profile, config_file) - use_session_token = auth_type == "security_token" or ( - auth_type is None and config.get("security_token_file") - ) - if use_session_token: - if not config.get("security_token_file"): - raise UserError( - "auth_type='security_token' requires a `security_token_file` entry in the " - "selected OCI config profile." - ) - signer = _build_security_token_signer(config) - - def _refresh_from_disk(cfg: dict[str, Any] = config) -> Any: - return _build_security_token_signer(cfg) - - refresh = _refresh_from_disk - else: - signer = _build_api_key_signer(config) - - resolved_region = ( - region - or os.environ.get("OCI_REGION") - or config.get("region") - or getattr(signer, "region", None) - or DEFAULT_OCI_REGION - ) - resolved_compartment = ( - compartment_id or os.environ.get("OCI_COMPARTMENT_ID") or config.get("tenancy") - ) - - return OCIClientConfig( - signer=signer, - config=config, - region=str(resolved_region), - compartment_id=resolved_compartment, - refresh_signer=refresh, - ) - - -class OCIRequestSigner(httpx.Auth): - """`httpx.Auth` hook that applies OCI request signing to every request. - - The hook strips any bearer auth injected by the OpenAI client, attaches the - `opc-compartment-id` header expected by the OCI OpenAI-compatible endpoints, signs - the request with the configured signer, and transparently rebuilds expiring signers - both on a timed interval and on a 401 response. - """ - - requires_request_body = True - - def __init__( - self, - signer: Any, - *, - compartment_id: str | None = None, - refresh_signer: Callable[[], Any] | None = None, - refresh_interval: float = 600.0, - ) -> None: - self._signer = signer - self._compartment_id = compartment_id - self._refresh_signer = refresh_signer - self._refresh_interval = refresh_interval - self._last_refresh = time.monotonic() - self._lock = threading.Lock() - - def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: - self._maybe_refresh() - self._sign(request) - response = yield request - if response.status_code == 401 and self._refresh_signer is not None: - self._refresh(force=True) - self._sign(request) - yield request - - def _maybe_refresh(self) -> None: - if self._refresh_signer is None: - return - if time.monotonic() - self._last_refresh >= self._refresh_interval: - self._refresh(force=False) - - def _refresh(self, *, force: bool) -> None: - if self._refresh_signer is None: - return - with self._lock: - if not force and time.monotonic() - self._last_refresh < self._refresh_interval: - return - self._signer = self._refresh_signer() - self._last_refresh = time.monotonic() - - def _sign(self, request: httpx.Request) -> None: - # The OpenAI client always sends a bearer token; OCI uses signature auth instead. - request.headers.pop("Authorization", None) - if self._compartment_id is not None: - request.headers["opc-compartment-id"] = self._compartment_id - - try: - content = request.content - except httpx.RequestNotRead: - content = request.read() - - # OCI signers operate on `requests.PreparedRequest`; rebuild the request in that - # shape, sign it, and copy the signature headers (authorization, date, host, - # x-content-sha256, ...) back onto the httpx request. - prepared = requests.Request( - method=request.method, - url=str(request.url), - headers=dict(request.headers), - data=content, - ).prepare() - self._signer.do_request_sign(prepared) - for key, value in prepared.headers.items(): - request.headers[key] = value - - -__all__ = [ - "DEFAULT_OCI_REGION", - "OCIAuthType", - "OCIClientConfig", - "OCIRequestSigner", - "oci_openai_base_url", - "resolve_client_config", -] diff --git a/tests/models/test_oci_integration.py b/tests/models/test_oci_integration.py index 980b43b800..b1a9d1e7ac 100644 --- a/tests/models/test_oci_integration.py +++ b/tests/models/test_oci_integration.py @@ -19,7 +19,6 @@ OCIChatCompletionsModel, OCIResponsesModel, ) -from agents.extensions.models.oci_signer import OCIRequestSigner, oci_openai_base_url COMPARTMENT_ID = "ocid1.compartment.oc1..testcompartment" REGION = "us-chicago-1" @@ -38,9 +37,16 @@ def get_weather(city: str) -> str: return f"The weather in {city} is sunny." -class FakeSigner: - def do_request_sign(self, prepared: Any) -> None: - prepared.headers["authorization"] = "Signature integration-test" +OCI_BASE_URL = f"https://inference.generativeai.{REGION}.oci.oraclecloud.com/openai/v1" + + +class FakeOciAuth(httpx.Auth): + """Sets the headers the oci-openai auth and client attach on real requests.""" + + def auth_flow(self, request: httpx.Request) -> Any: + request.headers["authorization"] = "Signature integration-test" + request.headers["opc-compartment-id"] = COMPARTMENT_ID + yield request def _signed_openai_client(replies: list[dict[str, Any]], seen: list[httpx.Request]) -> AsyncOpenAI: @@ -50,10 +56,10 @@ def handler(request: httpx.Request) -> httpx.Response: http_client = httpx.AsyncClient( transport=httpx.MockTransport(handler), - auth=OCIRequestSigner(FakeSigner(), compartment_id=COMPARTMENT_ID), + auth=FakeOciAuth(), ) return AsyncOpenAI( - base_url=oci_openai_base_url(REGION), + base_url=OCI_BASE_URL, api_key="oci-request-signing", http_client=http_client, ) @@ -163,19 +169,8 @@ async def test_agent_run_over_responses_transport() -> None: async def test_project_id_is_sent_as_openai_project_header() -> None: - from agents.extensions.models.oci_model import build_signed_openai_client - from agents.extensions.models.oci_signer import OCIClientConfig - project_id = "ocid1.generativeaiproject.oc1..testproject" - # The builder propagates project_id onto the OpenAI client. - client_config = OCIClientConfig( - signer=FakeSigner(), config={}, region=REGION, compartment_id=COMPARTMENT_ID - ) - built = build_signed_openai_client(client_config, project_id=project_id) - assert built.project == project_id - await built.close() - # On the wire, the project lands as the OpenAI-Project header next to the # signature and compartment headers. seen: list[httpx.Request] = [] @@ -185,12 +180,12 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_responses_reply(text="Hello.", response_id="resp_1")) openai_client = AsyncOpenAI( - base_url=oci_openai_base_url(REGION), + base_url=OCI_BASE_URL, api_key="oci-request-signing", project=project_id, http_client=httpx.AsyncClient( transport=httpx.MockTransport(handler), - auth=OCIRequestSigner(FakeSigner(), compartment_id=COMPARTMENT_ID), + auth=FakeOciAuth(), ), ) model = OCIResponsesModel("openai.gpt-5", openai_client=openai_client) @@ -237,12 +232,12 @@ def handler(request: httpx.Request) -> httpx.Response: http_client = httpx.AsyncClient( transport=httpx.MockTransport(handler), - auth=OCIRequestSigner(FakeSigner(), compartment_id=COMPARTMENT_ID), + auth=FakeOciAuth(), ) model = OCIChatCompletionsModel( "openai.gpt-4o", openai_client=AsyncOpenAI( - base_url=oci_openai_base_url(REGION), + base_url=OCI_BASE_URL, api_key="oci-request-signing", http_client=http_client, ), diff --git a/tests/models/test_oci_model.py b/tests/models/test_oci_model.py index 55160e59ba..69327bd10d 100644 --- a/tests/models/test_oci_model.py +++ b/tests/models/test_oci_model.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Generator from typing import Any import httpx @@ -7,110 +8,90 @@ from agents.exceptions import UserError from agents.extensions.models.oci_provider import OCIProvider -from agents.extensions.models.oci_signer import ( - OCIClientConfig, - OCIRequestSigner, - oci_openai_base_url, -) COMPARTMENT_ID = "ocid1.compartment.oc1..testcompartment" +PROJECT_ID = "ocid1.generativeaiproject.oc1..testproject" -class FakeSigner: - """Stands in for an OCI signer; records what it signed.""" +class FakeOciAuth(httpx.Auth): + """Stands in for the oci-openai auth implementations.""" - def __init__(self, signature: str = "Signature test") -> None: - self.signature = signature - self.signed_bodies: list[Any] = [] + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + request.headers["authorization"] = "Signature test" + yield request - def do_request_sign(self, prepared: Any) -> None: - self.signed_bodies.append(prepared.body) - prepared.headers["authorization"] = self.signature - prepared.headers["date"] = "Mon, 01 Jan 2026 00:00:00 GMT" +class FakeAsyncOpenAI: + def __init__(self) -> None: + self.closed = False -def _drive_auth_flow(signer: OCIRequestSigner, request: httpx.Request) -> httpx.Request: - flow = signer.auth_flow(request) - return next(flow) + async def close(self) -> None: + self.closed = True -def test_endpoint_construction() -> None: - assert ( - oci_openai_base_url("us-chicago-1") - == "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1" - ) +def test_builder_constructs_signed_client(monkeypatch: pytest.MonkeyPatch) -> None: + import agents.extensions.models.oci_model as oci_model_module + from agents.extensions.models.oci_model import build_signed_openai_client + monkeypatch.setattr(oci_model_module, "_load_profile", lambda profile, config_file: {}) + monkeypatch.setattr(oci_model_module, "_build_auth", lambda *args, **kwargs: FakeOciAuth()) -def test_signer_replaces_bearer_auth_and_adds_compartment() -> None: - fake_signer = FakeSigner() - signer = OCIRequestSigner(fake_signer, compartment_id=COMPARTMENT_ID) - request = httpx.Request( - "POST", - "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1/chat/completions", - json={"model": "openai.gpt-4o"}, - headers={"Authorization": "Bearer should-be-removed"}, + client = build_signed_openai_client( + region="us-chicago-1", + compartment_id=COMPARTMENT_ID, + project_id=PROJECT_ID, ) + assert "inference.generativeai.us-chicago-1.oci.oraclecloud.com" in str(client.base_url) + assert client.project == PROJECT_ID + # The compartment header is attached by the oci-openai client. + assert client._client.headers.get("opc-compartment-id") == COMPARTMENT_ID - signed = _drive_auth_flow(signer, request) - - assert signed.headers["authorization"] == "Signature test" - assert signed.headers["opc-compartment-id"] == COMPARTMENT_ID - assert fake_signer.signed_bodies == [request.content] +def test_builder_requires_compartment(monkeypatch: pytest.MonkeyPatch) -> None: + import agents.extensions.models.oci_model as oci_model_module + from agents.extensions.models.oci_model import build_signed_openai_client -def test_signer_rebuilds_signer_on_401() -> None: - rebuilt: list[FakeSigner] = [] + monkeypatch.setattr(oci_model_module, "_load_profile", lambda profile, config_file: {}) + monkeypatch.setattr(oci_model_module, "_build_auth", lambda *args, **kwargs: FakeOciAuth()) + monkeypatch.delenv("OCI_COMPARTMENT_ID", raising=False) - def refresh() -> FakeSigner: - new_signer = FakeSigner(signature=f"Signature refreshed-{len(rebuilt)}") - rebuilt.append(new_signer) - return new_signer + with pytest.raises(UserError): + build_signed_openai_client(region="us-chicago-1") - signer = OCIRequestSigner(FakeSigner(), refresh_signer=refresh) - request = httpx.Request("POST", "https://example.com/openai/v1/chat/completions", json={}) - flow = signer.auth_flow(request) - first = next(flow) - assert first.headers["authorization"] == "Signature test" +def test_auth_mode_selection(monkeypatch: pytest.MonkeyPatch) -> None: + import agents.extensions.models.oci_model as oci_model_module + from agents.extensions.models.oci_model import _build_auth - retried = flow.send(httpx.Response(401, request=request)) - assert len(rebuilt) == 1 - assert retried.headers["authorization"] == "Signature refreshed-0" + selected: list[tuple[str, dict[str, Any]]] = [] + def make_stub(kind: str) -> Any: + def factory(**kwargs: Any) -> Any: + selected.append((kind, kwargs)) + return FakeOciAuth() -def test_signer_does_not_retry_without_refresh() -> None: - signer = OCIRequestSigner(FakeSigner()) - request = httpx.Request("POST", "https://example.com/openai/v1/chat/completions", json={}) + return factory - flow = signer.auth_flow(request) - next(flow) - with pytest.raises(StopIteration): - flow.send(httpx.Response(401, request=request)) + monkeypatch.setattr(oci_model_module, "OciUserPrincipalAuth", make_stub("api_key")) + monkeypatch.setattr(oci_model_module, "OciSessionAuth", make_stub("security_token")) + # Profiles with a security token use session auth automatically. + _build_auth(None, "PROFILE_A", None, {"security_token_file": "/tmp/token"}) + # Plain API-key profiles use user-principal auth. + _build_auth(None, "PROFILE_B", None, {}) + # Explicit modes are honored regardless of the profile contents. + _build_auth("security_token", "PROFILE_C", None, {}) -def test_provider_requires_model_name() -> None: - provider = OCIProvider(compartment_id=COMPARTMENT_ID) - with pytest.raises(UserError): - provider.get_model(None) + assert [kind for kind, _ in selected] == ["security_token", "api_key", "security_token"] + assert selected[0][1]["profile_name"] == "PROFILE_A" async def test_close_releases_internally_created_client(monkeypatch: pytest.MonkeyPatch) -> None: import agents.extensions.models.oci_model as oci_model_module - class FakeAsyncOpenAI: - def __init__(self) -> None: - self.closed = False - - async def close(self) -> None: - self.closed = True - fake_client = FakeAsyncOpenAI() - client_config = OCIClientConfig( - signer=FakeSigner(), config={}, region="us-chicago-1", compartment_id=COMPARTMENT_ID - ) - monkeypatch.setattr(oci_model_module, "resolve_client_config", lambda **kwargs: client_config) monkeypatch.setattr( - oci_model_module, "build_signed_openai_client", lambda config, **kwargs: fake_client + oci_model_module, "build_signed_openai_client", lambda **kwargs: fake_client ) from agents.extensions.models.oci_model import OCIChatCompletionsModel, OCIResponsesModel @@ -120,6 +101,9 @@ async def close(self) -> None: assert fake_client.closed fake_client = FakeAsyncOpenAI() + monkeypatch.setattr( + oci_model_module, "build_signed_openai_client", lambda **kwargs: fake_client + ) responses_model = OCIResponsesModel("openai.gpt-5") await responses_model.close() assert fake_client.closed @@ -128,32 +112,21 @@ async def close(self) -> None: async def test_close_leaves_caller_provided_client_open() -> None: from agents.extensions.models.oci_model import OCIChatCompletionsModel - class FakeAsyncOpenAI: - def __init__(self) -> None: - self.closed = False - - async def close(self) -> None: - self.closed = True - caller_client = FakeAsyncOpenAI() model = OCIChatCompletionsModel("openai.gpt-4o", openai_client=caller_client) # type: ignore[arg-type] await model.close() assert not caller_client.closed +def test_provider_requires_model_name() -> None: + provider = OCIProvider(compartment_id=COMPARTMENT_ID) + with pytest.raises(UserError): + provider.get_model(None) + + def test_provider_routes_to_model_classes(monkeypatch: pytest.MonkeyPatch) -> None: import agents.extensions.models.oci_provider as oci_provider_module - client_config = OCIClientConfig( - signer=FakeSigner(), - config={}, - region="us-chicago-1", - compartment_id=COMPARTMENT_ID, - ) - monkeypatch.setattr( - oci_provider_module, "resolve_client_config", lambda **kwargs: client_config - ) - created: list[tuple[str, str]] = [] class StubChatModel: @@ -167,9 +140,7 @@ def __init__(self, model: str, **kwargs: Any) -> None: monkeypatch.setattr(oci_provider_module, "OCIChatCompletionsModel", StubChatModel) monkeypatch.setattr(oci_provider_module, "OCIResponsesModel", StubResponsesModel) monkeypatch.setattr( - oci_provider_module, - "build_signed_openai_client", - lambda config, **kwargs: object(), + oci_provider_module, "build_signed_openai_client", lambda **kwargs: object() ) provider = OCIProvider(compartment_id=COMPARTMENT_ID) diff --git a/uv.lock b/uv.lock index 24c4fbecc0..63b63070d0 100644 --- a/uv.lock +++ b/uv.lock @@ -2505,6 +2505,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/a6/260b0e2ef9de7356030ea82dec56b61c40ac2907590f6aecaa9ce7b11059/oci-2.177.0-py3-none-any.whl", hash = "sha256:fd45a4a4d81764315123283f66cf33cc536ede3e3008dd37c871c89103012a81", size = 35629529, upload-time = "2026-06-02T01:55:07.57Z" }, ] +[[package]] +name = "oci-openai" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "oci" }, + { name = "openai" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/93/c395a92c8019dec50bd5760a9fcfd718cedb416824f8ebba0b26568ab6f4/oci_openai-1.1.0.tar.gz", hash = "sha256:1819ef7d17c1fdbe05c5c0653301fdca0d2fa99f6f8b1b7bd7667da9704d62a1", size = 199961, upload-time = "2026-02-03T05:15:12.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/a5/48aa98c4b68f3cc55bf74ef7a8d691d230ff09d5fab521e17ffd8b155ac4/oci_openai-1.1.0-py3-none-any.whl", hash = "sha256:a028ee3e1a1b1ad4e0495b10ef70b81b5e6cd50e7f13cf485a112762641a9160", size = 11449, upload-time = "2026-02-03T05:15:10.957Z" }, +] + [[package]] name = "openai" version = "2.36.0" @@ -2577,7 +2592,7 @@ mongodb = [ { name = "pymongo" }, ] oci = [ - { name = "oci" }, + { name = "oci-openai" }, ] realtime = [ { name = "websockets" }, @@ -2666,7 +2681,7 @@ requires-dist = [ { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<2" }, { name = "modal", marker = "extra == 'modal'", specifier = "==1.4.3" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, - { name = "oci", marker = "extra == 'oci'", specifier = ">=2.150.0" }, + { name = "oci-openai", marker = "extra == 'oci'", specifier = ">=1.1.0" }, { name = "openai", specifier = ">=2.36.0,<3" }, { name = "pydantic", specifier = ">=2.12.2,<3" }, { name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.14" }, From f5fd275792a4f9d7c47b44b408640471cc70d890 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Wed, 10 Jun 2026 11:35:04 -0400 Subject: [PATCH 5/5] Resolve OCI project from OCI_PROJECT_ID when not passed explicitly Oracle documents that projects are required to call the OCI OpenAI-compatible API, so deployments need a code-free way to supply the project OCID. Resolution order is the explicit project_id argument, then the OCI_PROJECT_ID environment variable. The value stays optional because tenancies without project enforcement accept requests without one. --- src/agents/extensions/models/oci_model.py | 13 ++++++++----- tests/models/test_oci_model.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/agents/extensions/models/oci_model.py b/src/agents/extensions/models/oci_model.py index 8f868d1185..121311b740 100644 --- a/src/agents/extensions/models/oci_model.py +++ b/src/agents/extensions/models/oci_model.py @@ -116,10 +116,12 @@ def build_signed_openai_client( config_file: OCI config file location (defaults to `~/.oci/config`). region: OCI region whose Generative AI endpoint should be called. compartment_id: Compartment all inference requests are billed against. - project_id: Optional OCI Generative AI project OCID - (`ocid1.generativeaiproject...`), sent as the `OpenAI-Project` header. - Projects scope response/conversation retention and memory settings on the - Responses endpoint. + project_id: OCI Generative AI project OCID (`ocid1.generativeaiproject...`), + sent as the `OpenAI-Project` header; falls back to the `OCI_PROJECT_ID` + env var. Oracle's documentation states that projects are required to call + the OCI OpenAI-compatible API; they scope response/conversation retention + and memory settings. Left optional here because tenancies without project + enforcement accept requests without one. request_timeout: Per-request timeout in seconds. """ uses_file_config = auth_type not in ("instance_principal", "resource_principal") @@ -132,6 +134,7 @@ def build_signed_openai_client( resolved_compartment = ( compartment_id or os.environ.get("OCI_COMPARTMENT_ID") or profile_config.get("tenancy") ) + resolved_project = project_id or os.environ.get("OCI_PROJECT_ID") if not resolved_compartment: raise UserError( "A compartment_id is required for OCI Generative AI. Pass it explicitly or set " @@ -145,7 +148,7 @@ def build_signed_openai_client( region=str(resolved_region), compartment_id=resolved_compartment, timeout=request_timeout, - project=project_id, + project=resolved_project, ), ) diff --git a/tests/models/test_oci_model.py b/tests/models/test_oci_model.py index 69327bd10d..21795524da 100644 --- a/tests/models/test_oci_model.py +++ b/tests/models/test_oci_model.py @@ -47,6 +47,18 @@ def test_builder_constructs_signed_client(monkeypatch: pytest.MonkeyPatch) -> No assert client._client.headers.get("opc-compartment-id") == COMPARTMENT_ID +def test_builder_resolves_project_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + import agents.extensions.models.oci_model as oci_model_module + from agents.extensions.models.oci_model import build_signed_openai_client + + monkeypatch.setattr(oci_model_module, "_load_profile", lambda profile, config_file: {}) + monkeypatch.setattr(oci_model_module, "_build_auth", lambda *args, **kwargs: FakeOciAuth()) + monkeypatch.setenv("OCI_PROJECT_ID", PROJECT_ID) + + client = build_signed_openai_client(region="us-chicago-1", compartment_id=COMPARTMENT_ID) + assert client.project == PROJECT_ID + + def test_builder_requires_compartment(monkeypatch: pytest.MonkeyPatch) -> None: import agents.extensions.models.oci_model as oci_model_module from agents.extensions.models.oci_model import build_signed_openai_client