@howaboua/pi-codex-conversion 1.0.19 → 1.0.20

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,1543 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Box, Image, Spacer, Text } from "@mariozechner/pi-tui";
3
+ import {
4
+ createAssistantMessageEventStream,
5
+ getEnvApiKey,
6
+ supportsXhigh,
7
+ type Api,
8
+ type AssistantMessage,
9
+ type AssistantMessageEventStream,
10
+ type Context,
11
+ type Model,
12
+ type SimpleStreamOptions,
13
+ } from "@mariozechner/pi-ai";
14
+ import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
15
+ import {
16
+ convertResponsesMessages,
17
+ convertResponsesTools,
18
+ processResponsesStream,
19
+ } from "./openai-responses-shared.ts";
20
+
21
+ const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
22
+ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
23
+ export const IMAGE_SAVE_DISPLAY_MESSAGE_TYPE = "codex-image-generation-display";
24
+ export const WEB_SEARCH_ACTIVITY_MESSAGE_TYPE = "codex-web-search-activity";
25
+ const OPENAI_CODEX_IMAGE_DIR = ".pi/openai-codex-images";
26
+ const OPENAI_CODEX_LATEST_IMAGE_NAME = "latest.png";
27
+ const MAX_RETRIES = 3;
28
+ const BASE_DELAY_MS = 1000;
29
+ const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
30
+ const CODEX_RESPONSE_STATUSES = new Set(["completed", "incomplete", "failed", "cancelled", "queued", "in_progress"]);
31
+ const OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06";
32
+ const SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1000;
33
+ const dynamicImport = (specifier: string) => import(specifier);
34
+ let _os: { platform(): string; release(): string; arch(): string } | null = null;
35
+
36
+ if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
37
+ dynamicImport("node:os")
38
+ .then((module) => {
39
+ _os = module;
40
+ })
41
+ .catch(() => {
42
+ _os = null;
43
+ });
44
+ }
45
+
46
+ interface SavedGeneratedImage {
47
+ absolutePath: string;
48
+ relativePath: string;
49
+ latestAbsolutePath: string;
50
+ latestRelativePath: string;
51
+ responseId: string | undefined;
52
+ callId: string;
53
+ outputFormat: string;
54
+ revisedPrompt?: string;
55
+ }
56
+
57
+ interface ImageDisplayMessageDetails {
58
+ savedImages: SavedGeneratedImage[];
59
+ }
60
+
61
+ interface PendingImageDisplay {
62
+ savedImage: SavedGeneratedImage;
63
+ imageData: { data: string; mimeType: string };
64
+ }
65
+
66
+ interface QueuedImageActivity extends PendingImageDisplay {
67
+ kind: "image";
68
+ }
69
+
70
+ interface SurfacedWebSearch {
71
+ callId: string;
72
+ status?: string;
73
+ query?: string;
74
+ queries: string[];
75
+ sources: Array<{ title?: string; url: string }>;
76
+ }
77
+
78
+ interface QueuedWebSearchActivity {
79
+ kind: "web-search";
80
+ search: SurfacedWebSearch;
81
+ }
82
+
83
+ type PendingActivity = QueuedImageActivity | QueuedWebSearchActivity;
84
+
85
+ interface CachedImagePreview {
86
+ data: string;
87
+ mimeType: string;
88
+ }
89
+
90
+ interface WebSocketLike {
91
+ readyState?: number;
92
+ send(data: string): void;
93
+ close(code?: number, reason?: string): void;
94
+ addEventListener(type: string, listener: (event: unknown) => void): void;
95
+ removeEventListener(type: string, listener: (event: unknown) => void): void;
96
+ }
97
+
98
+ interface WebSocketConstructorLike {
99
+ new (url: string, options?: { headers?: Record<string, string> } | string | string[]): WebSocketLike;
100
+ }
101
+
102
+ interface SessionWebSocketCacheEntry {
103
+ socket: WebSocketLike;
104
+ busy: boolean;
105
+ idleTimer?: ReturnType<typeof setTimeout>;
106
+ }
107
+
108
+ let webSocketConstructorPromise: Promise<WebSocketConstructorLike | null> | undefined;
109
+ let fsPromisesPromise: Promise<typeof import("node:fs/promises")> | undefined;
110
+ const workspaceRootCache = new Map<string, Promise<string>>();
111
+
112
+ const PATH_SEPARATOR = "/";
113
+
114
+ interface ResponsesBody {
115
+ model: string;
116
+ store: boolean;
117
+ stream: boolean;
118
+ instructions?: string;
119
+ input: unknown;
120
+ text: { verbosity: string };
121
+ include: string[];
122
+ prompt_cache_key?: string;
123
+ tool_choice: "auto";
124
+ parallel_tool_calls: boolean;
125
+ temperature?: number;
126
+ service_tier?: string;
127
+ tools?: unknown[];
128
+ reasoning?: {
129
+ effort: string;
130
+ summary: string;
131
+ };
132
+ [key: string]: unknown;
133
+ }
134
+
135
+ interface ResponseEnvelope {
136
+ id?: string;
137
+ status?: string;
138
+ usage?: {
139
+ input_tokens?: number;
140
+ output_tokens?: number;
141
+ total_tokens?: number;
142
+ input_tokens_details?: { cached_tokens?: number };
143
+ };
144
+ service_tier?: string;
145
+ error?: { message?: string };
146
+ [key: string]: unknown;
147
+ }
148
+
149
+ type ServiceTier = ResponseCreateParamsStreaming["service_tier"];
150
+
151
+ const websocketSessionCache = new Map<string, SessionWebSocketCacheEntry>();
152
+
153
+ interface StreamEventShape {
154
+ type?: string;
155
+ response?: ResponseEnvelope;
156
+ item?: {
157
+ id?: string;
158
+ type?: string;
159
+ result?: string | null;
160
+ output_format?: string;
161
+ revised_prompt?: string;
162
+ status?: string;
163
+ [key: string]: unknown;
164
+ };
165
+ code?: string;
166
+ message?: string;
167
+ [key: string]: unknown;
168
+ }
169
+
170
+ function sanitizeFilePart(value: string | undefined, fallback: string): string {
171
+ const trimmed = (value ?? "").trim();
172
+ if (!trimmed) return fallback;
173
+ return trimmed.replace(/[^a-zA-Z0-9._-]+/g, "-");
174
+ }
175
+
176
+ function shortenFilePart(value: string | undefined, fallback: string): string {
177
+ const safe = sanitizeFilePart(value, fallback);
178
+ const match = /^([a-zA-Z]+_)(.+)$/.exec(safe);
179
+ const prefix = match?.[1] ?? "";
180
+ const body = match?.[2] ?? safe;
181
+ if (body.length <= 12) return `${prefix}${body}`;
182
+ return `${prefix}${body.slice(0, 8)}-${body.slice(-4)}`;
183
+ }
184
+
185
+ function shortHash(str: string): string {
186
+ let h1 = 0xdeadbeef;
187
+ let h2 = 0x41c6ce57;
188
+ for (let i = 0; i < str.length; i++) {
189
+ const ch = str.charCodeAt(i);
190
+ h1 = Math.imul(h1 ^ ch, 2654435761);
191
+ h2 = Math.imul(h2 ^ ch, 1597334677);
192
+ }
193
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
194
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
195
+ return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
196
+ }
197
+
198
+ function normalizePath(value: string): string {
199
+ if (!value) return ".";
200
+ const normalized = value.replace(/\/+/g, PATH_SEPARATOR);
201
+ if (normalized === PATH_SEPARATOR) return normalized;
202
+ return normalized.replace(/\/+$/g, "") || PATH_SEPARATOR;
203
+ }
204
+
205
+ function joinPaths(...parts: string[]): string {
206
+ if (parts.length === 0) return ".";
207
+ let result = parts[0] ?? "";
208
+ for (let i = 1; i < parts.length; i++) {
209
+ const part = parts[i];
210
+ if (!part) continue;
211
+ if (!result || result.endsWith(PATH_SEPARATOR)) {
212
+ result += part.replace(/^\/+/, "");
213
+ } else {
214
+ result += `${PATH_SEPARATOR}${part.replace(/^\/+/, "")}`;
215
+ }
216
+ }
217
+ return normalizePath(result);
218
+ }
219
+
220
+ function dirnamePath(value: string): string {
221
+ const normalized = normalizePath(value);
222
+ if (normalized === PATH_SEPARATOR) return PATH_SEPARATOR;
223
+ const index = normalized.lastIndexOf(PATH_SEPARATOR);
224
+ if (index < 0) return ".";
225
+ if (index === 0) return PATH_SEPARATOR;
226
+ return normalized.slice(0, index);
227
+ }
228
+
229
+ function splitPathSegments(value: string): string[] {
230
+ const normalized = normalizePath(value);
231
+ if (normalized === PATH_SEPARATOR) return [];
232
+ return normalized.replace(/^\/+/, "").split(PATH_SEPARATOR).filter(Boolean);
233
+ }
234
+
235
+ function relativePath(from: string, to: string): string {
236
+ const normalizedFrom = normalizePath(from);
237
+ const normalizedTo = normalizePath(to);
238
+ if (normalizedFrom === normalizedTo) return "";
239
+ const fromSegments = splitPathSegments(normalizedFrom);
240
+ const toSegments = splitPathSegments(normalizedTo);
241
+ let shared = 0;
242
+ while (shared < fromSegments.length && shared < toSegments.length && fromSegments[shared] === toSegments[shared]) {
243
+ shared++;
244
+ }
245
+ const upSegments = new Array(fromSegments.length - shared).fill("..");
246
+ const downSegments = toSegments.slice(shared);
247
+ return [...upSegments, ...downSegments].join(PATH_SEPARATOR);
248
+ }
249
+
250
+ async function getNodeFsPromises(): Promise<typeof import("node:fs/promises")> {
251
+ if (!fsPromisesPromise) {
252
+ fsPromisesPromise = dynamicImport("node:fs/promises") as Promise<typeof import("node:fs/promises")>;
253
+ }
254
+ return fsPromisesPromise;
255
+ }
256
+
257
+ function getNodeFsSync(): { readFileSync(path: string): Buffer } | null {
258
+ if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
259
+ return null;
260
+ }
261
+ const builtinProcess = process as typeof process & { getBuiltinModule?: (specifier: string) => unknown };
262
+ if (typeof builtinProcess.getBuiltinModule !== "function") {
263
+ return null;
264
+ }
265
+ try {
266
+ const module = builtinProcess.getBuiltinModule("node:fs") as { readFileSync?: (path: string) => Buffer } | undefined;
267
+ return typeof module?.readFileSync === "function" ? { readFileSync: module.readFileSync } : null;
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ async function pathExists(value: string): Promise<boolean> {
274
+ try {
275
+ const fs = await getNodeFsPromises();
276
+ await fs.access(value);
277
+ return true;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ async function resolveWorkspaceRoot(cwd: string): Promise<string> {
284
+ const normalizedCwd = normalizePath(cwd);
285
+ const cached = workspaceRootCache.get(normalizedCwd);
286
+ if (cached) return cached;
287
+
288
+ const promise = (async () => {
289
+ let current = normalizedCwd;
290
+ while (true) {
291
+ if (await pathExists(joinPaths(current, ".git"))) {
292
+ return current;
293
+ }
294
+ const parent = dirnamePath(current);
295
+ if (parent === current || parent === ".") {
296
+ return normalizedCwd;
297
+ }
298
+ current = parent;
299
+ }
300
+ })();
301
+
302
+ workspaceRootCache.set(normalizedCwd, promise);
303
+ return promise;
304
+ }
305
+
306
+ export function getOpenAICodexImageDirectory(cwd: string): string {
307
+ return joinPaths(cwd, OPENAI_CODEX_IMAGE_DIR);
308
+ }
309
+
310
+ export function getOpenAICodexImagePath(cwd: string, responseId: string | undefined, callId: string, outputFormat?: string): string {
311
+ const ext = (outputFormat ?? "png").toLowerCase();
312
+ const safeCallId = shortenFilePart(callId, "image");
313
+ const safeResponseId = shortenFilePart(responseId, "response");
314
+ return joinPaths(getOpenAICodexImageDirectory(cwd), `${safeCallId}-${safeResponseId}.${ext}`);
315
+ }
316
+
317
+ export function getOpenAICodexLatestImagePath(cwd: string): string {
318
+ return joinPaths(getOpenAICodexImageDirectory(cwd), OPENAI_CODEX_LATEST_IMAGE_NAME);
319
+ }
320
+
321
+ export function buildGeneratedImageDisplayText(savedImage: SavedGeneratedImage, options?: { expanded?: boolean }): string {
322
+ const lines: string[] = [];
323
+ if (options?.expanded && savedImage.revisedPrompt) {
324
+ lines.push(`Prompt: ${savedImage.revisedPrompt}`);
325
+ }
326
+ lines.push(`File: ${savedImage.relativePath}`);
327
+ return lines.join("\n");
328
+ }
329
+
330
+ export async function saveOpenAICodexGeneratedImage(
331
+ cwd: string,
332
+ image: { responseId?: string; callId: string; result: string; outputFormat?: string; revisedPrompt?: string },
333
+ ): Promise<SavedGeneratedImage> {
334
+ const workspaceRoot = await resolveWorkspaceRoot(cwd);
335
+ const fs = await getNodeFsPromises();
336
+ const bytes = Buffer.from(image.result, "base64");
337
+ const absolutePath = getOpenAICodexImagePath(workspaceRoot, image.responseId, image.callId, image.outputFormat);
338
+ const latestAbsolutePath = getOpenAICodexLatestImagePath(workspaceRoot);
339
+ await fs.mkdir(dirnamePath(absolutePath), { recursive: true });
340
+ await fs.writeFile(absolutePath, bytes);
341
+ await fs.writeFile(latestAbsolutePath, bytes);
342
+
343
+ const relativeFilePath = relativePath(workspaceRoot, absolutePath);
344
+ const latestRelativeFilePath = relativePath(workspaceRoot, latestAbsolutePath);
345
+ const relativePathValue = relativeFilePath && !relativeFilePath.startsWith("..") ? relativeFilePath : absolutePath;
346
+ const latestRelativePathValue =
347
+ latestRelativeFilePath && !latestRelativeFilePath.startsWith("..") ? latestRelativeFilePath : latestAbsolutePath;
348
+
349
+ return {
350
+ absolutePath,
351
+ relativePath: relativePathValue,
352
+ latestAbsolutePath,
353
+ latestRelativePath: latestRelativePathValue,
354
+ responseId: image.responseId,
355
+ callId: image.callId,
356
+ outputFormat: (image.outputFormat ?? "png").toLowerCase(),
357
+ revisedPrompt: image.revisedPrompt,
358
+ };
359
+ }
360
+
361
+ function extractAccountId(token: string): string {
362
+ try {
363
+ const parts = token.split(".");
364
+ if (parts.length !== 3) throw new Error("Invalid token");
365
+ const payload = JSON.parse(Buffer.from(parts[1] ?? "", "base64").toString("utf8"));
366
+ const accountId = payload?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
367
+ if (!accountId) throw new Error("No account ID in token");
368
+ return accountId;
369
+ } catch {
370
+ throw new Error("Failed to extract accountId from token");
371
+ }
372
+ }
373
+
374
+ function resolveCodexUrl(baseUrl: string | undefined): string {
375
+ const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : DEFAULT_CODEX_BASE_URL;
376
+ const normalized = raw.replace(/\/+$/, "");
377
+ if (normalized.endsWith("/codex/responses")) return normalized;
378
+ if (normalized.endsWith("/codex")) return `${normalized}/responses`;
379
+ return `${normalized}/codex/responses`;
380
+ }
381
+
382
+ function resolveCodexWebSocketUrl(baseUrl: string | undefined): string {
383
+ const url = new URL(resolveCodexUrl(baseUrl));
384
+ if (url.protocol === "https:") url.protocol = "wss:";
385
+ if (url.protocol === "http:") url.protocol = "ws:";
386
+ return url.toString();
387
+ }
388
+
389
+ function headersToRecord(headers: Headers): Record<string, string> {
390
+ return Object.fromEntries(headers.entries());
391
+ }
392
+
393
+ function buildWebSocketCacheKey(url: string, headers: Headers, sessionId: string | undefined): string | undefined {
394
+ if (!sessionId) return undefined;
395
+ const headerFingerprint = Object.entries(headersToRecord(headers))
396
+ .map(([key, value]) => [key.toLowerCase(), value] as const)
397
+ .sort(([a], [b]) => a.localeCompare(b))
398
+ .map(([key, value]) => `${key}:${value}`)
399
+ .join("\n");
400
+ return `${sessionId}:${shortHash(`${url}\n${headerFingerprint}`)}`;
401
+ }
402
+
403
+ function createCodexRequestId(): string {
404
+ if (typeof globalThis.crypto?.randomUUID === "function") {
405
+ return globalThis.crypto.randomUUID();
406
+ }
407
+ return `codex_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
408
+ }
409
+
410
+ function buildBaseCodexHeaders(
411
+ modelHeaders: Record<string, string> | undefined,
412
+ additionalHeaders: Record<string, string> | undefined,
413
+ accountId: string,
414
+ token: string,
415
+ ): Headers {
416
+ const headers = new Headers(modelHeaders);
417
+ for (const [key, value] of Object.entries(additionalHeaders ?? {})) {
418
+ headers.set(key, value);
419
+ }
420
+
421
+ headers.set("Authorization", `Bearer ${token}`);
422
+ headers.set("chatgpt-account-id", accountId);
423
+ headers.set("originator", "pi");
424
+ headers.set("User-Agent", _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : "pi (browser)");
425
+ return headers;
426
+ }
427
+
428
+ function buildSSEHeaders(
429
+ modelHeaders: Record<string, string> | undefined,
430
+ additionalHeaders: Record<string, string> | undefined,
431
+ accountId: string,
432
+ token: string,
433
+ sessionId: string | undefined,
434
+ ): Headers {
435
+ const headers = buildBaseCodexHeaders(modelHeaders, additionalHeaders, accountId, token);
436
+ headers.set("OpenAI-Beta", "responses=experimental");
437
+ headers.set("accept", "text/event-stream");
438
+ headers.set("content-type", "application/json");
439
+
440
+ if (sessionId) {
441
+ headers.set("session_id", sessionId);
442
+ headers.set("x-client-request-id", sessionId);
443
+ }
444
+
445
+ return headers;
446
+ }
447
+
448
+ function buildWebSocketHeaders(
449
+ modelHeaders: Record<string, string> | undefined,
450
+ additionalHeaders: Record<string, string> | undefined,
451
+ accountId: string,
452
+ token: string,
453
+ requestId: string,
454
+ ): Headers {
455
+ const headers = buildBaseCodexHeaders(modelHeaders, additionalHeaders, accountId, token);
456
+ headers.delete("accept");
457
+ headers.delete("content-type");
458
+ headers.delete("OpenAI-Beta");
459
+ headers.delete("openai-beta");
460
+ headers.set("OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS);
461
+ headers.set("x-client-request-id", requestId);
462
+ headers.set("session_id", requestId);
463
+ return headers;
464
+ }
465
+
466
+ function clampReasoningEffort(modelId: string, effort: string): string {
467
+ const id = modelId.includes("/") ? (modelId.split("/").pop() ?? modelId) : modelId;
468
+ if ((id.startsWith("gpt-5.2") || id.startsWith("gpt-5.3") || id.startsWith("gpt-5.4")) && effort === "minimal") return "low";
469
+ if (id === "gpt-5.1" && effort === "xhigh") return "high";
470
+ if (id === "gpt-5.1-codex-mini") return effort === "high" || effort === "xhigh" ? "high" : "medium";
471
+ return effort;
472
+ }
473
+
474
+ function getServiceTierCostMultiplier(serviceTier: ServiceTier): number {
475
+ switch (serviceTier) {
476
+ case "flex":
477
+ return 0.5;
478
+ case "priority":
479
+ return 2;
480
+ default:
481
+ return 1;
482
+ }
483
+ }
484
+
485
+ function applyServiceTierPricing(usage: AssistantMessage["usage"], serviceTier: ServiceTier): void {
486
+ const multiplier = getServiceTierCostMultiplier(serviceTier);
487
+ if (multiplier === 1) return;
488
+ usage.cost.input *= multiplier;
489
+ usage.cost.output *= multiplier;
490
+ usage.cost.cacheRead *= multiplier;
491
+ usage.cost.cacheWrite *= multiplier;
492
+ usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
493
+ }
494
+
495
+ function resolveCodexServiceTier(responseServiceTier: ServiceTier, requestServiceTier: ServiceTier): ServiceTier {
496
+ if (responseServiceTier === "default" && (requestServiceTier === "flex" || requestServiceTier === "priority")) {
497
+ return requestServiceTier;
498
+ }
499
+ return responseServiceTier ?? requestServiceTier;
500
+ }
501
+
502
+ function buildRequestBody<TApi extends Api>(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): ResponsesBody {
503
+ const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, {
504
+ includeSystemPrompt: false,
505
+ });
506
+
507
+ const body: ResponsesBody = {
508
+ model: model.id,
509
+ store: false,
510
+ stream: true,
511
+ instructions: context.systemPrompt,
512
+ input: messages,
513
+ text: { verbosity: ((options as { textVerbosity?: string } | undefined)?.textVerbosity ?? "medium") as string },
514
+ include: ["reasoning.encrypted_content"],
515
+ prompt_cache_key: options?.sessionId,
516
+ tool_choice: "auto",
517
+ parallel_tool_calls: true,
518
+ };
519
+
520
+ if ((options as { temperature?: number } | undefined)?.temperature !== undefined) {
521
+ body.temperature = (options as { temperature?: number }).temperature;
522
+ }
523
+
524
+ const serviceTier = (options as { serviceTier?: string } | undefined)?.serviceTier;
525
+ if (serviceTier !== undefined) {
526
+ body.service_tier = serviceTier;
527
+ }
528
+
529
+ if (context.tools) {
530
+ body.tools = convertResponsesTools(context.tools, { strict: null });
531
+ const hasWebSearchTool = context.tools.some((tool) => tool.name === "web_search");
532
+ if (hasWebSearchTool) {
533
+ body.include.push("web_search_call.action.sources", "web_search_call.results");
534
+ }
535
+ }
536
+
537
+ if (options?.reasoning !== undefined) {
538
+ const requested = supportsXhigh(model) ? options.reasoning : options.reasoning === "xhigh" ? "high" : options.reasoning;
539
+ body.reasoning = {
540
+ effort: clampReasoningEffort(model.id, requested),
541
+ summary: ((options as { reasoningSummary?: string } | undefined)?.reasoningSummary ?? "auto") as string,
542
+ };
543
+ }
544
+
545
+ return body;
546
+ }
547
+
548
+ function isRetryableError(status: number, errorText: string): boolean {
549
+ if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
550
+ return true;
551
+ }
552
+ return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(errorText);
553
+ }
554
+
555
+ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
556
+ return new Promise((resolve, reject) => {
557
+ if (signal?.aborted) {
558
+ reject(new Error("Request was aborted"));
559
+ return;
560
+ }
561
+
562
+ const timeout = setTimeout(resolve, ms);
563
+ signal?.addEventListener(
564
+ "abort",
565
+ () => {
566
+ clearTimeout(timeout);
567
+ reject(new Error("Request was aborted"));
568
+ },
569
+ { once: true },
570
+ );
571
+ });
572
+ }
573
+
574
+ async function* parseSSE(response: Response): AsyncIterable<StreamEventShape> {
575
+ if (!response.body) return;
576
+
577
+ const reader = response.body.getReader();
578
+ const decoder = new TextDecoder();
579
+ let buffer = "";
580
+
581
+ try {
582
+ while (true) {
583
+ const { done, value } = await reader.read();
584
+ if (done) break;
585
+
586
+ buffer += decoder.decode(value, { stream: true });
587
+ buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
588
+ let idx = buffer.indexOf("\n\n");
589
+ while (idx !== -1) {
590
+ const chunk = buffer.slice(0, idx);
591
+ buffer = buffer.slice(idx + 2);
592
+ const dataLines = chunk
593
+ .split("\n")
594
+ .filter((line) => line.startsWith("data:"))
595
+ .map((line) => line.slice(5).trim());
596
+ if (dataLines.length > 0) {
597
+ const data = dataLines.join("\n").trim();
598
+ if (data && data !== "[DONE]") {
599
+ try {
600
+ yield JSON.parse(data) as StreamEventShape;
601
+ } catch {
602
+ // Ignore malformed SSE chunks and continue consuming the stream.
603
+ }
604
+ }
605
+ }
606
+ idx = buffer.indexOf("\n\n");
607
+ }
608
+ }
609
+ } finally {
610
+ try {
611
+ await reader.cancel();
612
+ } catch {
613
+ // ignore cancellation errors
614
+ }
615
+ try {
616
+ reader.releaseLock();
617
+ } catch {
618
+ // ignore lock release errors
619
+ }
620
+ }
621
+ }
622
+
623
+ async function getWebSocketConstructor(): Promise<WebSocketConstructorLike | null> {
624
+ if (webSocketConstructorPromise) {
625
+ return webSocketConstructorPromise;
626
+ }
627
+
628
+ webSocketConstructorPromise = (async () => {
629
+ const globalCtor = (globalThis as typeof globalThis & { WebSocket?: WebSocketConstructorLike }).WebSocket;
630
+ if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
631
+ return typeof globalCtor === "function" ? globalCtor : null;
632
+ }
633
+
634
+ try {
635
+ const wsModule = (await dynamicImport("ws")) as { WebSocket?: WebSocketConstructorLike; default?: WebSocketConstructorLike };
636
+ const ctor = wsModule.WebSocket ?? wsModule.default;
637
+ return typeof ctor === "function" ? ctor : null;
638
+ } catch {
639
+ return typeof globalCtor === "function" ? globalCtor : null;
640
+ }
641
+ })();
642
+
643
+ return webSocketConstructorPromise;
644
+ }
645
+
646
+ function getWebSocketReadyState(socket: WebSocketLike): number | undefined {
647
+ return typeof socket.readyState === "number" ? socket.readyState : undefined;
648
+ }
649
+
650
+ function isWebSocketReusable(socket: WebSocketLike): boolean {
651
+ const readyState = getWebSocketReadyState(socket);
652
+ return readyState === undefined || readyState === 1;
653
+ }
654
+
655
+ function closeWebSocketSilently(socket: WebSocketLike, code = 1000, reason = "done"): void {
656
+ try {
657
+ socket.close(code, reason);
658
+ } catch {
659
+ // ignore close errors
660
+ }
661
+ }
662
+
663
+ function scheduleSessionWebSocketExpiry(cacheKey: string, entry: SessionWebSocketCacheEntry): void {
664
+ if (entry.idleTimer) {
665
+ clearTimeout(entry.idleTimer);
666
+ }
667
+ entry.idleTimer = setTimeout(() => {
668
+ if (entry.busy) return;
669
+ closeWebSocketSilently(entry.socket, 1000, "idle_timeout");
670
+ websocketSessionCache.delete(cacheKey);
671
+ }, SESSION_WEBSOCKET_CACHE_TTL_MS);
672
+ }
673
+
674
+ function extractWebSocketError(event: unknown): Error {
675
+ if (event && typeof event === "object" && "message" in event) {
676
+ const message = (event as { message?: unknown }).message;
677
+ if (typeof message === "string" && message.length > 0) {
678
+ return new Error(message);
679
+ }
680
+ }
681
+ return new Error("WebSocket error");
682
+ }
683
+
684
+ function extractWebSocketCloseError(event: unknown): Error {
685
+ if (event && typeof event === "object") {
686
+ const code = "code" in event ? (event as { code?: unknown }).code : undefined;
687
+ const reason = "reason" in event ? (event as { reason?: unknown }).reason : undefined;
688
+ const codeText = typeof code === "number" ? ` ${code}` : "";
689
+ const reasonText = typeof reason === "string" && reason.length > 0 ? ` ${reason}` : "";
690
+ return new Error(`WebSocket closed${codeText}${reasonText}`.trim());
691
+ }
692
+ return new Error("WebSocket closed");
693
+ }
694
+
695
+ async function connectWebSocket(url: string, headers: Headers, signal: AbortSignal | undefined): Promise<WebSocketLike> {
696
+ const WebSocketCtor = await getWebSocketConstructor();
697
+ if (!WebSocketCtor) {
698
+ throw new Error("WebSocket transport is not available in this runtime");
699
+ }
700
+
701
+ const wsHeaders = headersToRecord(headers);
702
+
703
+ return new Promise((resolve, reject) => {
704
+ let settled = false;
705
+ let socket: WebSocketLike;
706
+ const isNodeLike = typeof process !== "undefined" && !!(process.versions?.node || process.versions?.bun);
707
+
708
+ try {
709
+ socket = isNodeLike ? new WebSocketCtor(url, { headers: wsHeaders }) : new WebSocketCtor(url);
710
+ } catch (error) {
711
+ reject(error instanceof Error ? error : new Error(String(error)));
712
+ return;
713
+ }
714
+
715
+ const onOpen = () => {
716
+ if (settled) return;
717
+ settled = true;
718
+ cleanup();
719
+ resolve(socket);
720
+ };
721
+ const onError = (event: unknown) => {
722
+ if (settled) return;
723
+ settled = true;
724
+ cleanup();
725
+ reject(extractWebSocketError(event));
726
+ };
727
+ const onClose = (event: unknown) => {
728
+ if (settled) return;
729
+ settled = true;
730
+ cleanup();
731
+ reject(extractWebSocketCloseError(event));
732
+ };
733
+ const onAbort = () => {
734
+ if (settled) return;
735
+ settled = true;
736
+ cleanup();
737
+ socket.close(1000, "aborted");
738
+ reject(new Error("Request was aborted"));
739
+ };
740
+
741
+ const cleanup = () => {
742
+ socket.removeEventListener("open", onOpen);
743
+ socket.removeEventListener("error", onError);
744
+ socket.removeEventListener("close", onClose);
745
+ signal?.removeEventListener("abort", onAbort);
746
+ };
747
+
748
+ socket.addEventListener("open", onOpen);
749
+ socket.addEventListener("error", onError);
750
+ socket.addEventListener("close", onClose);
751
+ signal?.addEventListener("abort", onAbort);
752
+ });
753
+ }
754
+
755
+ async function acquireWebSocket(
756
+ url: string,
757
+ headers: Headers,
758
+ sessionId: string | undefined,
759
+ signal: AbortSignal | undefined,
760
+ ): Promise<{ socket: WebSocketLike; release: (options?: { keep?: boolean }) => void }> {
761
+ const cacheKey = buildWebSocketCacheKey(url, headers, sessionId);
762
+ if (!cacheKey) {
763
+ const socket = await connectWebSocket(url, headers, signal);
764
+ return {
765
+ socket,
766
+ release: ({ keep } = {}) => {
767
+ if (keep === false) {
768
+ closeWebSocketSilently(socket);
769
+ return;
770
+ }
771
+ closeWebSocketSilently(socket);
772
+ },
773
+ };
774
+ }
775
+
776
+ const cached = websocketSessionCache.get(cacheKey);
777
+ if (cached) {
778
+ if (cached.idleTimer) {
779
+ clearTimeout(cached.idleTimer);
780
+ cached.idleTimer = undefined;
781
+ }
782
+
783
+ if (!cached.busy && isWebSocketReusable(cached.socket)) {
784
+ cached.busy = true;
785
+ return {
786
+ socket: cached.socket,
787
+ release: ({ keep } = {}) => {
788
+ if (!keep || !isWebSocketReusable(cached.socket)) {
789
+ closeWebSocketSilently(cached.socket);
790
+ websocketSessionCache.delete(cacheKey);
791
+ return;
792
+ }
793
+ cached.busy = false;
794
+ scheduleSessionWebSocketExpiry(cacheKey, cached);
795
+ },
796
+ };
797
+ }
798
+
799
+ if (cached.busy) {
800
+ const socket = await connectWebSocket(url, headers, signal);
801
+ return {
802
+ socket,
803
+ release: () => {
804
+ closeWebSocketSilently(socket);
805
+ },
806
+ };
807
+ }
808
+
809
+ if (!isWebSocketReusable(cached.socket)) {
810
+ closeWebSocketSilently(cached.socket);
811
+ websocketSessionCache.delete(cacheKey);
812
+ }
813
+ }
814
+
815
+ const socket = await connectWebSocket(url, headers, signal);
816
+ const entry: SessionWebSocketCacheEntry = { socket, busy: true };
817
+ websocketSessionCache.set(cacheKey, entry);
818
+ return {
819
+ socket,
820
+ release: ({ keep } = {}) => {
821
+ if (!keep || !isWebSocketReusable(entry.socket)) {
822
+ closeWebSocketSilently(entry.socket);
823
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
824
+ if (websocketSessionCache.get(cacheKey) === entry) {
825
+ websocketSessionCache.delete(cacheKey);
826
+ }
827
+ return;
828
+ }
829
+ entry.busy = false;
830
+ scheduleSessionWebSocketExpiry(cacheKey, entry);
831
+ },
832
+ };
833
+ }
834
+
835
+ async function decodeWebSocketData(data: unknown): Promise<string | null> {
836
+ if (typeof data === "string") return data;
837
+ if (data instanceof ArrayBuffer) {
838
+ return new TextDecoder().decode(new Uint8Array(data));
839
+ }
840
+ if (ArrayBuffer.isView(data)) {
841
+ return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
842
+ }
843
+ if (data && typeof data === "object" && "arrayBuffer" in data) {
844
+ const arrayBuffer = await (data as { arrayBuffer: () => Promise<ArrayBuffer> }).arrayBuffer();
845
+ return new TextDecoder().decode(new Uint8Array(arrayBuffer));
846
+ }
847
+ return null;
848
+ }
849
+
850
+ async function* parseWebSocket(socket: WebSocketLike, signal: AbortSignal | undefined): AsyncIterable<StreamEventShape> {
851
+ const queue: StreamEventShape[] = [];
852
+ let pending: (() => void) | null = null;
853
+ let done = false;
854
+ let failed: Error | null = null;
855
+ let sawCompletion = false;
856
+ let pendingMessages = 0;
857
+ let messageChain = Promise.resolve();
858
+
859
+ const wake = () => {
860
+ if (!pending) return;
861
+ const resolve = pending;
862
+ pending = null;
863
+ resolve();
864
+ };
865
+
866
+ const onMessage = (event: unknown) => {
867
+ pendingMessages++;
868
+ messageChain = messageChain
869
+ .then(async () => {
870
+ if (!event || typeof event !== "object" || !("data" in event)) return;
871
+ const text = await decodeWebSocketData((event as { data?: unknown }).data);
872
+ if (!text) return;
873
+ try {
874
+ const parsed = JSON.parse(text) as StreamEventShape;
875
+ const type = typeof parsed.type === "string" ? parsed.type : "";
876
+ if (type === "response.completed" || type === "response.done" || type === "response.incomplete") {
877
+ sawCompletion = true;
878
+ done = true;
879
+ }
880
+ queue.push(parsed);
881
+ } catch {
882
+ // ignore malformed websocket messages
883
+ }
884
+ })
885
+ .catch((error: unknown) => {
886
+ failed = error instanceof Error ? error : new Error(String(error));
887
+ done = true;
888
+ })
889
+ .finally(() => {
890
+ pendingMessages--;
891
+ wake();
892
+ });
893
+ };
894
+
895
+ const onError = (event: unknown) => {
896
+ failed = extractWebSocketError(event);
897
+ done = true;
898
+ wake();
899
+ };
900
+
901
+ const onClose = (event: unknown) => {
902
+ if (sawCompletion) {
903
+ done = true;
904
+ wake();
905
+ return;
906
+ }
907
+ if (!failed) {
908
+ failed = extractWebSocketCloseError(event);
909
+ }
910
+ done = true;
911
+ wake();
912
+ };
913
+
914
+ const onAbort = () => {
915
+ failed = new Error("Request was aborted");
916
+ done = true;
917
+ wake();
918
+ };
919
+
920
+ socket.addEventListener("message", onMessage);
921
+ socket.addEventListener("error", onError);
922
+ socket.addEventListener("close", onClose);
923
+ signal?.addEventListener("abort", onAbort);
924
+
925
+ try {
926
+ while (true) {
927
+ if (signal?.aborted) {
928
+ throw new Error("Request was aborted");
929
+ }
930
+ if (queue.length > 0) {
931
+ yield queue.shift() as StreamEventShape;
932
+ continue;
933
+ }
934
+ if (done && pendingMessages === 0) break;
935
+ await new Promise<void>((resolve) => {
936
+ pending = resolve;
937
+ });
938
+ }
939
+
940
+ if (failed) throw failed;
941
+ if (!sawCompletion) {
942
+ throw new Error("WebSocket stream closed before response.completed");
943
+ }
944
+ } finally {
945
+ socket.removeEventListener("message", onMessage);
946
+ socket.removeEventListener("error", onError);
947
+ socket.removeEventListener("close", onClose);
948
+ signal?.removeEventListener("abort", onAbort);
949
+ }
950
+ }
951
+
952
+ async function* mapCodexEvents(events: AsyncIterable<StreamEventShape>): AsyncIterable<StreamEventShape> {
953
+ for await (const event of events) {
954
+ const type = typeof event.type === "string" ? event.type : undefined;
955
+ if (!type) continue;
956
+
957
+ if (type === "error") {
958
+ throw new Error(`Codex error: ${event.message || event.code || JSON.stringify(event)}`);
959
+ }
960
+
961
+ if (type === "response.failed") {
962
+ throw new Error(event.response?.error?.message || "Codex response failed");
963
+ }
964
+
965
+ if (type === "response.done" || type === "response.completed" || type === "response.incomplete") {
966
+ const response = event.response;
967
+ yield {
968
+ ...event,
969
+ type: "response.completed",
970
+ response: response ? { ...response, status: normalizeCodexStatus(response.status) } : response,
971
+ };
972
+ return;
973
+ }
974
+
975
+ yield event;
976
+ }
977
+ }
978
+
979
+ function normalizeCodexStatus(status: string | undefined): string | undefined {
980
+ if (typeof status !== "string") return undefined;
981
+ return CODEX_RESPONSE_STATUSES.has(status) ? status : undefined;
982
+ }
983
+
984
+ function getLatestUserText(context: Context): string | undefined {
985
+ for (let i = context.messages.length - 1; i >= 0; i--) {
986
+ const message = context.messages[i];
987
+ if (message.role !== "user") continue;
988
+ if (typeof message.content === "string") {
989
+ const trimmed = message.content.trim();
990
+ if (trimmed) return trimmed;
991
+ continue;
992
+ }
993
+ const text = message.content
994
+ .filter((item) => item.type === "text")
995
+ .map((item) => item.text)
996
+ .join("\n")
997
+ .trim();
998
+ if (text) return text;
999
+ }
1000
+ return undefined;
1001
+ }
1002
+
1003
+ async function* captureGeneratedImages(
1004
+ events: AsyncIterable<StreamEventShape>,
1005
+ options: {
1006
+ cwd: string;
1007
+ requestPrompt?: string;
1008
+ onImageSaved: (image: SavedGeneratedImage, imageData: { data: string; mimeType: string }) => void;
1009
+ onWebSearchCaptured?: (search: SurfacedWebSearch) => void;
1010
+ },
1011
+ ): AsyncIterable<StreamEventShape> {
1012
+ let responseId: string | undefined;
1013
+
1014
+ for await (const event of events) {
1015
+ if (event.type === "response.created" && event.response?.id) {
1016
+ responseId = event.response.id;
1017
+ }
1018
+
1019
+ if (event.type === "response.output_item.done" && event.item?.type === "image_generation_call") {
1020
+ const callId = typeof event.item.id === "string" ? event.item.id : undefined;
1021
+ const result = typeof event.item.result === "string" ? event.item.result : undefined;
1022
+ if (callId && result) {
1023
+ try {
1024
+ const outputFormat = typeof event.item.output_format === "string" ? event.item.output_format : undefined;
1025
+ const saved = await saveOpenAICodexGeneratedImage(options.cwd, {
1026
+ responseId,
1027
+ callId,
1028
+ result,
1029
+ outputFormat,
1030
+ revisedPrompt:
1031
+ typeof event.item.revised_prompt === "string" ? event.item.revised_prompt : options.requestPrompt,
1032
+ });
1033
+ options.onImageSaved(saved, {
1034
+ data: result,
1035
+ mimeType: `image/${(outputFormat ?? "png").toLowerCase()}`,
1036
+ });
1037
+ } catch (error) {
1038
+ console.warn("[pi-codex-conversion] Failed to save generated image", error);
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ if (event.type === "response.output_item.done" && event.item?.type === "web_search_call") {
1044
+ const search = extractWebSearch(event.item);
1045
+ if (search) {
1046
+ options.onWebSearchCaptured?.(search);
1047
+ }
1048
+ }
1049
+
1050
+ yield event;
1051
+ }
1052
+ }
1053
+
1054
+ async function processCapturedResponsesStream<TApi extends Api>(
1055
+ events: AsyncIterable<StreamEventShape>,
1056
+ output: AssistantMessage,
1057
+ stream: AssistantMessageEventStream,
1058
+ model: Model<TApi>,
1059
+ options: SimpleStreamOptions | undefined,
1060
+ deps: {
1061
+ getCurrentCwd: () => string;
1062
+ onImageSaved?: (savedImage: SavedGeneratedImage, imageData: { data: string; mimeType: string }) => void;
1063
+ onWebSearchCaptured?: (search: SurfacedWebSearch) => void;
1064
+ },
1065
+ requestPrompt: string | undefined,
1066
+ ): Promise<void> {
1067
+ const tappedEvents = captureGeneratedImages(mapCodexEvents(events), {
1068
+ cwd: deps.getCurrentCwd(),
1069
+ requestPrompt,
1070
+ onImageSaved: (image, imageData) => deps.onImageSaved?.(image, imageData),
1071
+ onWebSearchCaptured: (search) => deps.onWebSearchCaptured?.(search),
1072
+ });
1073
+
1074
+ await processResponsesStream(tappedEvents as AsyncIterable<never>, output, stream, model, {
1075
+ serviceTier: (options as { serviceTier?: ServiceTier } | undefined)?.serviceTier,
1076
+ resolveServiceTier: resolveCodexServiceTier,
1077
+ applyServiceTierPricing,
1078
+ });
1079
+ }
1080
+
1081
+ async function processWebSocketStream<TApi extends Api>(
1082
+ url: string,
1083
+ body: ResponsesBody,
1084
+ headers: Headers,
1085
+ output: AssistantMessage,
1086
+ stream: AssistantMessageEventStream,
1087
+ model: Model<TApi>,
1088
+ onStart: () => void,
1089
+ options: SimpleStreamOptions | undefined,
1090
+ deps: {
1091
+ getCurrentCwd: () => string;
1092
+ onImageSaved?: (savedImage: SavedGeneratedImage, imageData: { data: string; mimeType: string }) => void;
1093
+ onWebSearchCaptured?: (search: SurfacedWebSearch) => void;
1094
+ },
1095
+ requestPrompt: string | undefined,
1096
+ ): Promise<void> {
1097
+ const { socket, release } = await acquireWebSocket(url, headers, options?.sessionId, options?.signal);
1098
+ let keepConnection = true;
1099
+
1100
+ try {
1101
+ socket.send(JSON.stringify({ type: "response.create", ...body }));
1102
+ onStart();
1103
+ stream.push({ type: "start", partial: output });
1104
+ await processCapturedResponsesStream(parseWebSocket(socket, options?.signal), output, stream, model, options, deps, requestPrompt);
1105
+ if (options?.signal?.aborted) {
1106
+ keepConnection = false;
1107
+ }
1108
+ } catch (error) {
1109
+ keepConnection = false;
1110
+ throw error;
1111
+ } finally {
1112
+ release({ keep: keepConnection });
1113
+ }
1114
+ }
1115
+
1116
+ function extractWebSearch(item: StreamEventShape["item"]): SurfacedWebSearch | undefined {
1117
+ if (!item || item.type !== "web_search_call") return undefined;
1118
+ const callId = typeof item.id === "string" ? item.id : undefined;
1119
+ if (!callId) return undefined;
1120
+
1121
+ const action = typeof item.action === "object" && item.action !== null ? (item.action as Record<string, unknown>) : undefined;
1122
+ const query = typeof action?.query === "string" ? action.query : undefined;
1123
+ const queries = Array.isArray(action?.queries) ? action.queries.filter((value): value is string => typeof value === "string") : [];
1124
+ const sourceUrls = Array.isArray(action?.sources)
1125
+ ? action.sources
1126
+ .map((source) => (typeof source === "object" && source !== null ? (source as Record<string, unknown>) : undefined))
1127
+ .map((source) => (typeof source?.url === "string" ? source.url : undefined))
1128
+ .filter((url): url is string => typeof url === "string")
1129
+ : [];
1130
+
1131
+ const results = Array.isArray(item.results)
1132
+ ? item.results
1133
+ .map((result) => (typeof result === "object" && result !== null ? (result as Record<string, unknown>) : undefined))
1134
+ .filter((result): result is Record<string, unknown> => !!result)
1135
+ : [];
1136
+
1137
+ const titledSources: Array<{ title?: string; url: string }> = [];
1138
+ for (const result of results) {
1139
+ if (typeof result.url !== "string") continue;
1140
+ titledSources.push({
1141
+ title: typeof result.title === "string" ? result.title : undefined,
1142
+ url: result.url,
1143
+ });
1144
+ }
1145
+
1146
+ const seenUrls = new Set<string>();
1147
+ const sources: Array<{ title?: string; url: string }> = [];
1148
+ for (const source of titledSources) {
1149
+ if (seenUrls.has(source.url)) continue;
1150
+ seenUrls.add(source.url);
1151
+ sources.push(source);
1152
+ }
1153
+ for (const url of sourceUrls) {
1154
+ if (seenUrls.has(url)) continue;
1155
+ seenUrls.add(url);
1156
+ sources.push({ url });
1157
+ }
1158
+
1159
+ return {
1160
+ callId,
1161
+ status: typeof item.status === "string" ? item.status : undefined,
1162
+ query,
1163
+ queries,
1164
+ sources,
1165
+ };
1166
+ }
1167
+
1168
+ export function buildWebSearchActivityMessage(searches: SurfacedWebSearch[]): string {
1169
+ const sections = searches.map((search, index) => {
1170
+ const heading = searches.length > 1 ? `Web search results ${index + 1}` : "Web search results";
1171
+ const lines = [heading];
1172
+ const queries = search.queries.length > 0 ? search.queries : search.query ? [search.query] : [];
1173
+ if (queries.length > 0) {
1174
+ lines.push("Queries:");
1175
+ for (const query of queries) {
1176
+ lines.push(`- ${query}`);
1177
+ }
1178
+ }
1179
+ if (search.sources.length > 0) {
1180
+ lines.push("Sources:");
1181
+ for (const source of search.sources.slice(0, 5)) {
1182
+ lines.push(`- ${source.title ? `${source.title} — ` : ""}${source.url}`);
1183
+ }
1184
+ }
1185
+ return lines.join("\n");
1186
+ });
1187
+
1188
+ return sections.join("\n\n");
1189
+ }
1190
+
1191
+ export function buildWebSearchSummaryText(searches: SurfacedWebSearch[]): string {
1192
+ return searches.length === 1 ? "Searched the web once" : `Searched the web ${searches.length} times`;
1193
+ }
1194
+
1195
+ function loadCachedImagePreview(savedImage: SavedGeneratedImage, imagePreviewCache: Map<string, CachedImagePreview>): CachedImagePreview | undefined {
1196
+ const cached = imagePreviewCache.get(savedImage.absolutePath);
1197
+ if (cached) return cached;
1198
+ const fs = getNodeFsSync();
1199
+ if (!fs) return undefined;
1200
+ try {
1201
+ const preview = {
1202
+ data: fs.readFileSync(savedImage.absolutePath).toString("base64"),
1203
+ mimeType: `image/${savedImage.outputFormat}`,
1204
+ };
1205
+ imagePreviewCache.set(savedImage.absolutePath, preview);
1206
+ return preview;
1207
+ } catch {
1208
+ return undefined;
1209
+ }
1210
+ }
1211
+
1212
+ function createInitialAssistantMessage<TApi extends Api>(model: Model<TApi>): AssistantMessage {
1213
+ return {
1214
+ role: "assistant",
1215
+ content: [],
1216
+ api: "openai-codex-responses",
1217
+ provider: model.provider,
1218
+ model: model.id,
1219
+ usage: {
1220
+ input: 0,
1221
+ output: 0,
1222
+ cacheRead: 0,
1223
+ cacheWrite: 0,
1224
+ totalTokens: 0,
1225
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1226
+ },
1227
+ stopReason: "stop",
1228
+ timestamp: Date.now(),
1229
+ };
1230
+ }
1231
+
1232
+ function createErrorMessage(message: AssistantMessage, error: unknown, aborted: boolean): AssistantMessage {
1233
+ for (const block of message.content) {
1234
+ if (typeof block === "object" && block !== null && "partialJson" in block) {
1235
+ delete (block as { partialJson?: string }).partialJson;
1236
+ }
1237
+ }
1238
+ message.stopReason = aborted ? "aborted" : "error";
1239
+ message.errorMessage = error instanceof Error ? error.message : String(error);
1240
+ return message;
1241
+ }
1242
+
1243
+ function finalizeUsage<TApi extends Api>(model: Model<TApi>, output: AssistantMessage): void {
1244
+ output.usage.cost.total = output.usage.cost.input + output.usage.cost.output + output.usage.cost.cacheRead + output.usage.cost.cacheWrite;
1245
+ }
1246
+
1247
+ async function parseErrorResponse(response: Response): Promise<{ message: string; friendlyMessage?: string }> {
1248
+ const raw = await response.text();
1249
+ let message = raw || response.statusText || "Request failed";
1250
+ let friendlyMessage: string | undefined;
1251
+
1252
+ try {
1253
+ const parsed = JSON.parse(raw) as { error?: { code?: string; type?: string; plan_type?: string; resets_at?: number; message?: string } };
1254
+ const err = parsed?.error;
1255
+ if (err) {
1256
+ const code = err.code || err.type || "";
1257
+ if (/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || response.status === 429) {
1258
+ const plan = err.plan_type ? ` (${err.plan_type.toLowerCase()} plan)` : "";
1259
+ const mins = err.resets_at ? Math.max(0, Math.round((err.resets_at * 1000 - Date.now()) / 60000)) : undefined;
1260
+ const when = mins !== undefined ? ` Try again in ~${mins} min.` : "";
1261
+ friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${when}`.trim();
1262
+ }
1263
+ message = err.message || friendlyMessage || message;
1264
+ }
1265
+ } catch {
1266
+ // ignore malformed error bodies
1267
+ }
1268
+
1269
+ return { message, friendlyMessage };
1270
+ }
1271
+
1272
+ function createCodexStream<TApi extends Api>(
1273
+ model: Model<TApi>,
1274
+ context: Context,
1275
+ options: SimpleStreamOptions | undefined,
1276
+ deps: {
1277
+ getCurrentCwd: () => string;
1278
+ onImageSaved?: (savedImage: SavedGeneratedImage, imageData: { data: string; mimeType: string }) => void;
1279
+ onWebSearchCaptured?: (search: SurfacedWebSearch) => void;
1280
+ },
1281
+ ): AssistantMessageEventStream {
1282
+ const stream = createAssistantMessageEventStream();
1283
+
1284
+ (async () => {
1285
+ const output = createInitialAssistantMessage(model);
1286
+ const requestPrompt = getLatestUserText(context);
1287
+
1288
+ try {
1289
+ const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
1290
+ if (!apiKey) {
1291
+ throw new Error(`No API key for provider: ${model.provider}`);
1292
+ }
1293
+
1294
+ const accountId = extractAccountId(apiKey);
1295
+ let body = buildRequestBody(model, context, options);
1296
+ const nextBody = await options?.onPayload?.(body, model);
1297
+ if (nextBody !== undefined) {
1298
+ body = nextBody as ResponsesBody;
1299
+ }
1300
+
1301
+ const websocketRequestId = options?.sessionId || createCodexRequestId();
1302
+ const sseHeaders = buildSSEHeaders(model.headers, options?.headers, accountId, apiKey, options?.sessionId);
1303
+ const websocketHeaders = buildWebSocketHeaders(model.headers, options?.headers, accountId, apiKey, websocketRequestId);
1304
+ const bodyJson = JSON.stringify(body);
1305
+ const transport = options?.transport || "sse";
1306
+
1307
+ if (transport !== "sse") {
1308
+ let websocketStarted = false;
1309
+ try {
1310
+ await processWebSocketStream(
1311
+ resolveCodexWebSocketUrl(model.baseUrl),
1312
+ body,
1313
+ websocketHeaders,
1314
+ output,
1315
+ stream,
1316
+ model,
1317
+ () => {
1318
+ websocketStarted = true;
1319
+ },
1320
+ options,
1321
+ deps,
1322
+ requestPrompt,
1323
+ );
1324
+ if (options?.signal?.aborted) {
1325
+ throw new Error("Request was aborted");
1326
+ }
1327
+ finalizeUsage(model, output);
1328
+ stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
1329
+ stream.end();
1330
+ return;
1331
+ } catch (error) {
1332
+ if (transport === "websocket" || websocketStarted) {
1333
+ throw error;
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ let response: Response | undefined;
1339
+ let lastError: Error | undefined;
1340
+
1341
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1342
+ if (options?.signal?.aborted) {
1343
+ throw new Error("Request was aborted");
1344
+ }
1345
+
1346
+ try {
1347
+ response = await fetch(resolveCodexUrl(model.baseUrl), {
1348
+ method: "POST",
1349
+ headers: sseHeaders,
1350
+ body: bodyJson,
1351
+ signal: options?.signal,
1352
+ });
1353
+
1354
+ await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
1355
+
1356
+ if (response.ok) {
1357
+ break;
1358
+ }
1359
+
1360
+ const errorText = await response.text();
1361
+ if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
1362
+ await sleep(BASE_DELAY_MS * 2 ** attempt, options?.signal);
1363
+ continue;
1364
+ }
1365
+
1366
+ const fakeResponse = new Response(errorText, {
1367
+ status: response.status,
1368
+ statusText: response.statusText,
1369
+ });
1370
+ const info = await parseErrorResponse(fakeResponse);
1371
+ throw new Error(info.friendlyMessage || info.message);
1372
+ } catch (error) {
1373
+ if (error instanceof Error && (error.name === "AbortError" || error.message === "Request was aborted")) {
1374
+ throw new Error("Request was aborted");
1375
+ }
1376
+
1377
+ lastError = error instanceof Error ? error : new Error(String(error));
1378
+ if (attempt < MAX_RETRIES && !lastError.message.includes("usage limit")) {
1379
+ await sleep(BASE_DELAY_MS * 2 ** attempt, options?.signal);
1380
+ continue;
1381
+ }
1382
+ throw lastError;
1383
+ }
1384
+ }
1385
+
1386
+ if (!response?.ok) {
1387
+ throw lastError ?? new Error("Failed after retries");
1388
+ }
1389
+
1390
+ if (!response.body) {
1391
+ throw new Error("No response body");
1392
+ }
1393
+
1394
+ stream.push({ type: "start", partial: output });
1395
+ await processCapturedResponsesStream(parseSSE(response), output, stream, model, options, deps, requestPrompt);
1396
+ finalizeUsage(model, output);
1397
+
1398
+ if (options?.signal?.aborted) {
1399
+ throw new Error("Request was aborted");
1400
+ }
1401
+
1402
+ stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
1403
+ stream.end();
1404
+ } catch (error) {
1405
+ stream.push({
1406
+ type: "error",
1407
+ reason: (options?.signal?.aborted ? "aborted" : "error") as "aborted" | "error",
1408
+ error: createErrorMessage(output, error, !!options?.signal?.aborted),
1409
+ });
1410
+ stream.end();
1411
+ }
1412
+ })();
1413
+
1414
+ return stream;
1415
+ }
1416
+
1417
+ export function registerOpenAICodexCustomProvider(pi: ExtensionAPI, options: { getCurrentCwd: () => string }): void {
1418
+ const pendingActivities: PendingActivity[] = [];
1419
+ const imagePreviewCache = new Map<string, CachedImagePreview>();
1420
+ let pendingFlushTimer: ReturnType<typeof setTimeout> | undefined;
1421
+
1422
+ const flushPendingMessages = () => {
1423
+ pendingFlushTimer = undefined;
1424
+ const activities = pendingActivities.splice(0, pendingActivities.length);
1425
+
1426
+ for (let index = 0; index < activities.length; index++) {
1427
+ const activity = activities[index];
1428
+ if (activity.kind === "image") {
1429
+ imagePreviewCache.set(activity.savedImage.absolutePath, activity.imageData);
1430
+ pi.sendMessage(
1431
+ {
1432
+ customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
1433
+ content: [{ type: "text", text: buildGeneratedImageDisplayText(activity.savedImage, { expanded: false }) }],
1434
+ display: true,
1435
+ details: { savedImages: [activity.savedImage] } satisfies ImageDisplayMessageDetails,
1436
+ },
1437
+ { triggerTurn: false },
1438
+ );
1439
+ continue;
1440
+ }
1441
+
1442
+ const searches = [activity.search];
1443
+ while (index + 1 < activities.length && activities[index + 1]?.kind === "web-search") {
1444
+ searches.push((activities[++index] as QueuedWebSearchActivity).search);
1445
+ }
1446
+ pi.sendMessage(
1447
+ {
1448
+ customType: WEB_SEARCH_ACTIVITY_MESSAGE_TYPE,
1449
+ content: buildWebSearchActivityMessage(searches),
1450
+ display: true,
1451
+ details: { searches },
1452
+ },
1453
+ { triggerTurn: false },
1454
+ );
1455
+ }
1456
+ };
1457
+
1458
+ const schedulePendingMessageFlush = () => {
1459
+ if (pendingFlushTimer || pendingActivities.length === 0) {
1460
+ return;
1461
+ }
1462
+ pendingFlushTimer = setTimeout(flushPendingMessages, 0);
1463
+ };
1464
+
1465
+ const clearPendingMessages = () => {
1466
+ if (pendingFlushTimer) {
1467
+ clearTimeout(pendingFlushTimer);
1468
+ pendingFlushTimer = undefined;
1469
+ }
1470
+ pendingActivities.length = 0;
1471
+ imagePreviewCache.clear();
1472
+ };
1473
+
1474
+ pi.registerProvider("openai-codex", {
1475
+ api: "openai-codex-responses",
1476
+ streamSimple: (model, context, streamOptions) =>
1477
+ createCodexStream(model, context, streamOptions, {
1478
+ getCurrentCwd: options.getCurrentCwd,
1479
+ onImageSaved: (savedImage, imageData) => {
1480
+ pendingActivities.push({ kind: "image", savedImage, imageData });
1481
+ },
1482
+ onWebSearchCaptured: (search) => {
1483
+ pendingActivities.push({ kind: "web-search", search });
1484
+ },
1485
+ }),
1486
+ });
1487
+
1488
+ pi.on("session_start", async () => {
1489
+ clearPendingMessages();
1490
+ });
1491
+
1492
+ pi.on("session_shutdown", async () => {
1493
+ if (pendingActivities.length > 0) {
1494
+ flushPendingMessages();
1495
+ }
1496
+ clearPendingMessages();
1497
+ });
1498
+
1499
+ pi.on("agent_end", async () => {
1500
+ schedulePendingMessageFlush();
1501
+ });
1502
+
1503
+ pi.registerMessageRenderer<ImageDisplayMessageDetails>(IMAGE_SAVE_DISPLAY_MESSAGE_TYPE, (message, options, theme) => {
1504
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
1505
+ box.addChild(new Text(theme.fg("customMessageLabel", theme.bold("[image_generation]")), 0, 0));
1506
+ const savedImage = message.details?.savedImages?.[0];
1507
+ const textContent = savedImage
1508
+ ? buildGeneratedImageDisplayText(savedImage, { expanded: options.expanded })
1509
+ : typeof message.content === "string"
1510
+ ? message.content
1511
+ : message.content
1512
+ .filter((item) => item.type === "text")
1513
+ .map((item) => item.text)
1514
+ .join("\n");
1515
+ box.addChild(new Text(`\n${theme.fg("customMessageText", textContent)}`, 0, 0));
1516
+ if (savedImage) {
1517
+ const preview = loadCachedImagePreview(savedImage, imagePreviewCache);
1518
+ if (preview) {
1519
+ box.addChild(new Spacer(1));
1520
+ box.addChild(
1521
+ new Image(preview.data, preview.mimeType, { fallbackColor: (text) => theme.fg("customMessageText", text) }, { maxWidthCells: 60 }),
1522
+ );
1523
+ }
1524
+ }
1525
+ return box;
1526
+ });
1527
+
1528
+ pi.registerMessageRenderer<{ searches?: SurfacedWebSearch[] }>(WEB_SEARCH_ACTIVITY_MESSAGE_TYPE, (message, options, theme) => {
1529
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
1530
+ const searches = message.details?.searches ?? [];
1531
+ box.addChild(new Text(theme.fg("customMessageLabel", theme.bold(buildWebSearchSummaryText(searches))), 0, 0));
1532
+ if (options.expanded) {
1533
+ const content = typeof message.content === "string"
1534
+ ? message.content
1535
+ : message.content
1536
+ .filter((item) => item.type === "text")
1537
+ .map((item) => item.text)
1538
+ .join("\n");
1539
+ box.addChild(new Text(`\n${theme.fg("customMessageText", content)}`, 0, 0));
1540
+ }
1541
+ return box;
1542
+ });
1543
+ }