mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-01 14:47:02 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ EXPECTED_ALL = [
|
||||
"__version__",
|
||||
"OpenAI",
|
||||
"ChatOpenAI",
|
||||
"ChatOpenAICodex",
|
||||
"OpenAIEmbeddings",
|
||||
"AzureOpenAI",
|
||||
"AzureChatOpenAI",
|
||||
|
||||
Reference in New Issue
Block a user