pi-ai-python 1.3.0__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 (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,1545 @@
1
+ """OpenAI Codex Responses API (``openai-codex-responses``): the ChatGPT subscription Codex backend.
2
+
3
+ Two transports:
4
+
5
+ * SSE over httpx (``POST {base_url}/codex/responses``). Request bodies are zstd-compressed
6
+ when a zstd codec is available (Python 3.14 ``compression.zstd`` or the ``zstandard``
7
+ package), like the official Codex client.
8
+ * WebSocket (``transport="websocket"``, ``"websocket-cached"`` or ``"auto"``, the default),
9
+ using the optional ``websockets`` package (``pip install "pi-ai-python[websocket]"``). Connections are
10
+ pooled per session and account; ``websocket-cached``/``auto`` send only the new input items
11
+ with ``previous_response_id`` when the request extends the previous one. Failures before the
12
+ first event fall back to SSE, and the session then stays on SSE.
13
+
14
+ The WebSocket connect function can be replaced by assigning :data:`websocket_connect`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import base64
21
+ import binascii
22
+ import email.utils
23
+ import importlib.util
24
+ import inspect
25
+ import json
26
+ import math
27
+ import re
28
+ import time
29
+ from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Mapping
30
+ from dataclasses import dataclass, replace
31
+ from typing import Any, Literal, Protocol
32
+
33
+ import httpx
34
+
35
+ from ..abort import abort_scope
36
+ from ..event_stream import AssistantMessageEventStream
37
+ from ..models import clamp_thinking_level
38
+ from ..session_resources import register_session_resource_cleanup
39
+ from ..types import (
40
+ AssistantMessage,
41
+ DoneEvent,
42
+ ErrorEvent,
43
+ Model,
44
+ ProviderResponse,
45
+ StartEvent,
46
+ StreamOptions,
47
+ TranscriptContext,
48
+ Usage,
49
+ )
50
+ from ..utils import fastjson
51
+ from ..utils.callbacks import maybe_await
52
+ from ..utils.diagnostics import (
53
+ append_assistant_message_diagnostic,
54
+ create_assistant_message_diagnostic,
55
+ format_thrown_value,
56
+ )
57
+ from ..utils.error_body import format_provider_error, normalize_provider_error
58
+ from ..utils.http import DEFAULT_TIMEOUT_MS, get_http_client, iter_sse
59
+ from ..utils.misc import get_pi_user_agent, get_provider_env_value, headers_to_record, uuidv7
60
+ from ..utils.text import get_system_message_text
61
+ from ..utils.transcript import (
62
+ get_declared_tools,
63
+ get_initial_system_message,
64
+ resolve_transcript,
65
+ resolve_transcript_tools,
66
+ )
67
+ from .constrained_sampling import create_grammar_tool_input_properties
68
+ from .openai_prompt_cache import clamp_openai_prompt_cache_key
69
+ from .openai_responses_shared import convert_responses_messages, convert_responses_tools, process_responses_stream
70
+ from .simple_options import build_base_options
71
+
72
+ __all__ = [
73
+ "OpenAICodexResponsesOptions",
74
+ "OpenAICodexWebSocketDebugStats",
75
+ "CodexWebSocket",
76
+ "CodexWebSocketClosed",
77
+ "WebSocketConnect",
78
+ "CodexApiError",
79
+ "CodexProtocolError",
80
+ "ProviderStreamEventCallbackError",
81
+ "WebSocketCloseError",
82
+ "websocket_connect",
83
+ "stream",
84
+ "stream_simple",
85
+ "get_openai_codex_websocket_debug_stats",
86
+ "reset_openai_codex_websocket_debug_stats",
87
+ "close_openai_codex_websocket_sessions",
88
+ ]
89
+
90
+ # ============================================================================
91
+ # Configuration
92
+ # ============================================================================
93
+
94
+ DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
95
+ JWT_CLAIM_PATH = "https://api.openai.com/auth"
96
+ DEFAULT_MAX_RETRIES = 0
97
+ BASE_DELAY_MS = 1000
98
+ DEFAULT_MAX_RETRY_DELAY_MS = 60_000
99
+ DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000
100
+ # The Codex backend accepts zstd-compressed request bodies on the SSE responses
101
+ # endpoint (the same endpoint the official Codex client compresses against).
102
+ REQUEST_COMPRESSION_ZSTD_LEVEL = 3
103
+ CODEX_TOOL_CALL_PROVIDERS = frozenset({"openai", "openai-codex", "opencode"})
104
+ WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009
105
+ WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"
106
+ PREVIOUS_RESPONSE_NOT_FOUND_CODE = "previous_response_not_found"
107
+ OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06"
108
+ SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1000
109
+ SESSION_WEBSOCKET_MAX_AGE_MS = 55 * 60 * 1000
110
+ WEBSOCKET_UNAVAILABLE_MESSAGE = (
111
+ "WebSocket transport requires the optional 'websockets' package. Install it with: "
112
+ 'pip install "pi-ai-python[websocket]"'
113
+ )
114
+
115
+ _CODEX_RESPONSE_STATUSES = frozenset({"completed", "incomplete", "failed", "cancelled", "queued", "in_progress"})
116
+ _TERMINAL_EVENT_TYPES = frozenset({"response.done", "response.completed", "response.incomplete"})
117
+ _SSE_TIMEOUT = httpx.Timeout(DEFAULT_TIMEOUT_MS / 1000)
118
+
119
+ # ============================================================================
120
+ # Types
121
+ # ============================================================================
122
+
123
+
124
+ class OpenAICodexResponsesOptions(StreamOptions, total=False):
125
+ reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
126
+ reasoning_summary: Literal["auto", "concise", "detailed", "off", "on"] | None
127
+ service_tier: str
128
+ text_verbosity: Literal["low", "medium", "high"]
129
+ tool_choice: Literal["auto", "none", "required"]
130
+
131
+
132
+ def _assert_successful_output(output: AssistantMessage) -> None:
133
+ if output.stop_reason == "pending":
134
+ raise RuntimeError("Codex stream ended without a stop reason")
135
+ if output.stop_reason in ("error", "aborted"):
136
+ raise RuntimeError(output.error_message or "An unknown error occurred")
137
+
138
+
139
+ class CodexApiError(Exception):
140
+ """An error reported by the Codex backend (``error`` or ``response.failed`` events)."""
141
+
142
+ def __init__(self, message: str, *, code: str | None = None, payload: Any = None) -> None:
143
+ super().__init__(message)
144
+ self.code = code
145
+ self.payload = payload
146
+
147
+
148
+ class CodexProtocolError(Exception):
149
+ """A malformed Codex stream frame."""
150
+
151
+ def __init__(self, message: str, *, payload: Any = None) -> None:
152
+ super().__init__(message)
153
+ self.payload = payload
154
+
155
+
156
+ class ProviderStreamEventCallbackError(Exception):
157
+ """``on_provider_stream_event`` raised; kept out of the WebSocket retry and SSE fallback path."""
158
+
159
+ def __init__(self, cause: BaseException) -> None:
160
+ super().__init__(format_thrown_value(cause))
161
+ self.cause = cause
162
+
163
+
164
+ class RetryDelayExceededError(Exception):
165
+ pass
166
+
167
+
168
+ class WebSocketCloseError(Exception):
169
+ def __init__(
170
+ self, message: str, *, code: int | None = None, reason: str | None = None, was_clean: bool | None = None
171
+ ) -> None:
172
+ super().__init__(message)
173
+ self.code = code
174
+ self.reason = reason
175
+ self.was_clean = was_clean
176
+
177
+
178
+ class CodexWebSocketClosed(Exception):
179
+ """Raised by :meth:`CodexWebSocket.recv` when the connection is closed."""
180
+
181
+ def __init__(self, code: int | None = None, reason: str = "", was_clean: bool | None = None) -> None:
182
+ super().__init__(f"WebSocket closed {code} {reason}".strip())
183
+ self.code = code
184
+ self.reason = reason
185
+ self.was_clean = was_clean
186
+
187
+
188
+ class CodexWebSocket(Protocol):
189
+ """The WebSocket connection interface the transport uses.
190
+
191
+ ``send`` and ``close`` may be sync or async. ``recv`` returns the next text or binary
192
+ message and raises :class:`CodexWebSocketClosed` when the connection closes. An optional
193
+ integer ``ready_state`` attribute (WebSocket ``readyState`` values, 1 = open) decides
194
+ whether a pooled connection can be reused; without it the connection is assumed open.
195
+ """
196
+
197
+ def send(self, data: str) -> Any: ...
198
+
199
+ async def recv(self) -> str | bytes: ...
200
+
201
+ def close(self, code: int = 1000, reason: str = "") -> Any: ...
202
+
203
+
204
+ #: ``(url, headers, env) -> connection``. Connect timeouts and aborts are applied by the caller.
205
+ WebSocketConnect = Callable[[str, dict[str, str], "Mapping[str, str] | None"], Awaitable[CodexWebSocket]]
206
+
207
+ #: Replace to supply a custom WebSocket implementation (``None`` uses the ``websockets`` package).
208
+ websocket_connect: WebSocketConnect | None = None
209
+
210
+
211
+ def _clock_ms() -> float:
212
+ return time.time() * 1000
213
+
214
+
215
+ async def _sleep(delay_ms: float) -> None:
216
+ await asyncio.sleep(max(0.0, delay_ms) / 1000)
217
+
218
+
219
+ # ============================================================================
220
+ # Retry Helpers
221
+ # ============================================================================
222
+
223
+ _TERMINAL_RATE_LIMIT_RE = re.compile(
224
+ r"GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota"
225
+ r"|out of budget|quota exceeded|billing",
226
+ re.IGNORECASE,
227
+ )
228
+ _RETRYABLE_TEXT_RE = re.compile(
229
+ r"rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused", re.IGNORECASE
230
+ )
231
+ _USAGE_LIMIT_CODE_RE = re.compile(r"usage_limit_reached|usage_not_included|rate_limit_exceeded", re.IGNORECASE)
232
+
233
+
234
+ def _is_terminal_rate_limit_error(error_text: str) -> bool:
235
+ return _TERMINAL_RATE_LIMIT_RE.search(error_text) is not None
236
+
237
+
238
+ def _is_retryable_error(status: int, error_text: str) -> bool:
239
+ if status == 429 and _is_terminal_rate_limit_error(error_text):
240
+ return False
241
+ if status in (429, 500, 502, 503, 504):
242
+ return True
243
+ return _RETRYABLE_TEXT_RE.search(error_text) is not None
244
+
245
+
246
+ def _js_number(text: str) -> float | None:
247
+ """``Number(text)`` for header values (``None`` for NaN)."""
248
+ stripped = text.strip()
249
+ if not stripped:
250
+ return 0.0
251
+ try:
252
+ return float(stripped)
253
+ except ValueError:
254
+ return None
255
+
256
+
257
+ def _get_retry_after_delay_ms(headers: httpx.Headers) -> float | None:
258
+ retry_after_ms = headers.get("retry-after-ms")
259
+ if retry_after_ms is not None:
260
+ millis = _js_number(retry_after_ms)
261
+ if millis is not None and math.isfinite(millis):
262
+ return max(0.0, millis)
263
+
264
+ retry_after = headers.get("retry-after")
265
+ if not retry_after:
266
+ return None
267
+ seconds = _js_number(retry_after)
268
+ if seconds is not None and math.isfinite(seconds):
269
+ return max(0.0, seconds * 1000)
270
+ try:
271
+ date = email.utils.parsedate_to_datetime(retry_after)
272
+ except (TypeError, ValueError, IndexError):
273
+ return None
274
+ return max(0.0, date.timestamp() * 1000 - _clock_ms())
275
+
276
+
277
+ def _validate_retry_delay_ms(delay_ms: float, options: Mapping[str, Any]) -> float:
278
+ max_retry_delay_ms = options.get("max_retry_delay_ms")
279
+ if max_retry_delay_ms is None:
280
+ max_retry_delay_ms = DEFAULT_MAX_RETRY_DELAY_MS
281
+ if max_retry_delay_ms > 0 and delay_ms > max_retry_delay_ms:
282
+ raise RetryDelayExceededError(
283
+ f"Server requested {math.ceil(delay_ms / 1000)}s retry delay (max: {math.ceil(max_retry_delay_ms / 1000)}s)"
284
+ )
285
+ return delay_ms
286
+
287
+
288
+ def _normalize_timeout_ms(value: Any) -> int | None:
289
+ if value is None:
290
+ return None
291
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or value < 0:
292
+ raise RuntimeError(f"Invalid timeoutMs: {value}")
293
+ return math.floor(value)
294
+
295
+
296
+ # ============================================================================
297
+ # Request Compression
298
+ # ============================================================================
299
+
300
+
301
+ def _compress_request_body_zstd(body: bytes) -> bytes | None:
302
+ """zstd-compressed body bytes, or ``None`` when no zstd codec is available.
303
+
304
+ Callers fall back to sending the uncompressed JSON when this returns ``None``.
305
+ """
306
+ try:
307
+ from compression import zstd # type: ignore[import-not-found] # Python 3.14+
308
+
309
+ return zstd.compress(body, level=REQUEST_COMPRESSION_ZSTD_LEVEL)
310
+ except ImportError:
311
+ pass
312
+ except Exception:
313
+ return None
314
+ try:
315
+ import zstandard # type: ignore[import-not-found]
316
+
317
+ return zstandard.ZstdCompressor(level=REQUEST_COMPRESSION_ZSTD_LEVEL).compress(body)
318
+ except ImportError:
319
+ return None
320
+ except Exception:
321
+ return None
322
+
323
+
324
+ # ============================================================================
325
+ # Main Stream Function
326
+ # ============================================================================
327
+
328
+
329
+ def stream(
330
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
331
+ ) -> AssistantMessageEventStream:
332
+ """Stream a response from the ChatGPT Codex backend."""
333
+ opts: dict[str, Any] = dict(options or {})
334
+ out = AssistantMessageEventStream()
335
+ normalized_context = resolve_transcript(context, (model.compat or {}).get("supportsMidConvoSystemMessages"))
336
+ out.start(_run(model, normalized_context, opts, out))
337
+ return out
338
+
339
+
340
+ class _StartState:
341
+ __slots__ = ("emitted", "out", "output")
342
+
343
+ def __init__(self, out: AssistantMessageEventStream, output: AssistantMessage) -> None:
344
+ self.out = out
345
+ self.output = output
346
+ self.emitted = False
347
+
348
+ def emit(self) -> None:
349
+ if not self.emitted:
350
+ self.emitted = True
351
+ self.out.push(StartEvent(self.output))
352
+
353
+
354
+ class _WebSocketAttempt:
355
+ __slots__ = ("start", "started")
356
+
357
+ def __init__(self, start: _StartState) -> None:
358
+ self.start = start
359
+ self.started = False
360
+
361
+ def on_start(self) -> None:
362
+ self.started = True
363
+ self.start.emit()
364
+
365
+
366
+ async def _run(
367
+ model: Model, context: TranscriptContext, options: dict[str, Any], out: AssistantMessageEventStream
368
+ ) -> None:
369
+ output = AssistantMessage(
370
+ [],
371
+ api="openai-codex-responses",
372
+ provider=model.provider,
373
+ model=model.id,
374
+ usage=Usage(),
375
+ stop_reason="pending",
376
+ )
377
+ signal = options.get("signal")
378
+ try:
379
+ async with abort_scope(signal):
380
+ await _run_request(model, context, options, out, output)
381
+ except Exception as error:
382
+ aborted = signal is not None and signal.aborted
383
+ output.stop_reason = "aborted" if aborted else "error"
384
+ output.error_message = (
385
+ "Request was aborted" if aborted else format_provider_error(normalize_provider_error(error))
386
+ )
387
+ out.push(ErrorEvent(output.stop_reason, output)) # type: ignore[arg-type]
388
+ out.end()
389
+
390
+
391
+ async def _run_request(
392
+ model: Model,
393
+ context: TranscriptContext,
394
+ options: dict[str, Any],
395
+ out: AssistantMessageEventStream,
396
+ output: AssistantMessage,
397
+ ) -> None:
398
+ signal = options.get("signal")
399
+ api_key = options.get("api_key")
400
+ if not api_key:
401
+ raise RuntimeError(f"No API key for provider: {model.provider}")
402
+
403
+ account_id = _extract_account_id(api_key)
404
+ grammar_tool_input_properties = create_grammar_tool_input_properties(
405
+ get_declared_tools(context.messages), bool((model.compat or {}).get("supportsOpenAIGrammarTools"))
406
+ )
407
+ cache_session_id = None if options.get("cache_retention") == "none" else options.get("session_id")
408
+ codex_session_id = clamp_openai_prompt_cache_key(cache_session_id)
409
+ body: Any = _build_request_body(model, context, options, codex_session_id, grammar_tool_input_properties)
410
+ on_payload = options.get("on_payload")
411
+ if on_payload is not None:
412
+ next_body = await maybe_await(on_payload(body, model))
413
+ if next_body is not None:
414
+ body = next_body
415
+ websocket_request_id = codex_session_id or uuidv7()
416
+ sse_headers = _build_sse_headers(model.headers, options.get("headers"), account_id, api_key, codex_session_id)
417
+ websocket_headers = _build_websocket_headers(
418
+ model.headers, options.get("headers"), account_id, api_key, websocket_request_id
419
+ )
420
+ body_bytes = fastjson.dumps_bytes(body)
421
+ http_timeout_ms = _normalize_timeout_ms(options.get("timeout_ms"))
422
+ websocket_connect_timeout_ms = _normalize_timeout_ms(options.get("websocket_connect_timeout_ms"))
423
+ transport = options.get("transport") or "auto"
424
+ start = _StartState(out, output)
425
+ websocket_disabled_for_session = transport != "sse" and _is_websocket_sse_fallback_active(cache_session_id)
426
+ if websocket_disabled_for_session:
427
+ _record_websocket_sse_fallback(cache_session_id)
428
+
429
+ if transport != "sse" and not websocket_disabled_for_session:
430
+ connect = _resolve_websocket_connect()
431
+ if connect is None and transport != "auto":
432
+ raise RuntimeError(WEBSOCKET_UNAVAILABLE_MESSAGE)
433
+ # Without a WebSocket implementation "auto" goes straight to SSE.
434
+ if connect is not None:
435
+ retried_websocket_connection_limit = False
436
+ retried_missing_websocket_continuation = False
437
+ while True:
438
+ attempt = _WebSocketAttempt(start)
439
+ try:
440
+ await _process_websocket_stream(
441
+ connect,
442
+ _resolve_codex_websocket_url(model.base_url),
443
+ body,
444
+ websocket_headers,
445
+ output,
446
+ out,
447
+ model,
448
+ attempt.on_start,
449
+ http_timeout_ms,
450
+ websocket_connect_timeout_ms,
451
+ cache_session_id,
452
+ account_id,
453
+ grammar_tool_input_properties,
454
+ options,
455
+ )
456
+ if signal is not None and signal.aborted:
457
+ raise RuntimeError("Request was aborted")
458
+ _assert_successful_output(output)
459
+ out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
460
+ out.end()
461
+ return
462
+ except Exception as error:
463
+ aborted = signal is not None and signal.aborted
464
+ connection_limit_before_start = (
465
+ not attempt.started and _is_websocket_connection_limit_reached_error(error)
466
+ )
467
+ previous_response_not_found = _is_previous_response_not_found_error(error)
468
+ if not aborted and previous_response_not_found and not retried_missing_websocket_continuation:
469
+ retried_missing_websocket_continuation = True
470
+ continue
471
+ if not aborted and connection_limit_before_start and not retried_websocket_connection_limit:
472
+ retried_websocket_connection_limit = True
473
+ continue
474
+ if aborted or (_is_codex_non_transport_error(error) and not connection_limit_before_start):
475
+ raise
476
+ details: dict[str, Any] = {"configuredTransport": transport}
477
+ if not attempt.started:
478
+ details["fallbackTransport"] = "sse"
479
+ details["eventsEmitted"] = attempt.started
480
+ details["phase"] = (
481
+ "after_message_stream_start" if attempt.started else "before_message_stream_start"
482
+ )
483
+ details["requestBytes"] = len(body_bytes)
484
+ append_assistant_message_diagnostic(
485
+ output, create_assistant_message_diagnostic("provider_transport_failure", error, details)
486
+ )
487
+ _record_websocket_failure(cache_session_id, error)
488
+ if attempt.started:
489
+ raise
490
+ _record_websocket_sse_fallback(cache_session_id)
491
+ break
492
+
493
+ # Compress the request body once for the SSE path. The Codex backend decodes
494
+ # Content-Encoding: zstd; the WebSocket transport above sends the uncompressed
495
+ # JSON frame, matching the official Codex client.
496
+ compressed_body = _compress_request_body_zstd(body_bytes)
497
+ if compressed_body is not None:
498
+ sse_headers["content-encoding"] = "zstd"
499
+ sse_body = compressed_body if compressed_body is not None else body_bytes
500
+
501
+ # Fetch with retry logic for rate limits and transient errors
502
+ url = _resolve_codex_url(model.base_url)
503
+ client = get_http_client(url, options)
504
+ response: httpx.Response | None = None
505
+ last_error: Exception | None = None
506
+ max_retries = options.get("max_retries")
507
+ if max_retries is None:
508
+ max_retries = DEFAULT_MAX_RETRIES
509
+ on_response = options.get("on_response")
510
+
511
+ for attempt_index in range(max_retries + 1):
512
+ if signal is not None and signal.aborted:
513
+ raise RuntimeError("Request was aborted")
514
+ try:
515
+ response = await _send_sse_request(client, url, sse_headers, sse_body, http_timeout_ms)
516
+ if on_response is not None:
517
+ await maybe_await(
518
+ on_response(ProviderResponse(response.status_code, headers_to_record(response.headers)), model)
519
+ )
520
+ if 200 <= response.status_code < 300:
521
+ break
522
+
523
+ try:
524
+ error_text = (await response.aread()).decode("utf-8", "replace")
525
+ finally:
526
+ await response.aclose()
527
+ if attempt_index < max_retries and _is_retryable_error(response.status_code, error_text):
528
+ retry_after_delay_ms = _get_retry_after_delay_ms(response.headers)
529
+ delay_ms = (
530
+ BASE_DELAY_MS * 2**attempt_index
531
+ if retry_after_delay_ms is None
532
+ else _validate_retry_delay_ms(retry_after_delay_ms, options)
533
+ )
534
+ await _sleep(delay_ms)
535
+ continue
536
+
537
+ # Parse error for friendly message on final attempt or non-retryable error
538
+ message, friendly_message = _parse_error_response(response.status_code, response.reason_phrase, error_text)
539
+ raise RuntimeError(friendly_message or message)
540
+ except Exception as error:
541
+ if response is not None and not response.is_closed:
542
+ await response.aclose()
543
+ last_error = error
544
+ # Network errors are retryable
545
+ if (
546
+ attempt_index < max_retries
547
+ and not isinstance(error, RetryDelayExceededError)
548
+ and "usage limit" not in str(error)
549
+ ):
550
+ await _sleep(BASE_DELAY_MS * 2**attempt_index)
551
+ continue
552
+ raise
553
+
554
+ if response is None or not (200 <= response.status_code < 300):
555
+ raise last_error or RuntimeError("Failed after retries")
556
+
557
+ start.emit()
558
+ try:
559
+ await _process_sse_stream(response, output, out, model, grammar_tool_input_properties, options)
560
+ finally:
561
+ await response.aclose()
562
+
563
+ if signal is not None and signal.aborted:
564
+ raise RuntimeError("Request was aborted")
565
+
566
+ _assert_successful_output(output)
567
+ out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
568
+ out.end()
569
+
570
+
571
+ async def _send_sse_request(
572
+ client: httpx.AsyncClient, url: str, headers: dict[str, str], body: bytes, http_timeout_ms: int | None
573
+ ) -> httpx.Response:
574
+ request = client.build_request("POST", url, headers=headers, content=body, timeout=_SSE_TIMEOUT)
575
+ if http_timeout_ms is None or http_timeout_ms <= 0:
576
+ return await client.send(request, stream=True)
577
+ timeout = asyncio.timeout(http_timeout_ms / 1000)
578
+ try:
579
+ async with timeout:
580
+ return await client.send(request, stream=True)
581
+ except TimeoutError:
582
+ if timeout.expired():
583
+ raise RuntimeError(f"Codex SSE response headers timed out after {http_timeout_ms}ms") from None
584
+ raise
585
+
586
+
587
+ def stream_simple(
588
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
589
+ ) -> AssistantMessageEventStream:
590
+ """Stream with provider-neutral options (``reasoning``, ``tool_choice``, ...)."""
591
+ options = options or {}
592
+ api_key = options.get("api_key")
593
+ if not api_key:
594
+ raise RuntimeError(f"No API key for provider: {model.provider}")
595
+
596
+ base = build_base_options(model, context, options, api_key)
597
+ if options.get("tool_choice") is not None:
598
+ base["tool_choice"] = options["tool_choice"]
599
+ reasoning = options.get("reasoning")
600
+ clamped = clamp_thinking_level(model, reasoning) if reasoning else None
601
+ if clamped is not None and clamped != "off":
602
+ base["reasoning_effort"] = clamped
603
+ return stream(model, context, base)
604
+
605
+
606
+ # ============================================================================
607
+ # Request Building
608
+ # ============================================================================
609
+
610
+
611
+ def _build_request_body(
612
+ model: Model,
613
+ context: TranscriptContext,
614
+ options: Mapping[str, Any],
615
+ cache_session_id: str | None,
616
+ grammar_tool_input_properties: Mapping[str, str] | None = None,
617
+ ) -> dict[str, Any]:
618
+ compat = model.compat or {}
619
+ if grammar_tool_input_properties is None:
620
+ grammar_tool_input_properties = create_grammar_tool_input_properties(
621
+ get_declared_tools(context.messages), bool(compat.get("supportsOpenAIGrammarTools"))
622
+ )
623
+ supports_strict_mode = compat.get("supportsStrictMode")
624
+ if supports_strict_mode is None:
625
+ supports_strict_mode = True
626
+ supports_openai_grammar_tools = bool(compat.get("supportsOpenAIGrammarTools"))
627
+ supports_additional_tools = bool(compat.get("supportsAdditionalTools"))
628
+ supports_tool_search = bool(compat.get("supportsToolSearch"))
629
+ tool_options: dict[str, Any] = {
630
+ "strict": None,
631
+ "supports_strict_mode": supports_strict_mode,
632
+ "supports_openai_grammar_tools": supports_openai_grammar_tools,
633
+ }
634
+ transcript_tools = resolve_transcript_tools(context.messages, supports_additional_tools or supports_tool_search)
635
+ messages = convert_responses_messages(
636
+ model,
637
+ context,
638
+ CODEX_TOOL_CALL_PROVIDERS,
639
+ {
640
+ "include_system_prompt": False,
641
+ "grammar_tool_input_properties": grammar_tool_input_properties,
642
+ "supports_mid_convo_system_messages": bool(compat.get("supportsMidConvoSystemMessages")),
643
+ "supports_additional_tools": supports_additional_tools,
644
+ "supports_tool_search": supports_tool_search,
645
+ "tool_options": tool_options, # type: ignore[typeddict-item]
646
+ },
647
+ )
648
+
649
+ initial_system_message = get_initial_system_message(context.messages)
650
+ instructions = get_system_message_text(initial_system_message) if initial_system_message else ""
651
+ body: dict[str, Any] = {
652
+ "model": model.id,
653
+ "store": False,
654
+ "stream": True,
655
+ "instructions": instructions or "You are a helpful assistant.",
656
+ "input": messages,
657
+ "text": {"verbosity": options.get("text_verbosity") or "low"},
658
+ "include": ["reasoning.encrypted_content"],
659
+ }
660
+ if cache_session_id is not None:
661
+ body["prompt_cache_key"] = cache_session_id
662
+ tool_choice = options.get("tool_choice")
663
+ body["tool_choice"] = tool_choice if tool_choice is not None else "auto"
664
+ body["parallel_tool_calls"] = True
665
+
666
+ if options.get("temperature") is not None:
667
+ body["temperature"] = options["temperature"]
668
+
669
+ if options.get("service_tier") is not None:
670
+ body["service_tier"] = options["service_tier"]
671
+
672
+ if transcript_tools.request_tools:
673
+ body["tools"] = convert_responses_tools(transcript_tools.request_tools, tool_options) # type: ignore[arg-type]
674
+
675
+ level_map = model.thinking_level_map
676
+ reasoning_effort = options.get("reasoning_effort")
677
+ if reasoning_effort is not None:
678
+ effort: Any
679
+ if reasoning_effort == "none":
680
+ effort = "none" if level_map is None or "off" not in level_map else level_map["off"]
681
+ else:
682
+ mapped = (level_map or {}).get(reasoning_effort)
683
+ effort = mapped if mapped is not None else reasoning_effort
684
+ if effort is not None:
685
+ summary = options.get("reasoning_summary")
686
+ body["reasoning"] = {"effort": effort, "summary": summary if summary is not None else "auto"}
687
+ elif model.reasoning and not (level_map is not None and "off" in level_map and level_map["off"] is None):
688
+ off = (level_map or {}).get("off")
689
+ body["reasoning"] = {"effort": off if off is not None else "none"}
690
+
691
+ return body
692
+
693
+
694
+ def _get_service_tier_cost_multiplier(model: Model, service_tier: Any) -> float:
695
+ if service_tier == "flex":
696
+ return 0.5
697
+ if service_tier == "priority":
698
+ return 2.5 if model.id == "gpt-5.5" else 2
699
+ return 1
700
+
701
+
702
+ def _apply_service_tier_pricing(usage: Usage, service_tier: Any, model: Model) -> None:
703
+ multiplier = _get_service_tier_cost_multiplier(model, service_tier)
704
+ if multiplier == 1:
705
+ return
706
+ cost = usage.cost
707
+ cost.input *= multiplier
708
+ cost.output *= multiplier
709
+ cost.cache_read *= multiplier
710
+ cost.cache_write *= multiplier
711
+ cost.total = cost.input + cost.output + cost.cache_read + cost.cache_write
712
+
713
+
714
+ def _resolve_codex_service_tier(response_service_tier: Any, request_service_tier: Any) -> Any:
715
+ if response_service_tier == "default" and request_service_tier in ("flex", "priority"):
716
+ return request_service_tier
717
+ return response_service_tier if response_service_tier is not None else request_service_tier
718
+
719
+
720
+ _TRAILING_SLASHES = re.compile(r"/+$")
721
+
722
+
723
+ def _resolve_codex_url(base_url: str | None = None) -> str:
724
+ raw = base_url if base_url and base_url.strip() else DEFAULT_CODEX_BASE_URL
725
+ normalized = _TRAILING_SLASHES.sub("", raw)
726
+ if normalized.endswith("/codex/responses"):
727
+ return normalized
728
+ if normalized.endswith("/codex"):
729
+ return f"{normalized}/responses"
730
+ return f"{normalized}/codex/responses"
731
+
732
+
733
+ def _resolve_codex_websocket_url(base_url: str | None = None) -> str:
734
+ url = _resolve_codex_url(base_url)
735
+ if url.startswith("https:"):
736
+ return "wss:" + url[len("https:") :]
737
+ if url.startswith("http:"):
738
+ return "ws:" + url[len("http:") :]
739
+ return url
740
+
741
+
742
+ # ============================================================================
743
+ # Response Processing
744
+ # ============================================================================
745
+
746
+
747
+ def _stream_options(
748
+ model: Model, options: Mapping[str, Any], grammar_tool_input_properties: Mapping[str, str]
749
+ ) -> dict[str, Any]:
750
+ return {
751
+ "service_tier": options.get("service_tier"),
752
+ "grammar_tool_input_properties": grammar_tool_input_properties,
753
+ "resolve_service_tier": _resolve_codex_service_tier,
754
+ "apply_service_tier_pricing": lambda usage, tier: _apply_service_tier_pricing(usage, tier, model),
755
+ }
756
+
757
+
758
+ async def _aclose(iterator: Any) -> None:
759
+ aclose = getattr(iterator, "aclose", None)
760
+ if aclose is not None:
761
+ await aclose()
762
+
763
+
764
+ async def _process_sse_stream(
765
+ response: httpx.Response,
766
+ output: AssistantMessage,
767
+ out: AssistantMessageEventStream,
768
+ model: Model,
769
+ grammar_tool_input_properties: Mapping[str, str],
770
+ options: Mapping[str, Any],
771
+ ) -> None:
772
+ events = _map_codex_events(_parse_sse(response), output, model, options.get("on_provider_stream_event"))
773
+ try:
774
+ await process_responses_stream(
775
+ events,
776
+ output,
777
+ out,
778
+ model,
779
+ _stream_options(model, options, grammar_tool_input_properties), # type: ignore[arg-type]
780
+ )
781
+ finally:
782
+ await _aclose(events)
783
+
784
+
785
+ def _is_codex_non_transport_error(error: BaseException) -> bool:
786
+ return isinstance(error, (CodexApiError, CodexProtocolError, ProviderStreamEventCallbackError))
787
+
788
+
789
+ def _is_websocket_connection_limit_reached_error(error: BaseException) -> bool:
790
+ return isinstance(error, CodexApiError) and error.code == WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE
791
+
792
+
793
+ def _is_previous_response_not_found_error(error: BaseException) -> bool:
794
+ return isinstance(error, CodexApiError) and error.code == PREVIOUS_RESPONSE_NOT_FOUND_CODE
795
+
796
+
797
+ def _extract_codex_event_error(event: Mapping[str, Any]) -> tuple[str | None, str | None]:
798
+ nested = event.get("error")
799
+ nested = nested if isinstance(nested, dict) else None
800
+ code = event.get("code")
801
+ if not isinstance(code, str):
802
+ code = nested.get("code") if nested is not None and isinstance(nested.get("code"), str) else None
803
+ message = event.get("message")
804
+ if not isinstance(message, str):
805
+ message = nested.get("message") if nested is not None and isinstance(nested.get("message"), str) else None
806
+ return code, message
807
+
808
+
809
+ async def _map_codex_events(
810
+ events: AsyncIterable[Any],
811
+ output: AssistantMessage,
812
+ model: Model,
813
+ on_provider_stream_event: Callable[[Any, Any], Any] | None,
814
+ ) -> AsyncIterator[Any]:
815
+ try:
816
+ async for event in events:
817
+ if on_provider_stream_event is not None:
818
+ try:
819
+ await maybe_await(on_provider_stream_event(event, model))
820
+ except Exception as error:
821
+ # Keep callback failures out of Codex's WebSocket retry and SSE fallback path.
822
+ raise ProviderStreamEventCallbackError(error) from error
823
+ event_type = event.get("type") if isinstance(event, dict) else None
824
+ if not isinstance(event_type, str) or not event_type:
825
+ continue
826
+
827
+ if event_type == "error":
828
+ code, message = _extract_codex_event_error(event)
829
+ raise CodexApiError(
830
+ f"Codex error: {message or code or json.dumps(event, separators=(',', ':'))}",
831
+ code=code,
832
+ payload=event,
833
+ )
834
+
835
+ if event_type == "response.failed":
836
+ response = event.get("response")
837
+ error = response.get("error") if isinstance(response, dict) else None
838
+ error = error if isinstance(error, dict) else {}
839
+ raise CodexApiError(
840
+ error.get("message") or "Codex response failed", code=error.get("code"), payload=event
841
+ )
842
+
843
+ if event_type in _TERMINAL_EVENT_TYPES:
844
+ response = event.get("response")
845
+ normalized_response = response
846
+ if isinstance(response, dict):
847
+ end_turn = response.get("end_turn")
848
+ if isinstance(end_turn, bool):
849
+ output.end_turn = end_turn
850
+ normalized_response = {**response, "status": _normalize_codex_status(response.get("status"))}
851
+ yield {**event, "type": "response.completed", "response": normalized_response}
852
+ return
853
+
854
+ yield event
855
+ finally:
856
+ await _aclose(events)
857
+
858
+
859
+ def _normalize_codex_status(status: Any) -> str | None:
860
+ if not isinstance(status, str):
861
+ return None
862
+ return status if status in _CODEX_RESPONSE_STATUSES else None
863
+
864
+
865
+ # ============================================================================
866
+ # SSE Parsing
867
+ # ============================================================================
868
+
869
+
870
+ async def _parse_sse(response: httpx.Response) -> AsyncIterator[Any]:
871
+ async for sse in iter_sse(response):
872
+ data = sse.data.strip()
873
+ if not data or data == "[DONE]":
874
+ continue
875
+ try:
876
+ parsed = fastjson.loads(data)
877
+ except ValueError as cause:
878
+ raise CodexProtocolError(f"Invalid Codex SSE JSON: {format_thrown_value(cause)}", payload=data) from cause
879
+ yield parsed
880
+
881
+
882
+ # ============================================================================
883
+ # WebSocket Parsing
884
+ # ============================================================================
885
+
886
+
887
+ @dataclass(slots=True)
888
+ class OpenAICodexWebSocketDebugStats:
889
+ requests: int = 0
890
+ connections_created: int = 0
891
+ connections_reused: int = 0
892
+ cached_context_requests: int = 0
893
+ store_true_requests: int = 0
894
+ full_context_requests: int = 0
895
+ delta_requests: int = 0
896
+ last_input_items: int = 0
897
+ last_delta_input_items: int | None = None
898
+ last_previous_response_id: str | None = None
899
+ websocket_failures: int = 0
900
+ sse_fallbacks: int = 0
901
+ websocket_fallback_active: bool | None = None
902
+ last_websocket_error: str | None = None
903
+
904
+
905
+ @dataclass(slots=True)
906
+ class _CachedWebSocketContinuationState:
907
+ last_request_body: dict[str, Any]
908
+ last_response_id: str
909
+ last_response_items: list[Any]
910
+
911
+
912
+ @dataclass(slots=True)
913
+ class _CachedWebSocketConnection:
914
+ socket: Any
915
+ busy: bool
916
+ created_at: float
917
+ loop: asyncio.AbstractEventLoop
918
+ idle_timer: asyncio.TimerHandle | None = None
919
+ continuation: _CachedWebSocketContinuationState | None = None
920
+
921
+
922
+ _websocket_session_cache: dict[str, dict[str, _CachedWebSocketConnection]] = {}
923
+ _websocket_debug_stats: dict[str, OpenAICodexWebSocketDebugStats] = {}
924
+ _websocket_sse_fallback_sessions: set[str] = set()
925
+ _background_tasks: set[asyncio.Future[Any]] = set()
926
+
927
+
928
+ def _get_or_create_websocket_debug_stats(session_id: str) -> OpenAICodexWebSocketDebugStats:
929
+ stats = _websocket_debug_stats.get(session_id)
930
+ if stats is None:
931
+ stats = OpenAICodexWebSocketDebugStats()
932
+ _websocket_debug_stats[session_id] = stats
933
+ return stats
934
+
935
+
936
+ def get_openai_codex_websocket_debug_stats(session_id: str) -> OpenAICodexWebSocketDebugStats | None:
937
+ """A copy of the WebSocket transport counters for ``session_id`` (``None`` when unused)."""
938
+ stats = _websocket_debug_stats.get(session_id)
939
+ return replace(stats) if stats is not None else None
940
+
941
+
942
+ def reset_openai_codex_websocket_debug_stats(session_id: str | None = None) -> None:
943
+ """Forget debug counters and the SSE fallback flag for one session (or all sessions)."""
944
+ if session_id:
945
+ _websocket_debug_stats.pop(session_id, None)
946
+ _websocket_sse_fallback_sessions.discard(session_id)
947
+ return
948
+ _websocket_debug_stats.clear()
949
+ _websocket_sse_fallback_sessions.clear()
950
+
951
+
952
+ def close_openai_codex_websocket_sessions(session_id: str | None = None) -> None:
953
+ """Close pooled WebSocket connections of one session (or all sessions)."""
954
+
955
+ def close_entry(entry: _CachedWebSocketConnection) -> None:
956
+ if entry.idle_timer is not None:
957
+ entry.idle_timer.cancel()
958
+ entry.idle_timer = None
959
+ _close_websocket_silently(entry.socket, 1000, "debug_close", entry.loop)
960
+
961
+ if session_id:
962
+ for entry in list((_websocket_session_cache.get(session_id) or {}).values()):
963
+ close_entry(entry)
964
+ _websocket_session_cache.pop(session_id, None)
965
+ return
966
+ for account_entries in list(_websocket_session_cache.values()):
967
+ for entry in list(account_entries.values()):
968
+ close_entry(entry)
969
+ _websocket_session_cache.clear()
970
+
971
+
972
+ register_session_resource_cleanup(close_openai_codex_websocket_sessions)
973
+
974
+
975
+ def _is_websocket_sse_fallback_active(session_id: str | None) -> bool:
976
+ return session_id in _websocket_sse_fallback_sessions if session_id else False
977
+
978
+
979
+ def _record_websocket_sse_fallback(session_id: str | None) -> None:
980
+ if not session_id:
981
+ return
982
+ stats = _get_or_create_websocket_debug_stats(session_id)
983
+ stats.sse_fallbacks += 1
984
+ stats.websocket_fallback_active = _is_websocket_sse_fallback_active(session_id)
985
+
986
+
987
+ def _record_websocket_failure(session_id: str | None, error: BaseException) -> None:
988
+ if not session_id:
989
+ return
990
+ _websocket_sse_fallback_sessions.add(session_id)
991
+ stats = _get_or_create_websocket_debug_stats(session_id)
992
+ stats.websocket_failures += 1
993
+ stats.last_websocket_error = format_thrown_value(error)
994
+ stats.websocket_fallback_active = True
995
+
996
+
997
+ _websockets_available: bool | None = None
998
+
999
+
1000
+ def _resolve_websocket_connect() -> WebSocketConnect | None:
1001
+ global _websockets_available
1002
+ if websocket_connect is not None:
1003
+ return websocket_connect
1004
+ if _websockets_available is None:
1005
+ _websockets_available = importlib.util.find_spec("websockets") is not None
1006
+ return _connect_with_websockets if _websockets_available else None
1007
+
1008
+
1009
+ def _websocket_proxy_for(url: str, env: Mapping[str, str] | None) -> str | None:
1010
+ """Proxy from provider-scoped env overrides (``websockets`` honors ``os.environ`` itself)."""
1011
+ if not env:
1012
+ return None
1013
+ secure = url.startswith("wss:")
1014
+ names = ("HTTPS_PROXY", "https_proxy") if secure else ("HTTP_PROXY", "http_proxy")
1015
+ for name in (*names, "ALL_PROXY", "all_proxy"):
1016
+ value = env.get(name)
1017
+ if value:
1018
+ no_proxy = env.get("NO_PROXY") or env.get("no_proxy") or get_provider_env_value("NO_PROXY")
1019
+ if no_proxy:
1020
+ host = httpx.URL(url.replace("wss:", "https:", 1).replace("ws:", "http:", 1)).host
1021
+ for entry in (part.strip().lstrip(".") for part in no_proxy.split(",")):
1022
+ if entry and (entry == "*" or host == entry or host.endswith("." + entry)):
1023
+ return None
1024
+ return value
1025
+ return None
1026
+
1027
+
1028
+ class _WebsocketsConnection:
1029
+ """Adapts a ``websockets`` client connection to :class:`CodexWebSocket`."""
1030
+
1031
+ __slots__ = ("_connection",)
1032
+
1033
+ def __init__(self, connection: Any) -> None:
1034
+ self._connection = connection
1035
+
1036
+ @property
1037
+ def ready_state(self) -> int:
1038
+ return int(self._connection.state)
1039
+
1040
+ async def send(self, data: str) -> None:
1041
+ await self._connection.send(data)
1042
+
1043
+ async def recv(self) -> str | bytes:
1044
+ from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
1045
+
1046
+ try:
1047
+ return await self._connection.recv()
1048
+ except ConnectionClosed as error:
1049
+ frame = error.rcvd
1050
+ raise CodexWebSocketClosed(
1051
+ frame.code if frame is not None else 1006,
1052
+ frame.reason if frame is not None else "",
1053
+ isinstance(error, ConnectionClosedOK),
1054
+ ) from error
1055
+
1056
+ async def close(self, code: int = 1000, reason: str = "") -> None:
1057
+ await self._connection.close(code, reason)
1058
+
1059
+
1060
+ async def _connect_with_websockets(url: str, headers: dict[str, str], env: Mapping[str, str] | None) -> Any:
1061
+ try:
1062
+ from websockets.asyncio.client import connect
1063
+ except ImportError as error:
1064
+ raise RuntimeError(WEBSOCKET_UNAVAILABLE_MESSAGE) from error
1065
+ kwargs: dict[str, Any] = {"additional_headers": headers, "open_timeout": None, "max_size": None}
1066
+ if any(name.lower() == "user-agent" for name in headers):
1067
+ kwargs["user_agent_header"] = None
1068
+ proxy = _websocket_proxy_for(url, env)
1069
+ if proxy:
1070
+ kwargs["proxy"] = proxy
1071
+ connection = await connect(url, **kwargs)
1072
+ return _WebsocketsConnection(connection)
1073
+
1074
+
1075
+ def _websocket_ready_state(socket: Any) -> int | None:
1076
+ ready_state = getattr(socket, "ready_state", None)
1077
+ if isinstance(ready_state, int) and not isinstance(ready_state, bool):
1078
+ return ready_state
1079
+ return None
1080
+
1081
+
1082
+ def _is_websocket_reusable(socket: Any) -> bool:
1083
+ ready_state = _websocket_ready_state(socket)
1084
+ # If ready_state is unavailable, assume the implementation keeps it open/reusable.
1085
+ return ready_state is None or ready_state == 1
1086
+
1087
+
1088
+ def _is_websocket_session_expired(entry: _CachedWebSocketConnection) -> bool:
1089
+ return _clock_ms() - entry.created_at >= SESSION_WEBSOCKET_MAX_AGE_MS
1090
+
1091
+
1092
+ async def _await_quietly(awaitable: Awaitable[Any]) -> None:
1093
+ try:
1094
+ await awaitable
1095
+ except Exception:
1096
+ pass
1097
+
1098
+
1099
+ def _close_websocket_silently(
1100
+ socket: Any, code: int = 1000, reason: str = "done", loop: asyncio.AbstractEventLoop | None = None
1101
+ ) -> None:
1102
+ try:
1103
+ result = socket.close(code, reason)
1104
+ except Exception:
1105
+ return
1106
+ if not inspect.isawaitable(result):
1107
+ return
1108
+ try:
1109
+ running = asyncio.get_running_loop()
1110
+ except RuntimeError:
1111
+ running = None
1112
+ target = loop or running
1113
+ if target is not None and target is running:
1114
+ task = target.create_task(_await_quietly(result))
1115
+ _background_tasks.add(task)
1116
+ task.add_done_callback(_background_tasks.discard)
1117
+ elif target is not None and target.is_running() and not target.is_closed():
1118
+ asyncio.run_coroutine_threadsafe(_await_quietly(result), target)
1119
+ elif inspect.iscoroutine(result):
1120
+ result.close()
1121
+
1122
+
1123
+ def _remove_cached_entry(session_id: str, account_id: str, entry: _CachedWebSocketConnection) -> None:
1124
+ account_entries = _websocket_session_cache.get(session_id)
1125
+ if account_entries is not None and account_entries.get(account_id) is entry:
1126
+ del account_entries[account_id]
1127
+ if account_entries is not None and not account_entries:
1128
+ _websocket_session_cache.pop(session_id, None)
1129
+
1130
+
1131
+ def _schedule_session_websocket_expiry(session_id: str, account_id: str, entry: _CachedWebSocketConnection) -> None:
1132
+ if entry.idle_timer is not None:
1133
+ entry.idle_timer.cancel()
1134
+
1135
+ def expire() -> None:
1136
+ entry.idle_timer = None
1137
+ if entry.busy:
1138
+ return
1139
+ _close_websocket_silently(entry.socket, 1000, "idle_timeout", entry.loop)
1140
+ _remove_cached_entry(session_id, account_id, entry)
1141
+
1142
+ entry.idle_timer = entry.loop.call_later(SESSION_WEBSOCKET_CACHE_TTL_MS / 1000, expire)
1143
+
1144
+
1145
+ async def _connect_websocket(
1146
+ connect: WebSocketConnect,
1147
+ url: str,
1148
+ headers: Mapping[str, str],
1149
+ connect_timeout_ms: int | None,
1150
+ env: Mapping[str, str] | None,
1151
+ ) -> Any:
1152
+ ws_headers = dict(headers)
1153
+ timeout_ms = DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS if connect_timeout_ms is None else connect_timeout_ms
1154
+ if timeout_ms <= 0:
1155
+ return await connect(url, ws_headers, env)
1156
+ timeout = asyncio.timeout(timeout_ms / 1000)
1157
+ try:
1158
+ async with timeout:
1159
+ return await connect(url, ws_headers, env)
1160
+ except TimeoutError:
1161
+ if timeout.expired():
1162
+ raise RuntimeError(f"WebSocket connect timeout after {timeout_ms}ms") from None
1163
+ raise
1164
+
1165
+
1166
+ @dataclass(slots=True)
1167
+ class _AcquiredWebSocket:
1168
+ socket: Any
1169
+ entry: _CachedWebSocketConnection | None
1170
+ reused: bool
1171
+ release: Callable[[bool], None]
1172
+
1173
+
1174
+ async def _acquire_websocket(
1175
+ connect: WebSocketConnect,
1176
+ url: str,
1177
+ headers: Mapping[str, str],
1178
+ session_id: str | None,
1179
+ account_id: str,
1180
+ connect_timeout_ms: int | None,
1181
+ env: Mapping[str, str] | None,
1182
+ ) -> _AcquiredWebSocket:
1183
+ if not session_id:
1184
+ socket = await _connect_websocket(connect, url, headers, connect_timeout_ms, env)
1185
+ return _AcquiredWebSocket(socket, None, False, lambda keep=False: _close_websocket_silently(socket))
1186
+
1187
+ loop = asyncio.get_running_loop()
1188
+ account_entries = _websocket_session_cache.get(session_id)
1189
+ cached = account_entries.get(account_id) if account_entries is not None else None
1190
+ if cached is not None and cached.loop is not loop:
1191
+ # Connections belong to the event loop that opened them.
1192
+ if not cached.busy:
1193
+ if cached.idle_timer is not None:
1194
+ cached.idle_timer.cancel()
1195
+ cached.idle_timer = None
1196
+ _close_websocket_silently(cached.socket, 1000, "done", cached.loop)
1197
+ _remove_cached_entry(session_id, account_id, cached)
1198
+ cached = None
1199
+ if cached is not None:
1200
+ if cached.idle_timer is not None:
1201
+ cached.idle_timer.cancel()
1202
+ cached.idle_timer = None
1203
+ if cached.busy or cached.loop is not loop:
1204
+ socket = await _connect_websocket(connect, url, headers, connect_timeout_ms, env)
1205
+ return _AcquiredWebSocket(socket, None, False, lambda keep=False: _close_websocket_silently(socket))
1206
+ if _is_websocket_session_expired(cached):
1207
+ _close_websocket_silently(cached.socket, 1000, "connection_age_limit", cached.loop)
1208
+ _remove_cached_entry(session_id, account_id, cached)
1209
+ elif _is_websocket_reusable(cached.socket):
1210
+ cached.busy = True
1211
+ reused_entry = cached
1212
+
1213
+ def release_reused(keep: bool = False) -> None:
1214
+ if not keep or not _is_websocket_reusable(reused_entry.socket):
1215
+ _close_websocket_silently(reused_entry.socket)
1216
+ _remove_cached_entry(session_id, account_id, reused_entry)
1217
+ return
1218
+ reused_entry.busy = False
1219
+ _schedule_session_websocket_expiry(session_id, account_id, reused_entry)
1220
+
1221
+ return _AcquiredWebSocket(cached.socket, cached, True, release_reused)
1222
+ else:
1223
+ _close_websocket_silently(cached.socket)
1224
+ _remove_cached_entry(session_id, account_id, cached)
1225
+
1226
+ socket = await _connect_websocket(connect, url, headers, connect_timeout_ms, env)
1227
+ entry = _CachedWebSocketConnection(socket=socket, busy=True, created_at=_clock_ms(), loop=loop)
1228
+ _websocket_session_cache.setdefault(session_id, {})[account_id] = entry
1229
+
1230
+ def release(keep: bool = False) -> None:
1231
+ if not keep or not _is_websocket_reusable(entry.socket):
1232
+ _close_websocket_silently(entry.socket)
1233
+ if entry.idle_timer is not None:
1234
+ entry.idle_timer.cancel()
1235
+ entry.idle_timer = None
1236
+ _remove_cached_entry(session_id, account_id, entry)
1237
+ return
1238
+ entry.busy = False
1239
+ _schedule_session_websocket_expiry(session_id, account_id, entry)
1240
+
1241
+ return _AcquiredWebSocket(socket, entry, False, release)
1242
+
1243
+
1244
+ def _websocket_close_error(code: Any, reason: Any, was_clean: Any) -> Exception:
1245
+ code_text = f" {code}" if isinstance(code, int) and not isinstance(code, bool) else ""
1246
+ reason_text = f" {reason}" if isinstance(reason, str) and reason else ""
1247
+ if not reason_text and code == WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE:
1248
+ reason_text = " message too big"
1249
+ return WebSocketCloseError(
1250
+ f"WebSocket closed{code_text}{reason_text}".strip(),
1251
+ code=code if isinstance(code, int) and not isinstance(code, bool) else None,
1252
+ reason=reason if isinstance(reason, str) and reason else None,
1253
+ was_clean=was_clean if isinstance(was_clean, bool) else None,
1254
+ )
1255
+
1256
+
1257
+ def _decode_websocket_data(data: Any) -> str | None:
1258
+ if isinstance(data, str):
1259
+ return data
1260
+ if isinstance(data, (bytes, bytearray, memoryview)):
1261
+ return bytes(data).decode("utf-8", "replace")
1262
+ return None
1263
+
1264
+
1265
+ async def _recv_websocket(socket: Any, idle_timeout_ms: int | None) -> Any:
1266
+ try:
1267
+ if idle_timeout_ms is None or idle_timeout_ms <= 0:
1268
+ return await socket.recv()
1269
+ timeout = asyncio.timeout(idle_timeout_ms / 1000)
1270
+ try:
1271
+ async with timeout:
1272
+ return await socket.recv()
1273
+ except TimeoutError:
1274
+ if not timeout.expired():
1275
+ raise
1276
+ _close_websocket_silently(socket, 1000, "idle_timeout")
1277
+ raise RuntimeError(f"WebSocket idle timeout after {idle_timeout_ms}ms") from None
1278
+ except CodexWebSocketClosed as closed:
1279
+ raise _websocket_close_error(closed.code, closed.reason, closed.was_clean) from closed
1280
+
1281
+
1282
+ async def _parse_websocket(socket: Any, idle_timeout_ms: int | None) -> AsyncIterator[Any]:
1283
+ while True:
1284
+ try:
1285
+ data = await _recv_websocket(socket, idle_timeout_ms)
1286
+ except Exception as error:
1287
+ if str(error):
1288
+ raise
1289
+ raise RuntimeError("WebSocket error") from error
1290
+ if data is None:
1291
+ raise RuntimeError("WebSocket stream closed before response.completed")
1292
+ text = _decode_websocket_data(data)
1293
+ if not text:
1294
+ continue
1295
+ try:
1296
+ parsed = fastjson.loads(text)
1297
+ except ValueError as cause:
1298
+ raise CodexProtocolError(
1299
+ f"Invalid Codex WebSocket JSON: {format_thrown_value(cause)}", payload=text
1300
+ ) from cause
1301
+ event_type = parsed.get("type") if isinstance(parsed, dict) else None
1302
+ yield parsed
1303
+ if event_type in _TERMINAL_EVENT_TYPES:
1304
+ return
1305
+
1306
+
1307
+ def _request_body_without_input(body: Mapping[str, Any]) -> dict[str, Any]:
1308
+ return {key: value for key, value in body.items() if key not in ("input", "previous_response_id")}
1309
+
1310
+
1311
+ def _get_cached_websocket_input_delta(
1312
+ body: Mapping[str, Any], continuation: _CachedWebSocketContinuationState
1313
+ ) -> list[Any] | None:
1314
+ if _request_body_without_input(body) != _request_body_without_input(continuation.last_request_body):
1315
+ return None
1316
+ current_input = body.get("input") or []
1317
+ baseline = [*(continuation.last_request_body.get("input") or []), *continuation.last_response_items]
1318
+ if len(current_input) < len(baseline):
1319
+ return None
1320
+ if current_input[: len(baseline)] != baseline:
1321
+ return None
1322
+ return current_input[len(baseline) :]
1323
+
1324
+
1325
+ def _build_cached_websocket_request_body(entry: _CachedWebSocketConnection, body: dict[str, Any]) -> dict[str, Any]:
1326
+ continuation = entry.continuation
1327
+ if continuation is None:
1328
+ return body
1329
+ delta = _get_cached_websocket_input_delta(body, continuation)
1330
+ if delta is None or not continuation.last_response_id:
1331
+ entry.continuation = None
1332
+ return body
1333
+ return {**body, "previous_response_id": continuation.last_response_id, "input": delta}
1334
+
1335
+
1336
+ async def _start_on_first_event(events: AsyncIterable[Any], on_start: Callable[[], None]) -> AsyncIterator[Any]:
1337
+ started = False
1338
+ try:
1339
+ async for event in events:
1340
+ if not started:
1341
+ started = True
1342
+ on_start()
1343
+ yield event
1344
+ finally:
1345
+ await _aclose(events)
1346
+
1347
+
1348
+ async def _process_websocket_stream(
1349
+ connect: WebSocketConnect,
1350
+ url: str,
1351
+ body: dict[str, Any],
1352
+ headers: Mapping[str, str],
1353
+ output: AssistantMessage,
1354
+ out: AssistantMessageEventStream,
1355
+ model: Model,
1356
+ on_start: Callable[[], None],
1357
+ idle_timeout_ms: int | None,
1358
+ websocket_connect_timeout_ms: int | None,
1359
+ cache_session_id: str | None,
1360
+ account_id: str,
1361
+ grammar_tool_input_properties: Mapping[str, str],
1362
+ options: Mapping[str, Any],
1363
+ ) -> None:
1364
+ acquired = await _acquire_websocket(
1365
+ connect, url, headers, cache_session_id, account_id, websocket_connect_timeout_ms, options.get("env")
1366
+ )
1367
+ socket, entry = acquired.socket, acquired.entry
1368
+ keep_connection = True
1369
+ use_cached_context = options.get("transport") in ("websocket-cached", "auto")
1370
+ # ChatGPT Codex Responses rejects `store: true` ("Store must be set to false").
1371
+ # WebSocket continuation still works via connection-scoped previous_response_id state.
1372
+ full_body = body
1373
+ request_body = (
1374
+ _build_cached_websocket_request_body(entry, full_body)
1375
+ if use_cached_context and entry is not None
1376
+ else full_body
1377
+ )
1378
+ if cache_session_id:
1379
+ stats = _get_or_create_websocket_debug_stats(cache_session_id)
1380
+ stats.requests += 1
1381
+ if acquired.reused:
1382
+ stats.connections_reused += 1
1383
+ else:
1384
+ stats.connections_created += 1
1385
+ if use_cached_context:
1386
+ stats.cached_context_requests += 1
1387
+ if request_body.get("store") is True:
1388
+ stats.store_true_requests += 1
1389
+ stats.last_input_items = len(request_body.get("input") or [])
1390
+ if request_body.get("previous_response_id"):
1391
+ stats.delta_requests += 1
1392
+ stats.last_delta_input_items = len(request_body.get("input") or [])
1393
+ stats.last_previous_response_id = request_body["previous_response_id"]
1394
+ else:
1395
+ stats.full_context_requests += 1
1396
+ stats.last_delta_input_items = None
1397
+ stats.last_previous_response_id = None
1398
+ try:
1399
+ await maybe_await(socket.send(fastjson.dumps({"type": "response.create", **request_body})))
1400
+ events = _start_on_first_event(
1401
+ _map_codex_events(
1402
+ _parse_websocket(socket, idle_timeout_ms), output, model, options.get("on_provider_stream_event")
1403
+ ),
1404
+ on_start,
1405
+ )
1406
+ try:
1407
+ await process_responses_stream(
1408
+ events,
1409
+ output,
1410
+ out,
1411
+ model,
1412
+ _stream_options(model, options, grammar_tool_input_properties), # type: ignore[arg-type]
1413
+ )
1414
+ finally:
1415
+ await _aclose(events)
1416
+ signal = options.get("signal")
1417
+ if signal is not None and signal.aborted:
1418
+ keep_connection = False
1419
+ elif use_cached_context and entry is not None and output.response_id:
1420
+ response_items = [
1421
+ item
1422
+ for item in convert_responses_messages(
1423
+ model,
1424
+ TranscriptContext([output]),
1425
+ CODEX_TOOL_CALL_PROVIDERS,
1426
+ {"include_system_prompt": False, "grammar_tool_input_properties": grammar_tool_input_properties},
1427
+ )
1428
+ if item.get("type") not in ("function_call_output", "custom_tool_call_output")
1429
+ ]
1430
+ entry.continuation = _CachedWebSocketContinuationState(full_body, output.response_id, response_items)
1431
+ except BaseException:
1432
+ if entry is not None:
1433
+ entry.continuation = None
1434
+ keep_connection = False
1435
+ raise
1436
+ finally:
1437
+ acquired.release(keep_connection)
1438
+
1439
+
1440
+ # ============================================================================
1441
+ # Error Handling
1442
+ # ============================================================================
1443
+
1444
+
1445
+ def _parse_error_response(status: int, status_text: str, raw: str) -> tuple[str, str | None]:
1446
+ """``(message, friendly_message)`` for a failed Codex response."""
1447
+ message = raw or status_text or "Request failed"
1448
+ friendly_message: str | None = None
1449
+ try:
1450
+ parsed = json.loads(raw)
1451
+ err = parsed.get("error") if isinstance(parsed, dict) else None
1452
+ if err:
1453
+ fields = err if isinstance(err, dict) else {}
1454
+ code = fields.get("code") or fields.get("type") or ""
1455
+ if (isinstance(code, str) and _USAGE_LIMIT_CODE_RE.search(code)) or status == 429:
1456
+ plan_type = fields.get("plan_type")
1457
+ plan = f" ({plan_type.lower()} plan)" if plan_type else ""
1458
+ resets_at = fields.get("resets_at")
1459
+ mins = max(0, math.floor((resets_at * 1000 - _clock_ms()) / 60000 + 0.5)) if resets_at else None
1460
+ when = f" Try again in ~{mins} min." if mins is not None else ""
1461
+ friendly_message = f"You have hit your ChatGPT usage limit{plan}.{when}".strip()
1462
+ message = fields.get("message") or friendly_message or message
1463
+ except Exception:
1464
+ pass
1465
+ return message, friendly_message
1466
+
1467
+
1468
+ # ============================================================================
1469
+ # Auth & Headers
1470
+ # ============================================================================
1471
+
1472
+
1473
+ def _decode_base64_json(segment: str) -> Any:
1474
+ normalized = segment.replace("-", "+").replace("_", "/")
1475
+ normalized += "=" * (-len(normalized) % 4)
1476
+ return json.loads(base64.b64decode(normalized, validate=True).decode("utf-8"))
1477
+
1478
+
1479
+ def _extract_account_id(token: str) -> str:
1480
+ try:
1481
+ parts = token.split(".")
1482
+ if len(parts) != 3:
1483
+ raise ValueError("Invalid token")
1484
+ payload = _decode_base64_json(parts[1])
1485
+ claims = payload.get(JWT_CLAIM_PATH) if isinstance(payload, dict) else None
1486
+ account_id = claims.get("chatgpt_account_id") if isinstance(claims, dict) else None
1487
+ if not account_id:
1488
+ raise ValueError("No account ID in token")
1489
+ return str(account_id)
1490
+ except (ValueError, TypeError, binascii.Error, UnicodeDecodeError):
1491
+ raise RuntimeError("Failed to extract accountId from token") from None
1492
+
1493
+
1494
+ def _build_base_codex_headers(
1495
+ init_headers: Mapping[str, str] | None,
1496
+ additional_headers: Mapping[str, str | None] | None,
1497
+ account_id: str,
1498
+ token: str,
1499
+ ) -> dict[str, str]:
1500
+ # Defaults first so model and caller headers can override them, matching the other providers.
1501
+ # Names are lowercased like the Fetch ``Headers`` class does.
1502
+ headers: dict[str, str] = {"originator": "pi", "user-agent": get_pi_user_agent()}
1503
+ for key, value in (init_headers or {}).items():
1504
+ headers[key.lower()] = value
1505
+ for key, value in (additional_headers or {}).items():
1506
+ if value is None:
1507
+ headers.pop(key.lower(), None)
1508
+ else:
1509
+ headers[key.lower()] = value
1510
+ headers["authorization"] = f"Bearer {token}"
1511
+ headers["chatgpt-account-id"] = account_id
1512
+ return headers
1513
+
1514
+
1515
+ def _build_sse_headers(
1516
+ init_headers: Mapping[str, str] | None,
1517
+ additional_headers: Mapping[str, str | None] | None,
1518
+ account_id: str,
1519
+ token: str,
1520
+ session_id: str | None = None,
1521
+ ) -> dict[str, str]:
1522
+ headers = _build_base_codex_headers(init_headers, additional_headers, account_id, token)
1523
+ headers["openai-beta"] = "responses=experimental"
1524
+ headers["accept"] = "text/event-stream"
1525
+ headers["content-type"] = "application/json"
1526
+ if session_id:
1527
+ headers["session-id"] = session_id
1528
+ headers["x-client-request-id"] = session_id
1529
+ return headers
1530
+
1531
+
1532
+ def _build_websocket_headers(
1533
+ init_headers: Mapping[str, str] | None,
1534
+ additional_headers: Mapping[str, str | None] | None,
1535
+ account_id: str,
1536
+ token: str,
1537
+ request_id: str,
1538
+ ) -> dict[str, str]:
1539
+ headers = _build_base_codex_headers(init_headers, additional_headers, account_id, token)
1540
+ headers.pop("accept", None)
1541
+ headers.pop("content-type", None)
1542
+ headers["openai-beta"] = OPENAI_BETA_RESPONSES_WEBSOCKETS
1543
+ headers["x-client-request-id"] = request_id
1544
+ headers["session-id"] = request_id
1545
+ return headers