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
This commit is contained in:
Mason Daugherty
2026-06-12 22:19:40 -04:00
committed by GitHub
parent 34af8839c3
commit bf7b0180f2
12 changed files with 238 additions and 182 deletions

View File

@@ -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",

View File

@@ -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"]

View File

@@ -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] = []

View File

@@ -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",

View File

@@ -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.

View File

@@ -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,
)

View File

@@ -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"]

View File

@@ -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):

View File

@@ -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:

View File

@@ -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:

View File

@@ -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)

View File

@@ -4,7 +4,6 @@ EXPECTED_ALL = [
"__version__",
"OpenAI",
"ChatOpenAI",
"ChatOpenAICodex",
"OpenAIEmbeddings",
"AzureOpenAI",
"AzureChatOpenAI",