@oai404iao/pi-codex-minimal-tools 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +44 -17
  2. package/THIRD_PARTY_NOTICES.md +20 -0
  3. package/config.schema.json +1 -2
  4. package/index.ts +2 -0
  5. package/package.json +14 -13
  6. package/provenance/openai-codex-ddea03ad-astra.json +54 -0
  7. package/src/activation.ts +1 -56
  8. package/src/adapter/compaction/checkpoint.ts +1 -0
  9. package/src/adapter/compaction/collect.ts +1 -0
  10. package/src/adapter/compaction/http.ts +1 -0
  11. package/src/adapter/compaction/request.ts +1 -0
  12. package/src/adapter/compaction/transport.ts +1 -0
  13. package/src/adapter/compaction/websocket.ts +1 -0
  14. package/src/background-image-generation.ts +1 -574
  15. package/src/capabilities.ts +1 -146
  16. package/src/codex-http.ts +1 -133
  17. package/src/codex-identity-extension.ts +1 -339
  18. package/src/codex-request-profile.ts +1 -45
  19. package/src/codex-reserved-tools.ts +1 -323
  20. package/src/codex-wire-identity.ts +1 -596
  21. package/src/extension/prewarm-snapshot.ts +1 -0
  22. package/src/extension/provider-presentation.ts +1 -0
  23. package/src/extension/provider-runtime.ts +1 -0
  24. package/src/extension/register.ts +36 -0
  25. package/src/extension/startup-prewarm.ts +1 -0
  26. package/src/fast-mode.ts +1 -124
  27. package/src/glyphs.ts +1 -70
  28. package/src/index.ts +8 -331
  29. package/src/model-catalog/catalog.ts +1 -636
  30. package/src/model-catalog/default-models.json +8 -0
  31. package/src/model-catalog/runtime.ts +1 -113
  32. package/src/model-catalog/types.ts +1 -95
  33. package/src/native-compaction.ts +1 -393
  34. package/src/patch/apply.ts +1 -338
  35. package/src/patch/parser.ts +1 -224
  36. package/src/patch/render.ts +1 -201
  37. package/src/provider-headers.ts +1 -54
  38. package/src/provider-native-tools.ts +1 -71
  39. package/src/provider-shim.ts +20 -4511
  40. package/src/providers/codex-apply-patch-tool.ts +1 -23
  41. package/src/providers/openai-codex/cache-key.ts +1 -0
  42. package/src/providers/openai-codex/captured-stream.ts +1 -0
  43. package/src/providers/openai-codex/constants.ts +1 -0
  44. package/src/providers/openai-codex/continuation.ts +1 -0
  45. package/src/providers/openai-codex/errors.ts +1 -0
  46. package/src/providers/openai-codex/events.ts +1 -0
  47. package/src/providers/openai-codex/headers.ts +1 -0
  48. package/src/providers/openai-codex/lite.ts +1 -0
  49. package/src/providers/openai-codex/message.ts +1 -0
  50. package/src/providers/openai-codex/prewarm.ts +1 -0
  51. package/src/providers/openai-codex/proxy.ts +1 -0
  52. package/src/providers/openai-codex/reasoning.ts +1 -0
  53. package/src/providers/openai-codex/request-body.ts +1 -0
  54. package/src/providers/openai-codex/request-context.ts +1 -0
  55. package/src/providers/openai-codex/request-metadata.ts +1 -0
  56. package/src/providers/openai-codex/retry.ts +1 -0
  57. package/src/providers/openai-codex/runtime.ts +1 -0
  58. package/src/providers/openai-codex/sse.ts +1 -0
  59. package/src/providers/openai-codex/stream-effects.ts +1 -0
  60. package/src/providers/openai-codex/stream.ts +1 -0
  61. package/src/providers/openai-codex/types.ts +1 -0
  62. package/src/providers/openai-codex/urls.ts +1 -0
  63. package/src/providers/openai-codex/usage.ts +1 -0
  64. package/src/providers/openai-codex/websocket-connection.ts +1 -0
  65. package/src/providers/openai-codex/websocket-events.ts +1 -0
  66. package/src/providers/openai-codex/websocket-session.ts +1 -0
  67. package/src/providers/openai-codex/websocket-socket.ts +1 -0
  68. package/src/providers/openai-codex/websocket-stream.ts +1 -0
  69. package/src/providers/openai-responses-shared.ts +7 -1463
  70. package/src/providers/responses/citations.ts +1 -0
  71. package/src/providers/responses/history.ts +1 -0
  72. package/src/providers/responses/items.ts +1 -0
  73. package/src/providers/responses/markdown.ts +1 -0
  74. package/src/providers/responses/messages.ts +1 -0
  75. package/src/providers/responses/signatures.ts +1 -0
  76. package/src/providers/responses/stream-state.ts +1 -0
  77. package/src/providers/responses/stream.ts +1 -0
  78. package/src/providers/responses/text-renderer.ts +1 -0
  79. package/src/providers/responses/text.ts +1 -0
  80. package/src/providers/responses/tool-identity.ts +1 -0
  81. package/src/providers/responses/tools.ts +1 -0
  82. package/src/providers/responses/types.ts +1 -0
  83. package/src/providers/responses/usage.ts +1 -0
  84. package/src/settings.ts +1 -247
  85. package/src/subagent-inline.ts +1 -8
  86. package/src/tools/apply-patch.ts +1 -84
  87. package/src/tools/image-generation/capture.ts +1 -0
  88. package/src/tools/image-generation/display.ts +1 -0
  89. package/src/tools/image-generation/preview.ts +1 -0
  90. package/src/tools/image-generation/storage.ts +1 -0
  91. package/src/tools/image-generation/types.ts +1 -0
  92. package/src/tools/image-generation.ts +1 -274
  93. package/src/tools/view-image.ts +1 -98
  94. package/src/tools/web-search/activity.ts +1 -0
  95. package/src/tools/web-search/capture.ts +1 -0
  96. package/src/tools/web-search/render.ts +1 -0
  97. package/src/tools/web-search.ts +1 -554
  98. package/src/utils/images.ts +1 -73
  99. package/src/utils/theme.ts +1 -0
