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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- 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
|