zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1 @@
1
+ """Integrations for external systems used by the Deep Agents Code."""
@@ -0,0 +1,551 @@
1
+ """ChatGPT OAuth integration for the `openai_codex` model provider.
2
+
3
+ Thin orchestration layer over `langchain_openai.chatgpt_oauth`. Reuses the
4
+ upstream PKCE/token primitives directly (`_generate_pkce_pair`,
5
+ `_build_authorize_url`, `_CallbackHandler`, `_post_form`,
6
+ `_token_from_response`, `_FileChatGPTOAuthTokenProvider`) so this module only
7
+ adds:
8
+
9
+ - a UI-friendly entry point that surfaces the authorize URL to the caller
10
+ *before* the callback server starts blocking (the upstream `login_chatgpt`
11
+ uses `print()`, which is invisible inside a Textual app),
12
+ - a cancel-aware reimplementation of the loopback callback wait so a
13
+ cancelled sign-in releases the bound port promptly instead of holding it
14
+ until the upstream wall-clock timeout elapses (see
15
+ `_wait_for_oauth_callback`), and
16
+ - helpers for `/auth` to read sign-in status, expiry, and the linked account
17
+ without re-implementing token parsing.
18
+
19
+ The browser-loopback flow mirrors the MCP one in `mcp_auth` from the user's
20
+ POV — PKCE + `state` CSRF + browser launch with a manual-URL fallback — but
21
+ the callback server here is upstream's single-threaded polling
22
+ `http.server.HTTPServer` (reused via `_CallbackHandler`), not the
23
+ `ThreadingHTTPServer` that `mcp_auth` runs itself. The OAuth-specific parts
24
+ (token exchange, refresh, file storage with 0600 perms) are delegated to
25
+ upstream.
26
+
27
+ !!! note
28
+
29
+ The OAuth primitives are reused from `langchain-openai>=1.3.1`, which is
30
+ the first release to ship `langchain_openai.chatgpt_oauth`. This module
31
+ deliberately reaches into upstream-internal (`_`-prefixed) helpers; their
32
+ stability is an upstream concern, so pin the minimum `langchain-openai`
33
+ version when bumping and re-check the imported names against the release.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import secrets
40
+ import webbrowser
41
+ from dataclasses import dataclass
42
+ from typing import TYPE_CHECKING, Any
43
+
44
+ if TYPE_CHECKING:
45
+ import threading
46
+ from datetime import datetime
47
+ from pathlib import Path
48
+
49
+ from langchain_core.language_models import BaseChatModel
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class CodexAuthStatus:
56
+ """Snapshot of the ChatGPT OAuth login state.
57
+
58
+ Attributes:
59
+ logged_in: Whether a usable token bundle exists on disk.
60
+ store_path: Path to the token store (present whether or not the
61
+ token exists, so the UI can show "no token yet at <path>").
62
+ account_id: ChatGPT account ID parsed from the ID token, when
63
+ available.
64
+ plan_type: ChatGPT plan tier (e.g. `plus`, `pro`), when available.
65
+ expires_at: Token expiry (UTC). `None` if no token is stored.
66
+ is_expired: Whether the stored token is past expiry *or within the
67
+ refresh skew window of it* (upstream's `_ChatGPTToken.is_expired`
68
+ uses a 5-minute skew). Computed once at snapshot time, so it does
69
+ not track the clock — re-fetch for a live check. Cheap users of
70
+ this struct (e.g. switcher labels) often only need `logged_in`;
71
+ this field lets the manager surface a "token expired" warning
72
+ explicitly.
73
+ unreadable_reason: Set when the token file exists but cannot be
74
+ parsed. Surfaces corruption to the UI without crashing
75
+ credential listing.
76
+ """
77
+
78
+ logged_in: bool
79
+ store_path: Path
80
+ account_id: str | None = None
81
+ plan_type: str | None = None
82
+ expires_at: datetime | None = None
83
+ is_expired: bool = False
84
+ unreadable_reason: str | None = None
85
+
86
+ def __post_init__(self) -> None:
87
+ """Reject incoherent snapshots the factory should never build.
88
+
89
+ The valid/expired/missing/unreadable states are encoded as a
90
+ `bool` plus optionals rather than a tagged union, so this guards the
91
+ cross-field rules the attribute docs promise — catching future
92
+ construction drift the same way upstream's `_ChatGPTToken` guards
93
+ its own invariants.
94
+
95
+ Raises:
96
+ ValueError: An unreadable token is also marked `logged_in`; a
97
+ logged-out snapshot carries an `expires_at`, `is_expired`,
98
+ `account_id`, or `plan_type`; or a logged-in snapshot is
99
+ missing its `expires_at`.
100
+ """
101
+ if self.unreadable_reason is not None and self.logged_in:
102
+ msg = (
103
+ "`unreadable_reason` implies the token is not usable; "
104
+ "`logged_in` must be False."
105
+ )
106
+ raise ValueError(msg)
107
+ if self.logged_in and self.expires_at is None:
108
+ msg = "`expires_at` must be set when `logged_in` is True."
109
+ raise ValueError(msg)
110
+ if not self.logged_in and self.expires_at is not None:
111
+ msg = "`expires_at` is only meaningful when `logged_in` is True."
112
+ raise ValueError(msg)
113
+ if not self.logged_in and self.is_expired:
114
+ msg = "`is_expired` is only meaningful when `logged_in` is True."
115
+ raise ValueError(msg)
116
+ if not self.logged_in and (self.account_id or self.plan_type):
117
+ msg = (
118
+ "`account_id`/`plan_type` are only meaningful when `logged_in` is True."
119
+ )
120
+ raise ValueError(msg)
121
+
122
+
123
+ def default_store_path() -> Path:
124
+ """Return the ChatGPT OAuth token store path.
125
+
126
+ Stored under Deep Agents' own state dir
127
+ (`~/.deepagents/.state/chatgpt-auth.json`) so the credential lives
128
+ alongside the rest of the app's state rather than in `langchain_openai`'s
129
+ default `~/.langchain`.
130
+ """
131
+ from deepagents_code.model_config import DEFAULT_STATE_DIR
132
+
133
+ return DEFAULT_STATE_DIR / "chatgpt-auth.json"
134
+
135
+
136
+ def get_status(*, store_path: Path | None = None) -> CodexAuthStatus:
137
+ """Return the current ChatGPT OAuth sign-in state.
138
+
139
+ Reads the on-disk token *without* triggering a refresh (a passive
140
+ inspect, suitable for switcher labels and the `/auth` manager). If a
141
+ refresh is needed for actual usage, callers should construct a
142
+ `_FileChatGPTOAuthTokenProvider` and call `get_token()` instead.
143
+
144
+ Args:
145
+ store_path: Override the token store path. Defaults to
146
+ `default_store_path()` (`~/.deepagents/.state/chatgpt-auth.json`).
147
+
148
+ Returns:
149
+ A `CodexAuthStatus` populated from the on-disk token, or one with
150
+ `logged_in=False` when no token exists or the file is unreadable.
151
+ """
152
+ path = store_path or default_store_path()
153
+ try:
154
+ from langchain_openai.chatgpt_oauth import (
155
+ _FileChatGPTOAuthTokenProvider, # noqa: PLC2701
156
+ )
157
+ except ImportError as exc:
158
+ # Defensive: `get_status` sits on the `/auth` manager and `/model`
159
+ # switcher hot paths. A future `langchain-openai` that renamed these
160
+ # upstream-internal symbols would otherwise crash credential listing;
161
+ # degrade to an unreadable status so the UI still renders.
162
+ return CodexAuthStatus(
163
+ logged_in=False,
164
+ store_path=path,
165
+ unreadable_reason=f"langchain-openai ChatGPT OAuth API unavailable: {exc}",
166
+ )
167
+
168
+ provider = _FileChatGPTOAuthTokenProvider(path=path)
169
+ try:
170
+ # `_read_from_disk` is a passive inspect; the public `get_token`
171
+ # would refresh on expiry and hit the network, which is wrong for
172
+ # the switcher/`/auth` listing paths that call this on the hot
173
+ # path. Project policy allows SLF001 access.
174
+ token = provider._read_from_disk()
175
+ except RuntimeError as exc:
176
+ return CodexAuthStatus(
177
+ logged_in=False,
178
+ store_path=path,
179
+ unreadable_reason=str(exc),
180
+ )
181
+ if token is None:
182
+ return CodexAuthStatus(logged_in=False, store_path=path)
183
+ return CodexAuthStatus(
184
+ logged_in=True,
185
+ store_path=path,
186
+ account_id=token.account_id,
187
+ plan_type=token.plan_type,
188
+ expires_at=token.expires_at,
189
+ is_expired=token.is_expired(),
190
+ )
191
+
192
+
193
+ def is_logged_in(*, store_path: Path | None = None) -> bool:
194
+ """Return whether a ChatGPT OAuth token is stored on disk."""
195
+ return get_status(store_path=store_path).logged_in
196
+
197
+
198
+ def logout(*, store_path: Path | None = None) -> bool:
199
+ """Delete the stored ChatGPT OAuth token.
200
+
201
+ Args:
202
+ store_path: Override the token store path.
203
+
204
+ Returns:
205
+ `True` if a token file was removed, `False` if no file existed.
206
+ """
207
+ path = store_path or default_store_path()
208
+ if not path.exists():
209
+ return False
210
+ path.unlink()
211
+ return True
212
+
213
+
214
+ class CodexLoginCancelledError(RuntimeError):
215
+ """Raised when the user cancels a sign-in flow mid-callback wait."""
216
+
217
+
218
+ class CodexAuthExpiredError(RuntimeError):
219
+ """Raised when a stored ChatGPT token cannot be refreshed.
220
+
221
+ Distinct from a missing token (`FileNotFoundError`): a token exists on
222
+ disk but its refresh token was revoked or expired, so an automatic
223
+ refresh failed and the user must sign in again. `create_model` maps this
224
+ to a `MissingCredentialsError` that points at `/auth`.
225
+ """
226
+
227
+
228
+ class CodexLoginInteraction:
229
+ """UI hooks for the browser loopback sign-in flow.
230
+
231
+ Implementations decide how to surface the authorize URL and whether to
232
+ auto-open a browser. The Textual screen subclasses this; CLI / headless
233
+ callers can supply a minimal stdout-based implementation.
234
+
235
+ The default base class implements the print-to-stdout fallback so it
236
+ works for headless tests and the `-x` non-interactive path; UI callers
237
+ override `show_authorize_url`.
238
+ """
239
+
240
+ async def show_authorize_url( # noqa: PLR6301 # override hook; `self` is meaningful in subclasses
241
+ self,
242
+ url: str,
243
+ *,
244
+ opened_in_browser: bool,
245
+ ) -> None:
246
+ """Surface the authorize URL to the user.
247
+
248
+ Called once, immediately after the URL is built and before the
249
+ callback wait begins. `opened_in_browser` is whether we already
250
+ invoked `webbrowser.open` successfully — false means the user has
251
+ to copy the URL manually.
252
+ """
253
+ prefix = (
254
+ "Browser opened to: "
255
+ if opened_in_browser
256
+ else "Open this URL in a browser: "
257
+ )
258
+ print(f"\n{prefix}{url}\n") # noqa: T201
259
+
260
+
261
+ _LOOPBACK_TIMEOUT_SECONDS = 300.0
262
+ """Total seconds to wait for the browser callback before giving up.
263
+
264
+ Matches the upstream `login_chatgpt(timeout=300.0)` default so behavior is
265
+ identical whether a user signs in via the TUI or the bare upstream helper.
266
+ """
267
+
268
+
269
+ async def run_browser_login(
270
+ interaction: CodexLoginInteraction | None = None,
271
+ *,
272
+ store_path: Path | None = None,
273
+ open_browser: bool = True,
274
+ cancel_event: threading.Event | None = None,
275
+ ) -> CodexAuthStatus:
276
+ """Run the ChatGPT OAuth Authorization Code Flow with PKCE.
277
+
278
+ Reimplements the upstream `langchain_openai.chatgpt_oauth` browser
279
+ sign-in flow over its lower-level helpers, but routes the authorize-URL
280
+ display through `interaction` so a Textual screen can render it inline.
281
+ The blocking callback wait and the synchronous token exchange both run
282
+ inside `asyncio.to_thread` so the calling event loop stays responsive.
283
+
284
+ Args:
285
+ interaction: UI hooks for surfacing the URL / notices. A default
286
+ stdout-based implementation is used when `None`.
287
+ store_path: Override the token store path. Defaults to
288
+ `default_store_path()` (`~/.deepagents/.state/chatgpt-auth.json`).
289
+ open_browser: Whether to call `webbrowser.open`. Disable in
290
+ headless environments or tests.
291
+ cancel_event: Optional event the caller can set to abandon the
292
+ wait. Polled between callback server requests by
293
+ `_wait_for_oauth_callback`, so setting it stops the wait and
294
+ closes the loopback server within one poll interval (~1s),
295
+ freeing the port for an immediate retry.
296
+
297
+ Returns:
298
+ A fresh `CodexAuthStatus` reflecting the just-saved token.
299
+
300
+ Raises:
301
+ CodexLoginCancelledError: `cancel_event` was set before the callback
302
+ arrived.
303
+ RuntimeError: OAuth state mismatch, missing code, port bind failure,
304
+ or upstream token-endpoint error.
305
+
306
+ !!! note
307
+
308
+ `_wait_for_oauth_callback` raises `TimeoutError` after 300s of
309
+ inactivity; that exception propagates unchanged.
310
+ """
311
+ import asyncio
312
+
313
+ # We deliberately reach into the underscored building blocks of
314
+ # `chatgpt_oauth` rather than calling `login_chatgpt`. The top-level
315
+ # helper uses `print()` to surface the authorize URL — invisible inside
316
+ # a Textual app — and bundles the browser open into one blocking call,
317
+ # so we cannot show the URL ahead of the wait. These private helpers are
318
+ # the same primitives upstream's own `login_chatgpt` composes; their
319
+ # stability is an upstream concern (see the module docstring), so re-check
320
+ # these names whenever the `langchain-openai` pin is bumped.
321
+ from langchain_openai.chatgpt_oauth import (
322
+ CHATGPT_AUTHORIZE_URL,
323
+ CHATGPT_CLIENT_ID,
324
+ CHATGPT_TOKEN_URL,
325
+ DEFAULT_REDIRECT_HOST,
326
+ DEFAULT_REDIRECT_PATH,
327
+ DEFAULT_REDIRECT_PORT,
328
+ _build_authorize_url, # noqa: PLC2701
329
+ _FileChatGPTOAuthTokenProvider, # noqa: PLC2701
330
+ _generate_pkce_pair, # noqa: PLC2701
331
+ _post_form, # noqa: PLC2701
332
+ _token_from_response, # noqa: PLC2701
333
+ )
334
+
335
+ ui = interaction if interaction is not None else CodexLoginInteraction()
336
+ redirect_uri = (
337
+ f"http://{DEFAULT_REDIRECT_HOST}:{DEFAULT_REDIRECT_PORT}{DEFAULT_REDIRECT_PATH}"
338
+ )
339
+ state = secrets.token_urlsafe(32)
340
+ verifier, challenge = _generate_pkce_pair()
341
+ authorize_url = _build_authorize_url(
342
+ client_id=CHATGPT_CLIENT_ID,
343
+ redirect_uri=redirect_uri,
344
+ state=state,
345
+ code_challenge=challenge,
346
+ )
347
+ logger.info("Starting ChatGPT OAuth sign-in flow at %s", CHATGPT_AUTHORIZE_URL)
348
+
349
+ opened = False
350
+ if open_browser:
351
+ try:
352
+ opened = await asyncio.to_thread(webbrowser.open, authorize_url)
353
+ except (webbrowser.Error, OSError) as exc:
354
+ # `webbrowser.open` can raise `OSError` (not just `webbrowser.Error`)
355
+ # when a configured launcher's binary is missing. Fall through to
356
+ # the manual-URL display instead of aborting sign-in — the
357
+ # headless / SSH case is exactly where this fallback matters.
358
+ logger.warning("Could not launch a browser for ChatGPT sign-in: %s", exc)
359
+ opened = False
360
+ await ui.show_authorize_url(authorize_url, opened_in_browser=opened)
361
+
362
+ callback_result = await asyncio.to_thread(
363
+ _wait_for_oauth_callback,
364
+ host=DEFAULT_REDIRECT_HOST,
365
+ port=DEFAULT_REDIRECT_PORT,
366
+ callback_path=DEFAULT_REDIRECT_PATH,
367
+ timeout=_LOOPBACK_TIMEOUT_SECONDS,
368
+ cancel_event=cancel_event,
369
+ )
370
+
371
+ if cancel_event is not None and cancel_event.is_set():
372
+ # Defense in depth: `_wait_for_oauth_callback` already raises on
373
+ # cancel, but a callback landing in the same poll as the cancel
374
+ # returns the result instead — honor the explicit cancel here so the
375
+ # token exchange below never runs and no token is saved.
376
+ msg = "Sign-in was cancelled."
377
+ raise CodexLoginCancelledError(msg)
378
+
379
+ if callback_result.get("state") != state:
380
+ msg = "ChatGPT OAuth callback state mismatch."
381
+ raise RuntimeError(msg)
382
+ if "error" in callback_result:
383
+ description = callback_result.get("error_description", "")
384
+ msg = (
385
+ f"ChatGPT OAuth callback returned error: "
386
+ f"{callback_result['error']} {description}".rstrip()
387
+ )
388
+ raise RuntimeError(msg)
389
+ code = callback_result.get("code")
390
+ if not code:
391
+ msg = "ChatGPT OAuth callback did not include an authorization code."
392
+ raise RuntimeError(msg)
393
+
394
+ response = await asyncio.to_thread(
395
+ _post_form,
396
+ CHATGPT_TOKEN_URL,
397
+ {
398
+ "grant_type": "authorization_code",
399
+ "code": code,
400
+ "redirect_uri": redirect_uri,
401
+ "client_id": CHATGPT_CLIENT_ID,
402
+ "code_verifier": verifier,
403
+ },
404
+ )
405
+ if cancel_event is not None and cancel_event.is_set():
406
+ # The user cancelled while the token exchange was in flight. The
407
+ # exchange already completed in-memory, but honor the cancel so no
408
+ # token lands on disk for a sign-in the user abandoned — the same
409
+ # "no token saved on cancel" guarantee the post-callback check makes.
410
+ msg = "Sign-in was cancelled."
411
+ raise CodexLoginCancelledError(msg)
412
+
413
+ token = _token_from_response(response)
414
+ path = store_path or default_store_path()
415
+ file_provider = _FileChatGPTOAuthTokenProvider(path=path)
416
+ file_provider.save(token)
417
+ return get_status(store_path=path)
418
+
419
+
420
+ def _wait_for_oauth_callback(
421
+ *,
422
+ host: str,
423
+ port: int,
424
+ callback_path: str,
425
+ timeout: float,
426
+ cancel_event: threading.Event | None = None,
427
+ poll_interval: float = 1.0,
428
+ ) -> dict[str, str]:
429
+ """Block on the loopback OAuth callback, polling `cancel_event`.
430
+
431
+ A cancel-aware reimplementation of upstream's `_wait_for_callback`,
432
+ reusing its `_CallbackHandler` so the request parsing stays identical.
433
+ Upstream's version loops on a wall-clock deadline only, so a cancelled
434
+ sign-in leaves the worker thread holding the bound port until a real
435
+ callback lands or the full `timeout` elapses (up to 5 minutes). This
436
+ variant checks `cancel_event` between single-request polls and always
437
+ closes the server in a `finally`, so a cancel frees the port within one
438
+ `poll_interval` and an immediate retry can rebind.
439
+
440
+ Args:
441
+ host: Loopback callback host (e.g. `localhost`).
442
+ port: Loopback callback port.
443
+ callback_path: Path the OAuth provider redirects back to.
444
+ timeout: Total seconds to wait before raising `TimeoutError`.
445
+ cancel_event: When set, abandon the wait at the next poll boundary.
446
+ poll_interval: Seconds each `handle_request` blocks before the loop
447
+ re-checks `cancel_event` and the deadline.
448
+
449
+ Returns:
450
+ The parsed callback query (`code` / `state`, or `error` /
451
+ `error_description`).
452
+
453
+ Raises:
454
+ CodexLoginCancelledError: `cancel_event` was set before a callback
455
+ arrived.
456
+ RuntimeError: The callback server could not bind to the port.
457
+ TimeoutError: No callback arrived within `timeout` seconds.
458
+ """
459
+ import http.server
460
+ import time
461
+
462
+ from langchain_openai.chatgpt_oauth import (
463
+ _CallbackHandler, # noqa: PLC2701
464
+ )
465
+
466
+ # A per-call subclass so the class-level `server_result` dict never leaks
467
+ # state across sign-in attempts (upstream's `_wait_for_callback` does the
468
+ # same).
469
+ class _BoundCallbackHandler(_CallbackHandler):
470
+ server_result: dict[str, str] = {} # noqa: RUF012 # mirrors upstream handler shape
471
+
472
+ _BoundCallbackHandler.callback_path = callback_path
473
+ try:
474
+ server = http.server.HTTPServer((host, port), _BoundCallbackHandler)
475
+ except OSError as exc:
476
+ msg = (
477
+ f"Could not bind ChatGPT OAuth callback server on "
478
+ f"http://{host}:{port}: {exc}. Free the port and try again."
479
+ )
480
+ raise RuntimeError(msg) from exc
481
+ server.timeout = poll_interval
482
+ deadline = time.monotonic() + timeout
483
+ try:
484
+ while time.monotonic() < deadline:
485
+ if cancel_event is not None and cancel_event.is_set():
486
+ msg = "Sign-in was cancelled."
487
+ raise CodexLoginCancelledError(msg)
488
+ # Blocks up to `poll_interval`; returns promptly once a request
489
+ # lands so the loop can re-check the cancel flag and deadline.
490
+ server.handle_request()
491
+ result = _BoundCallbackHandler.server_result
492
+ if result.get("code") or result.get("error"):
493
+ return dict(result)
494
+ finally:
495
+ server.server_close()
496
+ msg = f"Timed out waiting for ChatGPT OAuth callback on http://{host}:{port}"
497
+ raise TimeoutError(msg)
498
+
499
+
500
+ def build_chat_model(
501
+ model_name: str, /, *, store_path: Path | None = None, **kwargs: Any
502
+ ) -> BaseChatModel:
503
+ """Construct a `_ChatOpenAICodex` model wired to the on-disk token store.
504
+
505
+ Args:
506
+ model_name: Codex model identifier (e.g., `gpt-5.2-codex`).
507
+ store_path: Override the token store path. Defaults to
508
+ `default_store_path()` so the model reads the same file that
509
+ `get_status` / `run_browser_login` write.
510
+ **kwargs: Extra constructor kwargs forwarded to `_ChatOpenAICodex`.
511
+
512
+ Returns:
513
+ A configured `_ChatOpenAICodex` instance, narrowed to `BaseChatModel`
514
+ so `create_model` can splice it into the standard return path.
515
+
516
+ Raises:
517
+ FileNotFoundError: If no token has been stored yet. Surfaces as a
518
+ `MissingCredentialsError` upstream in `create_model`.
519
+ CodexAuthExpiredError: If a token exists but its refresh token was
520
+ revoked/expired so the eager refresh failed. Also surfaces as a
521
+ `MissingCredentialsError` upstream, pointing the user at `/auth`.
522
+ """ # noqa: DOC502 # `FileNotFoundError` is raised by `provider.get_token()` (`_load_existing`) when the on-disk token is missing
523
+ from langchain_openai.chat_models.codex import (
524
+ _ChatOpenAICodex, # noqa: PLC2701
525
+ )
526
+ from langchain_openai.chatgpt_oauth import (
527
+ _ChatGPTOAuthRefreshError, # noqa: PLC2701
528
+ _FileChatGPTOAuthTokenProvider, # noqa: PLC2701
529
+ )
530
+
531
+ provider = _FileChatGPTOAuthTokenProvider(path=store_path or default_store_path())
532
+ # Touch the provider's read path eagerly so a missing token surfaces as
533
+ # `FileNotFoundError` here instead of on first invocation — the app's
534
+ # `create_model` path expects credential failures up front. `get_token`
535
+ # refreshes if the stored token is past `refresh_skew`, so the model
536
+ # is guaranteed to receive a valid bearer at construction time.
537
+ try:
538
+ provider.get_token()
539
+ except _ChatGPTOAuthRefreshError as exc:
540
+ # A token exists but the refresh token is dead. This is a credentials
541
+ # problem with a clear remedy (sign in again), not an unexpected
542
+ # construction failure — translate it so `create_model` can route it
543
+ # to the same `/auth` recovery path as a missing token rather than a
544
+ # generic "failed to initialize" error.
545
+ msg = "ChatGPT session expired and could not be refreshed automatically."
546
+ raise CodexAuthExpiredError(msg) from exc
547
+ return _ChatOpenAICodex(
548
+ model=model_name,
549
+ token_provider=provider,
550
+ **kwargs,
551
+ )