From bf7b0180f2e5ccc71b15143e14622ba4e132c421 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Fri, 12 Jun 2026 22:19:40 -0400 Subject: [PATCH] refactor(openai): mark Codex OAuth classes private (#38122) - Mark the Codex OAuth model/token helper classes private with leading underscores - Remove `_ChatOpenAICodex` from package-level public exports - Keep a once-per-process runtime warning that use is experimental/unofficial and must comply with applicable OpenAI account, workspace, plan, terms, policies, rate limits, and safeguards --- .../openai/langchain_openai/__init__.py | 3 +- .../langchain_openai/chat_models/__init__.py | 3 +- .../langchain_openai/chat_models/codex.py | 90 +++++++++++----- .../openai/langchain_openai/chatgpt_oauth.py | 101 +++++++++--------- .../openai/scripts/RECORD_CODEX_CASSETTES.md | 13 ++- .../integration_tests/chat_models/conftest.py | 28 ++--- .../chat_models/test_codex.py | 47 ++++---- .../chat_models/test_codex_standard.py | 10 +- .../unit_tests/chat_models/test_codex.py | 44 +++++--- .../unit_tests/chat_models/test_imports.py | 2 +- .../tests/unit_tests/test_chatgpt_oauth.py | 78 +++++++------- .../openai/tests/unit_tests/test_imports.py | 1 - 12 files changed, 238 insertions(+), 182 deletions(-) diff --git a/libs/partners/openai/langchain_openai/__init__.py b/libs/partners/openai/langchain_openai/__init__.py index 81596180d59..c70be734874 100644 --- a/libs/partners/openai/langchain_openai/__init__.py +++ b/libs/partners/openai/langchain_openai/__init__.py @@ -1,7 +1,7 @@ """Module for OpenAI integrations.""" from langchain_openai._version import __version__ -from langchain_openai.chat_models import AzureChatOpenAI, ChatOpenAI, ChatOpenAICodex +from langchain_openai.chat_models import AzureChatOpenAI, ChatOpenAI from langchain_openai.chat_models._client_utils import StreamChunkTimeoutError from langchain_openai.embeddings import AzureOpenAIEmbeddings, OpenAIEmbeddings from langchain_openai.llms import AzureOpenAI, OpenAI @@ -12,7 +12,6 @@ __all__ = [ "AzureOpenAI", "AzureOpenAIEmbeddings", "ChatOpenAI", - "ChatOpenAICodex", "OpenAI", "OpenAIEmbeddings", "StreamChunkTimeoutError", diff --git a/libs/partners/openai/langchain_openai/chat_models/__init__.py b/libs/partners/openai/langchain_openai/chat_models/__init__.py index 7e4f50348cc..e43102ffabc 100644 --- a/libs/partners/openai/langchain_openai/chat_models/__init__.py +++ b/libs/partners/openai/langchain_openai/chat_models/__init__.py @@ -2,6 +2,5 @@ from langchain_openai.chat_models.azure import AzureChatOpenAI from langchain_openai.chat_models.base import ChatOpenAI -from langchain_openai.chat_models.codex import ChatOpenAICodex -__all__ = ["AzureChatOpenAI", "ChatOpenAI", "ChatOpenAICodex"] +__all__ = ["AzureChatOpenAI", "ChatOpenAI"] diff --git a/libs/partners/openai/langchain_openai/chat_models/codex.py b/libs/partners/openai/langchain_openai/chat_models/codex.py index 4644d94c2d6..b35fd1102f3 100644 --- a/libs/partners/openai/langchain_openai/chat_models/codex.py +++ b/libs/partners/openai/langchain_openai/chat_models/codex.py @@ -1,17 +1,26 @@ -"""`ChatOpenAICodex`: OAuth-backed chat model for ChatGPT subscription auth. +"""`_ChatOpenAICodex`: experimental OAuth-backed chat model. Wraps `ChatOpenAI` to target the ChatGPT codex backend (`https://chatgpt.com/backend-api/codex`) and supplies refresh-aware `Authorization` and `ChatGPT-Account-Id` headers from a -`ChatGPTOAuthTokenProvider`. +`_ChatGPTOAuthTokenProvider`. The standard `ChatOpenAI` (API-key) flow is untouched. + +!!! warning "Experimental and unofficial" + + `_ChatOpenAICodex` is not an official OpenAI API integration. Use it only + where your OpenAI account, workspace, plan, and applicable OpenAI terms + permit ChatGPT-authenticated Codex access. You are responsible for ensuring + your implementation complies with OpenAI's terms, usage policies, account + restrictions, rate limits, and safeguards. """ from __future__ import annotations import logging import os +import warnings from typing import TYPE_CHECKING, Any from langchain_core.language_models.chat_models import LangSmithParams @@ -20,8 +29,8 @@ from pydantic import Field, model_validator from langchain_openai.chat_models.base import ChatOpenAI from langchain_openai.chatgpt_oauth import ( - ChatGPTOAuthTokenProvider, - FileChatGPTOAuthTokenProvider, + _ChatGPTOAuthTokenProvider, + _FileChatGPTOAuthTokenProvider, ) if TYPE_CHECKING: @@ -46,6 +55,15 @@ env var. """ ORIGINATOR_ENV_VAR = "LANGCHAIN_CODEX_ORIGINATOR" ACCOUNT_ID_HEADER = "ChatGPT-Account-Id" +EXPERIMENTAL_UNOFFICIAL_WARNING = ( + "`_ChatOpenAICodex` is experimental and unofficial. It uses ChatGPT " + "subscription OAuth against Codex endpoints and must only be used where " + "permitted by your OpenAI account, workspace, plan, and applicable OpenAI " + "terms and policies. You are responsible for implementing and operating " + "it responsibly, including respecting OpenAI's usage policies, rate " + "limits, and safeguards." +) +_experimental_warning_emitted = False _INSTRUCTION_ROLES = frozenset({"system", "developer"}) @@ -54,6 +72,15 @@ def _default_originator() -> str: return os.environ.get(ORIGINATOR_ENV_VAR) or ORIGINATOR_VALUE +def _warn_experimental_unofficial() -> None: + """Warn once that `_ChatOpenAICodex` is experimental and unofficial.""" + global _experimental_warning_emitted + if _experimental_warning_emitted: + return + _experimental_warning_emitted = True + warnings.warn(EXPERIMENTAL_UNOFFICIAL_WARNING, UserWarning, stacklevel=5) + + def _maybe_has_system_messages(input_: Any) -> bool: """Return `True` if `input_` *could* contain a system-role message. @@ -113,7 +140,7 @@ def _flatten_system_message_content(system_messages: list[BaseMessage]) -> str: msg = ( f"`{message_name}` at index {index} has unsupported content " f"type {type(content).__name__!r}; only `str` and " - "list-of-text-blocks are accepted by `ChatOpenAICodex`." + "list-of-text-blocks are accepted by `_ChatOpenAICodex`." ) raise ValueError(msg) text_parts: list[str] = [] @@ -148,14 +175,14 @@ DEFAULT_INSTRUCTIONS = "You are ChatGPT, a large language model trained by OpenA The Codex backend rejects any request missing a top-level `instructions` value (400 `Instructions are required`), so this constant keeps zero-config construction working. **Most callers should override it** with their own -prompt — see `ChatOpenAICodex.instructions` for the resolution rules. +prompt — see `_ChatOpenAICodex.instructions` for the resolution rules. """ _FORCED_VALUES: dict[str, Any] = { "use_responses_api": True, "store": False, "streaming": True, } -"""Values forced onto every `ChatOpenAICodex` instance. +"""Values forced onto every `_ChatOpenAICodex` instance. These are the wire-level constraints the Codex backend imposes: @@ -182,8 +209,14 @@ rationale. """ -class ChatOpenAICodex(ChatOpenAI): - """`ChatOpenAI` variant authed by a ChatGPT OAuth subscription. +class _ChatOpenAICodex(ChatOpenAI): + """Experimental `ChatOpenAI` variant authed by ChatGPT OAuth. + + This integration is unofficial and should only be used where your OpenAI + account, workspace, plan, and applicable OpenAI terms permit + ChatGPT-authenticated Codex access. Users are responsible for implementing + and operating it in compliance with OpenAI's terms, usage policies, account + restrictions, rate limits, and safeguards. Routes requests to `https://chatgpt.com/backend-api/codex` and forces the wire-level fields the Codex backend requires @@ -196,15 +229,15 @@ class ChatOpenAICodex(ChatOpenAI): Example: ```python - from langchain_openai import ChatOpenAICodex + from langchain_openai.chat_models.codex import _ChatOpenAICodex from langchain_openai.chatgpt_oauth import login_chatgpt # One-time setup. The returned provider writes to the default store - # at `~/.langchain/chatgpt-auth.json`, which `ChatOpenAICodex` also + # at `~/.langchain/chatgpt-auth.json`, which `_ChatOpenAICodex` also # reads from by default — so subsequent constructions need no # explicit `token_provider`. login_chatgpt() - model = ChatOpenAICodex( + model = _ChatOpenAICodex( model="gpt-5.5", instructions="You are a senior Python reviewer. Be terse.", ) @@ -221,7 +254,7 @@ class ChatOpenAICodex(ChatOpenAI): !!! note - Token storage is handled by `FileChatGPTOAuthTokenProvider`, which + Token storage is handled by `_FileChatGPTOAuthTokenProvider`, which defaults to `~/.langchain/chatgpt-auth.json` so it does not collide with the Codex CLI / VS Code session at `~/.codex/auth.json`. @@ -237,8 +270,8 @@ class ChatOpenAICodex(ChatOpenAI): token_provider: Any = Field(default=None, exclude=True) """Refresh-aware ChatGPT OAuth token provider. - Must implement the `ChatGPTOAuthTokenProvider` protocol. If `None`, a - `FileChatGPTOAuthTokenProvider` rooted at the default store path is + Must implement the `_ChatGPTOAuthTokenProvider` protocol. If `None`, a + `_FileChatGPTOAuthTokenProvider` rooted at the default store path is constructed. """ @@ -247,14 +280,14 @@ class ChatOpenAICodex(ChatOpenAI): Identifies the client making the request. Defaults to `"langchain"` so OpenAI telemetry attributes calls to this package. Downstream consumers - (e.g., a framework built on top of `ChatOpenAICodex`) can override this + (e.g., a framework built on top of `_ChatOpenAICodex`) can override this to identify themselves instead, or set `None` to suppress the header. Resolution order (first match wins): 1. Per-call `extra_headers={"originator": "..."}` (always trumps the field; pass an explicit value to override on a single call). - 2. Constructor / kwarg value (`ChatOpenAICodex(originator="my-app")`). + 2. Constructor / kwarg value (`_ChatOpenAICodex(originator="my-app")`). 3. The `LANGCHAIN_CODEX_ORIGINATOR` env var, if set and non-empty. 4. `ORIGINATOR_VALUE` (`"langchain"`). @@ -270,7 +303,7 @@ class ChatOpenAICodex(ChatOpenAI): field is missing or empty (400 `Instructions are required`) **and** rejects any `SystemMessage` entry in the input list (400 `System messages are not allowed`). To bridge those constraints - transparently, `ChatOpenAICodex` resolves `instructions` per call with + transparently, `_ChatOpenAICodex` resolves `instructions` per call with this precedence (highest wins): 1. Explicit `instructions=` kwarg on `invoke` / `stream`. @@ -284,7 +317,7 @@ class ChatOpenAICodex(ChatOpenAI): between calls — useful for switching persona / tooling mid-conversation: ```python - model = ChatOpenAICodex( + model = _ChatOpenAICodex( model="gpt-5.5", instructions="You are a senior Python reviewer. Be terse.", ) @@ -305,13 +338,14 @@ class ChatOpenAICodex(ChatOpenAI): @classmethod def _apply_codex_defaults(cls, values: dict[str, Any]) -> dict[str, Any]: """Apply Codex-specific defaults before the parent validator runs.""" + _warn_experimental_unofficial() if not isinstance(values, dict): return values for key, forced in _FORCED_VALUES.items(): supplied = values.get(key) if supplied is not None and supplied != forced: msg = ( - f"`ChatOpenAICodex` requires `{key}={forced!r}`; " + f"`_ChatOpenAICodex` requires `{key}={forced!r}`; " f"got `{key}={supplied!r}`. Use `ChatOpenAI` if you " "need to customize this." ) @@ -326,7 +360,7 @@ class ChatOpenAICodex(ChatOpenAI): supplied = values.get(key) if supplied is not None and supplied != CHATGPT_CODEX_BASE_URL: msg = ( - f"`ChatOpenAICodex` requires `{key}={CHATGPT_CODEX_BASE_URL!r}`; " + f"`_ChatOpenAICodex` requires `{key}={CHATGPT_CODEX_BASE_URL!r}`; " f"got `{key}={supplied!r}`. Use `ChatOpenAI` if you need to " "target a different endpoint." ) @@ -335,12 +369,12 @@ class ChatOpenAICodex(ChatOpenAI): provider = values.get("token_provider") if provider is None: - provider = FileChatGPTOAuthTokenProvider.from_default_store() + provider = _FileChatGPTOAuthTokenProvider.from_default_store() values["token_provider"] = provider - if not isinstance(provider, ChatGPTOAuthTokenProvider): + if not isinstance(provider, _ChatGPTOAuthTokenProvider): msg = ( "`token_provider` must implement the " - "`ChatGPTOAuthTokenProvider` protocol." + "`_ChatGPTOAuthTokenProvider` protocol." ) raise TypeError(msg) @@ -354,7 +388,7 @@ class ChatOpenAICodex(ChatOpenAI): for key in ("api_key", "openai_api_key"): if values.get(key) is not None: msg = ( - f"`ChatOpenAICodex` manages authentication via " + f"`_ChatOpenAICodex` manages authentication via " f"`token_provider`; drop the explicit `{key}=`. Use " "`ChatOpenAI` if you want API-key authentication." ) @@ -473,7 +507,7 @@ class ChatOpenAICodex(ChatOpenAI): @classmethod def is_lc_serializable(cls) -> bool: - """`ChatOpenAICodex` is not serializable (holds a live token provider).""" + """`_ChatOpenAICodex` is not serializable (holds a live token provider).""" return False @@ -487,11 +521,11 @@ class _SyncTokenCallable: __slots__ = ("_provider",) - def __init__(self, provider: ChatGPTOAuthTokenProvider) -> None: + def __init__(self, provider: _ChatGPTOAuthTokenProvider) -> None: self._provider = provider def __call__(self) -> str: return self._provider.get_access_token() -__all__ = ["ChatOpenAICodex"] +__all__: list[str] = [] diff --git a/libs/partners/openai/langchain_openai/chatgpt_oauth.py b/libs/partners/openai/langchain_openai/chatgpt_oauth.py index b64d2376261..e2ed4f45bc8 100644 --- a/libs/partners/openai/langchain_openai/chatgpt_oauth.py +++ b/libs/partners/openai/langchain_openai/chatgpt_oauth.py @@ -1,17 +1,26 @@ -"""ChatGPT OAuth helpers for `ChatOpenAICodex`. +"""ChatGPT OAuth helpers for `_ChatOpenAICodex`. Implements OAuth 2.0 Authorization Code Flow with PKCE against the OpenAI auth endpoints used by Codex/ChatGPT subscription auth, plus a small file-backed token store and refresh logic. These helpers exist to keep login and token management *separate* from model -invocation. `ChatOpenAICodex` only consumes a `ChatGPTOAuthTokenProvider`. +invocation. `_ChatOpenAICodex` only consumes a `_ChatGPTOAuthTokenProvider`. !!! warning + This is provider-specific subscription auth and is independent from the standard OpenAI API-key flow used by `ChatOpenAI`. Refresh-token rotation against `~/.codex/auth.json` can break Codex CLI / VS Code sessions, so the default store lives at `~/.langchain/chatgpt-auth.json`. + +!!! warning "Experimental and unofficial" + + These helpers are not an official OpenAI API integration. Use them only + where your OpenAI account, workspace, plan, and applicable OpenAI terms + permit ChatGPT-authenticated Codex access. You are responsible for ensuring + your implementation complies with OpenAI's terms, usage policies, account + restrictions, rate limits, and safeguards. """ from __future__ import annotations @@ -60,7 +69,7 @@ DEFAULT_STORE_PATH = Path.home() / ".langchain" / "chatgpt-auth.json" @dataclass(frozen=True) -class ChatGPTToken: +class _ChatGPTToken: """A ChatGPT OAuth token bundle. `expires_at` is timezone-aware. The JWT-derived optionals (`account_id`, @@ -99,7 +108,7 @@ class ChatGPTToken: return datetime.now(timezone.utc) >= (self.expires_at - skew) -class ChatGPTOAuthRefreshError(RuntimeError): +class _ChatGPTOAuthRefreshError(RuntimeError): """Raised when a refresh-token grant fails irrecoverably. Typically signals that the stored refresh token has been revoked or has @@ -109,14 +118,14 @@ class ChatGPTOAuthRefreshError(RuntimeError): @runtime_checkable -class ChatGPTOAuthTokenProvider(Protocol): - """Refresh-aware token source consumed by `ChatOpenAICodex`.""" +class _ChatGPTOAuthTokenProvider(Protocol): # noqa: PYI046 + """Refresh-aware token source consumed by `_ChatOpenAICodex`.""" - def get_token(self) -> ChatGPTToken: + def get_token(self) -> _ChatGPTToken: """Return a current token, refreshing if necessary.""" ... - async def aget_token(self) -> ChatGPTToken: + async def aget_token(self) -> _ChatGPTToken: """Async variant of `get_token`. Implementations must offer the same locking and refresh guarantees @@ -194,13 +203,13 @@ def _expires_at_from_response(payload: dict[str, Any]) -> datetime: expires_in = int(raw) if raw is not None else 0 except (TypeError, ValueError) as exc: msg = f"OAuth token response had invalid `expires_in`: {raw!r}" - raise ChatGPTOAuthRefreshError(msg) from exc + raise _ChatGPTOAuthRefreshError(msg) from exc if expires_in <= 0: msg = ( "OAuth token response had missing or non-positive `expires_in`; " "refusing to store an immediately-expired token." ) - raise ChatGPTOAuthRefreshError(msg) + raise _ChatGPTOAuthRefreshError(msg) return datetime.now(timezone.utc) + timedelta(seconds=expires_in) @@ -208,11 +217,11 @@ def _token_from_response( payload: dict[str, Any], *, fallback_refresh_token: str | None = None, -) -> ChatGPTToken: - """Build a `ChatGPTToken` from an OAuth token-endpoint response.""" +) -> _ChatGPTToken: + """Build a `_ChatGPTToken` from an OAuth token-endpoint response.""" if not payload.get("access_token"): msg = "OAuth token response did not include an `access_token`." - raise ChatGPTOAuthRefreshError(msg) + raise _ChatGPTOAuthRefreshError(msg) id_token = payload.get("id_token") claims = _extract_chatgpt_claims(id_token) refresh_token = payload.get("refresh_token") or fallback_refresh_token @@ -221,8 +230,8 @@ def _token_from_response( "OAuth token response did not include a `refresh_token` and no " "prior refresh token was available; re-run `login_chatgpt()`." ) - raise ChatGPTOAuthRefreshError(msg) - return ChatGPTToken( + raise _ChatGPTOAuthRefreshError(msg) + return _ChatGPTToken( access_token=payload["access_token"], refresh_token=refresh_token, expires_at=_expires_at_from_response(payload), @@ -233,7 +242,7 @@ def _token_from_response( ) -def _serialize_token(token: ChatGPTToken) -> dict[str, Any]: +def _serialize_token(token: _ChatGPTToken) -> dict[str, Any]: return { "access_token": token.access_token, "refresh_token": token.refresh_token, @@ -245,7 +254,7 @@ def _serialize_token(token: ChatGPTToken) -> dict[str, Any]: } -def _deserialize_token(data: dict[str, Any]) -> ChatGPTToken: +def _deserialize_token(data: dict[str, Any]) -> _ChatGPTToken: expires_at_raw = data.get("expires_at") if isinstance(expires_at_raw, str): expires_at = datetime.fromisoformat(expires_at_raw) @@ -256,7 +265,7 @@ def _deserialize_token(data: dict[str, Any]) -> ChatGPTToken: else: msg = "Stored token is missing `expires_at`." raise ValueError(msg) - return ChatGPTToken( + return _ChatGPTToken( access_token=data["access_token"], refresh_token=data["refresh_token"], expires_at=expires_at, @@ -380,7 +389,7 @@ def _raise_for_oauth_response(url: str, resp: httpx.Response) -> None: "ChatGPT refresh token is no longer valid (`invalid_grant`). " "Re-run `login_chatgpt()` to obtain a new token." ) - raise ChatGPTOAuthRefreshError(msg) + raise _ChatGPTOAuthRefreshError(msg) msg = f"OAuth request to {url} failed with status {resp.status_code}: {excerpt}" raise RuntimeError(msg) @@ -445,8 +454,8 @@ async def _apost_form( @dataclass -class FileChatGPTOAuthTokenProvider: - """File-backed `ChatGPTOAuthTokenProvider`. +class _FileChatGPTOAuthTokenProvider: + """File-backed `_ChatGPTOAuthTokenProvider`. Stores tokens at `path` (defaults to `DEFAULT_STORE_PATH`) with private permissions and refreshes them on read when they are within @@ -465,22 +474,22 @@ class FileChatGPTOAuthTokenProvider: token_url: str = CHATGPT_TOKEN_URL refresh_skew: timedelta = DEFAULT_REFRESH_SKEW timeout: float = 30.0 - _cached: ChatGPTToken | None = field(default=None, init=False, repr=False) + _cached: _ChatGPTToken | None = field(default=None, init=False, repr=False) _lock: threading.Lock = field( default_factory=threading.Lock, init=False, repr=False ) @classmethod - def from_default_store(cls) -> FileChatGPTOAuthTokenProvider: + def from_default_store(cls) -> _FileChatGPTOAuthTokenProvider: """Construct a provider with all defaults (path, client ID, etc.). - Equivalent to `FileChatGPTOAuthTokenProvider()`; the alias exists as + Equivalent to `_FileChatGPTOAuthTokenProvider()`; the alias exists as a discoverable entry point for callers reading the default-path contract from the module docstring. """ return cls() - def _read_from_disk(self) -> ChatGPTToken | None: + def _read_from_disk(self) -> _ChatGPTToken | None: """Return the stored token, or `None` if no store exists. Raises `RuntimeError` (rather than returning `None`) if the file @@ -517,10 +526,10 @@ class FileChatGPTOAuthTokenProvider: ) raise RuntimeError(msg) from exc - def _write_to_disk(self, token: ChatGPTToken) -> None: + def _write_to_disk(self, token: _ChatGPTToken) -> None: _atomic_write_private_json(self.path, _serialize_token(token)) - def save(self, token: ChatGPTToken) -> None: + def save(self, token: _ChatGPTToken) -> None: """Persist `token` to disk and cache it in memory.""" with self._lock, _file_lock(self.path): self._write_to_disk(token) @@ -535,13 +544,13 @@ class FileChatGPTOAuthTokenProvider: def _apply_refresh_response( self, response: dict[str, Any], previous_refresh: str - ) -> ChatGPTToken: + ) -> _ChatGPTToken: token = _token_from_response(response, fallback_refresh_token=previous_refresh) self._write_to_disk(token) self._cached = token return token - def _refresh_sync(self, existing: ChatGPTToken) -> ChatGPTToken: + def _refresh_sync(self, existing: _ChatGPTToken) -> _ChatGPTToken: logger.debug( "Refreshing ChatGPT access token (refresh_token=%s).", _redact(existing.refresh_token), @@ -553,7 +562,7 @@ class FileChatGPTOAuthTokenProvider: ) return self._apply_refresh_response(response, existing.refresh_token) - def _load_existing(self) -> ChatGPTToken: + def _load_existing(self) -> _ChatGPTToken: existing = self._cached or self._read_from_disk() if existing is None: msg = ( @@ -563,7 +572,7 @@ class FileChatGPTOAuthTokenProvider: raise FileNotFoundError(msg) return existing - def _load_existing_before_refresh(self) -> ChatGPTToken: + def _load_existing_before_refresh(self) -> _ChatGPTToken: existing = self._load_existing() if not existing.is_expired(skew=self.refresh_skew): return existing @@ -573,13 +582,13 @@ class FileChatGPTOAuthTokenProvider: return disk_token return existing - def get_token(self) -> ChatGPTToken: + def get_token(self) -> _ChatGPTToken: """Return a fresh token, refreshing on disk if needed. Raises: FileNotFoundError: No token store exists at `self.path`; run `login_chatgpt()` first. - ChatGPTOAuthRefreshError: The stored refresh token was rejected + _ChatGPTOAuthRefreshError: The stored refresh token was rejected (e.g. revoked or expired); re-run `login_chatgpt()`. """ with self._lock, _file_lock(self.path): @@ -589,7 +598,7 @@ class FileChatGPTOAuthTokenProvider: return existing return self._refresh_sync(existing) - async def aget_token(self) -> ChatGPTToken: + async def aget_token(self) -> _ChatGPTToken: """Async variant of `get_token` with the same locking guarantees. The thread lock and cross-process file lock are acquired off the @@ -602,12 +611,12 @@ class FileChatGPTOAuthTokenProvider: Raises: FileNotFoundError: No token store exists at `self.path`; run `login_chatgpt()` first. - ChatGPTOAuthRefreshError: The stored refresh token was rejected + _ChatGPTOAuthRefreshError: The stored refresh token was rejected (e.g. revoked or expired); re-run `login_chatgpt()`. """ return await asyncio.to_thread(self._aget_token_locked_blocking) - def _aget_token_locked_blocking(self) -> ChatGPTToken: + def _aget_token_locked_blocking(self) -> _ChatGPTToken: with self._lock, _file_lock(self.path): existing = self._load_existing_before_refresh() if not existing.is_expired(skew=self.refresh_skew): @@ -850,13 +859,13 @@ def login_chatgpt( scope: str = DEFAULT_SCOPE, open_browser: bool = True, timeout: float = 300.0, -) -> FileChatGPTOAuthTokenProvider: +) -> _FileChatGPTOAuthTokenProvider: """Run the ChatGPT OAuth 2.0 Authorization Code Flow with PKCE. Starts a loopback callback server, optionally opens a browser to the OpenAI authorize endpoint (when `open_browser=True`; the URL is always printed as a fallback), exchanges the returned code for tokens, and - persists them via `FileChatGPTOAuthTokenProvider`. + persists them via `_FileChatGPTOAuthTokenProvider`. Args: store_path: Where to persist the token. Defaults to @@ -870,8 +879,8 @@ def login_chatgpt( timeout: Seconds to wait for the callback. Returns: - A `FileChatGPTOAuthTokenProvider` ready for use by - `ChatOpenAICodex`. + A `_FileChatGPTOAuthTokenProvider` ready for use by + `_ChatOpenAICodex`. Raises: ValueError: `host` is not a loopback address. @@ -940,7 +949,7 @@ def login_chatgpt( }, ) token = _token_from_response(response) - provider = FileChatGPTOAuthTokenProvider( + provider = _FileChatGPTOAuthTokenProvider( path=store_path or DEFAULT_STORE_PATH, client_id=client_id ) provider.save(token) @@ -953,7 +962,7 @@ def login_chatgpt_device( client_id: str = CHATGPT_CLIENT_ID, poll_interval: float = 5.0, timeout: float = 600.0, -) -> FileChatGPTOAuthTokenProvider: +) -> _FileChatGPTOAuthTokenProvider: """Run the ChatGPT device-code OAuth flow. This is the headless fallback for environments without a browser. The @@ -969,7 +978,7 @@ def login_chatgpt_device( timeout: Total seconds to wait. Returns: - A configured `FileChatGPTOAuthTokenProvider`. + A configured `_FileChatGPTOAuthTokenProvider`. Raises: RuntimeError: The device-code response was missing required fields, or @@ -1037,7 +1046,7 @@ def login_chatgpt_device( }, ) token = _token_from_response(response) - provider = FileChatGPTOAuthTokenProvider( + provider = _FileChatGPTOAuthTokenProvider( path=store_path or DEFAULT_STORE_PATH, client_id=client_id ) provider.save(token) @@ -1048,10 +1057,6 @@ __all__ = [ "CHATGPT_AUTHORIZE_URL", "CHATGPT_CLIENT_ID", "CHATGPT_TOKEN_URL", - "ChatGPTOAuthRefreshError", - "ChatGPTOAuthTokenProvider", - "ChatGPTToken", - "FileChatGPTOAuthTokenProvider", "decode_jwt_claims", "login_chatgpt", "login_chatgpt_device", diff --git a/libs/partners/openai/scripts/RECORD_CODEX_CASSETTES.md b/libs/partners/openai/scripts/RECORD_CODEX_CASSETTES.md index 3dba9ee5f9c..9de2234a0b9 100644 --- a/libs/partners/openai/scripts/RECORD_CODEX_CASSETTES.md +++ b/libs/partners/openai/scripts/RECORD_CODEX_CASSETTES.md @@ -1,11 +1,18 @@ -# Recording `ChatOpenAICodex` VCR cassettes +# Recording `_ChatOpenAICodex` VCR cassettes -`ChatOpenAICodex` authenticates with a ChatGPT subscription OAuth bundle, so +`_ChatOpenAICodex` authenticates with a ChatGPT subscription OAuth bundle, so its integration tests cannot run in PR CI without a live login. The workflow below records cassettes once locally with a real token, scrubs OAuth secrets before they hit disk, and commits the cassettes so CI replays them through `_test_vcr.yml` (the `vcr-tests` job in `check_diffs.yml`). +> [!WARNING] +> `_ChatOpenAICodex` is experimental and unofficial. Use it only where your +> OpenAI account, workspace, plan, and applicable OpenAI terms permit +> ChatGPT-authenticated Codex access. You are responsible for ensuring your +> implementation complies with OpenAI's terms, usage policies, account +> restrictions, rate limits, and safeguards. + ## Prerequisites 1. A ChatGPT subscription (Plus / Pro / Team / Enterprise) — required for @@ -20,7 +27,7 @@ before they hit disk, and commits the cassettes so CI replays them through The default store is `~/.langchain/chatgpt-auth.json`. It is intentionally distinct from `~/.codex/auth.json` so the Codex CLI / VS Code session is not invalidated by refresh-token rotation here. -3. An integration test that instantiates `ChatOpenAICodex` and is marked +3. An integration test that instantiates `_ChatOpenAICodex` and is marked `@pytest.mark.vcr`. Tests written against the API-key `ChatOpenAI` will not exercise the Codex backend — only Codex-specific tests should be passed to the script. diff --git a/libs/partners/openai/tests/integration_tests/chat_models/conftest.py b/libs/partners/openai/tests/integration_tests/chat_models/conftest.py index b4b31653920..17f66be2048 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/conftest.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/conftest.py @@ -1,7 +1,7 @@ """Shared fixtures for chat-model integration tests. -The `ChatOpenAICodex` integration tests run under VCR cassette playback in -CI (`make test_vcr`), but its `FileChatGPTOAuthTokenProvider` still tries +The `_ChatOpenAICodex` integration tests run under VCR cassette playback in +CI (`make test_vcr`), but its `_FileChatGPTOAuthTokenProvider` still tries to read `~/.langchain/chatgpt-auth.json` from disk on every request to build the `Authorization` header. CI has no such file, so every Codex test would fail with `FileNotFoundError` before VCR ever replays the @@ -65,13 +65,13 @@ def _vcr_record_mode(config: pytest.Config) -> str | None: def _fake_codex_oauth_token( request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch ) -> None: - """Stub `FileChatGPTOAuthTokenProvider` token reads for Codex VCR tests.""" + """Stub `_FileChatGPTOAuthTokenProvider` token reads for Codex VCR tests.""" if "codex" not in request.module.__name__: return if _vcr_record_mode(request.config) != "none": return - fake_token = chatgpt_oauth.ChatGPTToken( + fake_token = chatgpt_oauth._ChatGPTToken( access_token="vcr-fake-access-token", # noqa: S106 refresh_token="vcr-fake-refresh-token", # noqa: S106 expires_at=datetime.now(timezone.utc) + timedelta(hours=1), @@ -79,38 +79,38 @@ def _fake_codex_oauth_token( ) def _get_token( - self: chatgpt_oauth.FileChatGPTOAuthTokenProvider, - ) -> chatgpt_oauth.ChatGPTToken: + self: chatgpt_oauth._FileChatGPTOAuthTokenProvider, + ) -> chatgpt_oauth._ChatGPTToken: return fake_token async def _aget_token( - self: chatgpt_oauth.FileChatGPTOAuthTokenProvider, - ) -> chatgpt_oauth.ChatGPTToken: + self: chatgpt_oauth._FileChatGPTOAuthTokenProvider, + ) -> chatgpt_oauth._ChatGPTToken: return fake_token def _get_access_token( - self: chatgpt_oauth.FileChatGPTOAuthTokenProvider, + self: chatgpt_oauth._FileChatGPTOAuthTokenProvider, ) -> str: return fake_token.access_token async def _aget_access_token( - self: chatgpt_oauth.FileChatGPTOAuthTokenProvider, + self: chatgpt_oauth._FileChatGPTOAuthTokenProvider, ) -> str: return fake_token.access_token monkeypatch.setattr( - chatgpt_oauth.FileChatGPTOAuthTokenProvider, "get_token", _get_token + chatgpt_oauth._FileChatGPTOAuthTokenProvider, "get_token", _get_token ) monkeypatch.setattr( - chatgpt_oauth.FileChatGPTOAuthTokenProvider, "aget_token", _aget_token + chatgpt_oauth._FileChatGPTOAuthTokenProvider, "aget_token", _aget_token ) monkeypatch.setattr( - chatgpt_oauth.FileChatGPTOAuthTokenProvider, + chatgpt_oauth._FileChatGPTOAuthTokenProvider, "get_access_token", _get_access_token, ) monkeypatch.setattr( - chatgpt_oauth.FileChatGPTOAuthTokenProvider, + chatgpt_oauth._FileChatGPTOAuthTokenProvider, "aget_access_token", _aget_access_token, ) diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_codex.py b/libs/partners/openai/tests/integration_tests/chat_models/test_codex.py index 94a49c3f620..f7a0cbd93b0 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_codex.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_codex.py @@ -1,11 +1,11 @@ -"""Integration tests for `ChatOpenAICodex`. +"""Integration tests for `_ChatOpenAICodex`. These tests exercise the ChatGPT subscription OAuth path against the `https://chatgpt.com/backend-api/codex` endpoint. They are recorded with VCR (cassettes live alongside, under `tests/cassettes/`) so CI replays them in `--record-mode=none` without a live token. -`ChatOpenAICodex` forces `use_responses_api=True`, `store=False`, and +`_ChatOpenAICodex` forces `use_responses_api=True`, `store=False`, and `streaming=True` at the wire level (`output_version` is a client-side projection and is *not* forced). The cassettes here are recorded with a single `output_version` for stability; per-projection coverage already @@ -35,7 +35,8 @@ from langchain_core.tools import tool from pydantic import BaseModel from typing_extensions import TypedDict -from langchain_openai import ChatOpenAICodex, custom_tool +from langchain_openai import custom_tool +from langchain_openai.chat_models.codex import _ChatOpenAICodex if TYPE_CHECKING: from collections.abc import AsyncIterator, Awaitable, Iterator @@ -101,7 +102,7 @@ async def _aaggregate(stream: AsyncIterator[BaseMessage]) -> AIMessageChunk: def test_codex_invoke() -> None: - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) response = llm.invoke("Say hi in one word.") _check_response(response) @@ -109,12 +110,12 @@ def test_codex_invoke() -> None: def test_codex_invoke_lifts_system_message_into_instructions() -> None: """`SystemMessage` content is lifted into top-level `instructions`. - Codex rejects `SystemMessage` chat turns; `ChatOpenAICodex` works + Codex rejects `SystemMessage` chat turns; `_ChatOpenAICodex` works around this by moving the `SystemMessage` content into the `instructions` field and stripping it from the input list. The model should respect the lifted instruction (here: respond with HELLO). """ - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) response = llm.invoke( [ SystemMessage("Respond with exactly one word: HELLO. No punctuation."), @@ -127,7 +128,7 @@ def test_codex_invoke_lifts_system_message_into_instructions() -> None: def test_codex_invoke_with_instructions_override() -> None: """Per-call `instructions=` overrides the constructor value for one call.""" - llm = ChatOpenAICodex( + llm = _ChatOpenAICodex( model=MODEL_NAME, instructions="You are an English assistant." ) response = llm.invoke( @@ -141,30 +142,30 @@ def test_codex_invoke_with_instructions_override() -> None: async def test_codex_invoke_async() -> None: - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) response = await llm.ainvoke("Say hi in one word.") _check_response(response) def test_codex_stream() -> None: - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) _check_response(_aggregate(llm.stream("Count to three."))) async def test_codex_stream_async() -> None: - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) _check_response(await _aaggregate(llm.astream("Count to three."))) def test_codex_stream_events_v3() -> None: - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) stream = cast("ChatModelStream", llm.stream_events("Count to three.", version="v3")) response = stream.output _check_response(response) async def test_codex_stream_events_v3_async() -> None: - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) stream = await cast( "Awaitable[AsyncChatModelStream]", llm.astream_events("Count to three.", version="v3"), @@ -180,7 +181,7 @@ async def test_codex_stream_events_v3_async() -> None: def test_codex_multi_turn_no_tools() -> None: """Pass full chat history (the backend is stateless for this client).""" - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) first = llm.invoke("My name is Bobo.") assert isinstance(first, AIMessage) second = llm.invoke( @@ -205,7 +206,7 @@ def test_codex_function_calling() -> None: """Return x * y.""" return x * y - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) bound = llm.bind_tools([multiply]) ai_msg = cast(AIMessage, bound.invoke("What is 5 times 4?")) @@ -227,7 +228,7 @@ def test_codex_agent_loop() -> None: """Get the weather for a location.""" return "It's sunny." - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) bound = llm.bind_tools([get_weather]) user_msg = HumanMessage("What is the weather in San Francisco, CA?") @@ -247,7 +248,7 @@ def test_codex_agent_loop_streaming() -> None: """Get the weather for a location.""" return "It's sunny." - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) bound = llm.bind_tools([get_weather]) user_msg = HumanMessage("What is the weather in San Francisco, CA?") @@ -266,9 +267,9 @@ def test_codex_custom_tool() -> None: """Execute Python code and return the result.""" return "27" - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS).bind_tools( - [execute_code] - ) + llm = _ChatOpenAICodex( + model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS + ).bind_tools([execute_code]) input_message = { "role": "user", @@ -288,7 +289,7 @@ def test_codex_custom_tool() -> None: def test_codex_reasoning() -> None: """`reasoning={'effort': 'low'}` produces a reasoning block in content.""" - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) response = llm.invoke("What is 2 + 2?", reasoning={"effort": "low"}) assert isinstance(response, AIMessage) block_types = [ @@ -299,7 +300,7 @@ def test_codex_reasoning() -> None: def test_codex_reasoning_summary_streaming() -> None: """`reasoning.summary='auto'` carries a populated summary list.""" - llm = ChatOpenAICodex( + llm = _ChatOpenAICodex( model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS, reasoning={"effort": "medium", "summary": "auto"}, @@ -347,7 +348,7 @@ class FooDict(TypedDict): def test_codex_structured_output_pydantic() -> None: - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) response = llm.invoke("Say hi.", response_format=Foo) parsed = Foo(**json.loads(response.text)) assert parsed == response.additional_kwargs["parsed"] @@ -355,7 +356,7 @@ def test_codex_structured_output_pydantic() -> None: def test_codex_structured_output_typed_dict() -> None: - llm = ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) + llm = _ChatOpenAICodex(model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS) response = llm.invoke("Say hi.", response_format=FooDict) parsed = json.loads(response.text) assert parsed == response.additional_kwargs["parsed"] diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_codex_standard.py b/libs/partners/openai/tests/integration_tests/chat_models/test_codex_standard.py index 069612d0237..853b8020e11 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_codex_standard.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_codex_standard.py @@ -1,4 +1,4 @@ -"""Standard LangChain interface tests for `ChatOpenAICodex`. +"""Standard LangChain interface tests for `_ChatOpenAICodex`. Drives the full `ChatModelIntegrationTests` suite against the Codex backend. The module-level `pytestmark = pytest.mark.vcr` makes every @@ -23,7 +23,7 @@ from langchain_core.language_models import BaseChatModel from langchain_core.messages import AIMessage from langchain_tests.integration_tests import ChatModelIntegrationTests -from langchain_openai import ChatOpenAICodex +from langchain_openai.chat_models.codex import _ChatOpenAICodex pytestmark = pytest.mark.vcr @@ -42,7 +42,7 @@ class TestChatOpenAICodexStandard(ChatModelIntegrationTests): @property def chat_model_class(self) -> type[BaseChatModel]: - return ChatOpenAICodex + return _ChatOpenAICodex @property def chat_model_params(self) -> dict: @@ -120,7 +120,7 @@ class TestChatOpenAICodexStandard(ChatModelIntegrationTests): # -- Helpers used by the shared suite --------------------------------- def invoke_with_reasoning_output(self, *, stream: bool = False) -> AIMessage: - llm = ChatOpenAICodex( + llm = _ChatOpenAICodex( model=MODEL_NAME, instructions=TERSE_INSTRUCTIONS, reasoning={"effort": "medium", "summary": "auto"}, @@ -148,7 +148,7 @@ class TestChatOpenAICodexStandard(ChatModelIntegrationTests): super().test_structured_few_shot_examples(model, my_adder_tool) -def _invoke(llm: ChatOpenAICodex, prompt: str, stream: bool) -> AIMessage: +def _invoke(llm: _ChatOpenAICodex, prompt: str, stream: bool) -> AIMessage: if stream: full = None for chunk in llm.stream(prompt): diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_codex.py b/libs/partners/openai/tests/unit_tests/chat_models/test_codex.py index 73bc8d9ee0d..c98cb808c9e 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_codex.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_codex.py @@ -1,4 +1,4 @@ -"""Unit tests for `ChatOpenAICodex`.""" +"""Unit tests for `_ChatOpenAICodex`.""" # ruff: noqa: S106, S107 from __future__ import annotations @@ -9,22 +9,24 @@ from typing import Any import pytest from langchain_core.messages import ChatMessage, HumanMessage, SystemMessage -from langchain_openai import ChatOpenAICodex +import langchain_openai.chat_models.codex as codex_module from langchain_openai.chat_models.base import ChatOpenAI from langchain_openai.chat_models.codex import ( ACCOUNT_ID_HEADER, CHATGPT_CODEX_BASE_URL, DEFAULT_INSTRUCTIONS, + EXPERIMENTAL_UNOFFICIAL_WARNING, ORIGINATOR_ENV_VAR, ORIGINATOR_HEADER, ORIGINATOR_VALUE, + _ChatOpenAICodex, _SyncTokenCallable, ) -from langchain_openai.chatgpt_oauth import ChatGPTToken +from langchain_openai.chatgpt_oauth import _ChatGPTToken class FakeTokenProvider: - """Minimal `ChatGPTOAuthTokenProvider` for tests.""" + """Minimal `_ChatGPTOAuthTokenProvider` for tests.""" def __init__( self, @@ -36,16 +38,16 @@ class FakeTokenProvider: self.calls = 0 self.async_calls = 0 - def get_token(self) -> ChatGPTToken: + def get_token(self) -> _ChatGPTToken: self.calls += 1 - return ChatGPTToken( + return _ChatGPTToken( access_token=self.access_token, refresh_token="rt", expires_at=datetime.now(timezone.utc) + timedelta(hours=1), account_id=self.account_id, ) - async def aget_token(self) -> ChatGPTToken: + async def aget_token(self) -> _ChatGPTToken: self.async_calls += 1 return self.get_token() @@ -57,15 +59,25 @@ class FakeTokenProvider: return token.access_token -def _build_model(**overrides: Any) -> ChatOpenAICodex: +def _build_model(**overrides: Any) -> _ChatOpenAICodex: provider = overrides.pop("token_provider", None) or FakeTokenProvider() - return ChatOpenAICodex( + return _ChatOpenAICodex( model=overrides.pop("model", "gpt-5.2-codex"), token_provider=provider, **overrides, ) +def test_experimental_unofficial_warning_is_emitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(codex_module, "_experimental_warning_emitted", False) + with pytest.warns(UserWarning, match="experimental and unofficial") as warning: + _build_model() + assert warning[0].filename == __file__ + assert "applicable OpenAI terms" in EXPERIMENTAL_UNOFFICIAL_WARNING + + def test_defaults_route_to_chatgpt_codex_backend() -> None: model = _build_model() assert model.openai_api_base == CHATGPT_CODEX_BASE_URL @@ -203,12 +215,12 @@ def test_request_payload_pulls_fresh_account_id_each_call() -> None: def test_invalid_token_provider_rejected() -> None: with pytest.raises(TypeError): - ChatOpenAICodex(model="gpt-5.2-codex", token_provider="not-a-provider") + _ChatOpenAICodex(model="gpt-5.2-codex", token_provider="not-a-provider") def test_conflicting_use_responses_api_raises() -> None: with pytest.raises(ValueError, match="use_responses_api"): - ChatOpenAICodex( + _ChatOpenAICodex( model="gpt-5.2-codex", token_provider=FakeTokenProvider(), use_responses_api=False, @@ -223,7 +235,7 @@ def test_explicit_output_version_is_respected(output_version: str) -> None: choice, `output_version` never appears in the outbound payload (it's consumed entirely by the response projection layer). """ - model = ChatOpenAICodex( + model = _ChatOpenAICodex( model="gpt-5.2-codex", token_provider=FakeTokenProvider(), output_version=output_version, @@ -235,7 +247,7 @@ def test_explicit_output_version_is_respected(output_version: str) -> None: def test_conflicting_store_raises() -> None: with pytest.raises(ValueError, match="store"): - ChatOpenAICodex( + _ChatOpenAICodex( model="gpt-5.2-codex", token_provider=FakeTokenProvider(), store=True, @@ -244,7 +256,7 @@ def test_conflicting_store_raises() -> None: def test_conflicting_streaming_raises() -> None: with pytest.raises(ValueError, match="streaming"): - ChatOpenAICodex( + _ChatOpenAICodex( model="gpt-5.2-codex", token_provider=FakeTokenProvider(), streaming=False, @@ -296,7 +308,7 @@ def test_system_message_is_lifted_into_top_level_instructions() -> None: """`SystemMessage` content overrides the constructor `instructions`. Codex rejects `SystemMessage` chat turns (400 "System messages are - not allowed"), so `ChatOpenAICodex` lifts their content into the + not allowed"), so `_ChatOpenAICodex` lifts their content into the top-level `instructions` field and strips them from the input list. """ model = _build_model(instructions="model-level") @@ -527,7 +539,7 @@ def test_ls_params_uses_codex_provider_tag() -> None: def test_is_not_serializable_due_to_live_token_provider() -> None: - assert ChatOpenAICodex.is_lc_serializable() is False + assert _ChatOpenAICodex.is_lc_serializable() is False def test_sync_token_callable_delegates() -> None: diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_imports.py b/libs/partners/openai/tests/unit_tests/chat_models/test_imports.py index e622bfdbe02..ef3ae2fb3e8 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_imports.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_imports.py @@ -1,6 +1,6 @@ from langchain_openai.chat_models import __all__ -EXPECTED_ALL = ["ChatOpenAI", "AzureChatOpenAI", "ChatOpenAICodex"] +EXPECTED_ALL = ["ChatOpenAI", "AzureChatOpenAI"] def test_all_imports() -> None: diff --git a/libs/partners/openai/tests/unit_tests/test_chatgpt_oauth.py b/libs/partners/openai/tests/unit_tests/test_chatgpt_oauth.py index a7e671b7dd7..de6e85f0d75 100644 --- a/libs/partners/openai/tests/unit_tests/test_chatgpt_oauth.py +++ b/libs/partners/openai/tests/unit_tests/test_chatgpt_oauth.py @@ -20,11 +20,11 @@ from langchain_openai import chatgpt_oauth as oauth_module from langchain_openai.chatgpt_oauth import ( CHATGPT_AUTH_CLAIMS_NAMESPACE, CHATGPT_TOKEN_URL, - ChatGPTOAuthRefreshError, - ChatGPTToken, - FileChatGPTOAuthTokenProvider, _build_authorize_url, _CallbackHandler, + _ChatGPTOAuthRefreshError, + _ChatGPTToken, + _FileChatGPTOAuthTokenProvider, _generate_pkce_pair, _serialize_token, _token_from_response, @@ -103,7 +103,7 @@ def test_token_from_response_extracts_claims_and_falls_back_to_existing_refresh( def test_token_is_expired_uses_skew() -> None: now = datetime.now(timezone.utc) - token = ChatGPTToken( + token = _ChatGPTToken( access_token="x", refresh_token="y", expires_at=now + timedelta(minutes=1), @@ -114,8 +114,8 @@ def test_token_is_expired_uses_skew() -> None: def test_file_provider_persists_token_with_private_perms(tmp_path: Path) -> None: store = tmp_path / "chatgpt-auth.json" - provider = FileChatGPTOAuthTokenProvider(path=store) - token = ChatGPTToken( + provider = _FileChatGPTOAuthTokenProvider(path=store) + token = _ChatGPTToken( access_token="at", refresh_token="rt", expires_at=datetime.now(timezone.utc) + timedelta(hours=1), @@ -133,7 +133,7 @@ def test_file_provider_persists_token_with_private_perms(tmp_path: Path) -> None assert raw["access_token"] == "at" assert raw["account_id"] == "acct-1" - fresh = FileChatGPTOAuthTokenProvider(path=store) + fresh = _FileChatGPTOAuthTokenProvider(path=store) reloaded = fresh.get_token() assert reloaded.access_token == "at" assert reloaded.account_id == "acct-1" @@ -144,8 +144,8 @@ def test_file_provider_get_token_does_not_refresh_when_valid( monkeypatch: pytest.MonkeyPatch, ) -> None: store = tmp_path / "auth.json" - provider = FileChatGPTOAuthTokenProvider(path=store) - valid_token = ChatGPTToken( + provider = _FileChatGPTOAuthTokenProvider(path=store) + valid_token = _ChatGPTToken( access_token="at", refresh_token="rt", expires_at=datetime.now(timezone.utc) + timedelta(hours=1), @@ -166,8 +166,8 @@ def test_file_provider_get_token_refreshes_when_expired( monkeypatch: pytest.MonkeyPatch, ) -> None: store = tmp_path / "auth.json" - provider = FileChatGPTOAuthTokenProvider(path=store) - expired = ChatGPTToken( + provider = _FileChatGPTOAuthTokenProvider(path=store) + expired = _ChatGPTToken( access_token="old-at", refresh_token="old-rt", expires_at=datetime.now(timezone.utc) - timedelta(minutes=10), @@ -210,19 +210,19 @@ def test_file_provider_reloads_expired_cached_token_before_refresh( monkeypatch: pytest.MonkeyPatch, ) -> None: store = tmp_path / "auth.json" - provider = FileChatGPTOAuthTokenProvider(path=store) - expired = ChatGPTToken( + provider = _FileChatGPTOAuthTokenProvider(path=store) + expired = _ChatGPTToken( access_token="old-at", refresh_token="old-rt", expires_at=datetime.now(timezone.utc) - timedelta(minutes=10), ) provider.save(expired) - rotated = ChatGPTToken( + rotated = _ChatGPTToken( access_token="rotated-at", refresh_token="rotated-rt", expires_at=datetime.now(timezone.utc) + timedelta(hours=1), ) - FileChatGPTOAuthTokenProvider(path=store).save(rotated) + _FileChatGPTOAuthTokenProvider(path=store).save(rotated) def _explode(*args: Any, **kwargs: Any) -> dict[str, Any]: msg = "should use disk token instead of refreshing stale cache" @@ -236,13 +236,13 @@ def test_file_provider_reloads_expired_cached_token_before_refresh( def test_file_provider_raises_when_no_token_exists(tmp_path: Path) -> None: - provider = FileChatGPTOAuthTokenProvider(path=tmp_path / "missing.json") + provider = _FileChatGPTOAuthTokenProvider(path=tmp_path / "missing.json") with pytest.raises(FileNotFoundError): provider.get_token() def test_serialize_roundtrip_preserves_fields() -> None: - token = ChatGPTToken( + token = _ChatGPTToken( access_token="a", refresh_token="b", expires_at=datetime(2030, 1, 1, tzinfo=timezone.utc), @@ -286,7 +286,7 @@ def test_pkce_pair_challenge_is_s256_of_verifier() -> None: def test_chatgpt_token_repr_does_not_leak_secrets() -> None: - token = ChatGPTToken( + token = _ChatGPTToken( access_token="super-secret-at", refresh_token="super-secret-rt", expires_at=datetime.now(timezone.utc) + timedelta(hours=1), @@ -302,19 +302,19 @@ def test_chatgpt_token_repr_does_not_leak_secrets() -> None: def test_chatgpt_token_rejects_empty_or_naive_fields() -> None: with pytest.raises(ValueError, match="access_token"): - ChatGPTToken( + _ChatGPTToken( access_token="", refresh_token="rt", expires_at=datetime.now(timezone.utc), ) with pytest.raises(ValueError, match="refresh_token"): - ChatGPTToken( + _ChatGPTToken( access_token="at", refresh_token="", expires_at=datetime.now(timezone.utc), ) with pytest.raises(ValueError, match="timezone-aware"): - ChatGPTToken( + _ChatGPTToken( access_token="at", refresh_token="rt", expires_at=datetime(2030, 1, 1), # noqa: DTZ001 @@ -327,7 +327,7 @@ def test_chatgpt_token_is_frozen() -> None: Providers cache and share a single instance, so post-construction mutation (which would bypass `__post_init__`) must be impossible. """ - token = ChatGPTToken( + token = _ChatGPTToken( access_token="at", refresh_token="rt", expires_at=datetime.now(timezone.utc) + timedelta(hours=1), @@ -362,7 +362,7 @@ def test_login_chatgpt_rejects_non_loopback_host(tmp_path: Path) -> None: def test_token_from_response_raises_on_missing_expires_in() -> None: - with pytest.raises(ChatGPTOAuthRefreshError, match="expires_in"): + with pytest.raises(_ChatGPTOAuthRefreshError, match="expires_in"): _token_from_response( {"access_token": "a", "refresh_token": "b"}, fallback_refresh_token=None, @@ -370,7 +370,7 @@ def test_token_from_response_raises_on_missing_expires_in() -> None: def test_token_from_response_raises_on_missing_refresh_token() -> None: - with pytest.raises(ChatGPTOAuthRefreshError, match="refresh_token"): + with pytest.raises(_ChatGPTOAuthRefreshError, match="refresh_token"): _token_from_response( {"access_token": "a", "expires_in": 3600}, fallback_refresh_token=None, @@ -378,7 +378,7 @@ def test_token_from_response_raises_on_missing_refresh_token() -> None: def test_token_from_response_raises_on_missing_access_token() -> None: - with pytest.raises(ChatGPTOAuthRefreshError, match="access_token"): + with pytest.raises(_ChatGPTOAuthRefreshError, match="access_token"): _token_from_response( {"expires_in": 3600, "refresh_token": "rt"}, fallback_refresh_token=None, @@ -388,7 +388,7 @@ def test_token_from_response_raises_on_missing_access_token() -> None: def test_corrupt_token_store_raises_actionable_error(tmp_path: Path) -> None: store = tmp_path / "auth.json" store.write_text("{not valid json") - provider = FileChatGPTOAuthTokenProvider(path=store) + provider = _FileChatGPTOAuthTokenProvider(path=store) with pytest.raises(RuntimeError, match="not valid JSON"): provider.get_token() @@ -396,7 +396,7 @@ def test_corrupt_token_store_raises_actionable_error(tmp_path: Path) -> None: def test_missing_expires_at_in_store_raises_actionable_error(tmp_path: Path) -> None: store = tmp_path / "auth.json" store.write_text(json.dumps({"access_token": "at", "refresh_token": "rt"})) - provider = FileChatGPTOAuthTokenProvider(path=store) + provider = _FileChatGPTOAuthTokenProvider(path=store) with pytest.raises(RuntimeError, match="missing required"): provider.get_token() @@ -406,9 +406,9 @@ def test_invalid_grant_refresh_raises_typed_error( monkeypatch: pytest.MonkeyPatch, ) -> None: store = tmp_path / "auth.json" - provider = FileChatGPTOAuthTokenProvider(path=store) + provider = _FileChatGPTOAuthTokenProvider(path=store) provider.save( - ChatGPTToken( + _ChatGPTToken( access_token="old-at", refresh_token="old-rt", expires_at=datetime.now(timezone.utc) - timedelta(minutes=10), @@ -417,10 +417,10 @@ def test_invalid_grant_refresh_raises_typed_error( def _fake_post(*_: Any, **__: Any) -> dict[str, Any]: msg = "ChatGPT refresh token is no longer valid (`invalid_grant`)." - raise ChatGPTOAuthRefreshError(msg) + raise _ChatGPTOAuthRefreshError(msg) monkeypatch.setattr("langchain_openai.chatgpt_oauth._post_form", _fake_post) - with pytest.raises(ChatGPTOAuthRefreshError, match="invalid_grant"): + with pytest.raises(_ChatGPTOAuthRefreshError, match="invalid_grant"): provider.get_token() # The on-disk token must be preserved so a follow-up `login_chatgpt()` # is the only thing needed. @@ -434,9 +434,9 @@ def test_refresh_failure_preserves_stored_token( monkeypatch: pytest.MonkeyPatch, ) -> None: store = tmp_path / "auth.json" - provider = FileChatGPTOAuthTokenProvider(path=store) + provider = _FileChatGPTOAuthTokenProvider(path=store) provider.save( - ChatGPTToken( + _ChatGPTToken( access_token="keep-at", refresh_token="keep-rt", expires_at=datetime.now(timezone.utc) - timedelta(minutes=1), @@ -460,9 +460,9 @@ def test_aget_token_refreshes_when_expired( monkeypatch: pytest.MonkeyPatch, ) -> None: store = tmp_path / "auth.json" - provider = FileChatGPTOAuthTokenProvider(path=store) + provider = _FileChatGPTOAuthTokenProvider(path=store) provider.save( - ChatGPTToken( + _ChatGPTToken( access_token="old-at", refresh_token="old-rt", expires_at=datetime.now(timezone.utc) - timedelta(minutes=1), @@ -487,9 +487,9 @@ def test_aget_access_token_returns_access_string( monkeypatch: pytest.MonkeyPatch, ) -> None: store = tmp_path / "auth.json" - provider = FileChatGPTOAuthTokenProvider(path=store) + provider = _FileChatGPTOAuthTokenProvider(path=store) provider.save( - ChatGPTToken( + _ChatGPTToken( access_token="at-x", refresh_token="rt", expires_at=datetime.now(timezone.utc) + timedelta(hours=1), @@ -517,7 +517,7 @@ def test_token_is_expired_uses_skew_with_frozen_clock( return frozen if tz is None else frozen.astimezone(tz) monkeypatch.setattr("langchain_openai.chatgpt_oauth.datetime", _FrozenDatetime) - token = ChatGPTToken( + token = _ChatGPTToken( access_token="x", refresh_token="y", expires_at=frozen + timedelta(minutes=1), @@ -540,7 +540,7 @@ def test_raise_for_oauth_response_detects_invalid_grant() -> None: resp = _make_response( 400, {"error": "invalid_grant", "error_description": "revoked"} ) - with pytest.raises(ChatGPTOAuthRefreshError, match="invalid_grant"): + with pytest.raises(_ChatGPTOAuthRefreshError, match="invalid_grant"): _raise_for_oauth_response(CHATGPT_TOKEN_URL, resp) diff --git a/libs/partners/openai/tests/unit_tests/test_imports.py b/libs/partners/openai/tests/unit_tests/test_imports.py index d21b6ba9dba..59994381974 100644 --- a/libs/partners/openai/tests/unit_tests/test_imports.py +++ b/libs/partners/openai/tests/unit_tests/test_imports.py @@ -4,7 +4,6 @@ EXPECTED_ALL = [ "__version__", "OpenAI", "ChatOpenAI", - "ChatOpenAICodex", "OpenAIEmbeddings", "AzureOpenAI", "AzureChatOpenAI",