codex-backend-sdk 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_backend_sdk/__init__.py +72 -0
- codex_backend_sdk/codex_client.py +1084 -0
- codex_backend_sdk/oauth.py +299 -0
- codex_backend_sdk/pkce.py +29 -0
- codex_backend_sdk/storage.py +166 -0
- codex_backend_sdk-0.1.1.dist-info/METADATA +369 -0
- codex_backend_sdk-0.1.1.dist-info/RECORD +9 -0
- codex_backend_sdk-0.1.1.dist-info/WHEEL +4 -0
- codex_backend_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom SDK for the ChatGPT Codex backend API.
|
|
3
|
+
|
|
4
|
+
Endpoint base: https://chatgpt.com/backend-api/codex
|
|
5
|
+
Auth: Bearer <access_token> + ChatGPT-Account-ID header
|
|
6
|
+
|
|
7
|
+
Reverse-engineered from codex-rs source:
|
|
8
|
+
- model-provider-info/src/lib.rs → base URL selection
|
|
9
|
+
- codex-api/src/endpoint/models.rs → GET /models
|
|
10
|
+
- codex-api/src/endpoint/responses.rs → POST /responses (SSE)
|
|
11
|
+
- codex-api/src/endpoint/compact.rs → POST /responses/compact
|
|
12
|
+
- codex-api/src/endpoint/memories.rs → POST /memories/trace_summarize
|
|
13
|
+
- codex-api/src/common.rs → full request schemas
|
|
14
|
+
|
|
15
|
+
Confirmed live behaviour (2026-04-20):
|
|
16
|
+
- /responses : stream=True ONLY; tool_choice required
|
|
17
|
+
- /responses/compact : sync POST; compaction_summary reusable as input
|
|
18
|
+
- /memories/… : 403 on Plus plan (Pro/Enterprise only)
|
|
19
|
+
- backend /realtime/calls: 404 on chatgpt.com/backend-api/codex
|
|
20
|
+
- public /v1/realtime/calls accepts ChatGPT OAuth bearer + account id
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import threading
|
|
27
|
+
from urllib.parse import urlencode, urlparse, urlunparse
|
|
28
|
+
import uuid
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any, Callable, Generator, Iterator, Literal, Optional, Union
|
|
31
|
+
|
|
32
|
+
import requests
|
|
33
|
+
|
|
34
|
+
from .storage import TokenStore, load_tokens, save_tokens, token_needs_refresh
|
|
35
|
+
|
|
36
|
+
BASE_URL = "https://chatgpt.com/backend-api/codex"
|
|
37
|
+
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
|
|
38
|
+
CLIENT_VERSION = "0.1.0"
|
|
39
|
+
ORIGINATOR = "codex_cli_rs"
|
|
40
|
+
|
|
41
|
+
ReasoningEffort = Literal["minimal", "low", "medium", "high", "xhigh"]
|
|
42
|
+
ReasoningSummary = Literal["concise", "detailed", "auto"]
|
|
43
|
+
Verbosity = Literal["low", "medium", "high"]
|
|
44
|
+
ServiceTier = Literal["flex", "fast"]
|
|
45
|
+
|
|
46
|
+
# Type for user_message: plain string, or a list of content blocks (text + images)
|
|
47
|
+
MessageContent = Union[str, list[dict]]
|
|
48
|
+
|
|
49
|
+
# Sentinel used as default for per-call parameters so we can distinguish
|
|
50
|
+
# "caller did not pass this" from "caller explicitly passed None".
|
|
51
|
+
_UNSET: Any = object()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Module-level helpers for image content blocks
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def image_url(url: str) -> dict:
|
|
59
|
+
"""Content block for an image at the given URL (for use in user_message lists)."""
|
|
60
|
+
return {"type": "input_image", "image_url": url}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def image_b64(data: str, media_type: str = "image/jpeg") -> dict:
|
|
64
|
+
"""Content block for a base64-encoded image (for use in user_message lists)."""
|
|
65
|
+
return {"type": "input_image", "image_url": f"data:{media_type};base64,{data}"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Data models
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ReasoningLevel:
|
|
74
|
+
effort: str
|
|
75
|
+
description: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ModelInfo:
|
|
80
|
+
slug: str
|
|
81
|
+
display_name: str
|
|
82
|
+
description: str
|
|
83
|
+
context_window: Optional[int] = None
|
|
84
|
+
supported_in_api: bool = False
|
|
85
|
+
priority: int = 0
|
|
86
|
+
supports_reasoning_summaries: bool = False
|
|
87
|
+
support_verbosity: bool = False
|
|
88
|
+
default_verbosity: Optional[str] = None
|
|
89
|
+
default_reasoning_level: Optional[str] = None
|
|
90
|
+
supported_reasoning_levels: list[ReasoningLevel] = field(default_factory=list)
|
|
91
|
+
auto_compact_token_limit: Optional[int] = None
|
|
92
|
+
prefer_websockets: bool = False
|
|
93
|
+
input_modalities: list[str] = field(default_factory=list)
|
|
94
|
+
available_in_plans: list[str] = field(default_factory=list)
|
|
95
|
+
base_instructions: str = ""
|
|
96
|
+
raw: dict = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_dict(cls, d: dict) -> "ModelInfo":
|
|
100
|
+
levels = [
|
|
101
|
+
ReasoningLevel(effort=r.get("effort", ""), description=r.get("description", ""))
|
|
102
|
+
for r in d.get("supported_reasoning_levels", [])
|
|
103
|
+
]
|
|
104
|
+
return cls(
|
|
105
|
+
slug=d.get("slug", ""),
|
|
106
|
+
display_name=d.get("display_name", d.get("slug", "")),
|
|
107
|
+
description=d.get("description", ""),
|
|
108
|
+
context_window=d.get("context_window"),
|
|
109
|
+
supported_in_api=d.get("supported_in_api", False),
|
|
110
|
+
priority=d.get("priority", 0),
|
|
111
|
+
supports_reasoning_summaries=d.get("supports_reasoning_summaries", False),
|
|
112
|
+
support_verbosity=d.get("support_verbosity", False),
|
|
113
|
+
default_verbosity=d.get("default_verbosity"),
|
|
114
|
+
default_reasoning_level=d.get("default_reasoning_level"),
|
|
115
|
+
supported_reasoning_levels=levels,
|
|
116
|
+
auto_compact_token_limit=d.get("auto_compact_token_limit"),
|
|
117
|
+
prefer_websockets=d.get("prefer_websockets", False),
|
|
118
|
+
input_modalities=d.get("input_modalities", []),
|
|
119
|
+
available_in_plans=d.get("available_in_plans", []),
|
|
120
|
+
base_instructions=d.get("base_instructions", ""),
|
|
121
|
+
raw=d,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class TextDelta:
|
|
127
|
+
"""Incremental text chunk from a streaming response."""
|
|
128
|
+
text: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class ReasoningDelta:
|
|
133
|
+
"""Reasoning content delivered when include_reasoning=True."""
|
|
134
|
+
text: str
|
|
135
|
+
summary_index: int = 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class ToolCall:
|
|
140
|
+
"""
|
|
141
|
+
Emitted when the model requests a function call.
|
|
142
|
+
|
|
143
|
+
Typical tool loop:
|
|
144
|
+
|
|
145
|
+
history = []
|
|
146
|
+
for event in client.stream("What's the weather in Paris?", tools=tools):
|
|
147
|
+
if isinstance(event, TextDelta):
|
|
148
|
+
print(event.text, end="")
|
|
149
|
+
elif isinstance(event, ToolCall):
|
|
150
|
+
result = dispatch(event.name, event.parsed_arguments())
|
|
151
|
+
history.append(event.as_history_item())
|
|
152
|
+
history.append(event.to_tool_result(json.dumps(result)))
|
|
153
|
+
|
|
154
|
+
# Continue — model sees the tool result and responds
|
|
155
|
+
for event in client.stream(None, conversation_history=history, tools=tools):
|
|
156
|
+
...
|
|
157
|
+
"""
|
|
158
|
+
call_id: str
|
|
159
|
+
name: str
|
|
160
|
+
arguments: str # JSON string, same convention as the official SDK
|
|
161
|
+
raw: dict = field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
def parsed_arguments(self) -> dict:
|
|
164
|
+
"""Deserialize the arguments JSON string to a dict."""
|
|
165
|
+
return json.loads(self.arguments)
|
|
166
|
+
|
|
167
|
+
def as_history_item(self) -> dict:
|
|
168
|
+
"""The raw function_call dict to append to conversation_history."""
|
|
169
|
+
return self.raw
|
|
170
|
+
|
|
171
|
+
def to_tool_result(self, output: str) -> dict:
|
|
172
|
+
"""
|
|
173
|
+
Build a function_call_output item for conversation_history.
|
|
174
|
+
|
|
175
|
+
history.append(call.as_history_item())
|
|
176
|
+
history.append(call.to_tool_result(json.dumps(result)))
|
|
177
|
+
"""
|
|
178
|
+
return {
|
|
179
|
+
"type": "function_call_output",
|
|
180
|
+
"call_id": self.call_id,
|
|
181
|
+
"output": output,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass
|
|
186
|
+
class TokenUsage:
|
|
187
|
+
input_tokens: int = 0
|
|
188
|
+
output_tokens: int = 0
|
|
189
|
+
total_tokens: int = 0
|
|
190
|
+
reasoning_tokens: int = 0
|
|
191
|
+
cached_tokens: int = 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class ResponseCompleted:
|
|
196
|
+
"""Final event emitted when a streaming response finishes successfully."""
|
|
197
|
+
response_id: str
|
|
198
|
+
usage: TokenUsage = field(default_factory=TokenUsage)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def input_tokens(self) -> int:
|
|
202
|
+
return self.usage.input_tokens
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def output_tokens(self) -> int:
|
|
206
|
+
return self.usage.output_tokens
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def total_tokens(self) -> int:
|
|
210
|
+
return self.usage.total_tokens
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class ResponseFailed:
|
|
215
|
+
"""Emitted on response.failed SSE events."""
|
|
216
|
+
code: str
|
|
217
|
+
message: str
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ResponseAborted(Exception):
|
|
221
|
+
"""Raised when an in-flight streaming response is aborted by the caller."""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class OutputItem:
|
|
226
|
+
"""A completed output item that is not a function call (message, compaction_summary, …)."""
|
|
227
|
+
item_type: str
|
|
228
|
+
role: Optional[str]
|
|
229
|
+
content: list[dict]
|
|
230
|
+
raw: dict = field(default_factory=dict)
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def from_dict(cls, d: dict) -> "OutputItem":
|
|
234
|
+
content_raw = d.get("content", [])
|
|
235
|
+
content = []
|
|
236
|
+
for c in content_raw:
|
|
237
|
+
if c.get("type") == "output_text":
|
|
238
|
+
content.append({"type": "text", "text": c.get("text", "")})
|
|
239
|
+
else:
|
|
240
|
+
content.append(c)
|
|
241
|
+
return cls(
|
|
242
|
+
item_type=d.get("type", ""),
|
|
243
|
+
role=d.get("role"),
|
|
244
|
+
content=content,
|
|
245
|
+
raw=d,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class CompactionResult:
|
|
251
|
+
"""
|
|
252
|
+
Result of POST /responses/compact.
|
|
253
|
+
|
|
254
|
+
output_items can be passed directly as conversation_history to stream().
|
|
255
|
+
It includes original messages plus a compaction_summary item (encrypted
|
|
256
|
+
blob that the model understands — treat it as opaque on the client side).
|
|
257
|
+
"""
|
|
258
|
+
response_id: str
|
|
259
|
+
output_items: list[dict]
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def has_summary(self) -> bool:
|
|
263
|
+
return any(item.get("type") == "compaction_summary" for item in self.output_items)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def summary_item(self) -> Optional[dict]:
|
|
267
|
+
for item in self.output_items:
|
|
268
|
+
if item.get("type") == "compaction_summary":
|
|
269
|
+
return item
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class RealtimeCallResult:
|
|
275
|
+
"""Result of creating a WebRTC realtime call."""
|
|
276
|
+
answer_sdp: str
|
|
277
|
+
call_id: str
|
|
278
|
+
location: str
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def sideband_url(self) -> str:
|
|
282
|
+
return f"wss://api.openai.com/v1/realtime?call_id={self.call_id}"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Union type for stream events
|
|
286
|
+
StreamEvent = Union[TextDelta, ReasoningDelta, ToolCall, OutputItem, ResponseCompleted, ResponseFailed]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Client
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
class CodexClient:
|
|
294
|
+
"""
|
|
295
|
+
Python SDK for the ChatGPT Codex backend API.
|
|
296
|
+
|
|
297
|
+
Session-level defaults can be set on the client and are used for every
|
|
298
|
+
stream()/respond() call unless overridden per-call:
|
|
299
|
+
|
|
300
|
+
client = CodexClient.from_saved_tokens(
|
|
301
|
+
model="gpt-5.3-codex",
|
|
302
|
+
instructions="You are a concise assistant.",
|
|
303
|
+
reasoning="medium",
|
|
304
|
+
web_search="cached",
|
|
305
|
+
service_tier="fast",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Uses session defaults:
|
|
309
|
+
client.stream("Explain quicksort")
|
|
310
|
+
|
|
311
|
+
# Overrides reasoning for this call only:
|
|
312
|
+
client.stream("Quick one-liner?", reasoning="minimal")
|
|
313
|
+
|
|
314
|
+
# Explicitly disables a default for this call (None overrides a set default):
|
|
315
|
+
client.stream("No web please", web_search=None)
|
|
316
|
+
|
|
317
|
+
Tool use (same tool definition format as the official OpenAI SDK):
|
|
318
|
+
tools = [{"type": "function", "name": "get_weather", ...}]
|
|
319
|
+
|
|
320
|
+
history = []
|
|
321
|
+
for event in client.stream("Weather in Paris?", tools=tools):
|
|
322
|
+
if isinstance(event, ToolCall):
|
|
323
|
+
result = dispatch(event.name, event.parsed_arguments())
|
|
324
|
+
history.append(event.as_history_item())
|
|
325
|
+
history.append(event.to_tool_result(json.dumps(result)))
|
|
326
|
+
|
|
327
|
+
for event in client.stream(None, conversation_history=history, tools=tools):
|
|
328
|
+
if isinstance(event, TextDelta):
|
|
329
|
+
print(event.text, end="")
|
|
330
|
+
|
|
331
|
+
Image input:
|
|
332
|
+
from codex_sdk import image_url, image_b64
|
|
333
|
+
client.stream(["Describe this image:", image_url("https://...")])
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
def __init__(
|
|
337
|
+
self,
|
|
338
|
+
store: Optional[TokenStore] = None,
|
|
339
|
+
*,
|
|
340
|
+
model: str = "gpt-5.4",
|
|
341
|
+
instructions: str = "",
|
|
342
|
+
reasoning: Optional[ReasoningEffort] = None,
|
|
343
|
+
reasoning_summary: Optional[ReasoningSummary] = None,
|
|
344
|
+
verbosity: Optional[Verbosity] = None,
|
|
345
|
+
web_search: Optional[str] = None,
|
|
346
|
+
service_tier: Optional[ServiceTier] = None,
|
|
347
|
+
parallel_tool_calls: bool = False,
|
|
348
|
+
tools: Optional[list[dict]] = None,
|
|
349
|
+
persist: bool = False,
|
|
350
|
+
include_reasoning: bool = False,
|
|
351
|
+
) -> None:
|
|
352
|
+
self._store = store
|
|
353
|
+
self._session = requests.Session()
|
|
354
|
+
if store:
|
|
355
|
+
self._session.headers.update(self._auth_headers())
|
|
356
|
+
self._stream_lock = threading.RLock()
|
|
357
|
+
self._active_response: Optional[requests.Response] = None
|
|
358
|
+
self._abort_requested = False
|
|
359
|
+
# Session-level defaults — resolved by _resolve() in stream()/respond()
|
|
360
|
+
self._defaults: dict[str, Any] = {
|
|
361
|
+
"model": model,
|
|
362
|
+
"instructions": instructions,
|
|
363
|
+
"reasoning": reasoning,
|
|
364
|
+
"reasoning_summary": reasoning_summary,
|
|
365
|
+
"verbosity": verbosity,
|
|
366
|
+
"web_search": web_search,
|
|
367
|
+
"service_tier": service_tier,
|
|
368
|
+
"parallel_tool_calls": parallel_tool_calls,
|
|
369
|
+
"tools": tools,
|
|
370
|
+
"store": persist,
|
|
371
|
+
"include_reasoning": include_reasoning,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
def _resolve(self, key: str, value: Any) -> Any:
|
|
375
|
+
"""Return value if explicitly passed, otherwise fall back to session default."""
|
|
376
|
+
return self._defaults[key] if value is _UNSET else value
|
|
377
|
+
|
|
378
|
+
# ------------------------------------------------------------------
|
|
379
|
+
# Authentication
|
|
380
|
+
# ------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def authenticate(
|
|
383
|
+
self,
|
|
384
|
+
*,
|
|
385
|
+
request_api_key: bool = True,
|
|
386
|
+
interactive: bool = True,
|
|
387
|
+
) -> "CodexClient":
|
|
388
|
+
"""
|
|
389
|
+
Ensure this client is authenticated against the server:
|
|
390
|
+
|
|
391
|
+
1. Load ~/.codex/auth.json if present.
|
|
392
|
+
2. If token is stale (exp within 5 min or last_refresh > 55 min) → refresh proactively.
|
|
393
|
+
3. If token is fresh → use it directly (no network probe needed).
|
|
394
|
+
4. If still stale after failed refresh → probe /wham/usage as last resort.
|
|
395
|
+
5. If probe fails or no tokens and interactive=True → full OAuth browser flow.
|
|
396
|
+
|
|
397
|
+
With interactive=False, this method only loads/refreshes/probes existing
|
|
398
|
+
stored credentials and raises RuntimeError instead of opening a browser.
|
|
399
|
+
|
|
400
|
+
Tokens are persisted after every refresh or new login.
|
|
401
|
+
Returns self for chaining:
|
|
402
|
+
|
|
403
|
+
client = CodexClient().authenticate()
|
|
404
|
+
client = CodexClient(model="gpt-5.4", reasoning="low").authenticate()
|
|
405
|
+
client = CodexClient().authenticate(interactive=False)
|
|
406
|
+
"""
|
|
407
|
+
from .oauth import refresh_access_token, run_oauth_flow
|
|
408
|
+
|
|
409
|
+
def _do_refresh(store: TokenStore) -> Optional[TokenStore]:
|
|
410
|
+
"""Attempt a token refresh; return updated store or None on failure."""
|
|
411
|
+
try:
|
|
412
|
+
data = refresh_access_token(store.refresh_token)
|
|
413
|
+
refreshed = TokenStore.from_exchange(
|
|
414
|
+
access_token=data.get("access_token", store.access_token),
|
|
415
|
+
refresh_token=data.get("refresh_token", store.refresh_token),
|
|
416
|
+
id_token=data.get("id_token", store.id_token_raw),
|
|
417
|
+
api_key=store.openai_api_key,
|
|
418
|
+
)
|
|
419
|
+
save_tokens(refreshed)
|
|
420
|
+
return refreshed
|
|
421
|
+
except Exception as exc:
|
|
422
|
+
print(f"[auth] Refresh failed: {exc}")
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
def _probe(store: TokenStore) -> bool:
|
|
426
|
+
"""Return True if the server accepts these tokens."""
|
|
427
|
+
try:
|
|
428
|
+
resp = requests.get(
|
|
429
|
+
"https://chatgpt.com/backend-api/wham/usage",
|
|
430
|
+
headers={
|
|
431
|
+
"Authorization": f"Bearer {store.access_token}",
|
|
432
|
+
"originator": ORIGINATOR,
|
|
433
|
+
**({"ChatGPT-Account-ID": store.account_id} if store.account_id else {}),
|
|
434
|
+
},
|
|
435
|
+
timeout=15,
|
|
436
|
+
)
|
|
437
|
+
return resp.ok
|
|
438
|
+
except Exception:
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
store = load_tokens()
|
|
442
|
+
|
|
443
|
+
if store is not None:
|
|
444
|
+
# Proactive refresh: don't wait for a 401
|
|
445
|
+
if token_needs_refresh(store):
|
|
446
|
+
print("[auth] Token stale — refreshing proactively…")
|
|
447
|
+
if store.refresh_token:
|
|
448
|
+
refreshed = _do_refresh(store)
|
|
449
|
+
if refreshed:
|
|
450
|
+
store = refreshed
|
|
451
|
+
print("[auth] Token refreshed.")
|
|
452
|
+
else:
|
|
453
|
+
print("[auth] Proactive refresh failed — trying existing tokens…")
|
|
454
|
+
|
|
455
|
+
# If token is now fresh, skip the probe entirely
|
|
456
|
+
if not token_needs_refresh(store):
|
|
457
|
+
self._store = store
|
|
458
|
+
self._session.headers.update(self._auth_headers())
|
|
459
|
+
return self
|
|
460
|
+
|
|
461
|
+
# Token still stale (no refresh_token or refresh failed) — probe server
|
|
462
|
+
if _probe(store):
|
|
463
|
+
self._store = store
|
|
464
|
+
self._session.headers.update(self._auth_headers())
|
|
465
|
+
return self
|
|
466
|
+
|
|
467
|
+
print("[auth] Tokens rejected — running full login…")
|
|
468
|
+
|
|
469
|
+
if not interactive:
|
|
470
|
+
raise RuntimeError("No usable stored Codex credentials; interactive login required.")
|
|
471
|
+
|
|
472
|
+
store = run_oauth_flow(request_api_key=request_api_key)
|
|
473
|
+
self._store = store
|
|
474
|
+
self._session.headers.update(self._auth_headers())
|
|
475
|
+
return self
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def authenticated(self) -> bool:
|
|
479
|
+
"""Whether this client currently has OAuth credentials loaded."""
|
|
480
|
+
return bool(self._store and self._store.account_id)
|
|
481
|
+
|
|
482
|
+
def account_info(self) -> dict[str, Any]:
|
|
483
|
+
"""Return non-secret account metadata decoded from the auth tokens.
|
|
484
|
+
|
|
485
|
+
The SDK keeps tokens private, but UIs often need a stable way to show
|
|
486
|
+
whether the client is authenticated and which ChatGPT plan/account is
|
|
487
|
+
active. This method exposes only safe metadata already decoded into
|
|
488
|
+
:class:`TokenStore`; it never returns access tokens, refresh tokens or
|
|
489
|
+
API keys.
|
|
490
|
+
"""
|
|
491
|
+
store = self._store
|
|
492
|
+
authenticated = bool(store and store.account_id)
|
|
493
|
+
return {
|
|
494
|
+
"authenticated": authenticated,
|
|
495
|
+
"account_id": store.account_id if store else None,
|
|
496
|
+
"email": store.email if store else None,
|
|
497
|
+
"plan_type": store.plan_type if store else None,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
def _ensure_auth(self) -> None:
|
|
501
|
+
if not self._store or not self._store.account_id:
|
|
502
|
+
raise RuntimeError(
|
|
503
|
+
"Not authenticated — call authenticate() first."
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def _auth_headers(self) -> dict:
|
|
507
|
+
headers = {
|
|
508
|
+
"Authorization": f"Bearer {self._store.access_token}",
|
|
509
|
+
"originator": ORIGINATOR,
|
|
510
|
+
"OpenAI-Beta": "responses=experimental",
|
|
511
|
+
}
|
|
512
|
+
if self._store.account_id:
|
|
513
|
+
headers["ChatGPT-Account-ID"] = self._store.account_id
|
|
514
|
+
return headers
|
|
515
|
+
|
|
516
|
+
def _openai_auth_headers(self) -> dict:
|
|
517
|
+
"""Auth headers for api.openai.com using ChatGPT OAuth credentials."""
|
|
518
|
+
headers = {
|
|
519
|
+
"Authorization": f"Bearer {self._store.access_token}",
|
|
520
|
+
"originator": ORIGINATOR,
|
|
521
|
+
"version": CLIENT_VERSION,
|
|
522
|
+
}
|
|
523
|
+
if self._store.account_id:
|
|
524
|
+
headers["ChatGPT-Account-ID"] = self._store.account_id
|
|
525
|
+
return headers
|
|
526
|
+
|
|
527
|
+
def abort(self) -> None:
|
|
528
|
+
"""
|
|
529
|
+
Abort the currently active streaming response, if any.
|
|
530
|
+
|
|
531
|
+
The method is intentionally synchronous and best-effort: it marks the
|
|
532
|
+
active stream as aborted and closes the underlying HTTP response so a
|
|
533
|
+
thread blocked in ``stream()`` can unwind with ``ResponseAborted``.
|
|
534
|
+
Calling it with no stream in progress is a no-op.
|
|
535
|
+
"""
|
|
536
|
+
with self._stream_lock:
|
|
537
|
+
resp = self._active_response
|
|
538
|
+
if resp is None:
|
|
539
|
+
return
|
|
540
|
+
self._abort_requested = True
|
|
541
|
+
|
|
542
|
+
resp.close()
|
|
543
|
+
|
|
544
|
+
def _response_was_aborted(self) -> bool:
|
|
545
|
+
with self._stream_lock:
|
|
546
|
+
return self._abort_requested
|
|
547
|
+
|
|
548
|
+
def realtime_websocket_url(
|
|
549
|
+
self,
|
|
550
|
+
*,
|
|
551
|
+
model: str = "gpt-realtime-1.5",
|
|
552
|
+
api_base_url: str = OPENAI_API_BASE_URL,
|
|
553
|
+
) -> str:
|
|
554
|
+
"""Return the direct server-side Realtime WebSocket URL for a model."""
|
|
555
|
+
base = urlparse(api_base_url.rstrip("/"))
|
|
556
|
+
scheme = "wss" if base.scheme == "https" else "ws"
|
|
557
|
+
return urlunparse(
|
|
558
|
+
(
|
|
559
|
+
scheme,
|
|
560
|
+
base.netloc,
|
|
561
|
+
f"{base.path.rstrip('/')}/realtime",
|
|
562
|
+
"",
|
|
563
|
+
urlencode({"model": model}),
|
|
564
|
+
"",
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
def realtime_websocket_headers(self, *, session_id: Optional[str] = None) -> dict:
|
|
569
|
+
"""Return auth headers for a direct Realtime WebSocket connection."""
|
|
570
|
+
self._ensure_auth()
|
|
571
|
+
headers = self._openai_auth_headers()
|
|
572
|
+
if session_id:
|
|
573
|
+
headers["x-session-id"] = session_id
|
|
574
|
+
return headers
|
|
575
|
+
|
|
576
|
+
# ------------------------------------------------------------------
|
|
577
|
+
# Models
|
|
578
|
+
# ------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
def list_models(self) -> list[ModelInfo]:
|
|
581
|
+
"""
|
|
582
|
+
GET /models — list models available to this account.
|
|
583
|
+
|
|
584
|
+
Returns ModelInfo objects sorted by priority (highest first).
|
|
585
|
+
"""
|
|
586
|
+
self._ensure_auth()
|
|
587
|
+
resp = self._session.get(
|
|
588
|
+
f"{BASE_URL}/models",
|
|
589
|
+
params={"client_version": CLIENT_VERSION},
|
|
590
|
+
timeout=15,
|
|
591
|
+
)
|
|
592
|
+
resp.raise_for_status()
|
|
593
|
+
models = [ModelInfo.from_dict(m) for m in resp.json().get("models", [])]
|
|
594
|
+
models.sort(key=lambda m: m.priority, reverse=True)
|
|
595
|
+
return models
|
|
596
|
+
|
|
597
|
+
def get_model(self, slug: str) -> Optional[ModelInfo]:
|
|
598
|
+
"""Return the ModelInfo for a specific slug, or None if not found."""
|
|
599
|
+
return next((m for m in self.list_models() if m.slug == slug), None)
|
|
600
|
+
|
|
601
|
+
# ------------------------------------------------------------------
|
|
602
|
+
# Usage / quota
|
|
603
|
+
# ------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
def usage(self) -> dict:
|
|
606
|
+
"""
|
|
607
|
+
GET /backend-api/wham/usage — query rate limits and quota for this account.
|
|
608
|
+
|
|
609
|
+
Returns the raw JSON dict from the backend.
|
|
610
|
+
"""
|
|
611
|
+
self._ensure_auth()
|
|
612
|
+
resp = self._session.get(
|
|
613
|
+
"https://chatgpt.com/backend-api/wham/usage",
|
|
614
|
+
timeout=15,
|
|
615
|
+
)
|
|
616
|
+
resp.raise_for_status()
|
|
617
|
+
return resp.json()
|
|
618
|
+
|
|
619
|
+
# ------------------------------------------------------------------
|
|
620
|
+
# Realtime WebRTC call creation
|
|
621
|
+
# ------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
@staticmethod
|
|
624
|
+
def realtime_sideband_url(call_id: str) -> str:
|
|
625
|
+
"""Return the sideband WebSocket URL for a realtime WebRTC call id."""
|
|
626
|
+
return f"wss://api.openai.com/v1/realtime?call_id={call_id}"
|
|
627
|
+
|
|
628
|
+
@staticmethod
|
|
629
|
+
def _call_id_from_location(location: str) -> str:
|
|
630
|
+
path = location.split("?", 1)[0]
|
|
631
|
+
for segment in reversed(path.split("/")):
|
|
632
|
+
if segment.startswith("rtc_") and len(segment) > len("rtc_"):
|
|
633
|
+
return segment
|
|
634
|
+
raise RuntimeError(f"Realtime call Location does not contain a call id: {location}")
|
|
635
|
+
|
|
636
|
+
def create_realtime_call(
|
|
637
|
+
self,
|
|
638
|
+
offer_sdp: str,
|
|
639
|
+
*,
|
|
640
|
+
model: str = "gpt-realtime-1.5",
|
|
641
|
+
instructions: str = "",
|
|
642
|
+
voice: str = "marin",
|
|
643
|
+
session: Optional[dict] = None,
|
|
644
|
+
session_id: Optional[str] = None,
|
|
645
|
+
api_base_url: str = OPENAI_API_BASE_URL,
|
|
646
|
+
timeout: int = 30,
|
|
647
|
+
) -> RealtimeCallResult:
|
|
648
|
+
"""
|
|
649
|
+
Create a WebRTC realtime call using ChatGPT OAuth credentials.
|
|
650
|
+
|
|
651
|
+
The browser or WebView should generate offer_sdp with an audio
|
|
652
|
+
transceiver and the `oai-events` data channel before this call. This
|
|
653
|
+
method intentionally does not read OPENAI_API_KEY; it authenticates with
|
|
654
|
+
the saved ChatGPT bearer token and ChatGPT-Account-ID.
|
|
655
|
+
"""
|
|
656
|
+
self._ensure_auth()
|
|
657
|
+
if session is None:
|
|
658
|
+
session = {
|
|
659
|
+
"type": "realtime",
|
|
660
|
+
"model": model,
|
|
661
|
+
"instructions": instructions,
|
|
662
|
+
"audio": {"output": {"voice": voice}},
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
headers = self._openai_auth_headers()
|
|
666
|
+
if session_id:
|
|
667
|
+
headers["x-session-id"] = session_id
|
|
668
|
+
|
|
669
|
+
boundary = f"----codex-realtime-call-{uuid.uuid4().hex}"
|
|
670
|
+
body = (
|
|
671
|
+
f"--{boundary}\r\n"
|
|
672
|
+
'Content-Disposition: form-data; name="sdp"\r\n'
|
|
673
|
+
"Content-Type: application/sdp\r\n\r\n"
|
|
674
|
+
f"{offer_sdp}\r\n"
|
|
675
|
+
f"--{boundary}\r\n"
|
|
676
|
+
'Content-Disposition: form-data; name="session"\r\n'
|
|
677
|
+
"Content-Type: application/json\r\n\r\n"
|
|
678
|
+
f"{json.dumps(session)}\r\n"
|
|
679
|
+
f"--{boundary}--\r\n"
|
|
680
|
+
).encode("utf-8")
|
|
681
|
+
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
|
|
682
|
+
# Use a fresh request instead of self._session so backend-only headers
|
|
683
|
+
# such as OpenAI-Beta: responses=experimental are not merged in.
|
|
684
|
+
resp = requests.post(
|
|
685
|
+
f"{api_base_url.rstrip('/')}/realtime/calls",
|
|
686
|
+
data=body,
|
|
687
|
+
headers=headers,
|
|
688
|
+
timeout=timeout,
|
|
689
|
+
)
|
|
690
|
+
resp.raise_for_status()
|
|
691
|
+
location = resp.headers.get("Location") or resp.headers.get("location") or ""
|
|
692
|
+
call_id = self._call_id_from_location(location)
|
|
693
|
+
return RealtimeCallResult(
|
|
694
|
+
answer_sdp=resp.text,
|
|
695
|
+
call_id=call_id,
|
|
696
|
+
location=location,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
# ------------------------------------------------------------------
|
|
700
|
+
# Responses — SSE streaming (stream=True is mandatory by the backend)
|
|
701
|
+
# ------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
def stream(
|
|
704
|
+
self,
|
|
705
|
+
user_message: Optional[MessageContent] = None,
|
|
706
|
+
*,
|
|
707
|
+
# Per-call params — _UNSET means "use session default"
|
|
708
|
+
model: str = _UNSET,
|
|
709
|
+
instructions: str = _UNSET,
|
|
710
|
+
reasoning: Optional[ReasoningEffort] = _UNSET,
|
|
711
|
+
reasoning_summary: Optional[ReasoningSummary] = _UNSET,
|
|
712
|
+
verbosity: Optional[Verbosity] = _UNSET,
|
|
713
|
+
web_search: Optional[str] = _UNSET,
|
|
714
|
+
service_tier: Optional[ServiceTier] = _UNSET,
|
|
715
|
+
parallel_tool_calls: bool = _UNSET,
|
|
716
|
+
tools: Optional[list[dict]] = _UNSET,
|
|
717
|
+
store: bool = _UNSET,
|
|
718
|
+
include_reasoning: bool = _UNSET,
|
|
719
|
+
# Always per-call (no session default)
|
|
720
|
+
conversation_history: Optional[list[dict]] = None,
|
|
721
|
+
tool_choice: Union[str, dict] = "auto",
|
|
722
|
+
output_schema: Optional[dict] = None,
|
|
723
|
+
prompt_cache_key: Optional[str] = None,
|
|
724
|
+
) -> Iterator[StreamEvent]:
|
|
725
|
+
"""
|
|
726
|
+
POST /responses — stream a model response via SSE.
|
|
727
|
+
|
|
728
|
+
Yields StreamEvent objects:
|
|
729
|
+
TextDelta — incremental text chunk
|
|
730
|
+
ReasoningDelta — reasoning summary chunk (if include_reasoning=True)
|
|
731
|
+
ToolCall — model requests a function call
|
|
732
|
+
OutputItem — completed non-tool output item (message, …)
|
|
733
|
+
ResponseCompleted — final event with full token usage breakdown
|
|
734
|
+
ResponseFailed — error event
|
|
735
|
+
|
|
736
|
+
Parameters with a session default (set on the client, overridable per-call):
|
|
737
|
+
model, instructions, reasoning, reasoning_summary, verbosity,
|
|
738
|
+
web_search, service_tier, parallel_tool_calls, tools, store,
|
|
739
|
+
include_reasoning.
|
|
740
|
+
|
|
741
|
+
Parameters that are always per-call:
|
|
742
|
+
user_message: The user's prompt. May be:
|
|
743
|
+
- str: plain text message
|
|
744
|
+
- list[dict]: content blocks (text + images),
|
|
745
|
+
e.g. ["Describe:", image_url("https://...")]
|
|
746
|
+
- None: no new user message (use after tool results)
|
|
747
|
+
conversation_history: Prior turns as ResponseItem-compatible dicts.
|
|
748
|
+
Can include compaction_summary items from compact().
|
|
749
|
+
After a ToolCall, append call.as_history_item() and
|
|
750
|
+
call.to_tool_result(output) here before the next call.
|
|
751
|
+
tool_choice: "auto" | "none" | "required" | {"type": "function",
|
|
752
|
+
"name": "..."}. Ignored when no tools are active.
|
|
753
|
+
output_schema: JSON Schema dict for structured output. Mutually
|
|
754
|
+
exclusive with verbosity.
|
|
755
|
+
prompt_cache_key: UUID to share across calls that have a common prefix
|
|
756
|
+
to hit the server-side prompt cache.
|
|
757
|
+
"""
|
|
758
|
+
self._ensure_auth()
|
|
759
|
+
|
|
760
|
+
# Resolve session defaults
|
|
761
|
+
model = self._resolve("model", model)
|
|
762
|
+
instructions = self._resolve("instructions", instructions)
|
|
763
|
+
reasoning = self._resolve("reasoning", reasoning)
|
|
764
|
+
reasoning_summary = self._resolve("reasoning_summary", reasoning_summary)
|
|
765
|
+
verbosity = self._resolve("verbosity", verbosity)
|
|
766
|
+
web_search = self._resolve("web_search", web_search)
|
|
767
|
+
service_tier = self._resolve("service_tier", service_tier)
|
|
768
|
+
parallel_tool_calls = self._resolve("parallel_tool_calls", parallel_tool_calls)
|
|
769
|
+
tools = self._resolve("tools", tools)
|
|
770
|
+
store = self._resolve("store", store)
|
|
771
|
+
include_reasoning = self._resolve("include_reasoning", include_reasoning)
|
|
772
|
+
|
|
773
|
+
input_items = list(conversation_history or [])
|
|
774
|
+
|
|
775
|
+
if user_message is not None and user_message != "":
|
|
776
|
+
if isinstance(user_message, str):
|
|
777
|
+
content: list[dict] = [{"type": "input_text", "text": user_message}]
|
|
778
|
+
else:
|
|
779
|
+
content = []
|
|
780
|
+
for block in user_message:
|
|
781
|
+
if isinstance(block, str):
|
|
782
|
+
content.append({"type": "input_text", "text": block})
|
|
783
|
+
else:
|
|
784
|
+
content.append(block)
|
|
785
|
+
input_items.append({
|
|
786
|
+
"type": "message",
|
|
787
|
+
"role": "user",
|
|
788
|
+
"content": content,
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
# Build tools list — merge user-defined tools + built-in web_search
|
|
792
|
+
tools_payload: list[dict] = list(tools or [])
|
|
793
|
+
if web_search and web_search != "disabled":
|
|
794
|
+
tools_payload.append({"type": "web_search"})
|
|
795
|
+
|
|
796
|
+
if tools_payload:
|
|
797
|
+
tc: Any = tool_choice
|
|
798
|
+
ptc = parallel_tool_calls
|
|
799
|
+
else:
|
|
800
|
+
tc = "none"
|
|
801
|
+
ptc = False
|
|
802
|
+
|
|
803
|
+
payload: dict[str, Any] = {
|
|
804
|
+
"model": model,
|
|
805
|
+
"instructions": instructions,
|
|
806
|
+
"input": input_items,
|
|
807
|
+
"tools": tools_payload,
|
|
808
|
+
"tool_choice": tc,
|
|
809
|
+
"parallel_tool_calls": ptc,
|
|
810
|
+
"store": store,
|
|
811
|
+
"stream": True,
|
|
812
|
+
"include": ["reasoning.encrypted_content"] if include_reasoning else [],
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if service_tier is not None:
|
|
816
|
+
payload["service_tier"] = service_tier
|
|
817
|
+
if prompt_cache_key is not None:
|
|
818
|
+
payload["prompt_cache_key"] = prompt_cache_key
|
|
819
|
+
|
|
820
|
+
if reasoning or reasoning_summary:
|
|
821
|
+
reasoning_block: dict[str, Any] = {}
|
|
822
|
+
if reasoning:
|
|
823
|
+
reasoning_block["effort"] = reasoning
|
|
824
|
+
if reasoning_summary:
|
|
825
|
+
reasoning_block["summary"] = reasoning_summary
|
|
826
|
+
payload["reasoning"] = reasoning_block
|
|
827
|
+
|
|
828
|
+
if output_schema is not None:
|
|
829
|
+
payload["text"] = {
|
|
830
|
+
"format": {
|
|
831
|
+
"type": "json_schema",
|
|
832
|
+
"strict": True,
|
|
833
|
+
"schema": output_schema,
|
|
834
|
+
"name": output_schema.get("title", "output"),
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
elif verbosity:
|
|
838
|
+
payload["text"] = {"verbosity": verbosity}
|
|
839
|
+
|
|
840
|
+
with self._stream_lock:
|
|
841
|
+
self._abort_requested = False
|
|
842
|
+
|
|
843
|
+
with self._session.post(
|
|
844
|
+
f"{BASE_URL}/responses",
|
|
845
|
+
json=payload,
|
|
846
|
+
headers={"Accept": "text/event-stream"},
|
|
847
|
+
stream=True,
|
|
848
|
+
timeout=120,
|
|
849
|
+
) as resp:
|
|
850
|
+
with self._stream_lock:
|
|
851
|
+
self._active_response = resp
|
|
852
|
+
try:
|
|
853
|
+
resp.raise_for_status()
|
|
854
|
+
yield from _parse_sse_stream(resp, aborted=self._response_was_aborted)
|
|
855
|
+
except ResponseAborted:
|
|
856
|
+
raise
|
|
857
|
+
except (requests.RequestException, OSError):
|
|
858
|
+
if self._response_was_aborted():
|
|
859
|
+
raise ResponseAborted()
|
|
860
|
+
raise
|
|
861
|
+
finally:
|
|
862
|
+
with self._stream_lock:
|
|
863
|
+
if self._active_response is resp:
|
|
864
|
+
self._active_response = None
|
|
865
|
+
self._abort_requested = False
|
|
866
|
+
|
|
867
|
+
def respond(
|
|
868
|
+
self,
|
|
869
|
+
user_message: Optional[MessageContent] = None,
|
|
870
|
+
*,
|
|
871
|
+
# Per-call params with session defaults
|
|
872
|
+
model: str = _UNSET,
|
|
873
|
+
instructions: str = _UNSET,
|
|
874
|
+
reasoning: Optional[ReasoningEffort] = _UNSET,
|
|
875
|
+
reasoning_summary: Optional[ReasoningSummary] = _UNSET,
|
|
876
|
+
verbosity: Optional[Verbosity] = _UNSET,
|
|
877
|
+
web_search: Optional[str] = _UNSET,
|
|
878
|
+
service_tier: Optional[ServiceTier] = _UNSET,
|
|
879
|
+
parallel_tool_calls: bool = _UNSET,
|
|
880
|
+
tools: Optional[list[dict]] = _UNSET,
|
|
881
|
+
store: bool = _UNSET,
|
|
882
|
+
# Always per-call
|
|
883
|
+
conversation_history: Optional[list[dict]] = None,
|
|
884
|
+
tool_choice: Union[str, dict] = "auto",
|
|
885
|
+
output_schema: Optional[dict] = None,
|
|
886
|
+
prompt_cache_key: Optional[str] = None,
|
|
887
|
+
print_stream: bool = False,
|
|
888
|
+
) -> tuple[str, ResponseCompleted | None]:
|
|
889
|
+
"""
|
|
890
|
+
Collect the full text from a streamed response.
|
|
891
|
+
|
|
892
|
+
Returns (text, ResponseCompleted). For tool-calling scenarios use
|
|
893
|
+
stream() directly — respond() only captures text output.
|
|
894
|
+
"""
|
|
895
|
+
text_parts: list[str] = []
|
|
896
|
+
completion: ResponseCompleted | None = None
|
|
897
|
+
for event in self.stream(
|
|
898
|
+
user_message,
|
|
899
|
+
model=model,
|
|
900
|
+
instructions=instructions,
|
|
901
|
+
reasoning=reasoning,
|
|
902
|
+
reasoning_summary=reasoning_summary,
|
|
903
|
+
verbosity=verbosity,
|
|
904
|
+
web_search=web_search,
|
|
905
|
+
service_tier=service_tier,
|
|
906
|
+
parallel_tool_calls=parallel_tool_calls,
|
|
907
|
+
tools=tools,
|
|
908
|
+
store=store,
|
|
909
|
+
conversation_history=conversation_history,
|
|
910
|
+
tool_choice=tool_choice,
|
|
911
|
+
output_schema=output_schema,
|
|
912
|
+
prompt_cache_key=prompt_cache_key,
|
|
913
|
+
):
|
|
914
|
+
if isinstance(event, TextDelta):
|
|
915
|
+
text_parts.append(event.text)
|
|
916
|
+
if print_stream:
|
|
917
|
+
print(event.text, end="", flush=True)
|
|
918
|
+
elif isinstance(event, ResponseCompleted):
|
|
919
|
+
completion = event
|
|
920
|
+
elif isinstance(event, ResponseFailed):
|
|
921
|
+
raise RuntimeError(f"[{event.code}] {event.message}")
|
|
922
|
+
if print_stream:
|
|
923
|
+
print()
|
|
924
|
+
return "".join(text_parts), completion
|
|
925
|
+
|
|
926
|
+
# ------------------------------------------------------------------
|
|
927
|
+
# Context compaction POST /responses/compact
|
|
928
|
+
# ------------------------------------------------------------------
|
|
929
|
+
|
|
930
|
+
def compact(
|
|
931
|
+
self,
|
|
932
|
+
conversation_history: list[dict],
|
|
933
|
+
*,
|
|
934
|
+
model: str = _UNSET,
|
|
935
|
+
instructions: str = _UNSET,
|
|
936
|
+
) -> CompactionResult:
|
|
937
|
+
"""
|
|
938
|
+
POST /responses/compact — compress a long conversation history.
|
|
939
|
+
|
|
940
|
+
Returns a CompactionResult whose output_items can be passed directly
|
|
941
|
+
as conversation_history to stream(). The compaction_summary item is
|
|
942
|
+
an encrypted blob understood by the model — treat it as opaque.
|
|
943
|
+
|
|
944
|
+
Typical use:
|
|
945
|
+
result = client.compact(long_history)
|
|
946
|
+
for event in client.stream("next question",
|
|
947
|
+
conversation_history=result.output_items):
|
|
948
|
+
...
|
|
949
|
+
"""
|
|
950
|
+
self._ensure_auth()
|
|
951
|
+
payload: dict[str, Any] = {
|
|
952
|
+
"model": self._resolve("model", model),
|
|
953
|
+
"instructions": self._resolve("instructions", instructions),
|
|
954
|
+
"input": conversation_history,
|
|
955
|
+
"tools": [],
|
|
956
|
+
"parallel_tool_calls": False,
|
|
957
|
+
}
|
|
958
|
+
resp = self._session.post(
|
|
959
|
+
f"{BASE_URL}/responses/compact",
|
|
960
|
+
json=payload,
|
|
961
|
+
timeout=60,
|
|
962
|
+
)
|
|
963
|
+
resp.raise_for_status()
|
|
964
|
+
data = resp.json()
|
|
965
|
+
return CompactionResult(
|
|
966
|
+
response_id=data.get("id", ""),
|
|
967
|
+
output_items=data.get("output", []),
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
# ---------------------------------------------------------------------------
|
|
972
|
+
# SSE parser
|
|
973
|
+
# ---------------------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
def _parse_sse_stream(
|
|
976
|
+
resp: requests.Response,
|
|
977
|
+
*,
|
|
978
|
+
aborted: Optional[Callable[[], bool]] = None,
|
|
979
|
+
) -> Generator[StreamEvent, None, None]:
|
|
980
|
+
"""
|
|
981
|
+
Parse a text/event-stream response and yield typed StreamEvent objects.
|
|
982
|
+
|
|
983
|
+
SSE wire format: "event: <name>" and "data: <json>" lines separated by
|
|
984
|
+
blank lines. Mirrors codex-rs/codex-api/src/sse/responses.rs.
|
|
985
|
+
"""
|
|
986
|
+
event_name: Optional[str] = None
|
|
987
|
+
data_lines: list[str] = []
|
|
988
|
+
|
|
989
|
+
for raw_line in resp.iter_lines():
|
|
990
|
+
if aborted is not None and aborted():
|
|
991
|
+
raise ResponseAborted()
|
|
992
|
+
if isinstance(raw_line, bytes):
|
|
993
|
+
raw_line = raw_line.decode("utf-8")
|
|
994
|
+
if raw_line is None:
|
|
995
|
+
continue
|
|
996
|
+
|
|
997
|
+
if raw_line == "":
|
|
998
|
+
if data_lines:
|
|
999
|
+
data = "\n".join(data_lines)
|
|
1000
|
+
event = _dispatch_sse_event(event_name, data)
|
|
1001
|
+
if event is not None:
|
|
1002
|
+
yield event
|
|
1003
|
+
if isinstance(event, ResponseCompleted):
|
|
1004
|
+
return
|
|
1005
|
+
event_name = None
|
|
1006
|
+
data_lines = []
|
|
1007
|
+
continue
|
|
1008
|
+
|
|
1009
|
+
if raw_line.startswith("event:"):
|
|
1010
|
+
event_name = raw_line[len("event:"):].strip()
|
|
1011
|
+
elif raw_line.startswith("data:"):
|
|
1012
|
+
data_lines.append(raw_line[len("data:"):].strip())
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _dispatch_sse_event(
|
|
1016
|
+
event_name: Optional[str], data: str
|
|
1017
|
+
) -> Optional[StreamEvent]:
|
|
1018
|
+
"""Map one SSE event onto a domain object; return None for ignored events."""
|
|
1019
|
+
try:
|
|
1020
|
+
payload = json.loads(data)
|
|
1021
|
+
except json.JSONDecodeError:
|
|
1022
|
+
return None
|
|
1023
|
+
|
|
1024
|
+
kind = event_name or payload.get("type", "")
|
|
1025
|
+
|
|
1026
|
+
if kind == "response.output_text.delta":
|
|
1027
|
+
delta = payload.get("delta", "")
|
|
1028
|
+
if delta:
|
|
1029
|
+
return TextDelta(text=delta)
|
|
1030
|
+
|
|
1031
|
+
elif kind == "response.reasoning_summary_part.delta":
|
|
1032
|
+
delta = payload.get("delta", "")
|
|
1033
|
+
if delta:
|
|
1034
|
+
return ReasoningDelta(
|
|
1035
|
+
text=delta,
|
|
1036
|
+
summary_index=payload.get("summary_index", 0),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
elif kind == "response.output_item.done":
|
|
1040
|
+
item = payload.get("item")
|
|
1041
|
+
if item:
|
|
1042
|
+
item_type = item.get("type")
|
|
1043
|
+
if item_type == "function_call":
|
|
1044
|
+
return ToolCall(
|
|
1045
|
+
call_id=item.get("call_id", ""),
|
|
1046
|
+
name=item.get("name", ""),
|
|
1047
|
+
arguments=item.get("arguments", "{}"),
|
|
1048
|
+
raw=item,
|
|
1049
|
+
)
|
|
1050
|
+
if item_type == "reasoning":
|
|
1051
|
+
# Reasoning content is delivered as a completed item, not streaming deltas.
|
|
1052
|
+
# encrypted_content is an opaque blob; summary text lives in "summary" list.
|
|
1053
|
+
content = ""
|
|
1054
|
+
for part in item.get("summary") or []:
|
|
1055
|
+
content += part.get("text", "")
|
|
1056
|
+
encrypted = item.get("encrypted_content", "")
|
|
1057
|
+
if content or encrypted:
|
|
1058
|
+
return ReasoningDelta(text=content or encrypted, summary_index=0)
|
|
1059
|
+
return None
|
|
1060
|
+
return OutputItem.from_dict(item)
|
|
1061
|
+
|
|
1062
|
+
elif kind == "response.completed":
|
|
1063
|
+
r = payload.get("response", {})
|
|
1064
|
+
raw_usage = r.get("usage") or {}
|
|
1065
|
+
usage = TokenUsage(
|
|
1066
|
+
input_tokens=raw_usage.get("input_tokens", 0),
|
|
1067
|
+
output_tokens=raw_usage.get("output_tokens", 0),
|
|
1068
|
+
total_tokens=raw_usage.get("total_tokens", 0),
|
|
1069
|
+
reasoning_tokens=(raw_usage.get("output_tokens_details") or {}).get("reasoning_tokens", 0),
|
|
1070
|
+
cached_tokens=(raw_usage.get("input_tokens_details") or {}).get("cached_tokens", 0),
|
|
1071
|
+
)
|
|
1072
|
+
return ResponseCompleted(response_id=r.get("id", ""), usage=usage)
|
|
1073
|
+
|
|
1074
|
+
elif kind == "response.failed":
|
|
1075
|
+
r = payload.get("response", {})
|
|
1076
|
+
error = r.get("error") or {}
|
|
1077
|
+
return ResponseFailed(
|
|
1078
|
+
code=error.get("code", "unknown"),
|
|
1079
|
+
message=error.get("message", "Unknown error"),
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
# Silently ignored: response.created, response.in_progress,
|
|
1083
|
+
# response.output_item.added, response.content_part.*, rate_limits, …
|
|
1084
|
+
return None
|