@@ -1,4511 +1,20 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { ProviderHeaders } from "@earendil-works/pi-ai";
3
- import { glyphs, treeGlyph } from "./glyphs.js";
4
- import { loadSettings } from "./settings.js";
5
- import { loadModelSettings, type ResolvedCodexModelSettings } from "./model-catalog/runtime.js";
6
- import type { ReasoningSummary } from "./model-catalog/types.js";
7
- import { resolveCodexRequestProfile, type CodexRequestProfile } from "./codex-request-profile.js";
8
- import { saveBase64Image } from "./utils/images.js";
9
- import { Container, getCapabilities, getImageDimensions, Image, Spacer, Text } from "@earendil-works/pi-tui";
10
- import {
11
- createAssistantMessageEventStream,
12
- appendAssistantMessageDiagnostic,
13
- createAssistantMessageDiagnostic,
14
- getEnvApiKey,
15
- streamSimpleOpenAICodexResponses,
16
- streamSimpleOpenAIResponses,
17
- type Api,
18
- type AssistantMessage,
19
- type AssistantMessageEventStream,
20
- type Context,
21
- type Model,
22
- type SimpleStreamOptions,
23
- type ThinkingLevel,
24
- } from "@earendil-works/pi-ai/compat";
25
- import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
26
- import { ProxyAgent } from "undici";
27
- import type { Dispatcher } from "undici";
28
- import {
29
- collectHistoricalCitationSources,
30
- collectWebSearchCitationSources,
31
- convertResponsesMessages,
32
- convertResponsesTools,
33
- encodeWebSearchActivityTextSignature,
34
- processResponsesStream,
35
- type CitationSource,
36
- type WebSearchCitationSource,
37
- } from "./providers/openai-responses-shared.js";
38
- import { createCodexApplyPatchCustomTool } from "./providers/codex-apply-patch-tool.js";
39
- import { createCodexReservedNamespaceTool } from "./codex-reserved-tools.js";
40
- import { rewriteNativeOpenAiTools } from "./provider-native-tools.js";
41
- import {
42
- captureCodexTurnState,
43
- codexTurnStateFor,
44
- currentCodexTurn,
45
- isUuidV7,
46
- resolveCodexRequestIdentity,
47
- resolveCodexWireIdentity,
48
- type CodexRequestIdentity,
49
- uuidV7,
50
- } from "./codex-wire-identity.js";
51
- import { installCodexIdentityLifecycle } from "./codex-identity-extension.js";
52
- import { applyFastModeServiceTier } from "./fast-mode.js";
53
- import {
54
- hasCodexRequestAuth,
55
- resolveCodexRequestAccountId,
56
- } from "./codex-http.js";
57
- import {
58
- isProviderHeaderSuppressed,
59
- mergeProviderHeaders,
60
- providerHeaderDirective,
61
- setProviderDefaultHeader,
62
- setProviderGeneratedHeader,
63
- } from "./provider-headers.js";
64
-
65
- const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
66
- export const IMAGE_SAVE_DISPLAY_MESSAGE_TYPE = "codex-image-generation-display";
67
- export const WEB_SEARCH_ACTIVITY_MESSAGE_TYPE = "codex-web-search-activity";
68
- const OPENAI_CODEX_IMAGE_DIR = ".pi/openai-codex-images";
69
- const OPENAI_CODEX_LATEST_IMAGE_NAME = "latest.png";
70
- const MAX_RETRIES = 3;
71
- const BASE_DELAY_MS = 1000;
72
- const SSE_RESPONSE_HEADER_TIMEOUT_MS = 20_000;
73
- const WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
74
- const WEBSOCKET_PREWARM_TIMEOUT_MS = 15_000;
75
- const WEBSOCKET_IDLE_TIMEOUT_MS = 300_000;
76
- const WEBSOCKET_SEND_TIMEOUT_MS = 300_000;
77
- const WEBSOCKET_EVENT_QUEUE_CAPACITY = 1600;
78
- const DEFAULT_WEBSOCKET_STREAM_MAX_RETRIES = 5;
79
- const MAX_WEBSOCKET_STREAM_MAX_RETRIES = 100;
80
- const WEBSOCKET_RETRY_BASE_DELAY_MS = 200;
81
- const WEBSOCKET_RETRY_MAX_DELAY_MS = 60_000;
82
- const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
83
- const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
84
- const PREVIOUS_RESPONSE_NOT_FOUND_CODE = "previous_response_not_found";
85
- const CODEX_RESPONSE_STATUSES = new Set(["completed", "incomplete", "failed", "cancelled", "queued", "in_progress"]);
86
- const OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06";
87
- const X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE = "x-openai-internal-codex-responses-lite";
88
- const WS_RESPONSES_LITE_CLIENT_METADATA_KEY = "ws_request_header_x_openai_internal_codex_responses_lite";
89
- const WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY = "x-codex-ws-stream-request-start-ms";
90
- const WEB_SEARCH_SOURCES_INCLUDE = "web_search_call.action.sources";
91
- const WEB_SEARCH_RESULTS_INCLUDE = "web_search_call.results";
92
- const SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1000;
93
- const CODEX_COMPACTION_TRIGGER_TYPE = "compaction_trigger";
94
- const CODEX_RETAINED_MESSAGE_TOKEN_BUDGET = 64_000;
95
- const CODEX_MAX_RETAINED_AGENT_MESSAGE_TOKENS = 10_000;
96
- const CODEX_REMOTE_COMPACTION_STREAM_RETRIES = 2;
97
- const X_CODEX_BETA_FEATURES = "x-codex-beta-features";
98
- const CODEX_REMOTE_COMPACTION_V2_FEATURE = "remote_compaction_v2";
99
- const APPROX_BYTES_PER_TOKEN = 4;
100
- const dynamicImport = (specifier: string) => import(specifier);
101
- let _os: { platform(): string; release(): string; arch(): string } | null = null;
102
-
103
- if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
104
- dynamicImport("node:os")
105
- .then((module) => {
106
- _os = module;
107
- })
108
- .catch(() => {
109
- _os = null;
110
- });
111
- }
112
-
113
- export interface SavedGeneratedImage {
114
- absolutePath: string;
115
- relativePath: string;
116
- latestAbsolutePath: string;
117
- latestRelativePath: string;
118
- responseId: string | undefined;
119
- callId: string;
120
- outputFormat: string;
121
- imageModel?: string;
122
- revisedPrompt?: string;
123
- }
124
-
125
- interface ImageDisplayMessageDetails {
126
- savedImages: SavedGeneratedImage[];
127
- }
128
-
129
- interface PendingImageDisplay {
130
- savedImage: SavedGeneratedImage;
131
- imageData: { data: string; mimeType: string };
132
- }
133
-
134
- interface QueuedImageActivity extends PendingImageDisplay {
135
- kind: "image";
136
- }
137
-
138
- export interface SurfacedWebSearch {
139
- callId: string;
140
- status?: string;
141
- completed?: boolean;
142
- actionType?: string;
143
- query?: string;
144
- queries: string[];
145
- url?: string;
146
- pattern?: string;
147
- sources: Array<{ title?: string; url: string }>;
148
- responseItem?: Record<string, unknown>;
149
- }
150
-
151
- type PendingActivity = QueuedImageActivity;
152
-
153
- interface CachedImagePreview {
154
- data: string;
155
- mimeType: string;
156
- bytes: number;
157
- widthPx?: number;
158
- heightPx?: number;
159
- }
160
-
161
- interface WebSocketLike {
162
- readyState?: number;
163
- bufferedAmount?: number;
164
- send(data: string, callback?: (error?: Error) => void): void;
165
- close(code?: number, reason?: string): void;
166
- addEventListener(type: string, listener: (event: unknown) => void): void;
167
- removeEventListener(type: string, listener: (event: unknown) => void): void;
168
- }
169
-
170
- interface SessionWebSocketCacheEntry {
171
- socket: WebSocketLike;
172
- busy: boolean;
173
- waiters: WebSocketAcquireWaiter[];
174
- idleTimer?: ReturnType<typeof setTimeout>;
175
- continuation?: CachedWebSocketContinuationState;
176
- }
177
-
178
- interface WebSocketAcquireWaiter {
179
- resolve: (acquired: AcquiredWebSocket) => void;
180
- reject: (error: Error) => void;
181
- signal?: AbortSignal;
182
- onAbort?: () => void;
183
- }
184
-
185
- interface AcquiredWebSocket {
186
- socket: WebSocketLike;
187
- entry?: SessionWebSocketCacheEntry;
188
- reused: boolean;
189
- release: (options?: { keep?: boolean }) => void;
190
- }
191
-
192
- interface CachedWebSocketContinuationState {
193
- lastRequestBody: ResponsesBody;
194
- lastResponseId: string;
195
- lastResponseItems: unknown[];
196
- }
197
-
198
- interface WebSocketPrewarmRequest {
199
- url: string;
200
- headers: Headers;
201
- cacheKey: string;
202
- body: ResponsesBody;
203
- requestMetadata: WebSocketRequestMetadata;
204
- signal?: AbortSignal;
205
- connectTimeoutMs?: number;
206
- }
207
-
208
- type StartupPrewarmStatus = "pending" | "ready" | "failed";
209
-
210
- interface StartupPrewarmState {
211
- status: StartupPrewarmStatus;
212
- promise: Promise<void>;
213
- abortController: AbortController;
214
- }
215
-
216
- interface SessionStartupPrewarmTask {
217
- generation: number;
218
- modelIdentity: string;
219
- promise: Promise<void>;
220
- abortController: AbortController;
221
- }
222
-
223
- interface WebSocketRequestMetadata {
224
- /** Legacy Pi session lookup key used by exported test helpers. */
225
- sessionId?: string;
226
- threadId?: string;
227
- turnId: string;
228
- requestKind?: "turn" | "compaction" | "prewarm";
229
- identity?: CodexRequestIdentity;
230
- }
231
-
232
- export interface OpenAIResponsesProviderController {
233
- getCurrentTurnId(sessionId: string | undefined): string | undefined;
234
- getRequestIdentity?(
235
- sessionId: string | undefined,
236
- requestKind?: CodexRequestIdentity["requestKind"],
237
- ): CodexRequestIdentity | undefined;
238
- }
239
-
240
- let fsPromisesPromise: Promise<typeof import("node:fs/promises")> | undefined;
241
- const workspaceRootCache = new Map<string, Promise<string>>();
242
-
243
- const PATH_SEPARATOR = "/";
244
-
245
- interface ResponsesBody {
246
- model: string;
247
- store: boolean;
248
- stream: boolean;
249
- instructions?: string;
250
- previous_response_id?: string;
251
- input: unknown[];
252
- text: { verbosity: string };
253
- include: string[];
254
- prompt_cache_key?: string;
255
- tool_choice: "auto";
256
- parallel_tool_calls: boolean;
257
- temperature?: number;
258
- service_tier?: string;
259
- generate?: boolean;
260
- tools?: unknown[];
261
- reasoning?: {
262
- effort?: string;
263
- summary?: string;
264
- context?: "all_turns";
265
- };
266
- client_metadata?: Record<string, string>;
267
- [key: string]: unknown;
268
- }
269
-
270
- interface ResponseEnvelope {
271
- id?: string;
272
- status?: string;
273
- usage?: {
274
- input_tokens?: number;
275
- output_tokens?: number;
276
- total_tokens?: number;
277
- input_tokens_details?: { cached_tokens?: number };
278
- };
279
- service_tier?: string;
280
- error?: { message?: string };
281
- [key: string]: unknown;
282
- }
283
-
284
- type ServiceTier = ResponseCreateParamsStreaming["service_tier"];
285
- type ProviderTransport = NonNullable<SimpleStreamOptions["transport"]>;
286
-
287
- const websocketSessionCache = new Map<string, SessionWebSocketCacheEntry>();
288
- const websocketConnectionPromises = new Map<string, Promise<SessionWebSocketCacheEntry>>();
289
- const websocketHttpFallbackSessions = new Set<string>();
290
-
291
- class NonRetryableProviderError extends Error {}
292
- class ProviderResponseError extends Error {
293
- code?: string;
294
- errorType?: string;
295
- status?: number;
296
- retryAfterMs?: number;
297
- }
298
- class ProviderProtocolError extends Error {}
299
- class WebSocketHandshakeError extends Error {
300
- constructor(
301
- public readonly status: number,
302
- message: string,
303
- public readonly headers: Record<string, string> = {},
304
- public readonly body?: string,
305
- ) {
306
- super(withHttpStatusPrefix(status, message));
307
- this.name = "WebSocketHandshakeError";
308
- }
309
- }
310
-
311
- interface NodeWebSocketModule {
312
- WebSocket: new (url: string, options?: Record<string, unknown>) => {
313
- readyState: number;
314
- bufferedAmount: number;
315
- send(data: string, callback?: (error?: Error) => void): void;
316
- close(code?: number, reason?: string): void;
317
- terminate?(): void;
318
- on(type: string, listener: (...args: any[]) => void): void;
319
- off(type: string, listener: (...args: any[]) => void): void;
320
- };
321
- }
322
-
323
- let nodeWebSocketModulePromise: Promise<NodeWebSocketModule> | undefined;
324
-
325
- const HTTP_STATUS_MESSAGE_PREFIX = /^HTTP\s+\d{3}(?::|\b)/i;
326
-
327
- interface StreamEventShape {
328
- type?: string;
329
- status?: number;
330
- status_code?: number;
331
- sequence_number?: number;
332
- error?: {
333
- type?: string;
334
- message?: string;
335
- code?: string;
336
- plan_type?: string;
337
- resets_at?: number;
338
- [key: string]: unknown;
339
- };
340
- response?: ResponseEnvelope;
341
- item_id?: string;
342
- output_index?: number;
343
- item?: {
344
- id?: string;
345
- type?: string;
346
- result?: string | null;
347
- output_format?: string;
348
- revised_prompt?: string;
349
- status?: string;
350
- [key: string]: unknown;
351
- };
352
- code?: string;
353
- message?: string;
354
- [key: string]: unknown;
355
- }
356
-
357
- function sanitizeFilePart(value: string | undefined, fallback: string): string {
358
- const trimmed = (value ?? "").trim();
359
- if (!trimmed) return fallback;
360
- return trimmed.replace(/[^a-zA-Z0-9._-]+/g, "-");
361
- }
362
-
363
- function shortenFilePart(value: string | undefined, fallback: string): string {
364
- const safe = sanitizeFilePart(value, fallback);
365
- const match = /^([a-zA-Z]+_)(.+)$/.exec(safe);
366
- const prefix = match?.[1] ?? "";
367
- const body = match?.[2] ?? safe;
368
- if (body.length <= 12) return `${prefix}${body}`;
369
- return `${prefix}${body.slice(0, 8)}-${body.slice(-4)}`;
370
- }
371
-
372
- function normalizeImageOutputFormat(value: string | undefined): string {
373
- const format = (value ?? "png").toLowerCase();
374
- return format === "png" || format === "jpg" || format === "jpeg" || format === "webp" ? format : "png";
375
- }
376
-
377
- function shortHash(str: string): string {
378
- let h1 = 0xdeadbeef;
379
- let h2 = 0x41c6ce57;
380
- for (let i = 0; i < str.length; i++) {
381
- const ch = str.charCodeAt(i);
382
- h1 = Math.imul(h1 ^ ch, 2654435761);
383
- h2 = Math.imul(h2 ^ ch, 1597334677);
384
- }
385
- h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
386
- h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
387
- return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
388
- }
389
-
390
- function normalizePath(value: string): string {
391
- if (!value) return ".";
392
- const normalized = value.replace(/\/+/g, PATH_SEPARATOR);
393
- if (normalized === PATH_SEPARATOR) return normalized;
394
- return normalized.replace(/\/+$/g, "") || PATH_SEPARATOR;
395
- }
396
-
397
- function joinPaths(...parts: string[]): string {
398
- if (parts.length === 0) return ".";
399
- let result = parts[0] ?? "";
400
- for (let i = 1; i < parts.length; i++) {
401
- const part = parts[i];
402
- if (!part) continue;
403
- if (!result || result.endsWith(PATH_SEPARATOR)) {
404
- result += part.replace(/^\/+/, "");
405
- } else {
406
- result += `${PATH_SEPARATOR}${part.replace(/^\/+/, "")}`;
407
- }
408
- }
409
- return normalizePath(result);
410
- }
411
-
412
- function dirnamePath(value: string): string {
413
- const normalized = normalizePath(value);
414
- if (normalized === PATH_SEPARATOR) return PATH_SEPARATOR;
415
- const index = normalized.lastIndexOf(PATH_SEPARATOR);
416
- if (index < 0) return ".";
417
- if (index === 0) return PATH_SEPARATOR;
418
- return normalized.slice(0, index);
419
- }
420
-
421
- function splitPathSegments(value: string): string[] {
422
- const normalized = normalizePath(value);
423
- if (normalized === PATH_SEPARATOR) return [];
424
- return normalized.replace(/^\/+/, "").split(PATH_SEPARATOR).filter(Boolean);
425
- }
426
-
427
- function relativePath(from: string, to: string): string {
428
- const normalizedFrom = normalizePath(from);
429
- const normalizedTo = normalizePath(to);
430
- if (normalizedFrom === normalizedTo) return "";
431
- const fromSegments = splitPathSegments(normalizedFrom);
432
- const toSegments = splitPathSegments(normalizedTo);
433
- let shared = 0;
434
- while (shared < fromSegments.length && shared < toSegments.length && fromSegments[shared] === toSegments[shared]) {
435
- shared++;
436
- }
437
- const upSegments = new Array(fromSegments.length - shared).fill("..");
438
- const downSegments = toSegments.slice(shared);
439
- return [...upSegments, ...downSegments].join(PATH_SEPARATOR);
440
- }
441
-
442
- async function getNodeFsPromises(): Promise<typeof import("node:fs/promises")> {
443
- if (!fsPromisesPromise) {
444
- fsPromisesPromise = dynamicImport("node:fs/promises") as Promise<typeof import("node:fs/promises")>;
445
- }
446
- return fsPromisesPromise;
447
- }
448
-
449
- function getNodeFsSync(): { readFileSync(path: string): Buffer } | null {
450
- if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
451
- return null;
452
- }
453
- const builtinProcess = process as typeof process & { getBuiltinModule?: (specifier: string) => unknown };
454
- if (typeof builtinProcess.getBuiltinModule !== "function") {
455
- return null;
456
- }
457
- try {
458
- const module = builtinProcess.getBuiltinModule("node:fs") as { readFileSync?: (path: string) => Buffer } | undefined;
459
- return typeof module?.readFileSync === "function" ? { readFileSync: module.readFileSync } : null;
460
- } catch {
461
- return null;
462
- }
463
- }
464
-
465
- async function pathExists(value: string): Promise<boolean> {
466
- try {
467
- const fs = await getNodeFsPromises();
468
- await fs.access(value);
469
- return true;
470
- } catch {
471
- return false;
472
- }
473
- }
474
-
475
- async function resolveWorkspaceRoot(cwd: string): Promise<string> {
476
- const normalizedCwd = normalizePath(cwd);
477
- const cached = workspaceRootCache.get(normalizedCwd);
478
- if (cached) return cached;
479
-
480
- const promise = (async () => {
481
- let current = normalizedCwd;
482
- while (true) {
483
- if (await pathExists(joinPaths(current, ".git"))) {
484
- return current;
485
- }
486
- const parent = dirnamePath(current);
487
- if (parent === current || parent === ".") {
488
- return normalizedCwd;
489
- }
490
- current = parent;
491
- }
492
- })();
493
-
494
- workspaceRootCache.set(normalizedCwd, promise);
495
- return promise;
496
- }
497
-
498
- export function getOpenAICodexImageDirectory(cwd: string): string {
499
- return joinPaths(cwd, OPENAI_CODEX_IMAGE_DIR);
500
- }
501
-
502
- export function getOpenAICodexImagePath(cwd: string, responseId: string | undefined, callId: string, outputFormat?: string): string {
503
- const ext = normalizeImageOutputFormat(outputFormat);
504
- const safeCallId = shortenFilePart(callId, "image");
505
- const safeResponseId = shortenFilePart(responseId, "response");
506
- return joinPaths(getOpenAICodexImageDirectory(cwd), `${safeCallId}-${safeResponseId}.${ext}`);
507
- }
508
-
509
- export function getOpenAICodexLatestImagePath(cwd: string): string {
510
- return joinPaths(getOpenAICodexImageDirectory(cwd), OPENAI_CODEX_LATEST_IMAGE_NAME);
511
- }
512
-
513
- export function buildGeneratedImageDisplayText(savedImage: SavedGeneratedImage, options?: { expanded?: boolean }): string {
514
- const lines: string[] = [];
515
- if (options?.expanded && savedImage.revisedPrompt) {
516
- lines.push(`Prompt: ${savedImage.revisedPrompt}`);
517
- }
518
- lines.push(`File: ${savedImage.relativePath}`);
519
- return lines.join("\n");
520
- }
521
-
522
- export async function saveOpenAICodexGeneratedImage(
523
- cwd: string,
524
- image: { responseId?: string; callId: string; result: string; outputFormat?: string; imageModel?: string; revisedPrompt?: string },
525
- ): Promise<SavedGeneratedImage> {
526
- const workspaceRoot = await resolveWorkspaceRoot(cwd);
527
- const outputFormat = normalizeImageOutputFormat(image.outputFormat);
528
- const saved = await saveBase64Image({
529
- base64: image.result,
530
- callId: image.callId,
531
- cwd,
532
- format: outputFormat,
533
- responseId: image.responseId,
534
- settings: loadSettings(cwd),
535
- });
536
- const absolutePath = saved.path;
537
- const latestAbsolutePath = saved.latestPath ?? getOpenAICodexLatestImagePath(workspaceRoot);
538
-
539
- const relativeFilePath = relativePath(workspaceRoot, absolutePath);
540
- const latestRelativeFilePath = relativePath(workspaceRoot, latestAbsolutePath);
541
- const relativePathValue = relativeFilePath && !relativeFilePath.startsWith("..") ? relativeFilePath : absolutePath;
542
- const latestRelativePathValue =
543
- latestRelativeFilePath && !latestRelativeFilePath.startsWith("..") ? latestRelativeFilePath : latestAbsolutePath;
544
-
545
- return {
546
- absolutePath,
547
- relativePath: relativePathValue,
548
- latestAbsolutePath,
549
- latestRelativePath: latestRelativePathValue,
550
- responseId: image.responseId,
551
- callId: image.callId,
552
- outputFormat,
553
- imageModel: image.imageModel,
554
- revisedPrompt: image.revisedPrompt,
555
- };
556
- }
557
-
558
- export function resolveCodexUrl(baseUrl: string | undefined, options?: { apiKeyMode?: boolean }): string {
559
- const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : DEFAULT_CODEX_BASE_URL;
560
- const normalized = raw.replace(/\/+$/, "");
561
- if (options?.apiKeyMode) {
562
- if (normalized.endsWith("/responses")) return normalized;
563
- return `${normalized}/responses`;
564
- }
565
- if (normalized.endsWith("/codex/responses")) return normalized;
566
- if (normalized.endsWith("/codex")) return `${normalized}/responses`;
567
- return `${normalized}/codex/responses`;
568
- }
569
-
570
- export function resolveResponsesWebSocketUrl(baseUrl: string | undefined, options?: { apiKeyMode?: boolean }): string {
571
- const url = new URL(resolveCodexUrl(baseUrl, options));
572
- if (url.protocol === "https:") url.protocol = "wss:";
573
- if (url.protocol === "http:") url.protocol = "ws:";
574
- return url.toString();
575
- }
576
-
577
- function headersToRecord(headers: Headers): Record<string, string> {
578
- return Object.fromEntries(headers.entries());
579
- }
580
-
581
- function providerHeadersToHeaders(headers: ProviderHeaders): Headers {
582
- const result = new Headers();
583
- for (const [name, value] of Object.entries(headers)) {
584
- if (typeof value === "string") result.set(name, value);
585
- }
586
- return result;
587
- }
588
-
589
- function createCodexRequestId(): string {
590
- if (typeof globalThis.crypto?.randomUUID === "function") {
591
- return globalThis.crypto.randomUUID();
592
- }
593
- return `codex_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
594
- }
595
-
596
- function createPiTurnId(): string {
597
- return uuidV7();
598
- }
599
-
600
- function buildBaseCodexHeaders(
601
- modelHeaders: ProviderHeaders | undefined,
602
- additionalHeaders: ProviderHeaders | undefined,
603
- accountId: string | undefined,
604
- token: string,
605
- ): Headers {
606
- const headers = mergeProviderHeaders(modelHeaders, additionalHeaders);
607
- if (providerHeaderDirective(additionalHeaders, "authorization") === undefined && token) {
608
- setProviderGeneratedHeader(headers, "Authorization", `Bearer ${token}`);
609
- }
610
- if (accountId) setProviderDefaultHeader(headers, "chatgpt-account-id", accountId);
611
- setProviderDefaultHeader(headers, "originator", "pi");
612
- setProviderDefaultHeader(
613
- headers,
614
- "User-Agent",
615
- _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : "pi (browser)",
616
- );
617
- return headers;
618
- }
619
-
620
- /**
621
- * Resolve the wire identity (UUID v7 session/thread/window) for a pi session
622
- * and thread. Returns `undefined` when no pi session id is available so
623
- * session-less requests keep their previous behavior.
624
- */
625
- function wireIdentityFor(
626
- sessionId: string | undefined,
627
- threadId?: string,
628
- ): { sessionId: string; threadId: string; windowId: string } | undefined {
629
- if (!sessionId) return undefined;
630
- return resolveCodexWireIdentity(sessionId, threadId || sessionId);
631
- }
632
-
633
- /** Inject the Codex-compatible identity headers for a session. */
634
- function applyWireIdentityHeaders(
635
- headers: Headers,
636
- sessionId: string | undefined,
637
- threadId: string | undefined,
638
- requestIdentity?: CodexRequestIdentity,
639
- ): void {
640
- const wire = requestIdentity ?? wireIdentityFor(sessionId, threadId);
641
- if (!wire) return;
642
- setProviderGeneratedHeader(headers, "session-id", wire.sessionId);
643
- setProviderGeneratedHeader(headers, "thread-id", wire.threadId);
644
- setProviderGeneratedHeader(headers, "x-codex-window-id", wire.windowId);
645
- setProviderGeneratedHeader(headers, "x-client-request-id", wire.threadId);
646
- const turnState = requestIdentity?.turnState
647
- ?? (sessionId ? codexTurnStateFor(sessionId) : undefined);
648
- if (turnState) {
649
- setProviderGeneratedHeader(headers, "x-codex-turn-state", turnState);
650
- }
651
- if (requestIdentity?.parentThreadId) {
652
- setProviderGeneratedHeader(
653
- headers,
654
- "x-codex-parent-thread-id",
655
- requestIdentity.parentThreadId,
656
- );
657
- }
658
- if (requestIdentity?.subagentKind) {
659
- setProviderGeneratedHeader(
660
- headers,
661
- "x-openai-subagent",
662
- requestIdentity.subagentKind,
663
- );
664
- }
665
- if (requestIdentity) {
666
- setProviderGeneratedHeader(
667
- headers,
668
- "x-codex-turn-metadata",
669
- buildCodexTurnMetadataJson(requestIdentity),
670
- );
671
- }
672
- }
673
-
674
- /** Codex identity headers for an SSE request (exported for tests). */
675
- export function buildSSEHeaders(
676
- modelHeaders: ProviderHeaders | undefined,
677
- additionalHeaders: ProviderHeaders | undefined,
678
- accountId: string | undefined,
679
- token: string,
680
- sessionId: string | undefined,
681
- profile: CodexRequestProfile,
682
- threadId?: string,
683
- requestIdentity?: CodexRequestIdentity,
684
- ): Headers {
685
- const headers = buildBaseCodexHeaders(modelHeaders, additionalHeaders, accountId, token);
686
- setProviderDefaultHeader(headers, "OpenAI-Beta", "responses=experimental");
687
- setProviderDefaultHeader(headers, "accept", "text/event-stream");
688
- setProviderDefaultHeader(headers, "content-type", "application/json");
689
- if (profile.responsesMode === "lite") {
690
- setProviderDefaultHeader(headers, X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE, "true");
691
- }
692
-
693
- applyWireIdentityHeaders(headers, sessionId, threadId, requestIdentity);
694
-
695
- return headers;
696
- }
697
-
698
- function appendCommaSeparatedHeader(headers: Headers, name: string, value: string): void {
699
- if (isProviderHeaderSuppressed(headers, name)) return;
700
- const values = (headers.get(name) ?? "")
701
- .split(",")
702
- .map((entry) => entry.trim())
703
- .filter(Boolean);
704
- if (!values.includes(value)) headers.set(name, [...values, value].join(","));
705
- }
706
-
707
- function applyConfiguredResponsesFeatureHeaders(
708
- headers: Headers,
709
- settings: ResolvedCodexModelSettings,
710
- _model: Model<Api>,
711
- ): Headers {
712
- if (settings.compactionMode === "responses") {
713
- appendCommaSeparatedHeader(
714
- headers,
715
- X_CODEX_BETA_FEATURES,
716
- CODEX_REMOTE_COMPACTION_V2_FEATURE,
717
- );
718
- }
719
- return headers;
720
- }
721
-
722
- export function buildWebSocketHeaders(
723
- modelHeaders: ProviderHeaders | undefined,
724
- additionalHeaders: ProviderHeaders | undefined,
725
- accountId: string | undefined,
726
- token: string,
727
- sessionId: string,
728
- threadId = sessionId,
729
- requestIdentity?: CodexRequestIdentity,
730
- ): Headers {
731
- const headers = buildBaseCodexHeaders(modelHeaders, additionalHeaders, accountId, token);
732
- headers.delete("accept");
733
- headers.delete("content-type");
734
- headers.delete("OpenAI-Beta");
735
- headers.delete("openai-beta");
736
- setProviderDefaultHeader(headers, "OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS);
737
- setProviderDefaultHeader(headers, "x-client-request-id", threadId);
738
- setProviderDefaultHeader(headers, "session-id", sessionId);
739
- setProviderDefaultHeader(headers, "thread-id", threadId);
740
- const wire = requestIdentity ?? wireIdentityFor(sessionId, threadId);
741
- if (wire) {
742
- setProviderGeneratedHeader(headers, "session-id", wire.sessionId);
743
- setProviderGeneratedHeader(headers, "thread-id", wire.threadId);
744
- setProviderGeneratedHeader(headers, "x-codex-window-id", wire.windowId);
745
- setProviderGeneratedHeader(headers, "x-client-request-id", wire.threadId);
746
- }
747
- if (requestIdentity?.parentThreadId) {
748
- setProviderGeneratedHeader(
749
- headers,
750
- "x-codex-parent-thread-id",
751
- requestIdentity.parentThreadId,
752
- );
753
- }
754
- if (requestIdentity?.subagentKind) {
755
- setProviderGeneratedHeader(
756
- headers,
757
- "x-openai-subagent",
758
- requestIdentity.subagentKind,
759
- );
760
- }
761
- if (requestIdentity) {
762
- setProviderGeneratedHeader(
763
- headers,
764
- "x-codex-turn-metadata",
765
- buildCodexTurnMetadataJson(requestIdentity),
766
- );
767
- }
768
- return headers;
769
- }
770
-
771
- function clampReasoningEffort(modelId: string, effort: string): string {
772
- const id = modelId.includes("/") ? (modelId.split("/").pop() ?? modelId) : modelId;
773
- const gpt5MinorMatch = /^gpt-5\.(\d+)/.exec(id);
774
- const gpt5Minor = gpt5MinorMatch ? Number.parseInt(gpt5MinorMatch[1], 10) : undefined;
775
- if (gpt5Minor !== undefined && gpt5Minor >= 2 && effort === "minimal") return "low";
776
- if (id === "gpt-5.1" && effort === "xhigh") return "high";
777
- if (id === "gpt-5.1-codex-mini") return effort === "high" || effort === "xhigh" ? "high" : "medium";
778
- return effort;
779
- }
780
-
781
- const CODEX_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
782
- type CodexThinkingLevel = (typeof CODEX_THINKING_LEVELS)[number];
783
-
784
- /**
785
- * Keep this local instead of delegating to older Pi releases: Pi added `max`
786
- * after this extension's original minimum version, and an old clamp silently
787
- * turns it into `off`.
788
- */
789
- function clampCodexThinkingLevel(model: Model<Api>, level: ThinkingLevel): CodexThinkingLevel {
790
- if (!model.reasoning) return "off";
791
- const available = CODEX_THINKING_LEVELS.filter((candidate) => {
792
- if (candidate === "off") return true;
793
- const mapped = (model.thinkingLevelMap as Record<string, string | null | undefined> | undefined)?.[candidate];
794
- if (mapped === null) return false;
795
- if (candidate === "xhigh" || candidate === "max") return mapped !== undefined;
796
- return true;
797
- });
798
- if (available.includes(level as CodexThinkingLevel)) return level as CodexThinkingLevel;
799
- const requestedIndex = CODEX_THINKING_LEVELS.indexOf(level as CodexThinkingLevel);
800
- if (requestedIndex < 0) return available[0] ?? "off";
801
- for (let index = requestedIndex; index < CODEX_THINKING_LEVELS.length; index++) {
802
- const candidate = CODEX_THINKING_LEVELS[index]!;
803
- if (available.includes(candidate)) return candidate;
804
- }
805
- for (let index = requestedIndex - 1; index >= 0; index--) {
806
- const candidate = CODEX_THINKING_LEVELS[index]!;
807
- if (available.includes(candidate)) return candidate;
808
- }
809
- return available[0] ?? "off";
810
- }
811
-
812
- function thinkingLevelFromUnknown(value: unknown): ThinkingLevel | undefined {
813
- return value === "minimal"
814
- || value === "low"
815
- || value === "medium"
816
- || value === "high"
817
- || value === "xhigh"
818
- || value === "max"
819
- ? value
820
- : undefined;
821
- }
822
-
823
- interface StartupPrewarmSnapshot {
824
- systemPrompt: string;
825
- tools: Context["tools"];
826
- reasoning?: ThinkingLevel;
827
- }
828
-
829
- function startupPrewarmSnapshot(pi: ExtensionAPI, ctx: any): StartupPrewarmSnapshot {
830
- const activeToolNames = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
831
- return {
832
- systemPrompt: ctx.getSystemPrompt?.() ?? "",
833
- tools: (typeof pi.getAllTools === "function" ? pi.getAllTools() : [])
834
- .filter((tool) => activeToolNames.includes(tool.name))
835
- .map((tool) => ({
836
- name: tool.name,
837
- description: tool.description,
838
- parameters: tool.parameters,
839
- })),
840
- reasoning: thinkingLevelFromUnknown(
841
- (ctx as { thinkingLevel?: unknown }).thinkingLevel
842
- ?? (typeof pi.getThinkingLevel === "function" ? pi.getThinkingLevel() : undefined),
843
- ),
844
- };
845
- }
846
-
847
- function getServiceTierCostMultiplier(
848
- model: Model<Api>,
849
- serviceTier: ServiceTier,
850
- cwd: string,
851
- ): number {
852
- if (serviceTier === "flex") return 0.5;
853
- const settings = loadModelSettings(model, cwd);
854
- return serviceTier && serviceTier === settings.fastServiceTier
855
- ? settings.fastCostMultiplier ?? 1
856
- : 1;
857
- }
858
-
859
- function applyServiceTierPricing(
860
- usage: AssistantMessage["usage"],
861
- serviceTier: ServiceTier,
862
- model: Model<Api>,
863
- cwd: string,
864
- ): void {
865
- const multiplier = getServiceTierCostMultiplier(model, serviceTier, cwd);
866
- if (multiplier === 1) return;
867
- usage.cost.input *= multiplier;
868
- usage.cost.output *= multiplier;
869
- usage.cost.cacheRead *= multiplier;
870
- usage.cost.cacheWrite *= multiplier;
871
- usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
872
- }
873
-
874
- function resolveCodexServiceTier(responseServiceTier: ServiceTier, requestServiceTier: ServiceTier): ServiceTier {
875
- if (
876
- responseServiceTier === "default"
877
- && (requestServiceTier === "flex" || requestServiceTier === "priority")
878
- ) {
879
- return requestServiceTier;
880
- }
881
- return responseServiceTier ?? requestServiceTier;
882
- }
883
-
884
- function withRequestServiceTier(
885
- options: SimpleStreamOptions | undefined,
886
- serviceTier: unknown,
887
- ): SimpleStreamOptions | undefined {
888
- if (
889
- serviceTier !== "auto"
890
- && serviceTier !== "default"
891
- && serviceTier !== "flex"
892
- && serviceTier !== "scale"
893
- && serviceTier !== "priority"
894
- ) {
895
- return options;
896
- }
897
- return { ...options, serviceTier } as SimpleStreamOptions;
898
- }
899
-
900
- function hasNativeWebSearchTool(body: ResponsesBody): boolean {
901
- return Array.isArray(body.tools) && body.tools.some((tool) => Boolean(tool) && typeof tool === "object" && (tool as { type?: unknown }).type === "web_search");
902
- }
903
-
904
- function ensureWebSearchDetailsIncluded(body: ResponsesBody): void {
905
- if (!hasNativeWebSearchTool(body)) return;
906
- const include = Array.isArray(body.include) ? body.include : [];
907
- const missing = [WEB_SEARCH_SOURCES_INCLUDE, WEB_SEARCH_RESULTS_INCLUDE].filter((value) => !include.includes(value));
908
- if (missing.length > 0) body.include = [...include, ...missing];
909
- }
910
-
911
- function stripResponsesLiteImageDetails(value: unknown): void {
912
- if (Array.isArray(value)) {
913
- for (const item of value) stripResponsesLiteImageDetails(item);
914
- return;
915
- }
916
- if (!value || typeof value !== "object") return;
917
- const record = value as Record<string, unknown>;
918
- if (record.type === "input_image") delete record.detail;
919
- for (const entry of Object.values(record)) stripResponsesLiteImageDetails(entry);
920
- }
921
-
922
- export function withResponsesLiteWebSocketMetadata<T extends { client_metadata?: Record<string, string> }>(body: T, responsesMode: CodexRequestProfile["responsesMode"]): T {
923
- if (responsesMode !== "lite") return body;
924
- return {
925
- ...body,
926
- client_metadata: {
927
- ...body.client_metadata,
928
- [WS_RESPONSES_LITE_CLIENT_METADATA_KEY]: "true",
929
- },
930
- };
931
- }
932
-
933
- /**
934
- * Build the `x-codex-turn-metadata` compatibility blob the CLI sends inside
935
- * `client_metadata` for every request kind. The gateway currently rewrites
936
- * this blob (privacy normalization); once it honors client-supplied values,
937
- * this keeps the shape identical to the CLI.
938
- */
939
- function buildCodexTurnMetadataJson(identity: CodexRequestIdentity): string {
940
- const payload: Record<string, string | number> = {
941
- installation_id: identity.installationId,
942
- session_id: identity.sessionId,
943
- thread_id: identity.threadId,
944
- turn_id: identity.turnId,
945
- window_id: identity.windowId,
946
- request_kind: identity.requestKind,
947
- ...(identity.turnStartedAtMs !== undefined
948
- ? { turn_started_at_unix_ms: identity.turnStartedAtMs }
949
- : {}),
950
- ...(identity.agentName ? { agent_name: identity.agentName } : {}),
951
- ...(identity.forkedFromThreadId
952
- ? { forked_from_thread_id: identity.forkedFromThreadId }
953
- : {}),
954
- ...(identity.parentThreadId
955
- ? { parent_thread_id: identity.parentThreadId }
956
- : {}),
957
- ...(identity.parentTurnId
958
- ? { parent_turn_id: identity.parentTurnId }
959
- : {}),
960
- ...(identity.rootTurnId
961
- ? { root_turn_id: identity.rootTurnId }
962
- : {}),
963
- ...(identity.subagentKind
964
- ? { subagent_kind: identity.subagentKind }
965
- : {}),
966
- };
967
- return JSON.stringify(payload);
968
- }
969
-
970
- function identityForRequestMetadata(
971
- metadata: WebSocketRequestMetadata,
972
- ): CodexRequestIdentity | undefined {
973
- if (metadata.identity) return metadata.identity;
974
- const explicit: Record<string, unknown> = {
975
- turn_id: metadata.turnId,
976
- };
977
- if (isUuidV7(metadata.threadId)) explicit.thread_id = metadata.threadId;
978
- return resolveCodexRequestIdentity(
979
- metadata.sessionId,
980
- explicit,
981
- metadata.requestKind ?? "turn",
982
- );
983
- }
984
-
985
- /**
986
- * Inject the Codex-compatible `client_metadata` for an SSE request, sourced
987
- * from the same (session, thread, turn) metadata as the WebSocket path. The
988
- * WebSocket-only fields (`x-codex-ws-stream-request-start-ms`, turn-state,
989
- * Lite flag) stay out: SSE carries the Lite header and turn-state header.
990
- */
991
- export function withSseRequestMetadata(body: ResponsesBody, metadata: WebSocketRequestMetadata): ResponsesBody {
992
- const identity = identityForRequestMetadata(metadata);
993
- if (!identity) return body;
994
- const turnMetadata = buildCodexTurnMetadataJson(identity);
995
- return {
996
- ...body,
997
- client_metadata: {
998
- ...body.client_metadata,
999
- session_id: identity.sessionId,
1000
- thread_id: identity.threadId,
1001
- "x-codex-window-id": identity.windowId,
1002
- turn_id: identity.turnId,
1003
- "x-codex-installation-id": identity.installationId,
1004
- ...(identity.parentThreadId
1005
- ? { "x-codex-parent-thread-id": identity.parentThreadId }
1006
- : {}),
1007
- ...(identity.parentTurnId
1008
- ? { parent_turn_id: identity.parentTurnId }
1009
- : {}),
1010
- ...(identity.rootTurnId
1011
- ? { root_turn_id: identity.rootTurnId }
1012
- : {}),
1013
- ...(identity.subagentKind
1014
- ? { "x-openai-subagent": identity.subagentKind }
1015
- : {}),
1016
- ...(turnMetadata ? { "x-codex-turn-metadata": turnMetadata } : {}),
1017
- },
1018
- };
1019
- }
1020
-
1021
- function withWebSocketRequestMetadata(body: ResponsesBody, metadata: WebSocketRequestMetadata): ResponsesBody {
1022
- const identity = identityForRequestMetadata(metadata);
1023
- const turnMetadata = identity ? buildCodexTurnMetadataJson(identity) : "";
1024
- return {
1025
- ...body,
1026
- client_metadata: {
1027
- ...body.client_metadata,
1028
- ...(identity ? { session_id: identity.sessionId } : {}),
1029
- ...(identity ? { thread_id: identity.threadId } : {}),
1030
- ...(identity ? { "x-codex-window-id": identity.windowId } : {}),
1031
- turn_id: identity?.turnId ?? metadata.turnId,
1032
- ...(identity
1033
- ? { "x-codex-installation-id": identity.installationId }
1034
- : {}),
1035
- ...(identity?.parentThreadId
1036
- ? { "x-codex-parent-thread-id": identity.parentThreadId }
1037
- : {}),
1038
- ...(identity?.parentTurnId
1039
- ? { parent_turn_id: identity.parentTurnId }
1040
- : {}),
1041
- ...(identity?.rootTurnId
1042
- ? { root_turn_id: identity.rootTurnId }
1043
- : {}),
1044
- ...(identity?.subagentKind
1045
- ? { "x-openai-subagent": identity.subagentKind }
1046
- : {}),
1047
- ...(turnMetadata ? { "x-codex-turn-metadata": turnMetadata } : {}),
1048
- ...(identity?.turnState
1049
- ? { "x-codex-turn-state": identity.turnState }
1050
- : {}),
1051
- [WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY]: Date.now().toString(),
1052
- },
1053
- };
1054
- }
1055
-
1056
- export function buildRequestBody<TApi extends Api>(model: Model<TApi>, context: Context, profile: CodexRequestProfile, options?: SimpleStreamOptions): ResponsesBody {
1057
- const requestIdentity = resolveCodexRequestIdentity(
1058
- options?.sessionId,
1059
- options?.metadata as Record<string, unknown> | undefined,
1060
- // Only the session-scoped prompt cache key is needed here. Do not
1061
- // synthesize a logical turn while constructing startup/prewarm bodies.
1062
- "prewarm",
1063
- );
1064
- const messages = convertResponsesMessages(model, context, new Set([...CODEX_TOOL_CALL_PROVIDERS, model.provider]), {
1065
- includeSystemPrompt: false,
1066
- });
1067
- const tools = context.tools && context.tools.length > 0
1068
- ? convertResponsesTools(context.tools, { strict: null }).map((tool) =>
1069
- profile.patchTransport === "custom" && tool.type === "function" && tool.name === "apply_patch" ? createCodexApplyPatchCustomTool() : tool)
1070
- : [];
1071
- const lite = profile.responsesMode === "lite";
1072
- const liteTools = (): unknown[] => {
1073
- const namespaces = new Map<string, {
1074
- type: "namespace";
1075
- name: string;
1076
- description: string;
1077
- tools: unknown[];
1078
- }>();
1079
- for (const tool of tools as Array<Record<string, unknown>>) {
1080
- if (typeof tool.name !== "string") continue;
1081
- if (tool.name === "web_search" || tool.name === "image_generation") {
1082
- const reserved = createCodexReservedNamespaceTool(tool.name);
1083
- namespaces.set(reserved.name, reserved);
1084
- continue;
1085
- }
1086
- let namespace = namespaces.get("functions");
1087
- if (!namespace) {
1088
- namespace = {
1089
- type: "namespace",
1090
- name: "functions",
1091
- description: "",
1092
- tools: [],
1093
- };
1094
- namespaces.set("functions", namespace);
1095
- }
1096
- const nestedTool: Record<string, unknown> = { ...tool };
1097
- if (nestedTool.type === "function" && typeof nestedTool.strict !== "boolean") {
1098
- nestedTool.strict = false;
1099
- }
1100
- namespace.tools.push(nestedTool);
1101
- }
1102
- return [...namespaces.values()].filter((namespace) => namespace.tools.length > 0);
1103
- };
1104
-
1105
- const body: ResponsesBody = {
1106
- model: model.id,
1107
- store: false,
1108
- stream: true,
1109
- input: [],
1110
- text: { verbosity: ((options as { textVerbosity?: string } | undefined)?.textVerbosity ?? "low") as string },
1111
- include: ["reasoning.encrypted_content"],
1112
- prompt_cache_key: requestIdentity?.sessionId ?? options?.sessionId,
1113
- tool_choice: "auto",
1114
- parallel_tool_calls: profile.supportsParallelTools,
1115
- };
1116
- if (lite) {
1117
- stripResponsesLiteImageDetails(messages);
1118
- body.input = [
1119
- { type: "additional_tools", role: "developer", tools: liteTools() },
1120
- ...(context.systemPrompt
1121
- ? [{ type: "message", role: "developer", content: [{ type: "input_text", text: context.systemPrompt }] }]
1122
- : []),
1123
- ...messages,
1124
- ];
1125
- body.reasoning = { context: "all_turns" };
1126
- } else {
1127
- if (profile.systemPromptPlacement === "instructions") {
1128
- body.instructions = context.systemPrompt;
1129
- body.input = messages;
1130
- } else {
1131
- body.input = [
1132
- ...(context.systemPrompt
1133
- ? [{ type: "message", role: "developer", content: [{ type: "input_text", text: context.systemPrompt }] }]
1134
- : []),
1135
- ...messages,
1136
- ];
1137
- }
1138
- if (tools.length > 0) body.tools = tools;
1139
- }
1140
-
1141
- // The Codex ChatGPT-backed endpoint rejects output-token cap fields with
1142
- // `Unsupported parameter: max_output_tokens`. Pi's branch summarizer passes
1143
- // `maxTokens`, so forwarding it breaks `/tree` summaries and extensions that
1144
- // use `ctx.navigateTree(..., { summarize: true })`.
1145
-
1146
- if ((options as { temperature?: number } | undefined)?.temperature !== undefined) {
1147
- body.temperature = (options as { temperature?: number }).temperature;
1148
- }
1149
-
1150
- const serviceTier = (options as { serviceTier?: string } | undefined)?.serviceTier;
1151
- if (serviceTier !== undefined) {
1152
- body.service_tier = serviceTier;
1153
- }
1154
-
1155
- const clampedReasoning = options?.reasoning
1156
- ? clampCodexThinkingLevel(model as Model<Api>, options.reasoning)
1157
- : undefined;
1158
- const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
1159
- if (reasoningEffort !== undefined) {
1160
- const effort = model.thinkingLevelMap?.[reasoningEffort] ?? reasoningEffort;
1161
- if (effort === null) return body;
1162
- const reasoning = body.reasoning ?? {};
1163
- reasoning.effort = clampReasoningEffort(model.id, effort);
1164
- const summary = (options as { reasoningSummary?: ReasoningSummary | null } | undefined)?.reasoningSummary
1165
- ?? profile.reasoningSummary;
1166
- if (summary && summary !== "none") reasoning.summary = summary;
1167
- body.reasoning = reasoning;
1168
- } else if (lite && clampedReasoning !== "off") {
1169
- // Match the Codex CLI default reasoning level for Lite models: the CLI
1170
- // always sends `reasoning.effort` (default "low") for gpt-5.6-* models.
1171
- const reasoning = body.reasoning ?? {};
1172
- if (reasoning.effort === undefined) {
1173
- reasoning.effort = model.thinkingLevelMap?.low ?? "low";
1174
- }
1175
- body.reasoning = reasoning;
1176
- }
1177
-
1178
- return body;
1179
- }
1180
-
1181
- function isRetryableError(status: number, errorText: string): boolean {
1182
- if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
1183
- return true;
1184
- }
1185
- return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(errorText);
1186
- }
1187
-
1188
- export function withHttpStatusPrefix(status: number, message: string): string {
1189
- const trimmed = message.trim() || "Request failed";
1190
- if (HTTP_STATUS_MESSAGE_PREFIX.test(trimmed)) return trimmed;
1191
- return `HTTP ${status}: ${trimmed}`;
1192
- }
1193
-
1194
- function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
1195
- return new Promise((resolve, reject) => {
1196
- if (signal?.aborted) {
1197
- reject(new Error("Request was aborted"));
1198
- return;
1199
- }
1200
-
1201
- const timeout = setTimeout(resolve, ms);
1202
- signal?.addEventListener(
1203
- "abort",
1204
- () => {
1205
- clearTimeout(timeout);
1206
- reject(new Error("Request was aborted"));
1207
- },
1208
- { once: true },
1209
- );
1210
- });
1211
- }
1212
-
1213
- export function responseHeaderTimeoutMsFromOptions(options: SimpleStreamOptions | undefined): number {
1214
- const value = (options as { timeoutMs?: unknown } | undefined)?.timeoutMs;
1215
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : SSE_RESPONSE_HEADER_TIMEOUT_MS;
1216
- }
1217
-
1218
- export async function fetchWithResponseHeaderTimeout(
1219
- url: string,
1220
- init: RequestInit,
1221
- parentSignal: AbortSignal | undefined,
1222
- timeoutMs = SSE_RESPONSE_HEADER_TIMEOUT_MS,
1223
- ): Promise<Response> {
1224
- if (parentSignal?.aborted) throw new Error("Request was aborted");
1225
-
1226
- const controller = new AbortController();
1227
- let timedOut = false;
1228
- let parentAborted = false;
1229
- const timeoutMessage = `Codex Responses SSE response headers timed out after ${timeoutMs}ms`;
1230
-
1231
- const onParentAbort = () => {
1232
- parentAborted = true;
1233
- controller.abort(parentSignal?.reason);
1234
- };
1235
-
1236
- if (parentSignal) parentSignal.addEventListener("abort", onParentAbort, { once: true });
1237
- const timeout = setTimeout(() => {
1238
- timedOut = true;
1239
- controller.abort(new Error(timeoutMessage));
1240
- }, Math.max(1, timeoutMs));
1241
-
1242
- try {
1243
- return await fetch(url, { ...init, signal: controller.signal });
1244
- } catch (error) {
1245
- if (timedOut) throw new Error(timeoutMessage);
1246
- if (parentAborted || parentSignal?.aborted) throw new Error("Request was aborted");
1247
- throw error;
1248
- } finally {
1249
- clearTimeout(timeout);
1250
- if (parentSignal) parentSignal.removeEventListener("abort", onParentAbort);
1251
- }
1252
- }
1253
-
1254
- async function* parseSSE(response: Response): AsyncIterable<StreamEventShape> {
1255
- if (!response.body) return;
1256
-
1257
- const reader = response.body.getReader();
1258
- const decoder = new TextDecoder();
1259
- let buffer = "";
1260
-
1261
- try {
1262
- while (true) {
1263
- const { done, value } = await reader.read();
1264
- if (done) break;
1265
-
1266
- buffer += decoder.decode(value, { stream: true });
1267
- buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1268
- let idx = buffer.indexOf("\n\n");
1269
- while (idx !== -1) {
1270
- const chunk = buffer.slice(0, idx);
1271
- buffer = buffer.slice(idx + 2);
1272
- const dataLines = chunk
1273
- .split("\n")
1274
- .filter((line) => line.startsWith("data:"))
1275
- .map((line) => line.slice(5).trim());
1276
- if (dataLines.length > 0) {
1277
- const data = dataLines.join("\n").trim();
1278
- if (data && data !== "[DONE]") {
1279
- try {
1280
- yield JSON.parse(data) as StreamEventShape;
1281
- } catch {
1282
- // Ignore malformed SSE chunks and continue consuming the stream.
1283
- }
1284
- }
1285
- }
1286
- idx = buffer.indexOf("\n\n");
1287
- }
1288
- }
1289
- } finally {
1290
- try {
1291
- await reader.cancel();
1292
- } catch {
1293
- // ignore cancellation errors
1294
- }
1295
- try {
1296
- reader.releaseLock();
1297
- } catch {
1298
- // ignore lock release errors
1299
- }
1300
- }
1301
- }
1302
-
1303
- function envFirst(names: string[]): string | undefined {
1304
- if (typeof process === "undefined") return undefined;
1305
- for (const name of names) {
1306
- const value = process.env[name];
1307
- if (value?.trim()) return value.trim();
1308
- }
1309
- return undefined;
1310
- }
1311
-
1312
- function noProxyMatches(hostname: string, noProxy: string | undefined): boolean {
1313
- if (!noProxy) return false;
1314
- const host = hostname.toLowerCase();
1315
- for (const rawPart of noProxy.split(",")) {
1316
- const part = rawPart.trim().toLowerCase();
1317
- if (!part) continue;
1318
- if (part === "*") return true;
1319
- const normalized = part.startsWith(".") ? part.slice(1) : part;
1320
- if (host === normalized || host.endsWith(`.${normalized}`)) return true;
1321
- }
1322
- return false;
1323
- }
1324
-
1325
- export function proxyForWebSocketUrl(rawUrl: string): string | undefined {
1326
- let url: URL;
1327
- try {
1328
- url = new URL(rawUrl);
1329
- } catch {
1330
- return undefined;
1331
- }
1332
- const noProxy = envFirst(["NO_PROXY", "no_proxy"]);
1333
- if (noProxyMatches(url.hostname, noProxy)) return undefined;
1334
- if (url.protocol === "wss:" || url.protocol === "https:") {
1335
- return envFirst(["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]);
1336
- }
1337
- if (url.protocol === "ws:" || url.protocol === "http:") {
1338
- return envFirst(["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]);
1339
- }
1340
- return undefined;
1341
- }
1342
-
1343
- async function proxyDispatcherForUrl(rawUrl: string): Promise<Dispatcher | undefined> {
1344
- const proxy = proxyForWebSocketUrl(rawUrl);
1345
- if (!proxy) return undefined;
1346
- return new ProxyAgent(proxy);
1347
- }
1348
-
1349
- export async function webSocketOptionsForUrl(url: string, headers: Record<string, string>): Promise<{
1350
- headers: Record<string, string>;
1351
- dispatcher?: Dispatcher;
1352
- }> {
1353
- const dispatcher = await proxyDispatcherForUrl(url);
1354
- return dispatcher ? { headers, dispatcher } : { headers };
1355
- }
1356
-
1357
- function getWebSocketReadyState(socket: WebSocketLike): number | undefined {
1358
- return typeof socket.readyState === "number" ? socket.readyState : undefined;
1359
- }
1360
-
1361
- function isWebSocketReusable(socket: WebSocketLike): boolean {
1362
- const readyState = getWebSocketReadyState(socket);
1363
- return readyState === undefined || readyState === 1;
1364
- }
1365
-
1366
- function closeWebSocketSilently(socket: WebSocketLike, code = 1000, reason = "done"): void {
1367
- try {
1368
- socket.close(code, reason);
1369
- } catch {
1370
- // ignore close errors
1371
- }
1372
- }
1373
-
1374
- export function closeProviderWebSocketSessions(sessionId?: string): void {
1375
- for (const cacheKey of websocketConnectionPromises.keys()) {
1376
- if (sessionId && !cacheKey.startsWith(`${sessionId}\n`)) continue;
1377
- websocketConnectionPromises.delete(cacheKey);
1378
- }
1379
- for (const [cacheKey, entry] of websocketSessionCache) {
1380
- if (sessionId && !cacheKey.startsWith(`${sessionId}\n`)) continue;
1381
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
1382
- for (const waiter of entry.waiters.splice(0)) {
1383
- if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
1384
- waiter.reject(new Error("WebSocket session closed"));
1385
- }
1386
- closeWebSocketSilently(entry.socket, 1000, "session_shutdown");
1387
- websocketSessionCache.delete(cacheKey);
1388
- }
1389
- if (sessionId) {
1390
- for (const fallbackKey of websocketHttpFallbackSessions) {
1391
- if (fallbackKey.startsWith(`${sessionId}\n`)) websocketHttpFallbackSessions.delete(fallbackKey);
1392
- }
1393
- } else {
1394
- websocketHttpFallbackSessions.clear();
1395
- }
1396
- }
1397
-
1398
-
1399
- function scheduleSessionWebSocketExpiry(cacheKey: string, entry: SessionWebSocketCacheEntry): void {
1400
- if (entry.idleTimer) {
1401
- clearTimeout(entry.idleTimer);
1402
- }
1403
- entry.idleTimer = setTimeout(() => {
1404
- if (entry.busy || entry.waiters.length > 0) return;
1405
- closeWebSocketSilently(entry.socket, 1000, "idle_timeout");
1406
- websocketSessionCache.delete(cacheKey);
1407
- }, SESSION_WEBSOCKET_CACHE_TTL_MS);
1408
- }
1409
-
1410
- function removeWebSocketWaiter(entry: SessionWebSocketCacheEntry, waiter: WebSocketAcquireWaiter): void {
1411
- const index = entry.waiters.indexOf(waiter);
1412
- if (index >= 0) entry.waiters.splice(index, 1);
1413
- if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
1414
- }
1415
-
1416
- function acquireCachedWebSocketEntry(
1417
- cacheKey: string,
1418
- entry: SessionWebSocketCacheEntry,
1419
- reused: boolean,
1420
- ): AcquiredWebSocket {
1421
- entry.busy = true;
1422
- let released = false;
1423
- const release = ({ keep } = {} as { keep?: boolean }) => {
1424
- if (released) return;
1425
- const reusable = keep !== false && isWebSocketReusable(entry.socket);
1426
- if (!reusable) {
1427
- released = true;
1428
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
1429
- closeWebSocketSilently(entry.socket);
1430
- if (websocketSessionCache.get(cacheKey) === entry) {
1431
- websocketSessionCache.delete(cacheKey);
1432
- }
1433
- for (const waiter of entry.waiters.splice(0)) {
1434
- if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
1435
- waiter.reject(new Error("WebSocket connection became unavailable"));
1436
- }
1437
- return;
1438
- }
1439
-
1440
- while (entry.waiters.length > 0) {
1441
- const waiter = entry.waiters.shift()!;
1442
- if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
1443
- if (waiter.signal?.aborted) {
1444
- waiter.reject(new Error("Request was aborted"));
1445
- continue;
1446
- }
1447
- released = true;
1448
- waiter.resolve(acquireCachedWebSocketEntry(cacheKey, entry, true));
1449
- return;
1450
- }
1451
-
1452
- entry.busy = false;
1453
- released = true;
1454
- scheduleSessionWebSocketExpiry(cacheKey, entry);
1455
- };
1456
- return {
1457
- socket: entry.socket,
1458
- entry,
1459
- reused,
1460
- release,
1461
- };
1462
- }
1463
-
1464
- async function waitForCachedWebSocket(
1465
- cacheKey: string,
1466
- entry: SessionWebSocketCacheEntry,
1467
- signal: AbortSignal | undefined,
1468
- ): Promise<AcquiredWebSocket> {
1469
- if (signal?.aborted) throw new Error("Request was aborted");
1470
- return new Promise<AcquiredWebSocket>((resolve, reject) => {
1471
- const waiter: WebSocketAcquireWaiter = {
1472
- resolve,
1473
- reject,
1474
- ...(signal ? { signal } : {}),
1475
- };
1476
- if (signal) {
1477
- waiter.onAbort = () => {
1478
- removeWebSocketWaiter(entry, waiter);
1479
- reject(new Error("Request was aborted"));
1480
- };
1481
- signal.addEventListener("abort", waiter.onAbort, { once: true });
1482
- }
1483
- entry.waiters.push(waiter);
1484
- });
1485
- }
1486
-
1487
- function extractWebSocketError(event: unknown): Error {
1488
- if (event && typeof event === "object") {
1489
- const message = "message" in event ? (event as { message?: unknown }).message : undefined;
1490
- if (typeof message === "string" && message.length > 0) {
1491
- return new Error(message);
1492
- }
1493
- const nestedError = "error" in event ? (event as { error?: unknown }).error : undefined;
1494
- if (nestedError instanceof Error && nestedError.message.length > 0) {
1495
- return nestedError;
1496
- }
1497
- if (nestedError && typeof nestedError === "object" && "message" in nestedError) {
1498
- const nestedMessage = (nestedError as { message?: unknown }).message;
1499
- if (typeof nestedMessage === "string" && nestedMessage.length > 0) {
1500
- return new Error(nestedMessage);
1501
- }
1502
- }
1503
- }
1504
- return new Error("WebSocket error");
1505
- }
1506
-
1507
- function extractWebSocketCloseError(event: unknown): Error {
1508
- if (event && typeof event === "object") {
1509
- const code = "code" in event ? (event as { code?: unknown }).code : undefined;
1510
- const reason = "reason" in event ? (event as { reason?: unknown }).reason : undefined;
1511
- const codeText = typeof code === "number" ? ` ${code}` : "";
1512
- const reasonText = typeof reason === "string" && reason.length > 0 ? ` ${reason}` : "";
1513
- return new Error(`WebSocket closed${codeText}${reasonText}`.trim());
1514
- }
1515
- return new Error("WebSocket closed");
1516
- }
1517
-
1518
- async function loadNodeWebSocketModule(): Promise<NodeWebSocketModule> {
1519
- if (!nodeWebSocketModulePromise) {
1520
- nodeWebSocketModulePromise = dynamicImport("ws") as Promise<NodeWebSocketModule>;
1521
- }
1522
- return nodeWebSocketModulePromise;
1523
- }
1524
-
1525
- function nodeWebSocketHeaders(headers: Headers): Record<string, string> {
1526
- return Object.fromEntries(headers.entries());
1527
- }
1528
-
1529
- function nodeWebSocketResponseHeaders(headers: unknown): Record<string, string> {
1530
- if (!headers || typeof headers !== "object") return {};
1531
- const result: Record<string, string> = {};
1532
- for (const [name, value] of Object.entries(headers as Record<string, unknown>)) {
1533
- if (Array.isArray(value)) result[name] = value.join(", ");
1534
- else if (typeof value === "string") result[name] = value;
1535
- else if (value !== undefined) result[name] = String(value);
1536
- }
1537
- return result;
1538
- }
1539
-
1540
- function handshakeMessage(status: number, statusText: string | undefined, body: string): string {
1541
- const trimmedBody = body.trim();
1542
- if (trimmedBody) {
1543
- try {
1544
- const parsed = JSON.parse(trimmedBody) as { error?: { message?: unknown }; message?: unknown };
1545
- const message = typeof parsed.error?.message === "string"
1546
- ? parsed.error.message
1547
- : typeof parsed.message === "string"
1548
- ? parsed.message
1549
- : undefined;
1550
- if (message?.trim()) return message.trim();
1551
- } catch {
1552
- return trimmedBody;
1553
- }
1554
- }
1555
- return statusText?.trim() || "WebSocket upgrade failed";
1556
- }
1557
-
1558
- async function connectWebSocket(
1559
- url: string,
1560
- headers: Headers,
1561
- signal: AbortSignal | undefined,
1562
- timeoutMs = WEBSOCKET_CONNECT_TIMEOUT_MS,
1563
- ): Promise<WebSocketLike> {
1564
- if (signal?.aborted) throw new Error("Request was aborted");
1565
- const { WebSocket } = await loadNodeWebSocketModule();
1566
- const proxy = proxyForWebSocketUrl(url);
1567
- let agent: unknown;
1568
- if (proxy) {
1569
- const protocol = new URL(proxy).protocol.toLowerCase();
1570
- if (protocol === "http:" || protocol === "https:") {
1571
- const { HttpsProxyAgent } = await dynamicImport("https-proxy-agent") as typeof import("https-proxy-agent");
1572
- agent = new HttpsProxyAgent(proxy);
1573
- } else if (protocol === "socks:" || protocol === "socks4:" || protocol === "socks4a:" || protocol === "socks5:" || protocol === "socks5h:") {
1574
- const { SocksProxyAgent } = await dynamicImport("socks-proxy-agent") as {
1575
- SocksProxyAgent: new (proxy: string) => unknown;
1576
- };
1577
- agent = new SocksProxyAgent(proxy);
1578
- } else {
1579
- throw new Error(`Unsupported WebSocket proxy protocol: ${protocol}`);
1580
- }
1581
- }
1582
-
1583
- return new Promise((resolve, reject) => {
1584
- let settled = false;
1585
- let socket: InstanceType<NodeWebSocketModule["WebSocket"]>;
1586
- let timeout: ReturnType<typeof setTimeout> | undefined;
1587
-
1588
- try {
1589
- socket = new WebSocket(url, {
1590
- headers: nodeWebSocketHeaders(headers),
1591
- perMessageDeflate: true,
1592
- ...(agent ? { agent } : {}),
1593
- });
1594
- } catch (error) {
1595
- reject(error instanceof Error ? error : new Error(String(error)));
1596
- return;
1597
- }
1598
-
1599
- const onOpen = () => {
1600
- if (settled) return;
1601
- settled = true;
1602
- cleanup();
1603
- // Keep an error listener installed while the socket sits idle in the
1604
- // session cache. Request parsers add their own listener, but Node's ws
1605
- // EventEmitter would otherwise treat an idle "error" as uncaught.
1606
- socket.on("error", () => {});
1607
- const messageListeners = new Map<(event: unknown) => void, (...args: any[]) => void>();
1608
- const closeListeners = new Map<(event: unknown) => void, (...args: any[]) => void>();
1609
- resolve({
1610
- get readyState() {
1611
- return socket.readyState;
1612
- },
1613
- get bufferedAmount() {
1614
- return socket.bufferedAmount;
1615
- },
1616
- send(data, callback) {
1617
- socket.send(data, callback);
1618
- },
1619
- close(code, reason) {
1620
- socket.close(code, reason);
1621
- },
1622
- addEventListener(type, listener) {
1623
- if (type === "message") {
1624
- const wrapped = (data: unknown, isBinary: boolean) => listener({ data, isBinary });
1625
- messageListeners.set(listener, wrapped);
1626
- socket.on("message", wrapped);
1627
- return;
1628
- }
1629
- if (type === "close") {
1630
- const wrapped = (code: number, reason: Buffer) => listener({
1631
- code,
1632
- reason: reason.toString("utf8"),
1633
- });
1634
- closeListeners.set(listener, wrapped);
1635
- socket.on("close", wrapped);
1636
- return;
1637
- }
1638
- socket.on(type, listener as (...args: any[]) => void);
1639
- },
1640
- removeEventListener(type, listener) {
1641
- if (type === "message") {
1642
- const wrapped = messageListeners.get(listener);
1643
- if (wrapped) socket.off("message", wrapped);
1644
- messageListeners.delete(listener);
1645
- return;
1646
- }
1647
- if (type === "close") {
1648
- const wrapped = closeListeners.get(listener);
1649
- if (wrapped) socket.off("close", wrapped);
1650
- closeListeners.delete(listener);
1651
- return;
1652
- }
1653
- socket.off(type, listener as (...args: any[]) => void);
1654
- },
1655
- });
1656
- };
1657
- const onError = (event: unknown) => {
1658
- if (settled) return;
1659
- settled = true;
1660
- cleanup();
1661
- reject(event instanceof Error ? event : extractWebSocketError(event));
1662
- };
1663
- const onClose = (code: number, reason: Buffer) => {
1664
- if (settled) return;
1665
- settled = true;
1666
- cleanup();
1667
- reject(extractWebSocketCloseError({ code, reason: reason.toString("utf8") }));
1668
- };
1669
- const onUnexpectedResponse = (
1670
- _request: unknown,
1671
- response: { statusCode?: number; statusMessage?: string; headers?: unknown; on(type: string, listener: (...args: any[]) => void): void },
1672
- ) => {
1673
- if (settled) return;
1674
- let body = "";
1675
- response.on("data", (chunk: unknown) => {
1676
- if (body.length >= 64 * 1024) return;
1677
- body += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
1678
- });
1679
- response.on("end", () => {
1680
- if (settled) return;
1681
- settled = true;
1682
- cleanup();
1683
- const status = response.statusCode ?? 500;
1684
- reject(new WebSocketHandshakeError(
1685
- status,
1686
- handshakeMessage(status, response.statusMessage, body),
1687
- nodeWebSocketResponseHeaders(response.headers),
1688
- body || undefined,
1689
- ));
1690
- });
1691
- };
1692
- const onAbort = () => {
1693
- if (settled) return;
1694
- settled = true;
1695
- cleanup();
1696
- socket.on("error", () => {});
1697
- socket.terminate?.();
1698
- reject(new Error("Request was aborted"));
1699
- };
1700
- const onTimeout = () => {
1701
- if (settled) return;
1702
- settled = true;
1703
- cleanup();
1704
- socket.on("error", () => {});
1705
- socket.terminate?.();
1706
- reject(new Error(`OpenAI Responses WebSocket connection timed out after ${timeoutMs}ms`));
1707
- };
1708
-
1709
- const cleanup = () => {
1710
- if (timeout) clearTimeout(timeout);
1711
- socket.off("open", onOpen);
1712
- socket.off("error", onError);
1713
- socket.off("close", onClose);
1714
- socket.off("unexpected-response", onUnexpectedResponse);
1715
- signal?.removeEventListener("abort", onAbort);
1716
- };
1717
-
1718
- socket.on("open", onOpen);
1719
- socket.on("error", onError);
1720
- socket.on("close", onClose);
1721
- socket.on("unexpected-response", onUnexpectedResponse);
1722
- signal?.addEventListener("abort", onAbort, { once: true });
1723
- timeout = setTimeout(onTimeout, Math.max(1, timeoutMs));
1724
- });
1725
- }
1726
-
1727
- async function acquireWebSocket(
1728
- url: string,
1729
- headers: Headers,
1730
- cacheKey: string | undefined,
1731
- sessionId: string | undefined,
1732
- signal: AbortSignal | undefined,
1733
- connectTimeoutMs: number,
1734
- ): Promise<AcquiredWebSocket> {
1735
- if (!cacheKey || !sessionId) {
1736
- const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs);
1737
- return {
1738
- socket,
1739
- reused: false,
1740
- release: ({ keep } = {}) => {
1741
- if (keep === false) {
1742
- closeWebSocketSilently(socket);
1743
- return;
1744
- }
1745
- closeWebSocketSilently(socket);
1746
- },
1747
- };
1748
- }
1749
-
1750
- const cached = websocketSessionCache.get(cacheKey);
1751
- if (cached) {
1752
- if (cached.idleTimer) {
1753
- clearTimeout(cached.idleTimer);
1754
- cached.idleTimer = undefined;
1755
- }
1756
-
1757
- if (!cached.busy && isWebSocketReusable(cached.socket)) {
1758
- return acquireCachedWebSocketEntry(cacheKey, cached, true);
1759
- }
1760
-
1761
- if (cached.busy) {
1762
- return waitForCachedWebSocket(cacheKey, cached, signal);
1763
- }
1764
-
1765
- if (!isWebSocketReusable(cached.socket)) {
1766
- closeWebSocketSilently(cached.socket);
1767
- websocketSessionCache.delete(cacheKey);
1768
- }
1769
- }
1770
-
1771
- let pendingConnection = websocketConnectionPromises.get(cacheKey);
1772
- if (!pendingConnection) {
1773
- let connectionPromise!: Promise<SessionWebSocketCacheEntry>;
1774
- connectionPromise = connectWebSocket(url, headers, signal, connectTimeoutMs)
1775
- .then((socket) => {
1776
- if (websocketConnectionPromises.get(cacheKey) !== connectionPromise) {
1777
- closeWebSocketSilently(socket, 1000, "session_shutdown");
1778
- throw new Error("WebSocket session closed");
1779
- }
1780
- const entry: SessionWebSocketCacheEntry = { socket, busy: false, waiters: [] };
1781
- websocketSessionCache.set(cacheKey, entry);
1782
- return entry;
1783
- })
1784
- .finally(() => {
1785
- if (websocketConnectionPromises.get(cacheKey) === connectionPromise) {
1786
- websocketConnectionPromises.delete(cacheKey);
1787
- }
1788
- });
1789
- websocketConnectionPromises.set(cacheKey, connectionPromise);
1790
- pendingConnection = connectionPromise;
1791
- }
1792
- const entry = await pendingConnection;
1793
- if (entry.busy) return waitForCachedWebSocket(cacheKey, entry, signal);
1794
- return acquireCachedWebSocketEntry(cacheKey, entry, false);
1795
- }
1796
-
1797
- function requestBodyWithoutInput(body: ResponsesBody): ResponsesBody {
1798
- const {
1799
- input: _input,
1800
- previous_response_id: _previousResponseId,
1801
- client_metadata: _clientMetadata,
1802
- stream_options: _streamOptions,
1803
- generate: _generate,
1804
- ...rest
1805
- } = body;
1806
- return rest as ResponsesBody;
1807
- }
1808
-
1809
- function normalizeResponseItemForComparison(value: unknown): unknown {
1810
- if (Array.isArray(value)) return value.map(normalizeResponseItemForComparison);
1811
- if (!value || typeof value !== "object") return value;
1812
- const result: Record<string, unknown> = {};
1813
- for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
1814
- if (key === "internal_chat_message_metadata_passthrough") continue;
1815
- result[key] = normalizeResponseItemForComparison(entry);
1816
- }
1817
- return result;
1818
- }
1819
-
1820
- function stableJson(value: unknown): string {
1821
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
1822
- if (value && typeof value === "object") {
1823
- return `{${Object.entries(value as Record<string, unknown>)
1824
- .sort(([left], [right]) => left.localeCompare(right))
1825
- .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
1826
- .join(",")}}`;
1827
- }
1828
- return JSON.stringify(value) ?? "undefined";
1829
- }
1830
-
1831
- function responseInputsEqual(a: unknown[] | undefined, b: unknown[] | undefined): boolean {
1832
- const left = a ?? [];
1833
- const right = b ?? [];
1834
- if (left.length !== right.length) return false;
1835
- return left.every((item, index) =>
1836
- stableJson(normalizeResponseItemForComparison(item))
1837
- === stableJson(normalizeResponseItemForComparison(right[index])));
1838
- }
1839
-
1840
- function requestBodiesMatchExceptInput(a: ResponsesBody, b: ResponsesBody): boolean {
1841
- return stableJson(requestBodyWithoutInput(a)) === stableJson(requestBodyWithoutInput(b));
1842
- }
1843
-
1844
- function getCachedWebSocketInputDelta(body: ResponsesBody, continuation: CachedWebSocketContinuationState): unknown[] | undefined {
1845
- if (!requestBodiesMatchExceptInput(body, continuation.lastRequestBody)) {
1846
- return undefined;
1847
- }
1848
-
1849
- const currentInput = body.input ?? [];
1850
- const baseline = [...(continuation.lastRequestBody.input ?? []), ...continuation.lastResponseItems];
1851
- if (currentInput.length < baseline.length) {
1852
- return undefined;
1853
- }
1854
-
1855
- const prefix = currentInput.slice(0, baseline.length);
1856
- if (!responseInputsEqual(prefix, baseline)) {
1857
- return undefined;
1858
- }
1859
-
1860
- return currentInput.slice(baseline.length);
1861
- }
1862
-
1863
- function buildCachedWebSocketRequestBody(
1864
- entry: SessionWebSocketCacheEntry,
1865
- body: ResponsesBody,
1866
- ): ResponsesBody {
1867
- const continuation = entry.continuation;
1868
- if (!continuation) {
1869
- return body;
1870
- }
1871
-
1872
- const delta = getCachedWebSocketInputDelta(body, continuation);
1873
- if (delta === undefined || !continuation.lastResponseId) {
1874
- entry.continuation = undefined;
1875
- return body;
1876
- }
1877
- return {
1878
- ...body,
1879
- previous_response_id: continuation.lastResponseId,
1880
- input: delta,
1881
- };
1882
- }
1883
-
1884
- function isPrefixedResponseItemId(value: string): boolean {
1885
- const separator = value.indexOf("_");
1886
- return separator > 0 && separator < value.length - 1;
1887
- }
1888
-
1889
- function prepareResponseItemsForWire(items: unknown[]): unknown[] {
1890
- return items.map((item) => {
1891
- if (!item || typeof item !== "object" || Array.isArray(item)) return item;
1892
- const record = item as Record<string, unknown>;
1893
- if (typeof record.id !== "string" || isPrefixedResponseItemId(record.id)) return item;
1894
- const { id: _id, ...rest } = record;
1895
- return rest;
1896
- });
1897
- }
1898
-
1899
- function prepareWebSocketRequestBodyForWire(body: ResponsesBody): ResponsesBody {
1900
- return {
1901
- ...body,
1902
- input: prepareResponseItemsForWire(body.input ?? []),
1903
- };
1904
- }
1905
-
1906
- export async function sendWebSocketRequest(
1907
- socket: WebSocketLike,
1908
- payload: string,
1909
- signal: AbortSignal | undefined,
1910
- timeoutMs = WEBSOCKET_SEND_TIMEOUT_MS,
1911
- ): Promise<void> {
1912
- if (signal?.aborted) throw new Error("Request was aborted");
1913
- await new Promise<void>((resolve, reject) => {
1914
- let settled = false;
1915
- let timeout: ReturnType<typeof setTimeout> | undefined;
1916
- const finish = (error?: Error) => {
1917
- if (settled) return;
1918
- settled = true;
1919
- if (timeout) clearTimeout(timeout);
1920
- signal?.removeEventListener("abort", onAbort);
1921
- if (error) reject(error);
1922
- else resolve();
1923
- };
1924
- const onAbort = () => finish(new Error("Request was aborted"));
1925
- timeout = setTimeout(
1926
- () => finish(new Error(`OpenAI Responses WebSocket send timed out after ${timeoutMs}ms`)),
1927
- Math.max(1, timeoutMs),
1928
- );
1929
- signal?.addEventListener("abort", onAbort, { once: true });
1930
- try {
1931
- socket.send(payload, (error?: Error) => {
1932
- if (error) {
1933
- finish(new Error(`Failed to send OpenAI Responses WebSocket request: ${error.message}`));
1934
- return;
1935
- }
1936
- finish();
1937
- });
1938
- } catch (error) {
1939
- finish(error instanceof Error ? error : new Error(String(error)));
1940
- }
1941
- });
1942
- }
1943
-
1944
- async function* parseWebSocket(socket: WebSocketLike, signal: AbortSignal | undefined): AsyncIterable<StreamEventShape> {
1945
- const queue: StreamEventShape[] = [];
1946
- let pending: (() => void) | null = null;
1947
- let done = false;
1948
- let failed: Error | null = null;
1949
- let closeError: Error | null = null;
1950
- let sawCompletion = false;
1951
- let pendingMessages = 0;
1952
- let messageChain = Promise.resolve();
1953
-
1954
- const wake = () => {
1955
- if (!pending) return;
1956
- const resolve = pending;
1957
- pending = null;
1958
- resolve();
1959
- };
1960
-
1961
- const onMessage = (event: unknown) => {
1962
- if (done) return;
1963
- if (queue.length + pendingMessages >= WEBSOCKET_EVENT_QUEUE_CAPACITY) {
1964
- failed = new ProviderProtocolError(
1965
- `OpenAI Responses WebSocket event queue exceeded ${WEBSOCKET_EVENT_QUEUE_CAPACITY} items`,
1966
- );
1967
- done = true;
1968
- wake();
1969
- return;
1970
- }
1971
- pendingMessages++;
1972
- messageChain = messageChain
1973
- .then(async () => {
1974
- if (!event || typeof event !== "object" || !("data" in event)) return;
1975
- if ((event as { isBinary?: unknown }).isBinary === true) {
1976
- failed = new ProviderProtocolError("Unexpected binary OpenAI Responses WebSocket event");
1977
- done = true;
1978
- return;
1979
- }
1980
- const data = (event as { data?: unknown }).data;
1981
- const text = typeof data === "string"
1982
- ? data
1983
- : Buffer.isBuffer(data)
1984
- ? data.toString("utf8")
1985
- : ArrayBuffer.isView(data)
1986
- ? Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8")
1987
- : null;
1988
- if (text === null) {
1989
- failed = new ProviderProtocolError("Unsupported OpenAI Responses WebSocket message payload");
1990
- done = true;
1991
- return;
1992
- }
1993
- try {
1994
- const parsed = JSON.parse(text) as StreamEventShape;
1995
- const type = typeof parsed.type === "string" ? parsed.type : "";
1996
- if (type === "response.completed" || type === "response.done" || type === "response.incomplete") {
1997
- sawCompletion = true;
1998
- closeError = null;
1999
- done = true;
2000
- }
2001
- if (queue.length >= WEBSOCKET_EVENT_QUEUE_CAPACITY) {
2002
- failed = new ProviderProtocolError(
2003
- `OpenAI Responses WebSocket event queue exceeded ${WEBSOCKET_EVENT_QUEUE_CAPACITY} items`,
2004
- );
2005
- done = true;
2006
- return;
2007
- }
2008
- queue.push(parsed);
2009
- } catch {
2010
- // Match Codex: malformed text frames are logged/ignored rather than
2011
- // tearing down an otherwise healthy response stream.
2012
- }
2013
- })
2014
- .catch((error: unknown) => {
2015
- failed = error instanceof Error ? error : new Error(String(error));
2016
- done = true;
2017
- })
2018
- .finally(() => {
2019
- pendingMessages--;
2020
- wake();
2021
- });
2022
- };
2023
-
2024
- const onError = (event: unknown) => {
2025
- failed = extractWebSocketError(event);
2026
- done = true;
2027
- wake();
2028
- };
2029
-
2030
- const onClose = (event: unknown) => {
2031
- if (sawCompletion) {
2032
- done = true;
2033
- wake();
2034
- return;
2035
- }
2036
- if (!closeError) {
2037
- closeError = extractWebSocketCloseError(event);
2038
- }
2039
- done = true;
2040
- wake();
2041
- };
2042
-
2043
- const onAbort = () => {
2044
- failed = new Error("Request was aborted");
2045
- done = true;
2046
- wake();
2047
- };
2048
-
2049
- socket.addEventListener("message", onMessage);
2050
- socket.addEventListener("error", onError);
2051
- socket.addEventListener("close", onClose);
2052
- signal?.addEventListener("abort", onAbort);
2053
-
2054
- try {
2055
- while (true) {
2056
- if (signal?.aborted) {
2057
- throw new Error("Request was aborted");
2058
- }
2059
- if (queue.length > 0) {
2060
- yield queue.shift() as StreamEventShape;
2061
- continue;
2062
- }
2063
- if (done && pendingMessages === 0) break;
2064
- await new Promise<void>((resolve, reject) => {
2065
- const timeout = setTimeout(() => {
2066
- pending = null;
2067
- reject(new Error(`OpenAI Responses WebSocket idle timeout after ${WEBSOCKET_IDLE_TIMEOUT_MS}ms`));
2068
- }, WEBSOCKET_IDLE_TIMEOUT_MS);
2069
- pending = () => {
2070
- clearTimeout(timeout);
2071
- resolve();
2072
- };
2073
- });
2074
- }
2075
-
2076
- if (failed) throw failed;
2077
- if (closeError && !sawCompletion) throw closeError;
2078
- if (!sawCompletion) {
2079
- throw new Error("WebSocket stream closed before response.completed");
2080
- }
2081
- } finally {
2082
- socket.removeEventListener("message", onMessage);
2083
- socket.removeEventListener("error", onError);
2084
- socket.removeEventListener("close", onClose);
2085
- signal?.removeEventListener("abort", onAbort);
2086
- }
2087
- }
2088
-
2089
- async function* startWebSocketOutputOnFirstEvent(
2090
- events: AsyncIterable<StreamEventShape>,
2091
- onStart: () => void,
2092
- ): AsyncIterable<StreamEventShape> {
2093
- let started = false;
2094
- for await (const event of events) {
2095
- if (!started && event.type !== "error" && event.type !== "response.failed") {
2096
- started = true;
2097
- onStart();
2098
- }
2099
- yield event;
2100
- }
2101
- }
2102
-
2103
- async function* countWebSocketEvents(
2104
- events: AsyncIterable<StreamEventShape>,
2105
- onEvent: () => void,
2106
- ): AsyncIterable<StreamEventShape> {
2107
- for await (const event of events) {
2108
- onEvent();
2109
- yield event;
2110
- }
2111
- }
2112
-
2113
- function isRetryableEarlyWebSocketError(error: unknown): boolean {
2114
- const message = error instanceof Error ? error.message : String(error);
2115
- return /^WebSocket (error|closed)(?:\s|$)/.test(message);
2116
- }
2117
-
2118
- function retryAfterMsFromHeaders(headers: Record<string, string> | undefined): number | undefined {
2119
- if (!headers) return undefined;
2120
- const retryAfterMs = Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after-ms")?.[1];
2121
- if (retryAfterMs) {
2122
- const parsed = Number.parseFloat(retryAfterMs);
2123
- if (Number.isFinite(parsed) && parsed >= 0) return parsed;
2124
- }
2125
- const retryAfter = Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.[1];
2126
- if (!retryAfter) return undefined;
2127
- const seconds = Number.parseFloat(retryAfter);
2128
- if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
2129
- const date = Date.parse(retryAfter);
2130
- return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
2131
- }
2132
-
2133
- function isRetryableWebSocketError(error: unknown): boolean {
2134
- if (error instanceof WebSocketHandshakeError) {
2135
- return isRetryableError(error.status, error.body ?? error.message);
2136
- }
2137
- if (error instanceof ProviderResponseError) {
2138
- if (
2139
- /usage_limit_reached|usage_not_included/i.test(`${error.code ?? ""} ${error.errorType ?? ""}`)
2140
- ) {
2141
- return false;
2142
- }
2143
- if (typeof error.status === "number" && isRetryableError(error.status, error.message)) return true;
2144
- return /retry|rate.?limit|overloaded|service.?unavailable|connection.?limit/i.test(
2145
- `${error.code ?? ""} ${error.errorType ?? ""} ${error.message}`,
2146
- );
2147
- }
2148
- if (error instanceof ProviderProtocolError || error instanceof NonRetryableProviderError) return false;
2149
- const message = error instanceof Error ? error.message : String(error);
2150
- return /websocket|network|connection|socket|timed? out|timeout|fetch failed|terminated|closed before response\.completed|stream closed before response\.completed/i.test(
2151
- message,
2152
- );
2153
- }
2154
-
2155
- function explicitWebSocketRetryDelayMs(error: unknown): number | undefined {
2156
- return error instanceof WebSocketHandshakeError
2157
- ? retryAfterMsFromHeaders(error.headers)
2158
- : error instanceof ProviderResponseError
2159
- ? error.retryAfterMs
2160
- : undefined;
2161
- }
2162
-
2163
- function boundedWebSocketRetryDelayMs(
2164
- delayMs: number,
2165
- options: SimpleStreamOptions | undefined,
2166
- ): number {
2167
- const configuredMax = options?.maxRetryDelayMs;
2168
- const maxDelay = typeof configuredMax === "number" && Number.isFinite(configuredMax) && configuredMax >= 0
2169
- ? configuredMax
2170
- : WEBSOCKET_RETRY_MAX_DELAY_MS;
2171
- if (maxDelay > 0 && delayMs > maxDelay) {
2172
- throw new NonRetryableProviderError(
2173
- `WebSocket retry delay ${Math.round(delayMs)}ms exceeds maxRetryDelayMs ${Math.round(maxDelay)}ms`,
2174
- );
2175
- }
2176
- return delayMs;
2177
- }
2178
-
2179
- function webSocketRetryDelayMs(
2180
- error: unknown,
2181
- retryCount: number,
2182
- options: SimpleStreamOptions | undefined,
2183
- ): number {
2184
- const explicit = explicitWebSocketRetryDelayMs(error);
2185
- const connectionFailure = !(
2186
- error instanceof WebSocketHandshakeError
2187
- || error instanceof ProviderResponseError
2188
- || error instanceof ProviderProtocolError
2189
- );
2190
- const base = explicit
2191
- ?? (connectionFailure
2192
- ? Math.min(WEBSOCKET_RETRY_MAX_DELAY_MS, 5_000 * 2 ** Math.max(0, retryCount - 1))
2193
- : WEBSOCKET_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, retryCount - 1));
2194
- const jittered = explicit === undefined && !connectionFailure
2195
- ? Math.round(base * (0.9 + Math.random() * 0.2))
2196
- : base;
2197
- return boundedWebSocketRetryDelayMs(jittered, options);
2198
- }
2199
-
2200
- function webSocketCompactionRetryDelayMs(
2201
- error: unknown,
2202
- retryCount: number,
2203
- options: SimpleStreamOptions | undefined,
2204
- ): number {
2205
- const explicit = explicitWebSocketRetryDelayMs(error);
2206
- const base = explicit ?? WEBSOCKET_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, retryCount - 1);
2207
- const jittered = explicit === undefined
2208
- ? Math.round(base * (0.9 + Math.random() * 0.2))
2209
- : base;
2210
- return boundedWebSocketRetryDelayMs(jittered, options);
2211
- }
2212
-
2213
- function webSocketStreamMaxRetries(options: SimpleStreamOptions | undefined): number {
2214
- const value = options?.maxRetries;
2215
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
2216
- return DEFAULT_WEBSOCKET_STREAM_MAX_RETRIES;
2217
- }
2218
- return Math.min(MAX_WEBSOCKET_STREAM_MAX_RETRIES, Math.floor(value));
2219
- }
2220
-
2221
- function isProviderNonTransportError(error: unknown): error is ProviderResponseError | ProviderProtocolError {
2222
- return error instanceof ProviderResponseError || error instanceof ProviderProtocolError;
2223
- }
2224
-
2225
- function isWebSocketUpgradeRejectedError(error: unknown): error is WebSocketHandshakeError {
2226
- // `auto` is capability negotiation, not a generic recovery path. HTTP 426
2227
- // explicitly tells the client that the WebSocket upgrade cannot be used.
2228
- // Model/request failures and transient connection errors must stay on the
2229
- // WebSocket path so an agent-level retry does not silently change transport.
2230
- return error instanceof WebSocketHandshakeError && error.status === 426;
2231
- }
2232
-
2233
- function isWebSocketConnectionLimitReachedError(error: unknown): boolean {
2234
- const candidate = error as { code?: unknown; message?: unknown };
2235
- if (candidate?.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE) return true;
2236
- return typeof candidate?.message === "string" && candidate.message.includes(WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE);
2237
- }
2238
-
2239
- function isPreviousResponseNotFoundError(error: unknown): boolean {
2240
- const candidate = error as { code?: unknown; message?: unknown };
2241
- if (candidate?.code === PREVIOUS_RESPONSE_NOT_FOUND_CODE) return true;
2242
- return typeof candidate?.message === "string" && candidate.message.includes(PREVIOUS_RESPONSE_NOT_FOUND_CODE);
2243
- }
2244
-
2245
- function webSocketHeaderIdentity(headers: Headers): string {
2246
- const requestScoped = new Set([
2247
- "x-codex-turn-metadata",
2248
- "x-codex-turn-state",
2249
- "x-codex-window-id",
2250
- ]);
2251
- return shortHash(
2252
- [...headers.entries()]
2253
- .filter(([name]) => !requestScoped.has(name.toLowerCase()))
2254
- .sort(([left], [right]) => left.localeCompare(right))
2255
- .map(([name, value]) => `${name}:${value}`)
2256
- .join("\n"),
2257
- );
2258
- }
2259
-
2260
- function webSocketCacheKey(
2261
- sessionId: string | undefined,
2262
- model: Model<Api>,
2263
- url: string,
2264
- headers: Headers,
2265
- profileHash?: string,
2266
- ): string | undefined {
2267
- return sessionId
2268
- ? `${sessionId}\n${model.provider}\n${model.api}\n${model.id}\n${url}\n${profileHash ?? "no-profile"}\n${webSocketHeaderIdentity(headers)}`
2269
- : undefined;
2270
- }
2271
-
2272
- function webSocketFallbackKey(
2273
- sessionId: string | undefined,
2274
- model: Model<Api>,
2275
- url: string,
2276
- profileHash?: string,
2277
- ): string | undefined {
2278
- return sessionId
2279
- ? `${sessionId}\n${model.provider}\n${model.api}\n${model.id}\n${url}\n${profileHash ?? "no-profile"}`
2280
- : undefined;
2281
- }
2282
-
2283
- function friendlyUsageLimitMessage(error: StreamEventShape["error"], status: number | undefined): string | undefined {
2284
- const code = error?.code ?? error?.type ?? "";
2285
- if (!/usage_limit_reached|usage_not_included/i.test(code)) {
2286
- return undefined;
2287
- }
2288
- const plan = error?.plan_type ? ` (${error.plan_type.toLowerCase()} plan)` : "";
2289
- const mins = error?.resets_at
2290
- ? Math.max(0, Math.round((error.resets_at * 1000 - Date.now()) / 60_000))
2291
- : undefined;
2292
- const when = mins !== undefined ? ` Try again in ~${mins} min.` : "";
2293
- return `You have hit your OpenAI usage limit${plan}.${when}`.trim();
2294
- }
2295
-
2296
- async function prewarmWebSocket(request: WebSocketPrewarmRequest): Promise<void> {
2297
- if ((request.body.input?.length ?? 0) > 0) {
2298
- throw new Error("Startup WebSocket prewarm must not include conversation input");
2299
- }
2300
- const acquired = await acquireWebSocket(
2301
- request.url,
2302
- request.headers,
2303
- request.cacheKey,
2304
- request.requestMetadata.sessionId,
2305
- request.signal,
2306
- request.connectTimeoutMs ?? WEBSOCKET_CONNECT_TIMEOUT_MS,
2307
- );
2308
- const { socket, entry } = acquired;
2309
- let keepConnection = true;
2310
- let released = false;
2311
- const releaseOnce = (options?: { keep?: boolean }) => {
2312
- if (released) return;
2313
- released = true;
2314
- acquired.release(options);
2315
- };
2316
-
2317
- try {
2318
- const fullBody = withWebSocketRequestMetadata(request.body, request.requestMetadata);
2319
- const prewarmBody: ResponsesBody = {
2320
- ...fullBody,
2321
- generate: false,
2322
- };
2323
- // A startup prewarm is the root of a new session continuation. Never
2324
- // chain another generate:false request from an existing response.
2325
- if (entry?.continuation) entry.continuation = undefined;
2326
- const requestBody = prewarmBody;
2327
- const wireBody = prepareWebSocketRequestBodyForWire(requestBody);
2328
- if (request.signal?.aborted) throw new Error("Request was aborted");
2329
- await sendWebSocketRequest(
2330
- socket,
2331
- JSON.stringify({ type: "response.create", ...wireBody }),
2332
- request.signal,
2333
- WEBSOCKET_SEND_TIMEOUT_MS,
2334
- );
2335
- const responseItems: unknown[] = [];
2336
- let responseId: string | undefined;
2337
- for await (const event of mapCodexEvents(parseWebSocket(socket, request.signal), request.requestMetadata.sessionId)) {
2338
- if (event.type === "response.created" && event.response?.id) responseId = event.response.id;
2339
- if (event.type === "response.output_item.done" && event.item) responseItems.push(event.item);
2340
- if (
2341
- (event.type === "response.completed" || event.type === "response.incomplete")
2342
- && event.response?.id
2343
- ) {
2344
- responseId = event.response.id;
2345
- }
2346
- }
2347
- if (entry && responseId) {
2348
- entry.continuation = {
2349
- lastRequestBody: prewarmBody,
2350
- lastResponseId: responseId,
2351
- lastResponseItems: responseItems,
2352
- };
2353
- }
2354
- releaseOnce({ keep: true });
2355
- } catch (error) {
2356
- keepConnection = false;
2357
- if (entry) entry.continuation = undefined;
2358
- releaseOnce({ keep: false });
2359
- throw error;
2360
- } finally {
2361
- releaseOnce({ keep: keepConnection });
2362
- }
2363
- }
2364
-
2365
- async function* mapCodexEvents(
2366
- events: AsyncIterable<StreamEventShape>,
2367
- sessionKey?: string,
2368
- ): AsyncIterable<StreamEventShape> {
2369
- let sawTerminalResponse = false;
2370
- const completedOutputItems = new Set<string>();
2371
- const outputItemKey = (item: unknown, index?: number): string | undefined => {
2372
- if (!item || typeof item !== "object") return undefined;
2373
- const candidate = item as { id?: unknown; type?: unknown };
2374
- if (typeof candidate.id === "string" && typeof candidate.type === "string") return `${candidate.type}:${candidate.id}`;
2375
- return typeof candidate.type === "string" && typeof index === "number" ? `${candidate.type}:index:${index}` : undefined;
2376
- };
2377
- for await (const event of events) {
2378
- const type = typeof event.type === "string" ? event.type : undefined;
2379
- if (!type) continue;
2380
-
2381
- if (type === "response.metadata" && sessionKey) {
2382
- const eventHeaders = event.headers && typeof event.headers === "object" && !Array.isArray(event.headers)
2383
- ? (event.headers as Record<string, unknown>)
2384
- : undefined;
2385
- const turnState = eventHeaders?.["x-codex-turn-state"];
2386
- if (typeof turnState === "string") {
2387
- captureCodexTurnState(sessionKey, turnState);
2388
- }
2389
- }
2390
-
2391
- if (type === "error") {
2392
- const nestedError = event.error;
2393
- const status = typeof event.status === "number"
2394
- ? event.status
2395
- : typeof event.status_code === "number"
2396
- ? event.status_code
2397
- : undefined;
2398
- const eventHeaders = event.headers && typeof event.headers === "object" && !Array.isArray(event.headers)
2399
- ? Object.fromEntries(
2400
- Object.entries(event.headers as Record<string, unknown>)
2401
- .filter((entry): entry is [string, string | number | boolean] =>
2402
- typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean")
2403
- .map(([name, value]) => [name, String(value)]),
2404
- )
2405
- : undefined;
2406
- const code = typeof nestedError?.code === "string"
2407
- ? nestedError.code
2408
- : typeof event.code === "string"
2409
- ? event.code
2410
- : undefined;
2411
- const message = typeof nestedError?.message === "string"
2412
- ? nestedError.message
2413
- : typeof event.message === "string"
2414
- ? event.message
2415
- : undefined;
2416
- const displayMessage = friendlyUsageLimitMessage(nestedError, status)
2417
- ?? `Codex error: ${message || code || JSON.stringify(event)}`;
2418
- const error = new ProviderResponseError(displayMessage) as ProviderResponseError & {
2419
- sequenceNumber?: number;
2420
- };
2421
- if (code) error.code = code;
2422
- if (typeof nestedError?.type === "string") error.errorType = nestedError.type;
2423
- if (status !== undefined) error.status = status;
2424
- error.retryAfterMs = retryAfterMsFromHeaders(eventHeaders);
2425
- if (typeof event.sequence_number === "number") error.sequenceNumber = event.sequence_number;
2426
- throw error;
2427
- }
2428
-
2429
- if (type === "response.failed") {
2430
- const responseError = event.response?.error as { code?: unknown; message?: unknown } | undefined;
2431
- const error = new ProviderResponseError(
2432
- typeof responseError?.message === "string" ? responseError.message : "OpenAI Responses request failed",
2433
- );
2434
- if (typeof responseError?.code === "string") error.code = responseError.code;
2435
- throw error;
2436
- }
2437
-
2438
- if (type === "response.done" || type === "response.completed" || type === "response.incomplete") {
2439
- sawTerminalResponse = true;
2440
- const response = event.response;
2441
- const output = Array.isArray(response?.output) ? response.output : [];
2442
- for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
2443
- const item = output[outputIndex];
2444
- if (!item || typeof item !== "object") continue;
2445
- const itemType = (item as { type?: unknown }).type;
2446
- if (
2447
- itemType !== "image_generation_call"
2448
- && itemType !== "web_search_call"
2449
- && itemType !== "compaction"
2450
- && itemType !== "context_compaction"
2451
- ) continue;
2452
- const key = outputItemKey(item, outputIndex);
2453
- if (key && completedOutputItems.has(key)) continue;
2454
- if (key) completedOutputItems.add(key);
2455
- yield { type: "response.output_item.done", output_index: outputIndex, item } as StreamEventShape;
2456
- }
2457
- yield {
2458
- ...event,
2459
- type: "response.completed",
2460
- response: response ? { ...response, status: normalizeCodexStatus(response.status) } : response,
2461
- };
2462
- return;
2463
- }
2464
-
2465
- if (type === "response.output_item.done") {
2466
- const key = outputItemKey(event.item, typeof event.output_index === "number" ? event.output_index : undefined);
2467
- if (key) completedOutputItems.add(key);
2468
- }
2469
-
2470
- yield event;
2471
- }
2472
-
2473
- if (!sawTerminalResponse) {
2474
- throw new Error("Stream closed before response.completed");
2475
- }
2476
- }
2477
-
2478
- function normalizeCodexStatus(status: string | undefined): string | undefined {
2479
- if (typeof status !== "string") return undefined;
2480
- return CODEX_RESPONSE_STATUSES.has(status) ? status : undefined;
2481
- }
2482
-
2483
- function getLatestUserText(context: Context): string | undefined {
2484
- for (let i = context.messages.length - 1; i >= 0; i--) {
2485
- const message = context.messages[i];
2486
- if (message.role !== "user") continue;
2487
- if (typeof message.content === "string") {
2488
- const trimmed = message.content.trim();
2489
- if (trimmed) return trimmed;
2490
- continue;
2491
- }
2492
- const text = message.content
2493
- .filter((item) => item.type === "text")
2494
- .map((item) => item.text)
2495
- .join("\n")
2496
- .trim();
2497
- if (text) return text;
2498
- }
2499
- return undefined;
2500
- }
2501
-
2502
- async function* captureGeneratedImages(
2503
- events: AsyncIterable<StreamEventShape>,
2504
- options: {
2505
- cwd: string;
2506
- requestPrompt?: string;
2507
- onImageSaved: (image: SavedGeneratedImage, imageData: { data: string; mimeType: string }) => void;
2508
- onWebSearchCaptured?: (search: SurfacedWebSearch) => void;
2509
- },
2510
- ): AsyncIterable<StreamEventShape> {
2511
- let responseId: string | undefined;
2512
-
2513
- for await (const event of events) {
2514
- if (event.type === "response.created" && event.response?.id) {
2515
- responseId = event.response.id;
2516
- }
2517
-
2518
- if (event.type === "response.output_item.done" && event.item?.type === "image_generation_call") {
2519
- const callId = typeof event.item.id === "string" ? event.item.id : undefined;
2520
- const result = typeof event.item.result === "string" ? event.item.result : undefined;
2521
- if (callId && result) {
2522
- try {
2523
- const outputFormat = typeof event.item.output_format === "string" ? event.item.output_format : undefined;
2524
- const normalizedOutputFormat = normalizeImageOutputFormat(outputFormat);
2525
- const settings = loadSettings(options.cwd);
2526
- const imageModel = typeof event.item.model === "string" ? event.item.model : settings.imageModel;
2527
- const saved = await saveOpenAICodexGeneratedImage(options.cwd, {
2528
- responseId,
2529
- callId,
2530
- result,
2531
- outputFormat: normalizedOutputFormat,
2532
- imageModel,
2533
- revisedPrompt:
2534
- typeof event.item.revised_prompt === "string" ? event.item.revised_prompt : options.requestPrompt,
2535
- });
2536
- options.onImageSaved(saved, {
2537
- data: result,
2538
- mimeType: `image/${normalizedOutputFormat}`,
2539
- });
2540
- } catch {
2541
- // Image persistence is best-effort. Do not write raw diagnostics to
2542
- // stdout/stderr from inside the TUI; terminal output can corrupt active
2543
- // widgets and boxes.
2544
- }
2545
- }
2546
- }
2547
-
2548
- if (
2549
- (event.type === "response.output_item.added" || event.type === "response.output_item.done")
2550
- && event.item?.type === "web_search_call"
2551
- ) {
2552
- const search = extractWebSearch(event.item, { completed: event.type === "response.output_item.done" });
2553
- if (search) {
2554
- options.onWebSearchCaptured?.(search);
2555
- }
2556
- }
2557
-
2558
- const webSearchProgress = extractWebSearchProgress(event);
2559
- if (webSearchProgress) {
2560
- options.onWebSearchCaptured?.(webSearchProgress);
2561
- }
2562
-
2563
- yield event;
2564
- }
2565
- }
2566
-
2567
- async function processCapturedResponsesStream<TApi extends Api>(
2568
- events: AsyncIterable<StreamEventShape>,
2569
- output: AssistantMessage,
2570
- stream: AssistantMessageEventStream,
2571
- model: Model<TApi>,
2572
- options: SimpleStreamOptions | undefined,
2573
- sessionKey: string | undefined,
2574
- deps: {
2575
- onImageSaved?: (savedImage: SavedGeneratedImage, imageData: { data: string; mimeType: string }) => void;
2576
- },
2577
- cwd: string,
2578
- requestPrompt: string | undefined,
2579
- webSearchCitationSources: ReadonlyArray<WebSearchCitationSource>,
2580
- historicalCitationSources: ReadonlyArray<CitationSource>,
2581
- ): Promise<{ responseId?: string; responseItems: unknown[] }> {
2582
- type TextBlock = Extract<AssistantMessage["content"][number], { type: "text" }>;
2583
- const responseItems: unknown[] = [];
2584
- let responseId: string | undefined;
2585
- const webSearchStates = new Map<string, { search: SurfacedWebSearch; block: TextBlock; contentIndex: number }>();
2586
- const updateWebSearchActivity = (search: SurfacedWebSearch) => {
2587
- const existing = webSearchStates.get(search.callId);
2588
- const merged = mergeWebSearchActivity(existing?.search, search);
2589
- const text = buildWebSearchInlineText(merged, cwd);
2590
- const textSignature = encodeWebSearchActivityTextSignature(merged.callId, merged.responseItem);
2591
- if (existing) {
2592
- existing.search = merged;
2593
- existing.block.text = text;
2594
- existing.block.textSignature = textSignature;
2595
- stream.push({ type: "text_delta", contentIndex: existing.contentIndex, delta: "", partial: output });
2596
- return;
2597
- }
2598
-
2599
- const block: TextBlock = {
2600
- type: "text",
2601
- text: "",
2602
- textSignature,
2603
- };
2604
- output.content.push(block);
2605
- const contentIndex = output.content.length - 1;
2606
- webSearchStates.set(search.callId, { search: merged, block, contentIndex });
2607
- stream.push({ type: "text_start", contentIndex, partial: output });
2608
- block.text = text;
2609
- stream.push({ type: "text_delta", contentIndex, delta: text, partial: output });
2610
- };
2611
- const captureContinuation = async function* (
2612
- input: AsyncIterable<StreamEventShape>,
2613
- ): AsyncIterable<StreamEventShape> {
2614
- for await (const event of input) {
2615
- if (event.type === "response.created" && event.response?.id) responseId = event.response.id;
2616
- if (event.type === "response.output_item.done" && event.item) {
2617
- responseItems.push(event.item);
2618
- }
2619
- if (
2620
- (event.type === "response.completed" || event.type === "response.incomplete")
2621
- && event.response
2622
- ) {
2623
- if (event.response.id) responseId = event.response.id;
2624
- }
2625
- yield event;
2626
- }
2627
- };
2628
- const tappedEvents = captureGeneratedImages(captureContinuation(mapCodexEvents(events, sessionKey)), {
2629
- cwd,
2630
- requestPrompt,
2631
- onImageSaved: (image, imageData) => deps.onImageSaved?.(image, imageData),
2632
- onWebSearchCaptured: updateWebSearchActivity,
2633
- });
2634
-
2635
- await processResponsesStream(tappedEvents as AsyncIterable<never>, output, stream, model, {
2636
- serviceTier: (options as { serviceTier?: ServiceTier } | undefined)?.serviceTier,
2637
- resolveServiceTier: resolveCodexServiceTier,
2638
- applyServiceTierPricing: (usage, serviceTier) =>
2639
- applyServiceTierPricing(usage, serviceTier, model as Model<Api>, cwd),
2640
- webSearchCitationSources,
2641
- historicalCitationSources,
2642
- });
2643
- return { responseId: responseId ?? output.responseId, responseItems };
2644
- }
2645
-
2646
- function compactUrl(baseUrl: string | undefined, apiKeyMode: boolean): string {
2647
- return `${resolveCodexUrl(baseUrl, { apiKeyMode }).replace(/\/+$/, "")}/compact`;
2648
- }
2649
-
2650
- function buildJsonHeaders(
2651
- modelHeaders: ProviderHeaders | undefined,
2652
- additionalHeaders: ProviderHeaders | undefined,
2653
- accountId: string | undefined,
2654
- apiKey: string,
2655
- sessionId?: string,
2656
- requestIdentity?: CodexRequestIdentity,
2657
- ): Headers {
2658
- const headers = buildBaseCodexHeaders(modelHeaders, additionalHeaders, accountId, apiKey);
2659
- setProviderDefaultHeader(headers, "accept", "application/json");
2660
- setProviderDefaultHeader(headers, "content-type", "application/json");
2661
- applyWireIdentityHeaders(
2662
- headers,
2663
- sessionId,
2664
- requestIdentity?.threadId ?? sessionId,
2665
- requestIdentity,
2666
- );
2667
- return headers;
2668
- }
2669
-
2670
- async function postJsonWithRetries(
2671
- url: string,
2672
- headers: Headers,
2673
- body: unknown,
2674
- signal: AbortSignal | undefined,
2675
- timeoutMs = SSE_RESPONSE_HEADER_TIMEOUT_MS,
2676
- ): Promise<Record<string, unknown>> {
2677
- const bodyJson = JSON.stringify(body);
2678
- const dispatcher = await proxyDispatcherForUrl(url);
2679
- let lastError: Error | undefined;
2680
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
2681
- try {
2682
- const response = await fetchWithResponseHeaderTimeout(url, {
2683
- method: "POST",
2684
- headers,
2685
- body: bodyJson,
2686
- ...(dispatcher ? { dispatcher } : {}),
2687
- } as RequestInit, signal, timeoutMs);
2688
- if (response.ok) {
2689
- const parsed = await response.json();
2690
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2691
- throw new NonRetryableProviderError("OpenAI native compaction returned a non-object response");
2692
- }
2693
- return parsed as Record<string, unknown>;
2694
- }
2695
- const errorText = await response.text();
2696
- if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
2697
- await sleep(BASE_DELAY_MS * 2 ** attempt, signal);
2698
- continue;
2699
- }
2700
- const info = await parseErrorResponse(new Response(errorText, {
2701
- status: response.status,
2702
- statusText: response.statusText,
2703
- }));
2704
- throw new NonRetryableProviderError(withHttpStatusPrefix(response.status, info.friendlyMessage || info.message));
2705
- } catch (error) {
2706
- if (error instanceof NonRetryableProviderError) throw error;
2707
- if (signal?.aborted) throw new Error("Request was aborted");
2708
- lastError = error instanceof Error ? error : new Error(String(error));
2709
- if (attempt < MAX_RETRIES) {
2710
- await sleep(BASE_DELAY_MS * 2 ** attempt, signal);
2711
- continue;
2712
- }
2713
- throw lastError;
2714
- }
2715
- }
2716
- throw lastError ?? new Error("OpenAI native compaction failed");
2717
- }
2718
-
2719
- function compactionItems(output: unknown): unknown[] {
2720
- if (!Array.isArray(output)) throw new Error("OpenAI native compaction response omitted output");
2721
- return output.filter(isNativeCompactionItem);
2722
- }
2723
-
2724
- function isNativeCompactionItem(item: unknown): item is Record<string, unknown> {
2725
- const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined;
2726
- return type === "compaction" || type === "context_compaction";
2727
- }
2728
-
2729
- function approxTokenCount(text: string): number {
2730
- const bytes = new TextEncoder().encode(text).byteLength;
2731
- return Math.ceil(bytes / APPROX_BYTES_PER_TOKEN);
2732
- }
2733
-
2734
- function responseItemTokenCount(item: Record<string, unknown>): number {
2735
- if (item.type === "message" && Array.isArray(item.content)) {
2736
- const tokens = item.content.reduce((total, part) => {
2737
- if (!part || typeof part !== "object") return total;
2738
- const text = (part as { text?: unknown }).text;
2739
- return typeof text === "string" ? total + approxTokenCount(text) : total;
2740
- }, 0);
2741
- return Math.max(1, tokens);
2742
- }
2743
- try {
2744
- return Math.max(1, approxTokenCount(JSON.stringify(item)));
2745
- } catch {
2746
- return Number.MAX_SAFE_INTEGER;
2747
- }
2748
- }
2749
-
2750
- function truncateUtf8Prefix(text: string, maxBytes: number): string {
2751
- if (maxBytes <= 0) return "";
2752
- const encoder = new TextEncoder();
2753
- if (encoder.encode(text).byteLength <= maxBytes) return text;
2754
- let result = "";
2755
- let bytes = 0;
2756
- for (const character of text) {
2757
- const characterBytes = encoder.encode(character).byteLength;
2758
- if (bytes + characterBytes > maxBytes) break;
2759
- result += character;
2760
- bytes += characterBytes;
2761
- }
2762
- return result;
2763
- }
2764
-
2765
- function truncateResponseMessage(
2766
- item: Record<string, unknown>,
2767
- maxTokens: number,
2768
- ): Record<string, unknown> | undefined {
2769
- if (item.type !== "message" || !Array.isArray(item.content) || maxTokens <= 0) return undefined;
2770
- let remaining = maxTokens;
2771
- const content: unknown[] = [];
2772
- for (const part of item.content) {
2773
- if (!part || typeof part !== "object" || Array.isArray(part)) continue;
2774
- const record = part as Record<string, unknown>;
2775
- if (typeof record.text !== "string") {
2776
- content.push(part);
2777
- continue;
2778
- }
2779
- if (remaining <= 0) continue;
2780
- const tokenCount = approxTokenCount(record.text);
2781
- if (tokenCount <= remaining) {
2782
- content.push(part);
2783
- remaining -= tokenCount;
2784
- continue;
2785
- }
2786
- const text = truncateUtf8Prefix(record.text, remaining * APPROX_BYTES_PER_TOKEN);
2787
- if (text) content.push({ ...record, text });
2788
- remaining = 0;
2789
- }
2790
- return content.length > 0 ? { ...item, content } : undefined;
2791
- }
2792
-
2793
- function retainedResponsesCompactionItem(item: unknown): Record<string, unknown> | undefined {
2794
- if (!item || typeof item !== "object" || Array.isArray(item)) return undefined;
2795
- const record = item as Record<string, unknown>;
2796
- if (
2797
- (record.type === undefined || record.type === "message")
2798
- && record.role === "user"
2799
- && Array.isArray(record.content)
2800
- ) {
2801
- return { ...record, type: "message" };
2802
- }
2803
- if (record.type !== "agent_message" || !Array.isArray(record.content)) return undefined;
2804
- const first = record.content[0];
2805
- const firstText = first && typeof first === "object" ? (first as { text?: unknown }).text : undefined;
2806
- if (typeof firstText === "string" && firstText.startsWith("Message Type: FINAL_ANSWER\n")) {
2807
- return undefined;
2808
- }
2809
- return responseItemTokenCount(record) <= CODEX_MAX_RETAINED_AGENT_MESSAGE_TOKENS
2810
- ? record
2811
- : undefined;
2812
- }
2813
-
2814
- /**
2815
- * Match Codex remote compaction v2's installed checkpoint shape: retain the
2816
- * newest real user messages plus bounded delegated-agent state, drop stale
2817
- * developer/system/assistant/tool state, then append the opaque compaction
2818
- * item returned by Responses.
2819
- */
2820
- export function buildCodexCompactionCheckpoint(
2821
- input: unknown[],
2822
- compactionItem: unknown,
2823
- ): unknown[] {
2824
- const candidates = input
2825
- .map(retainedResponsesCompactionItem)
2826
- .filter((item): item is Record<string, unknown> => !!item);
2827
- let remaining = CODEX_RETAINED_MESSAGE_TOKEN_BUDGET;
2828
- const retainedReversed: Record<string, unknown>[] = [];
2829
- for (let index = candidates.length - 1; index >= 0 && remaining > 0; index--) {
2830
- const item = candidates[index]!;
2831
- const tokenCount = responseItemTokenCount(item);
2832
- if (tokenCount <= remaining) {
2833
- retainedReversed.push(item);
2834
- remaining -= tokenCount;
2835
- continue;
2836
- }
2837
- const truncated = truncateResponseMessage(item, remaining);
2838
- if (truncated) {
2839
- retainedReversed.push(truncated);
2840
- remaining = 0;
2841
- }
2842
- }
2843
- retainedReversed.reverse();
2844
- return [...retainedReversed, compactionItem];
2845
- }
2846
-
2847
- interface CodexCompactionStreamResult {
2848
- item: unknown;
2849
- responseId?: string;
2850
- responseItems: unknown[];
2851
- }
2852
-
2853
- async function collectCodexCompactionStream(
2854
- events: AsyncIterable<StreamEventShape>,
2855
- ): Promise<CodexCompactionStreamResult> {
2856
- let outputItemCount = 0;
2857
- const compacted: unknown[] = [];
2858
- const responseItems: unknown[] = [];
2859
- let responseId: string | undefined;
2860
- for await (const event of events) {
2861
- if (event.type === "response.created" && event.response?.id) {
2862
- responseId = event.response.id;
2863
- }
2864
- if (event.type === "response.output_item.done" && event.item) {
2865
- outputItemCount++;
2866
- responseItems.push(event.item);
2867
- if (isNativeCompactionItem(event.item)) compacted.push(event.item);
2868
- }
2869
- if (
2870
- (event.type === "response.completed" || event.type === "response.incomplete")
2871
- && event.response?.id
2872
- ) {
2873
- responseId = event.response.id;
2874
- }
2875
- }
2876
- if (compacted.length !== 1) {
2877
- throw new NonRetryableProviderError(
2878
- `OpenAI compaction trigger expected exactly one compaction item, received ${compacted.length} from ${outputItemCount} output items`,
2879
- );
2880
- }
2881
- return {
2882
- item: compacted[0],
2883
- ...(responseId ? { responseId } : {}),
2884
- responseItems,
2885
- };
2886
- }
2887
-
2888
- async function collectCodexCompactionOutput(response: Response, sessionKey?: string): Promise<unknown> {
2889
- const result = await collectCodexCompactionStream(mapCodexEvents(parseSSE(response), sessionKey));
2890
- return result.item;
2891
- }
2892
-
2893
- async function requestCodexCompactionTrigger(
2894
- url: string,
2895
- headers: Headers,
2896
- body: ResponsesBody,
2897
- signal: AbortSignal | undefined,
2898
- sessionKey?: string,
2899
- ): Promise<unknown> {
2900
- const bodyJson = JSON.stringify(body);
2901
- const dispatcher = await proxyDispatcherForUrl(url);
2902
- let lastError: Error | undefined;
2903
- const maxRetries = Math.min(MAX_RETRIES, CODEX_REMOTE_COMPACTION_STREAM_RETRIES);
2904
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
2905
- try {
2906
- const response = await fetchWithResponseHeaderTimeout(url, {
2907
- method: "POST",
2908
- headers,
2909
- body: bodyJson,
2910
- ...(dispatcher ? { dispatcher } : {}),
2911
- } as RequestInit, signal);
2912
- if (response.ok) return await collectCodexCompactionOutput(response, sessionKey);
2913
-
2914
- const errorText = await response.text();
2915
- if (attempt < maxRetries && isRetryableError(response.status, errorText)) {
2916
- await sleep(BASE_DELAY_MS * 2 ** attempt, signal);
2917
- continue;
2918
- }
2919
- const info = await parseErrorResponse(new Response(errorText, {
2920
- status: response.status,
2921
- statusText: response.statusText,
2922
- }));
2923
- throw new NonRetryableProviderError(withHttpStatusPrefix(response.status, info.friendlyMessage || info.message));
2924
- } catch (error) {
2925
- if (error instanceof NonRetryableProviderError) throw error;
2926
- if (signal?.aborted) throw new Error("Request was aborted");
2927
- lastError = error instanceof Error ? error : new Error(String(error));
2928
- if (attempt < maxRetries) {
2929
- await sleep(BASE_DELAY_MS * 2 ** attempt, signal);
2930
- continue;
2931
- }
2932
- throw lastError;
2933
- }
2934
- }
2935
- throw lastError ?? new Error("OpenAI compaction trigger failed");
2936
- }
2937
-
2938
- async function requestCodexCompactionTriggerWebSocket(
2939
- url: string,
2940
- headers: Headers,
2941
- body: ResponsesBody,
2942
- model: Model<Api>,
2943
- requestMetadata: WebSocketRequestMetadata,
2944
- signal: AbortSignal | undefined,
2945
- profileHash?: string,
2946
- ): Promise<unknown> {
2947
- let disableCachedContext = false;
2948
- let staleSocketRetried = false;
2949
- let missingPreviousResponseRetried = false;
2950
-
2951
- while (true) {
2952
- const cacheKey = webSocketCacheKey(
2953
- requestMetadata.sessionId,
2954
- model,
2955
- url,
2956
- headers,
2957
- profileHash,
2958
- );
2959
- const { socket, entry, release, reused } = await acquireWebSocket(
2960
- url,
2961
- headers,
2962
- cacheKey,
2963
- requestMetadata.sessionId,
2964
- signal,
2965
- WEBSOCKET_CONNECT_TIMEOUT_MS,
2966
- );
2967
- let keepConnection = true;
2968
- let released = false;
2969
- let eventCount = 0;
2970
- // All reusable WebSocket transports opportunistically continue an exact
2971
- // logical request prefix. `websocket` still sends a full request whenever
2972
- // the stable fields or input prefix do not match.
2973
- const useCachedContext = true;
2974
- const fullBody = withWebSocketRequestMetadata(body, requestMetadata);
2975
- const requestBody = useCachedContext && !disableCachedContext && entry
2976
- ? buildCachedWebSocketRequestBody(entry, fullBody)
2977
- : fullBody;
2978
- const wireRequestBody = prepareWebSocketRequestBodyForWire(requestBody);
2979
- const releaseOnce = (releaseOptions?: { keep?: boolean }) => {
2980
- if (released) return;
2981
- released = true;
2982
- release(releaseOptions);
2983
- };
2984
-
2985
- try {
2986
- await sendWebSocketRequest(
2987
- socket,
2988
- JSON.stringify({ type: "response.create", ...wireRequestBody }),
2989
- signal,
2990
- WEBSOCKET_SEND_TIMEOUT_MS,
2991
- );
2992
- const result = await collectCodexCompactionStream(
2993
- mapCodexEvents(
2994
- countWebSocketEvents(parseWebSocket(socket, signal), () => {
2995
- eventCount++;
2996
- }),
2997
- requestMetadata.sessionId,
2998
- ),
2999
- );
3000
- if (signal?.aborted) {
3001
- keepConnection = false;
3002
- throw new Error("Request was aborted");
3003
- }
3004
- if (entry && result.responseId) {
3005
- entry.continuation = {
3006
- lastRequestBody: fullBody,
3007
- lastResponseId: result.responseId,
3008
- lastResponseItems: result.responseItems,
3009
- };
3010
- } else if (entry) {
3011
- entry.continuation = undefined;
3012
- }
3013
- releaseOnce({ keep: true });
3014
- return result.item;
3015
- } catch (error) {
3016
- if (entry) entry.continuation = undefined;
3017
- keepConnection = false;
3018
- releaseOnce({ keep: false });
3019
- if (
3020
- !staleSocketRetried
3021
- && reused
3022
- && eventCount === 0
3023
- && !signal?.aborted
3024
- && isRetryableEarlyWebSocketError(error)
3025
- ) {
3026
- staleSocketRetried = true;
3027
- continue;
3028
- }
3029
- if (
3030
- !missingPreviousResponseRetried
3031
- && requestBody.previous_response_id
3032
- && !signal?.aborted
3033
- && isPreviousResponseNotFoundError(error)
3034
- ) {
3035
- missingPreviousResponseRetried = true;
3036
- disableCachedContext = true;
3037
- continue;
3038
- }
3039
- throw error;
3040
- } finally {
3041
- releaseOnce({ keep: keepConnection });
3042
- }
3043
- }
3044
- }
3045
-
3046
- async function requestCodexCompactionTriggerWithTransport(
3047
- model: Model<Api>,
3048
- headers: {
3049
- sse: Headers;
3050
- websocket: Headers;
3051
- },
3052
- body: ResponsesBody,
3053
- options: {
3054
- sessionId?: string;
3055
- turnId?: string;
3056
- requestIdentity?: CodexRequestIdentity;
3057
- signal?: AbortSignal;
3058
- settings: ResolvedCodexModelSettings;
3059
- maxRetries?: number;
3060
- maxRetryDelayMs?: number;
3061
- },
3062
- ): Promise<unknown> {
3063
- const transport = options.settings.openaiTransport;
3064
- const responsesMode = resolveCodexRequestProfile(options.settings.requestProfile).responsesMode;
3065
- const sseUrl = resolveCodexUrl(model.baseUrl, { apiKeyMode: options.settings.apiKeyMode });
3066
- const requestMetadata: WebSocketRequestMetadata = {
3067
- ...(options.sessionId ? { sessionId: options.sessionId } : {}),
3068
- ...(options.requestIdentity?.threadId
3069
- ? { threadId: options.requestIdentity.threadId }
3070
- : {}),
3071
- turnId: options.requestIdentity?.turnId
3072
- || options.turnId
3073
- || createPiTurnId(),
3074
- requestKind: "compaction",
3075
- ...(options.requestIdentity
3076
- ? { identity: options.requestIdentity }
3077
- : {}),
3078
- };
3079
- if (transport === "sse") {
3080
- return requestCodexCompactionTrigger(
3081
- sseUrl,
3082
- headers.sse,
3083
- withSseRequestMetadata(body, requestMetadata),
3084
- options.signal,
3085
- options.sessionId,
3086
- );
3087
- }
3088
-
3089
- const websocketUrl = resolveResponsesWebSocketUrl(model.baseUrl, { apiKeyMode: options.settings.apiKeyMode });
3090
- const fallbackKey = webSocketFallbackKey(
3091
- options.sessionId,
3092
- model,
3093
- websocketUrl,
3094
- options.settings.modelProfileHash,
3095
- );
3096
- if (
3097
- transport === "auto"
3098
- && fallbackKey
3099
- && websocketHttpFallbackSessions.has(fallbackKey)
3100
- ) {
3101
- return requestCodexCompactionTrigger(
3102
- sseUrl,
3103
- headers.sse,
3104
- withSseRequestMetadata(body, requestMetadata),
3105
- options.signal,
3106
- options.sessionId,
3107
- );
3108
- }
3109
-
3110
- const maxRetries = Math.min(
3111
- CODEX_REMOTE_COMPACTION_STREAM_RETRIES,
3112
- webSocketStreamMaxRetries({
3113
- maxRetries: options.maxRetries,
3114
- } as SimpleStreamOptions),
3115
- );
3116
- const retryOptions = {
3117
- ...(options.maxRetryDelayMs !== undefined ? { maxRetryDelayMs: options.maxRetryDelayMs } : {}),
3118
- } as SimpleStreamOptions;
3119
- let retries = 0;
3120
- while (true) {
3121
- try {
3122
- return await requestCodexCompactionTriggerWebSocket(
3123
- websocketUrl,
3124
- headers.websocket,
3125
- withResponsesLiteWebSocketMetadata(body, responsesMode),
3126
- model,
3127
- requestMetadata,
3128
- options.signal,
3129
- options.settings.modelProfileHash,
3130
- );
3131
- } catch (error) {
3132
- if (options.signal?.aborted) throw new Error("Request was aborted");
3133
- const upgradeRejected = isWebSocketUpgradeRejectedError(error);
3134
- if (transport === "auto" && upgradeRejected) {
3135
- if (fallbackKey) websocketHttpFallbackSessions.add(fallbackKey);
3136
- break;
3137
- }
3138
- const retryable = isWebSocketConnectionLimitReachedError(error) || isRetryableWebSocketError(error);
3139
- if (retryable && retries < maxRetries) {
3140
- retries++;
3141
- await sleep(webSocketCompactionRetryDelayMs(error, retries, retryOptions), options.signal);
3142
- continue;
3143
- }
3144
- throw error;
3145
- }
3146
- }
3147
-
3148
- return requestCodexCompactionTrigger(
3149
- sseUrl,
3150
- headers.sse,
3151
- withSseRequestMetadata(body, requestMetadata),
3152
- options.signal,
3153
- options.sessionId,
3154
- );
3155
- }
3156
-
3157
- function hasNonEmptyResponseMessageContent(item: Record<string, unknown>): boolean {
3158
- if (item.type !== "message") return false;
3159
- if (item.role !== "user" && item.role !== "assistant") return false;
3160
- if (!Array.isArray(item.content)) return false;
3161
- return item.content.some((part) => {
3162
- if (!part || typeof part !== "object") return false;
3163
- const content = part as Record<string, unknown>;
3164
- return (
3165
- (typeof content.text === "string" && content.text.trim().length > 0)
3166
- || (typeof content.refusal === "string" && content.refusal.trim().length > 0)
3167
- || typeof content.image_url === "string"
3168
- );
3169
- });
3170
- }
3171
-
3172
- /**
3173
- * Install remote compaction as a fresh history checkpoint, following Codex's
3174
- * compaction reducer: preserve only safe message/checkpoint items and discard
3175
- * reasoning, tool calls, and tool outputs. Replaying a partial call pair is
3176
- * invalid Responses input and can detach tool arguments from their result.
3177
- */
3178
- export function sanitizeNativeCompactionOutput(output: unknown[]): unknown[] {
3179
- return output.filter((item) => {
3180
- if (!item || typeof item !== "object" || Array.isArray(item)) return false;
3181
- const record = item as Record<string, unknown>;
3182
- if (record.type === "compaction" || record.type === "context_compaction") return true;
3183
- return hasNonEmptyResponseMessageContent(record);
3184
- });
3185
- }
3186
-
3187
- export async function requestOpenAINativeCompaction(
3188
- model: Model<Api>,
3189
- context: Context,
3190
- options: {
3191
- mode: "responses" | "responses-compact";
3192
- apiKey: string;
3193
- headers?: ProviderHeaders;
3194
- signal?: AbortSignal;
3195
- reasoning?: SimpleStreamOptions["reasoning"];
3196
- sessionId?: string;
3197
- turnId?: string;
3198
- maxRetries?: number;
3199
- maxRetryDelayMs?: number;
3200
- settings: ResolvedCodexModelSettings;
3201
- },
3202
- ): Promise<unknown[]> {
3203
- const settings = options.settings.modelProfile
3204
- ? options.settings
3205
- : loadModelSettings(model, undefined, options.settings);
3206
- const auth = { apiKey: options.apiKey || undefined, headers: options.headers };
3207
- if (!hasCodexRequestAuth({ modelHeaders: model.headers, auth })) {
3208
- throw new Error(`No request authentication for provider: ${model.provider}`);
3209
- }
3210
- if (settings.compactionMode === "pi") {
3211
- throw new Error("native compaction is disabled by the current model profile");
3212
- }
3213
- const profile = resolveCodexRequestProfile(settings.requestProfile);
3214
- const accountId = resolveCodexRequestAccountId({
3215
- modelHeaders: model.headers,
3216
- auth,
3217
- apiKeyMode: settings.apiKeyMode,
3218
- });
3219
- const requestIdentity = resolveCodexRequestIdentity(
3220
- options.sessionId,
3221
- options.turnId ? { turn_id: options.turnId } : undefined,
3222
- "compaction",
3223
- );
3224
- let body = applyFastModeServiceTier(buildRequestBody(model, context, profile, {
3225
- apiKey: options.apiKey,
3226
- headers: options.headers,
3227
- signal: options.signal,
3228
- reasoning: options.reasoning,
3229
- sessionId: options.sessionId,
3230
- }), settings, model);
3231
- if (settings.nativeProviderTools) {
3232
- const webSearch = settings.modelProfile?.effective.tools.webSearch;
3233
- body = rewriteNativeOpenAiTools(body, {
3234
- imageModel: settings.imageModel,
3235
- imageGeneration: settings.imageGenerationImplementation ?? false,
3236
- webSearch: settings.webSearchEnabled
3237
- && webSearch
3238
- ? {
3239
- implementation: webSearch.implementation,
3240
- contentTypes: webSearch.contentTypes,
3241
- }
3242
- : false,
3243
- }).payload;
3244
- }
3245
- ensureWebSearchDetailsIncluded(body);
3246
-
3247
- if (options.mode === "responses") {
3248
- const retainedInput = [...body.input];
3249
- body.input = [...retainedInput, { type: CODEX_COMPACTION_TRIGGER_TYPE }];
3250
- const sseHeaders = applyConfiguredResponsesFeatureHeaders(buildSSEHeaders(
3251
- model.headers,
3252
- options.headers,
3253
- accountId,
3254
- options.apiKey,
3255
- options.sessionId,
3256
- profile,
3257
- requestIdentity?.threadId,
3258
- requestIdentity,
3259
- ), settings, model);
3260
- const requestId = requestIdentity?.threadId
3261
- ?? options.sessionId
3262
- ?? createCodexRequestId();
3263
- const websocketHeaders = applyConfiguredResponsesFeatureHeaders(buildWebSocketHeaders(
3264
- model.headers,
3265
- options.headers,
3266
- accountId,
3267
- options.apiKey,
3268
- requestId,
3269
- requestId,
3270
- requestIdentity,
3271
- ), settings, model);
3272
- const item = await requestCodexCompactionTriggerWithTransport(
3273
- model,
3274
- { sse: sseHeaders, websocket: websocketHeaders },
3275
- body,
3276
- {
3277
- sessionId: options.sessionId,
3278
- turnId: options.turnId,
3279
- requestIdentity,
3280
- signal: options.signal,
3281
- settings,
3282
- maxRetries: options.maxRetries,
3283
- maxRetryDelayMs: options.maxRetryDelayMs,
3284
- },
3285
- );
3286
- return buildCodexCompactionCheckpoint(retainedInput, item);
3287
- }
3288
-
3289
- const headers = buildJsonHeaders(
3290
- model.headers,
3291
- options.headers,
3292
- accountId,
3293
- options.apiKey,
3294
- options.sessionId,
3295
- requestIdentity,
3296
- );
3297
- if (profile.responsesMode === "lite") {
3298
- setProviderGeneratedHeader(headers, X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE, "true");
3299
- }
3300
- const compactBody: Record<string, unknown> = {
3301
- model: body.model,
3302
- input: body.input,
3303
- parallel_tool_calls: body.parallel_tool_calls,
3304
- };
3305
- for (const key of ["instructions", "tools", "reasoning", "service_tier", "prompt_cache_key", "text"] as const) {
3306
- if (body[key] !== undefined) compactBody[key] = body[key];
3307
- }
3308
- const response = await postJsonWithRetries(
3309
- compactUrl(model.baseUrl, settings.apiKeyMode),
3310
- headers,
3311
- compactBody,
3312
- options.signal,
3313
- );
3314
- const output = response.output;
3315
- if (!Array.isArray(output) || output.length === 0) {
3316
- throw new Error("OpenAI /responses/compact returned no replacement output");
3317
- }
3318
- const sanitizedOutput = sanitizeNativeCompactionOutput(output);
3319
- if (compactionItems(sanitizedOutput).length === 0) {
3320
- throw new Error("OpenAI /responses/compact output did not contain a compaction item");
3321
- }
3322
- return sanitizedOutput;
3323
- }
3324
-
3325
- async function processWebSocketStream<TApi extends Api>(
3326
- url: string,
3327
- body: ResponsesBody,
3328
- headers: Headers,
3329
- output: AssistantMessage,
3330
- stream: AssistantMessageEventStream,
3331
- model: Model<TApi>,
3332
- onStart: () => void,
3333
- options: SimpleStreamOptions | undefined,
3334
- deps: {
3335
- onImageSaved?: (savedImage: SavedGeneratedImage, imageData: { data: string; mimeType: string }) => void;
3336
- },
3337
- cwd: string,
3338
- requestPrompt: string | undefined,
3339
- webSearchCitationSources: ReadonlyArray<WebSearchCitationSource>,
3340
- historicalCitationSources: ReadonlyArray<CitationSource>,
3341
- requestMetadata: WebSocketRequestMetadata,
3342
- profileHash?: string,
3343
- startupPrewarm?: Promise<void>,
3344
- ): Promise<void> {
3345
- let streamStarted = false;
3346
- let disableCachedContext = false;
3347
- let staleSocketRetried = false;
3348
- let missingPreviousResponseRetried = false;
3349
-
3350
- while (true) {
3351
- if (startupPrewarm) {
3352
- await startupPrewarm;
3353
- startupPrewarm = undefined;
3354
- }
3355
- const cacheKey = webSocketCacheKey(
3356
- options?.sessionId,
3357
- model as Model<Api>,
3358
- url,
3359
- headers,
3360
- profileHash,
3361
- );
3362
- const { socket, entry, release, reused } = await acquireWebSocket(
3363
- url,
3364
- headers,
3365
- cacheKey,
3366
- options?.sessionId,
3367
- options?.signal,
3368
- WEBSOCKET_CONNECT_TIMEOUT_MS,
3369
- );
3370
- let keepConnection = true;
3371
- let released = false;
3372
- let eventCount = 0;
3373
- // Continuation is safe only when buildCachedWebSocketRequestBody proves
3374
- // that this request exactly extends the cached logical request.
3375
- const useCachedContext = true;
3376
- // ChatGPT Codex Responses rejects `store: true` ("Store must be set to false").
3377
- // WebSocket continuation still works via connection-scoped previous_response_id state.
3378
- const fullBody = withWebSocketRequestMetadata(body, requestMetadata);
3379
- const requestBody = useCachedContext && !disableCachedContext && entry
3380
- ? buildCachedWebSocketRequestBody(entry, fullBody)
3381
- : fullBody;
3382
- const wireRequestBody = prepareWebSocketRequestBodyForWire(requestBody);
3383
-
3384
- const releaseOnce = (releaseOptions?: { keep?: boolean }) => {
3385
- if (released) return;
3386
- released = true;
3387
- release(releaseOptions);
3388
- };
3389
-
3390
- try {
3391
- await sendWebSocketRequest(
3392
- socket,
3393
- JSON.stringify({ type: "response.create", ...wireRequestBody }),
3394
- options?.signal,
3395
- WEBSOCKET_SEND_TIMEOUT_MS,
3396
- );
3397
- const startOutput = () => {
3398
- if (streamStarted) return;
3399
- onStart();
3400
- stream.push({ type: "start", partial: output });
3401
- streamStarted = true;
3402
- };
3403
- const continuationResult = await processCapturedResponsesStream(
3404
- startWebSocketOutputOnFirstEvent(
3405
- countWebSocketEvents(parseWebSocket(socket, options?.signal), () => {
3406
- eventCount++;
3407
- }),
3408
- startOutput,
3409
- ),
3410
- output,
3411
- stream,
3412
- model,
3413
- options,
3414
- options?.sessionId,
3415
- deps,
3416
- cwd,
3417
- requestPrompt,
3418
- webSearchCitationSources,
3419
- historicalCitationSources,
3420
- );
3421
- if (options?.signal?.aborted) {
3422
- keepConnection = false;
3423
- } else if (entry && continuationResult.responseId) {
3424
- entry.continuation = {
3425
- lastRequestBody: fullBody,
3426
- lastResponseId: continuationResult.responseId,
3427
- lastResponseItems: continuationResult.responseItems,
3428
- };
3429
- } else if (entry) {
3430
- entry.continuation = undefined;
3431
- }
3432
- releaseOnce({ keep: keepConnection });
3433
- return;
3434
- } catch (error) {
3435
- if (entry) {
3436
- entry.continuation = undefined;
3437
- }
3438
- keepConnection = false;
3439
- releaseOnce({ keep: false });
3440
- // Pi's stock provider reuses session WebSockets. In practice the Codex
3441
- // backend sometimes cleanly closes an idle cached socket between turns;
3442
- // if that stale socket fails before any response event, retry once on a
3443
- // fresh WebSocket without changing request shape or falling back transports.
3444
- if (!staleSocketRetried && reused && eventCount === 0 && !options?.signal?.aborted && isRetryableEarlyWebSocketError(error)) {
3445
- staleSocketRetried = true;
3446
- continue;
3447
- }
3448
- if (
3449
- !missingPreviousResponseRetried
3450
- && requestBody.previous_response_id
3451
- && !streamStarted
3452
- && !options?.signal?.aborted
3453
- && isPreviousResponseNotFoundError(error)
3454
- ) {
3455
- missingPreviousResponseRetried = true;
3456
- disableCachedContext = true;
3457
- continue;
3458
- }
3459
- throw error;
3460
- } finally {
3461
- releaseOnce({ keep: keepConnection });
3462
- }
3463
- }
3464
- }
3465
-
3466
- export function extractWebSearch(
3467
- item: StreamEventShape["item"],
3468
- options?: { completed?: boolean },
3469
- ): SurfacedWebSearch | undefined {
3470
- if (!item || item.type !== "web_search_call") return undefined;
3471
- const callId = typeof item.id === "string" ? item.id : typeof item.call_id === "string" ? item.call_id : undefined;
3472
- if (!callId) return undefined;
3473
-
3474
- const action = typeof item.action === "object" && item.action !== null ? (item.action as Record<string, unknown>) : undefined;
3475
- const actionType = typeof action?.type === "string" ? action.type : undefined;
3476
- const query = typeof action?.query === "string" ? action.query : typeof item.query === "string" ? item.query : undefined;
3477
- const queries = [
3478
- ...(Array.isArray(action?.queries) ? action.queries : []),
3479
- ...(Array.isArray(item.queries) ? item.queries : []),
3480
- ].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
3481
- const url = typeof action?.url === "string" && action.url.trim()
3482
- ? action.url.trim()
3483
- : typeof item.url === "string" && item.url.trim()
3484
- ? item.url.trim()
3485
- : undefined;
3486
- const pattern = typeof action?.pattern === "string" && action.pattern.trim() ? action.pattern.trim() : undefined;
3487
-
3488
- const asRecordArray = (value: unknown): Record<string, unknown>[] => Array.isArray(value)
3489
- ? value
3490
- .map((entry) => typeof entry === "object" && entry !== null ? entry as Record<string, unknown> : undefined)
3491
- .filter((entry): entry is Record<string, unknown> => !!entry)
3492
- : [];
3493
- const sourceCandidates = [
3494
- ...asRecordArray(action?.sources),
3495
- ...asRecordArray(action?.results),
3496
- ...asRecordArray(item.results),
3497
- ];
3498
- if (typeof item.url === "string") sourceCandidates.push(item as Record<string, unknown>);
3499
-
3500
- const seenUrls = new Set<string>();
3501
- const sources: Array<{ title?: string; url: string }> = [];
3502
- for (const source of sourceCandidates) {
3503
- const url = typeof source.url === "string" && source.url.trim() ? source.url.trim() : undefined;
3504
- if (!url || seenUrls.has(url)) continue;
3505
- seenUrls.add(url);
3506
- const title = typeof source.title === "string" && source.title.trim() ? source.title.trim() : undefined;
3507
- sources.push({ ...(title ? { title } : {}), url });
3508
- }
3509
-
3510
- return {
3511
- callId,
3512
- ...(typeof item.status === "string" ? { status: item.status } : {}),
3513
- ...(options?.completed !== undefined ? { completed: options.completed } : {}),
3514
- ...(actionType ? { actionType } : {}),
3515
- ...(query ? { query } : {}),
3516
- queries,
3517
- ...(url ? { url } : {}),
3518
- ...(pattern ? { pattern } : {}),
3519
- sources,
3520
- ...(options?.completed ? { responseItem: item as Record<string, unknown> } : {}),
3521
- };
3522
- }
3523
-
3524
- export function extractWebSearchProgress(event: StreamEventShape): SurfacedWebSearch | undefined {
3525
- const status = event.type === "response.web_search_call.in_progress"
3526
- ? "in_progress"
3527
- : event.type === "response.web_search_call.searching"
3528
- ? "searching"
3529
- : event.type === "response.web_search_call.completed"
3530
- ? "completed"
3531
- : undefined;
3532
- if (!status || typeof event.item_id !== "string" || !event.item_id) return undefined;
3533
- return {
3534
- callId: event.item_id,
3535
- status,
3536
- completed: status === "completed",
3537
- queries: [],
3538
- sources: [],
3539
- };
3540
- }
3541
-
3542
- export function mergeWebSearchActivity(
3543
- previous: SurfacedWebSearch | undefined,
3544
- next: SurfacedWebSearch,
3545
- ): SurfacedWebSearch {
3546
- if (!previous) return next;
3547
- const seenUrls = new Set<string>();
3548
- const sources = [...next.sources, ...previous.sources].filter((source) => {
3549
- if (seenUrls.has(source.url)) return false;
3550
- seenUrls.add(source.url);
3551
- return true;
3552
- });
3553
- const completed = Boolean(previous.completed || next.completed);
3554
- return {
3555
- callId: next.callId,
3556
- status: previous.completed && !next.completed ? previous.status : (next.status ?? previous.status),
3557
- completed,
3558
- actionType: next.actionType ?? previous.actionType,
3559
- query: next.query ?? previous.query,
3560
- queries: next.queries.length > 0 ? next.queries : previous.queries,
3561
- url: next.url ?? previous.url,
3562
- pattern: next.pattern ?? previous.pattern,
3563
- sources,
3564
- responseItem: next.responseItem ?? previous.responseItem,
3565
- };
3566
- }
3567
-
3568
- export function webSearchActivityDetail(search: SurfacedWebSearch): string {
3569
- if (search.actionType === "open_page") return search.url ?? "";
3570
- if (search.actionType === "find_in_page") {
3571
- if (search.pattern && search.url) return `'${search.pattern}' in ${search.url}`;
3572
- if (search.pattern) return `'${search.pattern}'`;
3573
- return search.url ?? "";
3574
- }
3575
- const query = search.query?.trim();
3576
- if (query) return query;
3577
- const first = search.queries[0]?.trim() ?? "";
3578
- return search.queries.length > 1 && first ? `${first} ...` : first;
3579
- }
3580
-
3581
- export function webSearchActivityHosts(search: SurfacedWebSearch): string[] {
3582
- const seen = new Set<string>();
3583
- const hosts: string[] = [];
3584
- for (const source of search.sources) {
3585
- try {
3586
- const host = new URL(source.url).hostname.replace(/^www\./i, "");
3587
- const key = host.toLowerCase();
3588
- if (!host || seen.has(key)) continue;
3589
- seen.add(key);
3590
- hosts.push(host);
3591
- } catch {
3592
- // Sources without a valid URL do not produce a host tag.
3593
- }
3594
- }
3595
- return hosts;
3596
- }
3597
-
3598
- export function buildWebSearchStatusText(search: SurfacedWebSearch): string {
3599
- const completed = search.completed ?? search.status === "completed";
3600
- const detail = webSearchActivityDetail(search);
3601
- if (completed) return `Searched the web${detail ? ` for ${detail}` : ""}`;
3602
- return `Searching the web${detail ? ` ${detail}` : ""}`;
3603
- }
3604
-
3605
- export function buildWebSearchInlineText(search: SurfacedWebSearch, cwd?: string): string {
3606
- const completed = search.completed ?? search.status === "completed";
3607
- const header = completed ? "Searched the web" : "Searching the web";
3608
- const detail = webSearchActivityDetail(search);
3609
- const separator = detail ? (completed ? " for " : " ") : "";
3610
- return `${glyphs(cwd).bullet}**${header}**${separator}${detail}`;
3611
- }
3612
-
3613
- export function buildWebSearchActivityMessage(searches: SurfacedWebSearch[]): string {
3614
- const sections = searches.map((search, index) => {
3615
- const heading = searches.length > 1
3616
- ? `${index + 1}. ${buildWebSearchStatusText(search)}`
3617
- : buildWebSearchStatusText(search);
3618
- const lines = [heading, `Call: ${search.callId}${search.status ? ` (${search.status})` : ""}`];
3619
- const queries = search.queries.length > 0 ? search.queries : search.query ? [search.query] : [];
3620
- if (queries.length > 0) {
3621
- lines.push(`Query: ${queries.join(" | ")}`);
3622
- }
3623
- if (search.sources.length > 0) {
3624
- lines.push("Sources:");
3625
- for (const source of search.sources.slice(0, 8)) {
3626
- lines.push(`- ${source.title ? `${source.title}: ` : ""}${source.url}`);
3627
- }
3628
- }
3629
- return lines.join("\n");
3630
- });
3631
-
3632
- return sections.join("\n\n");
3633
- }
3634
-
3635
- export function buildWebSearchSummaryText(searches: SurfacedWebSearch[]): string {
3636
- if (searches.length === 0) return "Web search";
3637
- if (searches.length === 1) return buildWebSearchStatusText(searches[0]!);
3638
- const completed = searches.filter((search) => search.completed ?? search.status === "completed").length;
3639
- if (completed === searches.length) return `Searched the web ${searches.length} times`;
3640
- if (completed === 0) return `Searching the web (${searches.length} calls)`;
3641
- return `Web search activity (${completed}/${searches.length} completed)`;
3642
- }
3643
-
3644
- function makeCachedImagePreview(data: string, mimeType: string, bytes?: number): CachedImagePreview {
3645
- const dimensions = getImageDimensions(data, mimeType) ?? undefined;
3646
- return { data, mimeType, bytes: bytes ?? Buffer.from(data, "base64").byteLength, widthPx: dimensions?.widthPx, heightPx: dimensions?.heightPx };
3647
- }
3648
-
3649
- function loadCachedImagePreview(savedImage: SavedGeneratedImage, imagePreviewCache: Map<string, CachedImagePreview>): CachedImagePreview | undefined {
3650
- const cached = imagePreviewCache.get(savedImage.absolutePath);
3651
- if (cached) return cached;
3652
- const fs = getNodeFsSync();
3653
- if (!fs) return undefined;
3654
- try {
3655
- const buffer = fs.readFileSync(savedImage.absolutePath);
3656
- const data = buffer.toString("base64");
3657
- const mimeType = `image/${savedImage.outputFormat}`;
3658
- const preview = makeCachedImagePreview(data, mimeType, buffer.byteLength);
3659
- imagePreviewCache.set(savedImage.absolutePath, preview);
3660
- return preview;
3661
- } catch {
3662
- return undefined;
3663
- }
3664
- }
3665
-
3666
- function formatImageBytes(bytes: number | undefined): string | undefined {
3667
- if (!Number.isFinite(bytes) || !bytes) return undefined;
3668
- if (bytes < 1024) return `${bytes} B`;
3669
- if (bytes < 1024 * 1024) return `${Math.round(bytes / 102.4) / 10}K`;
3670
- return `${Math.round(bytes / (1024 * 102.4)) / 10}M`;
3671
- }
3672
-
3673
- function themeFg(theme: any, token: string, text: string): string {
3674
- try { return theme?.fg?.(token, text) ?? text; } catch { return text; }
3675
- }
3676
-
3677
- function themeBold(theme: any, text: string): string {
3678
- try { return theme?.bold?.(text) ?? text; } catch { return text; }
3679
- }
3680
-
3681
- function shouldRenderInlineImage(): { ok: boolean; reason?: string } {
3682
- if (process.env.TMUX) return { ok: false, reason: "inline preview disabled in tmux to avoid overlay/stale image artifacts" };
3683
- const protocol = getCapabilities().images;
3684
- if (!protocol) return { ok: false, reason: "terminal image protocol unavailable" };
3685
- return { ok: true };
3686
- }
3687
-
3688
- function renderImageGenerationMessage(savedImage: SavedGeneratedImage | undefined, messageContent: unknown, options: any, theme: any, imagePreviewCache: Map<string, CachedImagePreview>): Container {
3689
- const container = new Container();
3690
- const preview = savedImage ? loadCachedImagePreview(savedImage, imagePreviewCache) : undefined;
3691
- const type = savedImage?.outputFormat?.toUpperCase() ?? preview?.mimeType?.replace(/^image\//, "").toUpperCase() ?? "IMAGE";
3692
- const dimensions = preview?.widthPx && preview?.heightPx ? `${preview.widthPx}x${preview.heightPx}` : undefined;
3693
- const size = formatImageBytes(preview?.bytes);
3694
- const imageModel = savedImage?.imageModel ? `model ${savedImage.imageModel}` : undefined;
3695
- const meta = [imageModel, type, dimensions, size].filter(Boolean).join(glyphs().dot);
3696
- const label = `${themeFg(theme, "accent", glyphs().bullet)}${themeFg(theme, "text", themeBold(theme, "Image Generation "))}`;
3697
- const pathText = savedImage?.relativePath ?? (typeof messageContent === "string" ? messageContent : "generated image");
3698
- const lines = [`${label}${themeFg(theme, "accent", pathText)}${meta ? themeFg(theme, "dim", `${glyphs().dot}${meta}`) : ""}`];
3699
- if (savedImage?.latestRelativePath) lines.push(`${themeFg(theme, "muted", ` ${treeGlyph("├")}`)}${themeFg(theme, "text", "Latest ")}${themeFg(theme, "accent", savedImage.latestRelativePath)}`);
3700
- if (options?.expanded && savedImage?.revisedPrompt) lines.push(`${themeFg(theme, "muted", ` ${treeGlyph("├")}`)}${themeFg(theme, "text", "Prompt ")}${themeFg(theme, "dim", savedImage.revisedPrompt)}`);
3701
- const inline = shouldRenderInlineImage();
3702
- if (!inline.ok) lines.push(`${themeFg(theme, "muted", ` ${treeGlyph("└")}`)}${themeFg(theme, "warning", inline.reason ?? "inline preview unavailable")}`);
3703
- container.addChild(new Text(lines.join("\n"), 0, 0));
3704
- if (savedImage && preview && inline.ok) {
3705
- container.addChild(new Spacer(1));
3706
- container.addChild(new Image(preview.data, preview.mimeType, { fallbackColor: (text) => themeFg(theme, "dim", text) }, { maxWidthCells: 72, maxHeightCells: options?.expanded ? 24 : 14, filename: savedImage.relativePath }));
3707
- }
3708
- return container;
3709
- }
3710
-
3711
- function createInitialAssistantMessage<TApi extends Api>(model: Model<TApi>): AssistantMessage {
3712
- return {
3713
- role: "assistant",
3714
- content: [],
3715
- api: model.api,
3716
- provider: model.provider,
3717
- model: model.id,
3718
- usage: {
3719
- input: 0,
3720
- output: 0,
3721
- cacheRead: 0,
3722
- cacheWrite: 0,
3723
- totalTokens: 0,
3724
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
3725
- },
3726
- stopReason: "stop",
3727
- timestamp: Date.now(),
3728
- };
3729
- }
3730
-
3731
- function createErrorMessage(message: AssistantMessage, error: unknown, aborted: boolean): AssistantMessage {
3732
- for (const block of message.content) {
3733
- if (typeof block === "object" && block !== null && "partialJson" in block) {
3734
- delete (block as { partialJson?: string }).partialJson;
3735
- }
3736
- }
3737
- message.stopReason = aborted ? "aborted" : "error";
3738
- message.errorMessage = buildProviderErrorMessage(error);
3739
- return message;
3740
- }
3741
-
3742
- export function buildProviderErrorMessage(error: unknown): string {
3743
- const message = error instanceof Error ? error.message : String(error);
3744
- const candidate = error as { code?: unknown; errorType?: unknown; status?: unknown };
3745
- if (
3746
- candidate?.code === "stream_read_error"
3747
- && candidate.errorType === "upstream_error"
3748
- ) {
3749
- // Pi's agent-level retry classifier recognizes "connection error". Surface
3750
- // this upstream SSE read failure in that category instead of
3751
- // retrying inside the provider, so Pi's retry settings and UI remain the
3752
- // single source of truth.
3753
- return `Connection error: ${message}`;
3754
- }
3755
- if (/^(?:WebSocket (?:error|closed)|WebSocket stream closed before response\.completed|Stream closed before response\.completed)/.test(message)) {
3756
- return `Connection error: ${message}`;
3757
- }
3758
- if (typeof candidate?.status === "number") {
3759
- return withHttpStatusPrefix(candidate.status, message);
3760
- }
3761
- return message;
3762
- }
3763
-
3764
- function finalizeUsage<TApi extends Api>(model: Model<TApi>, output: AssistantMessage): void {
3765
- output.usage.cost.total = output.usage.cost.input + output.usage.cost.output + output.usage.cost.cacheRead + output.usage.cost.cacheWrite;
3766
- }
3767
-
3768
- async function parseErrorResponse(response: Response): Promise<{ message: string; friendlyMessage?: string }> {
3769
- const raw = await response.text();
3770
- let message = raw || response.statusText || "Request failed";
3771
- let friendlyMessage: string | undefined;
3772
-
3773
- try {
3774
- const parsed = JSON.parse(raw) as { error?: { code?: string; type?: string; plan_type?: string; resets_at?: number; message?: string } };
3775
- const err = parsed?.error;
3776
- if (err) {
3777
- const code = err.code || err.type || "";
3778
- if (/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || response.status === 429) {
3779
- const plan = err.plan_type ? ` (${err.plan_type.toLowerCase()} plan)` : "";
3780
- const mins = err.resets_at ? Math.max(0, Math.round((err.resets_at * 1000 - Date.now()) / 60000)) : undefined;
3781
- const when = mins !== undefined ? ` Try again in ~${mins} min.` : "";
3782
- friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${when}`.trim();
3783
- }
3784
- message = err.message || friendlyMessage || message;
3785
- }
3786
- } catch {
3787
- // ignore malformed error bodies
3788
- }
3789
-
3790
- return { message, friendlyMessage };
3791
- }
3792
-
3793
- function createCodexStream<TApi extends Api>(
3794
- model: Model<TApi>,
3795
- context: Context,
3796
- options: SimpleStreamOptions | undefined,
3797
- deps: {
3798
- getCurrentCwd: () => string;
3799
- getCurrentTurnId?: (sessionId: string | undefined) => string | undefined;
3800
- getStartupPrewarm?: (sessionId: string, model: Model<Api>) => Promise<void> | undefined;
3801
- onImageSaved?: (savedImage: SavedGeneratedImage, imageData: { data: string; mimeType: string }) => void;
3802
- },
3803
- ): AssistantMessageEventStream {
3804
- const stream = createAssistantMessageEventStream();
3805
- const requestCwd = deps.getCurrentCwd();
3806
-
3807
- (async () => {
3808
- const output = createInitialAssistantMessage(model);
3809
- const requestPrompt = getLatestUserText(context);
3810
- const webSearchCitationSources = collectWebSearchCitationSources(model, context);
3811
- const historicalCitationSources = collectHistoricalCitationSources(model, context);
3812
-
3813
- try {
3814
- const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
3815
- const requestHeaders = options?.headers;
3816
- const auth = { apiKey: apiKey || undefined, headers: requestHeaders };
3817
- if (!hasCodexRequestAuth({ modelHeaders: model.headers, auth })) {
3818
- throw new Error(`No request authentication for provider: ${model.provider}`);
3819
- }
3820
-
3821
- const settings = loadModelSettings(model, requestCwd);
3822
- const requestProfile = resolveCodexRequestProfile(settings.requestProfile);
3823
- if (
3824
- !settings.enabled
3825
- || !settings.modelProfile?.effective.enabled
3826
- || !settings.providerShimActive
3827
- ) {
3828
- throw new Error(`No enabled Codex model profile for ${model.provider}/${model.id}`);
3829
- }
3830
- const apiKeyTransport = settings.apiKeyMode;
3831
- const accountId = resolveCodexRequestAccountId({
3832
- modelHeaders: model.headers,
3833
- auth,
3834
- apiKeyMode: apiKeyTransport,
3835
- });
3836
- const requestIdentity = resolveCodexRequestIdentity(
3837
- options?.sessionId,
3838
- options?.metadata as Record<string, unknown> | undefined,
3839
- "turn",
3840
- );
3841
- let body = applyFastModeServiceTier(
3842
- buildRequestBody(model, context, requestProfile, options),
3843
- settings,
3844
- model,
3845
- );
3846
- if (settings.nativeProviderTools) {
3847
- const webSearch = settings.modelProfile.effective.tools.webSearch;
3848
- body = rewriteNativeOpenAiTools(body, {
3849
- imageModel: settings.imageModel,
3850
- imageGeneration: settings.imageGenerationImplementation ?? false,
3851
- webSearch: settings.webSearchEnabled && webSearch
3852
- ? {
3853
- implementation: webSearch.implementation,
3854
- contentTypes: webSearch.contentTypes,
3855
- }
3856
- : false,
3857
- }).payload;
3858
- }
3859
- const nextBody = await options?.onPayload?.(body, model);
3860
- if (nextBody !== undefined) {
3861
- body = nextBody as ResponsesBody;
3862
- }
3863
- options = withRequestServiceTier(options, body.service_tier);
3864
- ensureWebSearchDetailsIncluded(body);
3865
-
3866
- const websocketSessionId = requestIdentity?.sessionId ?? options?.sessionId;
3867
- const websocketThreadId = requestIdentity?.threadId ?? options?.sessionId;
3868
- const websocketTurnId = requestIdentity?.turnId
3869
- || deps.getCurrentTurnId?.(options?.sessionId)
3870
- || createPiTurnId();
3871
- const websocketRequestId = websocketThreadId
3872
- || websocketSessionId
3873
- || createCodexRequestId();
3874
- const websocketRequestMetadata: WebSocketRequestMetadata = {
3875
- ...(options?.sessionId ? { sessionId: options.sessionId } : {}),
3876
- ...(websocketThreadId ? { threadId: websocketThreadId } : {}),
3877
- turnId: websocketTurnId,
3878
- ...(requestIdentity ? { identity: requestIdentity } : {}),
3879
- };
3880
- let sseHeaders = applyConfiguredResponsesFeatureHeaders(
3881
- buildSSEHeaders(
3882
- model.headers,
3883
- requestHeaders,
3884
- accountId,
3885
- apiKey,
3886
- options?.sessionId,
3887
- requestProfile,
3888
- websocketThreadId,
3889
- requestIdentity,
3890
- ),
3891
- settings,
3892
- model as Model<Api>,
3893
- );
3894
- let websocketHeaders = applyConfiguredResponsesFeatureHeaders(buildWebSocketHeaders(
3895
- model.headers,
3896
- requestHeaders,
3897
- accountId,
3898
- apiKey,
3899
- options?.sessionId ?? websocketRequestId,
3900
- websocketThreadId ?? websocketRequestId,
3901
- requestIdentity,
3902
- ), settings, model as Model<Api>);
3903
- const transformHeaders = (
3904
- options as
3905
- | (SimpleStreamOptions & {
3906
- transformHeaders?: (
3907
- headers: ProviderHeaders,
3908
- ) => ProviderHeaders | Promise<ProviderHeaders>;
3909
- })
3910
- | undefined
3911
- )?.transformHeaders;
3912
- const bodyJson = JSON.stringify(withSseRequestMetadata(body, websocketRequestMetadata));
3913
- const responseHeaderTimeoutMs = responseHeaderTimeoutMsFromOptions(options);
3914
- const configuredTransport: ProviderTransport = settings.openaiTransport;
3915
- // Pi exposes a session transport setting through stream options. Treat
3916
- // explicit non-auto values as overrides; otherwise use the model profile.
3917
- const transport: ProviderTransport = options?.transport && options.transport !== "auto"
3918
- ? options.transport
3919
- : configuredTransport;
3920
-
3921
- const websocketUrl = resolveResponsesWebSocketUrl(model.baseUrl, { apiKeyMode: apiKeyTransport });
3922
- const fallbackKey = webSocketFallbackKey(
3923
- options?.sessionId,
3924
- model as Model<Api>,
3925
- websocketUrl,
3926
- settings.modelProfileHash,
3927
- );
3928
- const sessionFellBackToHttp = transport === "auto"
3929
- && fallbackKey !== undefined
3930
- && websocketHttpFallbackSessions.has(fallbackKey);
3931
-
3932
- if (transport !== "sse" && !sessionFellBackToHttp) {
3933
- if (transformHeaders) {
3934
- websocketHeaders = providerHeadersToHeaders(
3935
- await transformHeaders(
3936
- headersToRecord(websocketHeaders),
3937
- ),
3938
- );
3939
- }
3940
- const startupPrewarmTask = options?.sessionId
3941
- ? deps.getStartupPrewarm?.(options.sessionId, model as Model<Api>)
3942
- : undefined;
3943
- const websocketBody = withResponsesLiteWebSocketMetadata(body, requestProfile.responsesMode);
3944
- let websocketStarted = false;
3945
- let websocketRetries = 0;
3946
- const maxWebSocketRetries = webSocketStreamMaxRetries(options);
3947
- while (true) {
3948
- websocketStarted = false;
3949
- try {
3950
- await processWebSocketStream(
3951
- websocketUrl,
3952
- websocketBody,
3953
- websocketHeaders,
3954
- output,
3955
- stream,
3956
- model,
3957
- () => {
3958
- websocketStarted = true;
3959
- },
3960
- options,
3961
- deps,
3962
- requestCwd,
3963
- requestPrompt,
3964
- webSearchCitationSources,
3965
- historicalCitationSources,
3966
- websocketRequestMetadata,
3967
- settings.modelProfileHash,
3968
- startupPrewarmTask,
3969
- );
3970
- if (options?.signal?.aborted) {
3971
- throw new Error("Request was aborted");
3972
- }
3973
- finalizeUsage(model, output);
3974
- stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
3975
- stream.end();
3976
- return;
3977
- } catch (error) {
3978
- const aborted = options?.signal?.aborted;
3979
- const upgradeRejected = isWebSocketUpgradeRejectedError(error);
3980
- if (
3981
- transport === "auto"
3982
- && !websocketStarted
3983
- && upgradeRejected
3984
- ) {
3985
- if (fallbackKey) websocketHttpFallbackSessions.add(fallbackKey);
3986
- appendAssistantMessageDiagnostic(
3987
- output,
3988
- createAssistantMessageDiagnostic("provider_transport_failure", error, {
3989
- configuredTransport,
3990
- fallbackTransport: "sse",
3991
- eventsEmitted: false,
3992
- phase: "websocket_upgrade_rejected",
3993
- retries: websocketRetries,
3994
- requestBytes: new TextEncoder().encode(bodyJson).byteLength,
3995
- }),
3996
- );
3997
- break;
3998
- }
3999
- const retryableTransport = !aborted
4000
- && (isWebSocketConnectionLimitReachedError(error) || isRetryableWebSocketError(error));
4001
- const retryableBeforeStart = !websocketStarted && retryableTransport;
4002
- if (retryableBeforeStart && websocketRetries < maxWebSocketRetries) {
4003
- websocketRetries++;
4004
- await sleep(webSocketRetryDelayMs(error, websocketRetries, options), options?.signal);
4005
- continue;
4006
- }
4007
- if (aborted || (isProviderNonTransportError(error) && !isWebSocketConnectionLimitReachedError(error))) {
4008
- throw error;
4009
- }
4010
- appendAssistantMessageDiagnostic(
4011
- output,
4012
- createAssistantMessageDiagnostic("provider_transport_failure", error, {
4013
- configuredTransport,
4014
- eventsEmitted: websocketStarted,
4015
- phase: websocketStarted ? "after_message_stream_start" : "before_message_stream_start",
4016
- retries: websocketRetries,
4017
- requestBytes: new TextEncoder().encode(bodyJson).byteLength,
4018
- }),
4019
- );
4020
- throw error;
4021
- }
4022
- }
4023
- }
4024
-
4025
- let response: Response | undefined;
4026
- let lastError: Error | undefined;
4027
- const sseUrl = resolveCodexUrl(model.baseUrl, { apiKeyMode: apiKeyTransport });
4028
- const sseDispatcher = await proxyDispatcherForUrl(sseUrl);
4029
- if (transformHeaders) {
4030
- sseHeaders = providerHeadersToHeaders(
4031
- await transformHeaders(headersToRecord(sseHeaders)),
4032
- );
4033
- }
4034
-
4035
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
4036
- if (options?.signal?.aborted) {
4037
- throw new Error("Request was aborted");
4038
- }
4039
-
4040
- try {
4041
- response = await fetchWithResponseHeaderTimeout(sseUrl, {
4042
- method: "POST",
4043
- headers: sseHeaders,
4044
- body: bodyJson,
4045
- ...(sseDispatcher ? { dispatcher: sseDispatcher } : {}),
4046
- } as RequestInit, options?.signal, responseHeaderTimeoutMs);
4047
-
4048
- await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
4049
-
4050
- if (response.ok) {
4051
- if (options?.sessionId) {
4052
- captureCodexTurnState(
4053
- options.sessionId,
4054
- response.headers.get("x-codex-turn-state") ?? undefined,
4055
- );
4056
- }
4057
- break;
4058
- }
4059
-
4060
- const errorText = await response.text();
4061
- if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
4062
- await sleep(BASE_DELAY_MS * 2 ** attempt, options?.signal);
4063
- continue;
4064
- }
4065
-
4066
- const fakeResponse = new Response(errorText, {
4067
- status: response.status,
4068
- statusText: response.statusText,
4069
- });
4070
- const info = await parseErrorResponse(fakeResponse);
4071
- throw new NonRetryableProviderError(withHttpStatusPrefix(response.status, info.friendlyMessage || info.message));
4072
- } catch (error) {
4073
- if (error instanceof NonRetryableProviderError) {
4074
- throw error;
4075
- }
4076
- if (error instanceof Error && (error.name === "AbortError" || error.message === "Request was aborted")) {
4077
- throw new Error("Request was aborted");
4078
- }
4079
-
4080
- lastError = error instanceof Error ? error : new Error(String(error));
4081
- if (attempt < MAX_RETRIES && !lastError.message.includes("usage limit")) {
4082
- await sleep(BASE_DELAY_MS * 2 ** attempt, options?.signal);
4083
- continue;
4084
- }
4085
- throw lastError;
4086
- }
4087
- }
4088
-
4089
- if (!response?.ok) {
4090
- throw lastError ?? new Error("Failed after retries");
4091
- }
4092
-
4093
- if (!response.body) {
4094
- throw new Error("No response body");
4095
- }
4096
-
4097
- stream.push({ type: "start", partial: output });
4098
- await processCapturedResponsesStream(
4099
- parseSSE(response),
4100
- output,
4101
- stream,
4102
- model,
4103
- options,
4104
- options?.sessionId,
4105
- deps,
4106
- requestCwd,
4107
- requestPrompt,
4108
- webSearchCitationSources,
4109
- historicalCitationSources,
4110
- );
4111
- finalizeUsage(model, output);
4112
-
4113
- if (options?.signal?.aborted) {
4114
- throw new Error("Request was aborted");
4115
- }
4116
-
4117
- stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
4118
- stream.end();
4119
- } catch (error) {
4120
- stream.push({
4121
- type: "error",
4122
- reason: (options?.signal?.aborted ? "aborted" : "error") as "aborted" | "error",
4123
- error: createErrorMessage(output, error, !!options?.signal?.aborted),
4124
- });
4125
- stream.end();
4126
- }
4127
- })();
4128
-
4129
- return stream;
4130
- }
4131
-
4132
- export function registerOpenAIResponsesProviders(
4133
- pi: ExtensionAPI,
4134
- options: { getCurrentCwd: () => string },
4135
- ): OpenAIResponsesProviderController {
4136
- installCodexIdentityLifecycle(pi);
4137
- const pendingActivities: PendingActivity[] = [];
4138
- const imagePreviewCache = new Map<string, CachedImagePreview>();
4139
- const startupPrewarms = new Map<string, StartupPrewarmState>();
4140
- const sessionStartupPrewarmTasks = new Map<string, SessionStartupPrewarmTask>();
4141
- let sessionGeneration = 0;
4142
- let pendingFlushTimer: ReturnType<typeof setTimeout> | undefined;
4143
-
4144
- const abortStartupPrewarms = () => {
4145
- for (const state of startupPrewarms.values()) state.abortController.abort();
4146
- for (const task of sessionStartupPrewarmTasks.values()) task.abortController.abort();
4147
- startupPrewarms.clear();
4148
- sessionStartupPrewarmTasks.clear();
4149
- };
4150
-
4151
- const modelIdentity = (model: Model<Api>): string =>
4152
- `${model.provider}\n${model.api}\n${model.id}\n${model.baseUrl}`;
4153
-
4154
- const scheduleStartupPrewarm = (
4155
- ctx: any,
4156
- generation: number,
4157
- startupSignal: AbortSignal,
4158
- snapshot: StartupPrewarmSnapshot,
4159
- ): Promise<void> => (async () => {
4160
- if (startupSignal.aborted) return;
4161
- const model = ctx.model as Model<Api> | undefined;
4162
- const sessionId = ctx?.sessionManager?.getSessionId?.();
4163
- if (!model || !sessionId || generation !== sessionGeneration) return;
4164
- const settings = loadModelSettings(model, ctx.cwd);
4165
- if (
4166
- !settings.enabled
4167
- || !settings.modelProfile?.effective.enabled
4168
- || !settings.providerShimActive
4169
- || !settings.openaiWebSocketPrewarm
4170
- || settings.openaiTransport === "sse"
4171
- ) {
4172
- return;
4173
- }
4174
-
4175
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
4176
- if (
4177
- startupSignal.aborted
4178
- || generation !== sessionGeneration
4179
- || !auth.ok
4180
- || !hasCodexRequestAuth({
4181
- modelHeaders: model.headers,
4182
- auth: { apiKey: auth.apiKey, headers: auth.headers },
4183
- })
4184
- ) {
4185
- return;
4186
- }
4187
-
4188
- const profile = resolveCodexRequestProfile(settings.requestProfile);
4189
- const requestIdentity = resolveCodexRequestIdentity(
4190
- sessionId,
4191
- undefined,
4192
- "prewarm",
4193
- );
4194
- // Match Codex startup prewarm: snapshot only stable request context.
4195
- // Conversation history and the first user message are sent by the first
4196
- // real request as an incremental continuation from this response.
4197
- let body = applyFastModeServiceTier(
4198
- buildRequestBody(model, {
4199
- systemPrompt: snapshot.systemPrompt,
4200
- messages: [],
4201
- tools: snapshot.tools,
4202
- }, profile, {
4203
- apiKey: auth.apiKey,
4204
- headers: auth.headers,
4205
- sessionId,
4206
- reasoning: snapshot.reasoning ?? (model.reasoning ? "medium" : undefined),
4207
- }),
4208
- settings,
4209
- model,
4210
- );
4211
- if (settings.nativeProviderTools) {
4212
- const webSearch = settings.modelProfile.effective.tools.webSearch;
4213
- body = rewriteNativeOpenAiTools(body, {
4214
- imageModel: settings.imageModel,
4215
- imageGeneration: settings.imageGenerationImplementation ?? false,
4216
- webSearch: settings.webSearchEnabled && webSearch
4217
- ? {
4218
- implementation: webSearch.implementation,
4219
- contentTypes: webSearch.contentTypes,
4220
- }
4221
- : false,
4222
- }).payload;
4223
- }
4224
- ensureWebSearchDetailsIncluded(body);
4225
- body = withResponsesLiteWebSocketMetadata(body, profile.responsesMode);
4226
-
4227
- const websocketUrl = resolveResponsesWebSocketUrl(model.baseUrl, { apiKeyMode: settings.apiKeyMode });
4228
- const fallbackKey = webSocketFallbackKey(
4229
- sessionId,
4230
- model,
4231
- websocketUrl,
4232
- settings.modelProfileHash,
4233
- );
4234
- if (settings.openaiTransport === "auto" && fallbackKey && websocketHttpFallbackSessions.has(fallbackKey)) {
4235
- return;
4236
- }
4237
- if (startupSignal.aborted || generation !== sessionGeneration) return;
4238
- const headers = applyConfiguredResponsesFeatureHeaders(buildWebSocketHeaders(
4239
- model.headers,
4240
- auth.headers,
4241
- resolveCodexRequestAccountId({
4242
- modelHeaders: model.headers,
4243
- auth: { apiKey: auth.apiKey, headers: auth.headers },
4244
- apiKeyMode: settings.apiKeyMode,
4245
- }),
4246
- auth.apiKey ?? "",
4247
- sessionId,
4248
- requestIdentity?.threadId ?? sessionId,
4249
- requestIdentity,
4250
- ), settings, model);
4251
- const cacheKey = webSocketCacheKey(
4252
- sessionId,
4253
- model,
4254
- websocketUrl,
4255
- headers,
4256
- settings.modelProfileHash,
4257
- );
4258
- if (
4259
- !cacheKey
4260
- || startupSignal.aborted
4261
- || generation !== sessionGeneration
4262
- || startupPrewarms.has(cacheKey)
4263
- ) {
4264
- return;
4265
- }
4266
- if (websocketSessionCache.get(cacheKey)?.continuation) return;
4267
-
4268
- const abortController = new AbortController();
4269
- const abortFromSession = () => abortController.abort();
4270
- startupSignal.addEventListener("abort", abortFromSession, { once: true });
4271
- const state: StartupPrewarmState = {
4272
- status: "pending",
4273
- abortController,
4274
- promise: Promise.resolve(),
4275
- };
4276
- startupPrewarms.set(cacheKey, state);
4277
- const timeout = setTimeout(() => abortController.abort(), WEBSOCKET_PREWARM_TIMEOUT_MS);
4278
- state.promise = prewarmWebSocket({
4279
- url: websocketUrl,
4280
- headers,
4281
- cacheKey,
4282
- body,
4283
- requestMetadata: {
4284
- sessionId,
4285
- threadId: requestIdentity?.threadId ?? sessionId,
4286
- turnId: requestIdentity?.turnId ?? "",
4287
- requestKind: "prewarm",
4288
- ...(requestIdentity ? { identity: requestIdentity } : {}),
4289
- },
4290
- signal: abortController.signal,
4291
- connectTimeoutMs: WEBSOCKET_PREWARM_TIMEOUT_MS,
4292
- })
4293
- .then(() => {
4294
- state.status = "ready";
4295
- })
4296
- .catch((error) => {
4297
- state.status = "failed";
4298
- if (
4299
- settings.openaiTransport === "auto"
4300
- && fallbackKey
4301
- && isWebSocketUpgradeRejectedError(error)
4302
- ) {
4303
- websocketHttpFallbackSessions.add(fallbackKey);
4304
- }
4305
- })
4306
- .finally(() => {
4307
- clearTimeout(timeout);
4308
- startupSignal.removeEventListener("abort", abortFromSession);
4309
- });
4310
- await state.promise;
4311
- })();
4312
-
4313
- const flushPendingMessages = () => {
4314
- pendingFlushTimer = undefined;
4315
- const activities = pendingActivities.splice(0, pendingActivities.length);
4316
-
4317
- for (const activity of activities) {
4318
- imagePreviewCache.set(activity.savedImage.absolutePath, makeCachedImagePreview(activity.imageData.data, activity.imageData.mimeType));
4319
- pi.sendMessage(
4320
- {
4321
- customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
4322
- content: [{ type: "text", text: buildGeneratedImageDisplayText(activity.savedImage, { expanded: false }) }],
4323
- display: true,
4324
- details: { savedImages: [activity.savedImage] } satisfies ImageDisplayMessageDetails,
4325
- },
4326
- { triggerTurn: false },
4327
- );
4328
- }
4329
- };
4330
-
4331
- const schedulePendingMessageFlush = () => {
4332
- if (pendingFlushTimer || pendingActivities.length === 0) {
4333
- return;
4334
- }
4335
- pendingFlushTimer = setTimeout(flushPendingMessages, 0);
4336
- };
4337
-
4338
- const clearPendingMessages = () => {
4339
- if (pendingFlushTimer) {
4340
- clearTimeout(pendingFlushTimer);
4341
- pendingFlushTimer = undefined;
4342
- }
4343
- pendingActivities.length = 0;
4344
- imagePreviewCache.clear();
4345
- };
4346
-
4347
- const streamSimple = <TApi extends Api>(model: Model<TApi>, context: Context, streamOptions?: SimpleStreamOptions) => {
4348
- const settings = loadModelSettings(model, options.getCurrentCwd());
4349
- if (
4350
- !settings.enabled
4351
- || !settings.modelProfile?.effective.enabled
4352
- || !settings.providerShimActive
4353
- ) {
4354
- return model.api === "openai-codex-responses"
4355
- ? streamSimpleOpenAICodexResponses(model as Model<"openai-codex-responses">, context, streamOptions)
4356
- : streamSimpleOpenAIResponses(model as Model<"openai-responses">, context, streamOptions);
4357
- }
4358
- return createCodexStream(model, context, streamOptions, {
4359
- getCurrentCwd: options.getCurrentCwd,
4360
- getCurrentTurnId: (sessionId) =>
4361
- currentCodexTurn(sessionId)?.turnId,
4362
- getStartupPrewarm: (sessionId, requestModel) => {
4363
- const task = sessionStartupPrewarmTasks.get(sessionId);
4364
- return task
4365
- && task.generation === sessionGeneration
4366
- && task.modelIdentity === modelIdentity(requestModel)
4367
- ? task.promise
4368
- : undefined;
4369
- },
4370
- onImageSaved: (savedImage, imageData) => {
4371
- pendingActivities.push({ kind: "image", savedImage, imageData });
4372
- },
4373
- });
4374
- };
4375
-
4376
- type CodexResponsesApi = "openai-responses" | "openai-codex-responses";
4377
- const registeredProviderApis = new Map<string, CodexResponsesApi>();
4378
- const registerProviderShim = (provider: string, api: CodexResponsesApi): void => {
4379
- if (!provider || registeredProviderApis.get(provider) === api) return;
4380
- pi.registerProvider(provider, { api, streamSimple });
4381
- registeredProviderApis.set(provider, api);
4382
- };
4383
- const ensureProviderShimForModel = (model: Model<Api> | undefined, cwd?: string): void => {
4384
- if (!model) return;
4385
- const settings = loadModelSettings(model, cwd);
4386
- if (
4387
- !settings.enabled
4388
- || !settings.modelProfile?.effective.enabled
4389
- || !settings.providerShimActive
4390
- ) {
4391
- return;
4392
- }
4393
- if (model.api === "openai-responses" || model.api === "openai-codex-responses") {
4394
- registerProviderShim(model.provider, model.api as CodexResponsesApi);
4395
- }
4396
- };
4397
-
4398
- // Pi 0.75 dispatches extension streams by API type, while newer Pi versions
4399
- // compose them per provider. Register both built-ins first. A user-defined
4400
- // provider is registered only after Pi supplies an actual selected model, so
4401
- // this extension never creates or overwrites its URL, auth, or model list.
4402
- registerProviderShim("openai-codex", "openai-codex-responses");
4403
- registerProviderShim("openai", "openai-responses");
4404
-
4405
- pi.on("session_start", async (_event, ctx) => {
4406
- sessionGeneration++;
4407
- abortStartupPrewarms();
4408
- ensureProviderShimForModel(ctx?.model as Model<Api> | undefined, ctx?.cwd);
4409
- clearPendingMessages();
4410
- const generation = sessionGeneration;
4411
- // Do not block session startup. The first provider request naturally
4412
- // serializes behind this socket operation if it is still pending.
4413
- const model = ctx?.model as Model<Api> | undefined;
4414
- const sessionId = ctx?.sessionManager?.getSessionId?.();
4415
- if (model && sessionId) {
4416
- const abortController = new AbortController();
4417
- const snapshot = startupPrewarmSnapshot(pi, ctx);
4418
- const promise = scheduleStartupPrewarm(ctx, generation, abortController.signal, snapshot);
4419
- sessionStartupPrewarmTasks.set(sessionId, {
4420
- generation,
4421
- modelIdentity: modelIdentity(model),
4422
- promise,
4423
- abortController,
4424
- });
4425
- void promise;
4426
- }
4427
- });
4428
-
4429
- pi.on("model_select", async (_event, ctx) => {
4430
- ensureProviderShimForModel(ctx?.model as Model<Api> | undefined, ctx?.cwd);
4431
- });
4432
-
4433
- pi.on("session_shutdown", async (_event, ctx) => {
4434
- sessionGeneration++;
4435
- abortStartupPrewarms();
4436
- if (pendingActivities.length > 0) {
4437
- flushPendingMessages();
4438
- }
4439
- closeProviderWebSocketSessions(
4440
- ctx?.sessionManager?.getSessionId?.(),
4441
- );
4442
- clearPendingMessages();
4443
- });
4444
-
4445
- pi.on("before_agent_start", async (_event, ctx) => {
4446
- ensureProviderShimForModel(ctx.model as Model<Api> | undefined, ctx.cwd);
4447
- });
4448
-
4449
- pi.on("agent_end", async (_event, ctx) => {
4450
- schedulePendingMessageFlush();
4451
- });
4452
-
4453
- pi.registerMessageRenderer<ImageDisplayMessageDetails>(IMAGE_SAVE_DISPLAY_MESSAGE_TYPE, (message, options, theme) => {
4454
- const savedImage = message.details?.savedImages?.[0];
4455
- const textContent = typeof message.content === "string"
4456
- ? message.content
4457
- : message.content
4458
- .filter((item) => item.type === "text")
4459
- .map((item) => item.text)
4460
- .join("\n");
4461
- return renderImageGenerationMessage(savedImage, textContent, options, theme, imagePreviewCache);
4462
- });
4463
-
4464
- pi.registerMessageRenderer<{ searches?: SurfacedWebSearch[] }>(WEB_SEARCH_ACTIVITY_MESSAGE_TYPE, (message, options, theme) => {
4465
- const searches = message.details?.searches ?? [];
4466
- const container = new Container();
4467
- if (searches.length > 0) {
4468
- searches.forEach((search, index) => {
4469
- const completed = search.completed ?? search.status === "completed";
4470
- const header = completed ? "Searched the web" : "Searching the web";
4471
- const detail = webSearchActivityDetail(search);
4472
- const separator = detail ? (completed ? " for " : " ") : "";
4473
- const bullet = themeFg(theme, completed ? "muted" : "accent", glyphs().bullet);
4474
- const lines = [
4475
- `${bullet}${themeFg(theme, "text", themeBold(theme, header))}${themeFg(theme, "dim", `${separator}${detail}`)}`,
4476
- ];
4477
- const hosts = webSearchActivityHosts(search);
4478
- if (hosts.length > 0) {
4479
- const shown = hosts.slice(0, 8);
4480
- const hostLine = shown.map((host) => themeFg(theme, "accent", host));
4481
- if (hosts.length > shown.length) {
4482
- hostLine.push(themeFg(theme, "dim", `+${hosts.length - shown.length}`));
4483
- }
4484
- lines.push(` ${hostLine.join(themeFg(theme, "dim", glyphs().dot))}`);
4485
- }
4486
- container.addChild(new Text(`${index > 0 ? "\n" : ""}${lines.join("\n")}`, 0, 0));
4487
- });
4488
- } else {
4489
- container.addChild(new Text(themeFg(theme, "text", themeBold(theme, buildWebSearchSummaryText(searches))), 0, 0));
4490
- }
4491
- if (options.expanded) {
4492
- const content = typeof message.content === "string"
4493
- ? message.content
4494
- : message.content
4495
- .filter((item) => item.type === "text")
4496
- .map((item) => item.text)
4497
- .join("\n");
4498
- container.addChild(new Text(`\n${themeFg(theme, "dim", content)}`, 0, 0));
4499
- }
4500
- return container;
4501
- });
4502
-
4503
- return {
4504
- getCurrentTurnId(sessionId) {
4505
- return currentCodexTurn(sessionId)?.turnId;
4506
- },
4507
- getRequestIdentity(sessionId, requestKind = "turn") {
4508
- return resolveCodexRequestIdentity(sessionId, undefined, requestKind);
4509
- },
4510
- };
4511
- }
1
+ // Compatibility facade. Implementations must import their owning modules directly.
2
+ export { buildCodexCompactionCheckpoint, sanitizeNativeCompactionOutput } from "@oai404iao/pi-codex-core/internal/adapter/compaction/checkpoint";
3
+ export { requestOpenAINativeCompaction } from "@oai404iao/pi-codex-core/internal/adapter/compaction/request";
4
+ export { registerOpenAIResponsesProviders } from "./extension/register.js";
5
+ export { buildProviderErrorMessage, withHttpStatusPrefix } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/errors";
6
+ export { buildSSEHeaders, buildWebSocketHeaders } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/headers";
7
+ export { withResponsesLiteWebSocketMetadata } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/lite";
8
+ export { proxyForWebSocketUrl, webSocketOptionsForUrl } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/proxy";
9
+ export { buildRequestBody } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/request-body";
10
+ export { withSseRequestMetadata } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/request-metadata";
11
+ export { fetchWithResponseHeaderTimeout, responseHeaderTimeoutMsFromOptions } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/sse";
12
+ export type { OpenAIResponsesProviderController } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/types";
13
+ export { resolveCodexUrl, resolveResponsesWebSocketUrl } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/urls";
14
+ export { sendWebSocketRequest } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/websocket-events";
15
+ export { closeProviderWebSocketSessions } from "@oai404iao/pi-codex-core/internal/providers/openai-codex/websocket-session";
16
+ export { buildGeneratedImageDisplayText, getOpenAICodexImageDirectory, getOpenAICodexImagePath, getOpenAICodexLatestImagePath, saveOpenAICodexGeneratedImage } from "@oai404iao/pi-codex-imagegen/internal/tools/image-generation/storage";
17
+ export { IMAGE_SAVE_DISPLAY_MESSAGE_TYPE } from "@oai404iao/pi-codex-imagegen/internal/tools/image-generation/types";
18
+ export type { SavedGeneratedImage } from "@oai404iao/pi-codex-imagegen/internal/tools/image-generation/types";
19
+ export { WEB_SEARCH_ACTIVITY_MESSAGE_TYPE, buildWebSearchActivityMessage, buildWebSearchInlineText, buildWebSearchStatusText, buildWebSearchSummaryText, extractWebSearch, extractWebSearchProgress, mergeWebSearchActivity, webSearchActivityDetail, webSearchActivityHosts } from "@oai404iao/pi-codex-web-search/internal/tools/web-search/activity";
20
+ export type { SurfacedWebSearch } from "@oai404iao/pi-codex-web-search/internal/tools/web-search/activity";