diff --git a/README.md b/README.md index 54a9ff27cb..92eb58d979 100644 --- a/README.md +++ b/README.md @@ -1166,6 +1166,47 @@ client = BedrockOpenAI( The legacy module client also continues to support `openai.api_type = "amazon-bedrock"` or `OPENAI_API_TYPE=amazon-bedrock`. +## OrcaRouter + +To use this library with [OrcaRouter](https://www.orcarouter.ai), an OpenAI-compatible model routing gateway, configure the standard `OpenAI` client with the OrcaRouter provider. + +```py +from openai import OpenAI +from openai.providers import orcarouter + +# Reads the API key from the ORCAROUTER_API_KEY environment variable. +client = OpenAI( + provider=orcarouter(), +) + +response = client.chat.completions.create( + model="orcarouter/auto", + messages=[{"role": "user", "content": "Say hello!"}], +) + +print(response.choices[0].message.content) +``` + +OrcaRouter exposes many models behind a single endpoint, including named routers such as `orcarouter/auto`. The provider configures the `https://api.orcarouter.ai/v1` endpoint and bearer authentication while retaining the normal SDK resources, retries, streaming, and error handling. + +You can pass `api_key` directly, provide a refresh callback, or set `ORCAROUTER_BASE_URL` to override the endpoint: + +```py +client = OpenAI( + provider=orcarouter( + api_key="sk-orca-...", + ) +) +``` + +```py +client = OpenAI( + provider=orcarouter( + token_provider=lambda: refresh_orcarouter_token(), + ) +) +``` + ## Versioning This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: diff --git a/src/openai/providers/__init__.py b/src/openai/providers/__init__.py index bb5bcdbd9e..e649f8c9e3 100644 --- a/src/openai/providers/__init__.py +++ b/src/openai/providers/__init__.py @@ -1,3 +1,4 @@ from .bedrock import bedrock as bedrock +from .orcarouter import OrcaRouterTokenProvider as OrcaRouterTokenProvider, orcarouter as orcarouter -__all__ = ["bedrock"] +__all__ = ["bedrock", "orcarouter", "OrcaRouterTokenProvider"] diff --git a/src/openai/providers/orcarouter.py b/src/openai/providers/orcarouter.py new file mode 100644 index 0000000000..f8d4e965e6 --- /dev/null +++ b/src/openai/providers/orcarouter.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import os +import inspect +from typing import Callable, Awaitable, cast +from dataclasses import field, dataclass + +import httpx2 + +from .._types import NOT_GIVEN, NotGiven +from .._httpx2 import normalize_httpx_url +from .._provider import _Provider, _create_provider, _ProviderRuntime +from .._exceptions import OpenAIError + +OrcaRouterTokenProvider = Callable[[], "str | Awaitable[str]"] + +_ORCAROUTER_BASE_URL = "https://api.orcarouter.ai/v1" + + +def _normalize_base_url(base_url: str | httpx2.URL) -> httpx2.URL: + return normalize_httpx_url(base_url) + + +def _same_origin(left: httpx2.URL, right: httpx2.URL) -> bool: + return (left.scheme, left.host, left.port) == (right.scheme, right.host, right.port) + + +def _assert_provider_owns_authorization(request: httpx2.Request) -> None: + if "Authorization" in request.headers: + raise OpenAIError("OrcaRouter provider authentication cannot be combined with a custom `Authorization` header.") + + +class _OrcaRouterBearerAuth: + def __init__(self, token_provider: OrcaRouterTokenProvider, *, base_url: httpx2.URL) -> None: + self._token_provider = token_provider + self._base_url = base_url + + def _validate_request(self, request: httpx2.Request) -> None: + _assert_provider_owns_authorization(request) + if not _same_origin(request.url, self._base_url): + raise OpenAIError( + "Refusing to authenticate an OrcaRouter request for an origin other than the configured provider URL." + ) + + def _resolve_token(self) -> str: + try: + token = cast(object, self._token_provider()) + except OpenAIError: + raise + except Exception as exc: + raise OpenAIError("Failed to resolve a bearer credential for OrcaRouter.") from exc + + if inspect.isawaitable(token): + close = getattr(token, "close", None) + if callable(close): + close() + raise OpenAIError("An async OrcaRouter token provider requires `AsyncOpenAI`.") + if not isinstance(token, str) or not token.strip(): + raise OpenAIError("The OrcaRouter bearer credential provider must return a non-empty string.") + return token + + async def _resolve_token_async(self) -> str: + try: + token = cast(object, self._token_provider()) + if inspect.isawaitable(token): + token = await token + except OpenAIError: + raise + except Exception as exc: + raise OpenAIError("Failed to resolve a bearer credential for OrcaRouter.") from exc + + if not isinstance(token, str) or not token.strip(): + raise OpenAIError("The OrcaRouter bearer credential provider must return a non-empty string.") + return token + + def prepare_request(self, request: httpx2.Request) -> None: + self._validate_request(request) + request.headers["Authorization"] = f"Bearer {self._resolve_token()}" + + async def prepare_async_request(self, request: httpx2.Request) -> None: + self._validate_request(request) + request.headers["Authorization"] = f"Bearer {await self._resolve_token_async()}" + + +@dataclass(frozen=True) +class _OrcaRouterProviderDefinition: + configured_base_url: httpx2.URL | None + api_key: str | None = field(default=None, repr=False) + token_provider: OrcaRouterTokenProvider | None = field(default=None, repr=False, compare=False) + use_environment_key: bool = False + name: str = field(default="orcarouter", init=False) + + def configure(self) -> _ProviderRuntime: + def environment_key() -> str: + token = os.environ.get("ORCAROUTER_API_KEY") + if not token: + raise OpenAIError( + "Could not find credentials for OrcaRouter. Pass `api_key` to `orcarouter(...)`, " + "provide a `token_provider`, or set the `ORCAROUTER_API_KEY` environment variable." + ) + return token + + if self.api_key is not None: + bearer_provider: OrcaRouterTokenProvider = lambda: cast(str, self.api_key) + elif self.token_provider is not None: + bearer_provider = self.token_provider + elif self.use_environment_key: + if not os.environ.get("ORCAROUTER_API_KEY"): + raise OpenAIError( + "Could not find credentials for OrcaRouter. Pass `api_key` to `orcarouter(...)`, " + "provide a `token_provider`, or set the `ORCAROUTER_API_KEY` environment variable." + ) + bearer_provider = environment_key + else: + raise OpenAIError( + "Could not find credentials for OrcaRouter. Pass `api_key` to `orcarouter(...)`, " + "provide a `token_provider`, or set the `ORCAROUTER_API_KEY` environment variable." + ) + + base_url = self.configured_base_url or httpx2.URL(_ORCAROUTER_BASE_URL) + auth = _OrcaRouterBearerAuth(bearer_provider, base_url=base_url) + + return _ProviderRuntime( + name=self.name, + base_url=base_url, + prepare_request=auth.prepare_request, + prepare_async_request=auth.prepare_async_request, + ) + + +def orcarouter( + *, + base_url: str | httpx2.URL | None | NotGiven = NOT_GIVEN, + api_key: str | None | NotGiven = NOT_GIVEN, + token_provider: OrcaRouterTokenProvider | None = None, +) -> _Provider: + """Configure the standard OpenAI client for OrcaRouter. + + OrcaRouter is an OpenAI-compatible model routing gateway. It exposes + OpenAI, Anthropic, Google, and other models behind a single endpoint, + alongside named routers such as ``orcarouter/auto``. + + By default the provider reads the API key from the ``ORCAROUTER_API_KEY`` + environment variable and uses the ``https://api.orcarouter.ai/v1`` + endpoint. Pass ``base_url`` to ``orcarouter(...)`` or set + ``ORCAROUTER_BASE_URL`` to override it. + """ + + explicit_api_key = not isinstance(api_key, NotGiven) and api_key is not None + if explicit_api_key and (not isinstance(api_key, str) or not api_key.strip()): + raise OpenAIError("The OrcaRouter API key must not be empty.") + if explicit_api_key and token_provider is not None: + raise OpenAIError("The `api_key` and `token_provider` options are mutually exclusive. Configure only one.") + + skip_environment_key = not isinstance(api_key, NotGiven) and api_key is None + + configured_base_url: httpx2.URL | None + if isinstance(base_url, NotGiven): + environment_base_url = os.environ.get("ORCAROUTER_BASE_URL") + configured_base_url = _normalize_base_url(environment_base_url) if environment_base_url else None + elif base_url is None: + configured_base_url = None + else: + if isinstance(base_url, str) and not base_url.strip(): + raise OpenAIError("The OrcaRouter `base_url` must not be empty.") + configured_base_url = _normalize_base_url(base_url) + + use_environment_key = not explicit_api_key and not skip_environment_key + + return _create_provider( + _OrcaRouterProviderDefinition( + configured_base_url=configured_base_url, + api_key=cast("str | None", api_key) if explicit_api_key else None, + token_provider=token_provider, + use_environment_key=use_environment_key, + ) + ) + + +__all__ = ["orcarouter", "OrcaRouterTokenProvider"] diff --git a/tests/lib/test_orcarouter_provider.py b/tests/lib/test_orcarouter_provider.py new file mode 100644 index 0000000000..97aa98efdd --- /dev/null +++ b/tests/lib/test_orcarouter_provider.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +from typing import Any, Iterator, cast + +import httpx2 +import pytest + +from openai import OpenAI, AsyncOpenAI, OpenAIError +from tests.utils import update_env +from openai._types import Omit +from openai.providers import orcarouter + + +def test_sync_provider_owns_endpoint_and_bearer_authentication() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, request=request, json={}) + + client = OpenAI( + provider=orcarouter(api_key="orcarouter token"), + http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + client.get("/models", cast_to=httpx2.Response) + + assert client.base_url == httpx2.URL("https://api.orcarouter.ai/v1/") + assert requests[0].url == httpx2.URL("https://api.orcarouter.ai/v1/models") + assert requests[0].headers["Authorization"] == "Bearer orcarouter token" + + +@pytest.mark.asyncio +async def test_async_provider_owns_endpoint_and_bearer_authentication() -> None: + requests: list[httpx2.Request] = [] + + async def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, request=request, json={}) + + client = AsyncOpenAI( + provider=orcarouter(token_provider=lambda: "orcarouter token"), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler), trust_env=False), + ) + await client.get("/models", cast_to=httpx2.Response) + await client.close() + + assert requests[0].headers["Authorization"] == "Bearer orcarouter token" + + +def test_provider_ignores_openai_environment_configuration() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, request=request, json={}) + + with update_env( + OPENAI_API_KEY="openai token", + OPENAI_BASE_URL="https://api.openai.invalid/v1", + OPENAI_CUSTOM_HEADERS="Authorization: Bearer openai custom token", + ): + client = OpenAI( + provider=orcarouter(api_key="orcarouter token"), + http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + client.get("/models", cast_to=httpx2.Response) + + assert client.api_key == "" + assert requests[0].url.host == "api.orcarouter.ai" + assert requests[0].headers["Authorization"] == "Bearer orcarouter token" + + +@pytest.mark.parametrize( + ("option", "value"), + [ + ("api_key", "openai token"), + ("admin_api_key", "admin token"), + ("workload_identity", cast(Any, object())), + ("base_url", "https://api.openai.invalid/v1"), + ], +) +def test_provider_rejects_top_level_authentication_and_routing(option: str, value: object) -> None: + with pytest.raises( + OpenAIError, + match=rf"`provider` cannot be combined with top-level `{option}`.*`orcarouter\(\.\.\.\)`", + ): + OpenAI(provider=orcarouter(api_key="orcarouter token"), **{option: value}) # type: ignore[arg-type] + + +def test_provider_survives_with_options_and_can_be_replaced() -> None: + client = OpenAI(provider=orcarouter(api_key="first")) + + copied = client.with_options(timeout=1) + replaced = client.with_options(provider=orcarouter(api_key="second")) + + assert copied.base_url == client.base_url + assert copied._provider is client._provider + assert replaced.base_url == httpx2.URL("https://api.orcarouter.ai/v1/") + assert replaced._provider is not client._provider + + +def test_switching_to_provider_drops_inherited_openai_metadata() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, request=request, json={}) + + with update_env( + OPENAI_CUSTOM_HEADERS="X-OpenAI-Ambient: leak", + OPENAI_ORG_ID="ambient-org", + OPENAI_PROJECT_ID="ambient-project", + ): + client = OpenAI( + api_key="openai token", + default_headers={"X-OpenAI-Custom": "leak"}, + http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + provider_client = client.with_options(provider=orcarouter(api_key="orcarouter token")) + + provider_client.get("/models", cast_to=httpx2.Response) + + headers = requests[0].headers + assert headers["Authorization"] == "Bearer orcarouter token" + assert "X-OpenAI-Ambient" not in headers + assert "X-OpenAI-Custom" not in headers + assert "OpenAI-Organization" not in headers + assert "OpenAI-Project" not in headers + + +@pytest.mark.asyncio +async def test_async_switching_to_provider_drops_inherited_openai_metadata() -> None: + requests: list[httpx2.Request] = [] + + async def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, request=request, json={}) + + client = AsyncOpenAI( + api_key="openai token", + organization="openai-org", + project="openai-project", + default_headers={"X-OpenAI-Custom": "leak"}, + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler), trust_env=False), + ) + provider_client = client.with_options(provider=orcarouter(api_key="orcarouter token")) + + await provider_client.get("/models", cast_to=httpx2.Response) + await provider_client.close() + + headers = requests[0].headers + assert headers["Authorization"] == "Bearer orcarouter token" + assert "X-OpenAI-Custom" not in headers + assert "OpenAI-Organization" not in headers + assert "OpenAI-Project" not in headers + + +def test_provider_metadata_survives_same_provider_clone_but_not_replacement() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, request=request, json={}) + + first_provider = orcarouter(api_key="first token") + client = OpenAI( + provider=first_provider, + organization="provider-org", + project="provider-project", + default_headers={"X-Provider-Custom": "preserve-me"}, + http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + + client.with_options(timeout=1).get("/models", cast_to=httpx2.Response) + client.with_options(provider=orcarouter(api_key="second token")).get("/models", cast_to=httpx2.Response) + + same_provider_headers, replacement_headers = (request.headers for request in requests) + assert same_provider_headers["X-Provider-Custom"] == "preserve-me" + assert same_provider_headers["OpenAI-Organization"] == "provider-org" + assert same_provider_headers["OpenAI-Project"] == "provider-project" + assert "X-Provider-Custom" not in replacement_headers + assert "OpenAI-Organization" not in replacement_headers + assert "OpenAI-Project" not in replacement_headers + + +def test_environment_api_key_mode_survives_clone_and_refreshes_each_attempt() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, request=request, json={}) + + with update_env(ORCAROUTER_API_KEY="first token"): + client = OpenAI( + provider=orcarouter(), + http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + client.get("/models", cast_to=httpx2.Response) + + copied = client.with_options(timeout=1) + with update_env(ORCAROUTER_API_KEY="second token"): + copied.get("/models", cast_to=httpx2.Response) + + assert [request.headers["Authorization"] for request in requests] == ["Bearer first token", "Bearer second token"] + + +def test_provider_can_be_removed_with_explicit_openai_credentials() -> None: + with update_env(OPENAI_CUSTOM_HEADERS=Omit(), OPENAI_ORG_ID=Omit(), OPENAI_PROJECT_ID=Omit()): + client = OpenAI( + provider=orcarouter(api_key="orcarouter token"), + organization="provider-org", + project="provider-project", + default_headers={"X-Provider-Custom": "provider value"}, + ) + + copied = client.with_options(provider=None, api_key="openai token") + + assert copied._provider is None + assert copied.api_key == "openai token" + assert copied.base_url == httpx2.URL("https://api.openai.com/v1/") + assert copied.organization is None + assert copied.project is None + assert "X-Provider-Custom" not in copied.default_headers + + +def test_bearer_provider_does_not_require_any_aws_dependency() -> None: + client = OpenAI(provider=orcarouter(api_key="orcarouter token")) + request = client._build_request(client._prepare_options(_get_options())) + client._prepare_request(request) + + assert request.headers["Authorization"] == "Bearer orcarouter token" + + +def test_explicit_api_key_none_skips_environment_key_and_raises() -> None: + with update_env( + ORCAROUTER_API_KEY="environment token", + ): + with pytest.raises(OpenAIError, match="ORCAROUTER_API_KEY"): + OpenAI(provider=orcarouter(api_key=None)) + + +def test_provider_rejects_custom_authorization_before_network() -> None: + network_calls = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal network_calls + network_calls += 1 + return httpx2.Response(200, request=request) + + client = OpenAI( + provider=orcarouter(api_key="orcarouter token"), + http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + + with pytest.raises(OpenAIError, match="cannot be combined with a custom `Authorization` header"): + client.get( + "/models", + cast_to=httpx2.Response, + options={"headers": {"Authorization": "Bearer custom"}}, + ) + + assert network_calls == 0 + + +def test_bearer_provider_rejects_cross_origin_requests_before_resolving_credentials() -> None: + network_calls = 0 + provider_calls = 0 + + def token_provider() -> str: + nonlocal provider_calls + provider_calls += 1 + return "orcarouter token" + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal network_calls + network_calls += 1 + return httpx2.Response(200, request=request) + + client = OpenAI( + provider=orcarouter(base_url="https://orcarouter.example/v1", token_provider=token_provider), + http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + + with pytest.raises(OpenAIError, match="origin other than the configured provider URL"): + client.get("https://attacker.example/steal", cast_to=httpx2.Response) + + assert (provider_calls, network_calls) == (0, 0) + + +@pytest.mark.asyncio +async def test_async_bearer_provider_rejects_cross_origin_requests_before_resolving_credentials() -> None: + network_calls = 0 + provider_calls = 0 + + async def token_provider() -> str: + nonlocal provider_calls + provider_calls += 1 + return "orcarouter token" + + async def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal network_calls + network_calls += 1 + return httpx2.Response(200, request=request) + + client = AsyncOpenAI( + provider=orcarouter(base_url="https://orcarouter.example/v1", token_provider=token_provider), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler), trust_env=False), + ) + + with pytest.raises(OpenAIError, match="origin other than the configured provider URL"): + await client.get("https://attacker.example/steal", cast_to=httpx2.Response) + + await client.close() + assert (provider_calls, network_calls) == (0, 0) + + +def test_bearer_provider_allows_one_shot_body_when_retries_are_disabled() -> None: + requests: list[httpx2.Request] = [] + + def body() -> Iterator[bytes]: + yield b"body" + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response(200, request=request) + + client = OpenAI( + provider=orcarouter(base_url="https://orcarouter.example/v1", api_key="orcarouter token"), + max_retries=0, + http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False), + ) + + client.post("/responses", content=body(), cast_to=httpx2.Response) + + assert requests[0].content == b"body" + + +def test_opaque_provider_repr_does_not_expose_credentials() -> None: + provider = orcarouter(api_key="secret orcarouter key") + + assert "secret" not in repr(provider) + + +def test_missing_credentials_is_actionable() -> None: + with update_env(ORCAROUTER_API_KEY=Omit()): + with pytest.raises(OpenAIError, match="ORCAROUTER_API_KEY"): + OpenAI(provider=orcarouter()) + + +def _get_options() -> Any: + from openai._models import FinalRequestOptions + + return FinalRequestOptions(method="get", url="/models", security={"bearer_auth": True})