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