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.
@@ -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