pi-codex-compaction 0.1.5 → 0.2.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.
package/src/codex-wire.ts DELETED
@@ -1,513 +0,0 @@
1
- import { createHash } from "node:crypto";
2
-
3
- export const MAX_COMPACTION_REQUEST_BYTES = 16 * 1024 * 1024;
4
- const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
5
- const MAX_ENCRYPTED_CONTENT_CHARS = 2_000_000;
6
- const REQUEST_HEADER_TIMEOUT_MS = 30_000;
7
- const REQUEST_IDLE_TIMEOUT_MS = 120_000;
8
- const REQUEST_TOTAL_TIMEOUT_MS = 300_000;
9
- const MAX_RETRIES = 2;
10
- const RETRY_BASE_DELAY_MS = 200;
11
- const MAX_RETRY_DELAY_MS = 30_000;
12
- const ACCOUNT_ID_CLAIM = "https://api.openai.com/auth";
13
- const TRUSTED_CODEX_HOST = "chatgpt.com";
14
- export const BETA_FEATURE = "remote_compaction_v2";
15
-
16
- export interface CodexCompactionRequest {
17
- model: {
18
- id: string;
19
- baseUrl: string;
20
- headers?: Record<string, string | null>;
21
- };
22
- apiKey: string;
23
- authHeaders?: Record<string, string | null>;
24
- sessionId?: string;
25
- body: Record<string, unknown>;
26
- signal?: AbortSignal;
27
- }
28
-
29
- export interface CodexCompactionUsage {
30
- inputTokens?: number;
31
- outputTokens?: number;
32
- totalTokens?: number;
33
- cachedInputTokens?: number;
34
- cacheWriteInputTokens?: number;
35
- reasoningTokens?: number;
36
- }
37
-
38
- export interface CodexCompactionResult {
39
- encryptedContent: string;
40
- usage?: CodexCompactionUsage;
41
- serviceTier?: "default" | "priority" | "flex";
42
- }
43
-
44
- /**
45
- * Keep the original string-returning API for callers that only need the
46
- * checkpoint. The extension uses requestRemoteCompactionWithUsage below.
47
- */
48
- export async function requestRemoteCompaction(request: CodexCompactionRequest): Promise<string> {
49
- return (await requestRemoteCompactionWithUsage(request)).encryptedContent;
50
- }
51
-
52
- export async function requestRemoteCompactionWithUsage(
53
- request: CodexCompactionRequest,
54
- ): Promise<CodexCompactionResult> {
55
- request.signal?.throwIfAborted();
56
- const endpoint = resolveCodexResponsesUrl(request.model.baseUrl);
57
- const accountId = extractAccountId(request.apiKey);
58
- const headers = buildHeaders(request, accountId);
59
- const body = JSON.stringify(request.body);
60
- if (Buffer.byteLength(body, "utf8") > MAX_COMPACTION_REQUEST_BYTES) {
61
- throw new Error("Codex compaction request exceeded the size limit");
62
- }
63
-
64
- const requestState = requestSignal(request.signal);
65
- try {
66
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
67
- requestState.signal.throwIfAborted();
68
- requestState.startAttempt();
69
-
70
- try {
71
- const response = await fetch(endpoint, {
72
- method: "POST",
73
- headers,
74
- body,
75
- redirect: "error",
76
- signal: requestState.signal,
77
- });
78
- requestState.headersReceived();
79
-
80
- if (!response.ok) {
81
- await response.body?.cancel().catch(() => undefined);
82
- const error = new CodexCompactionError(
83
- `Codex compaction request returned HTTP ${response.status}`,
84
- isRetryableStatus(response.status),
85
- );
86
- if (!error.retryable || attempt >= MAX_RETRIES) throw error;
87
- await waitForRetry(response, attempt, requestState.signal);
88
- continue;
89
- }
90
-
91
- try {
92
- return await readCompactionSse(response, requestState.signal, requestState.touch);
93
- } catch (error) {
94
- if (!isRetryableError(error) || attempt >= MAX_RETRIES || requestState.signal.aborted) {
95
- throw error;
96
- }
97
- await waitForRetry(undefined, attempt, requestState.signal);
98
- }
99
- } catch (error) {
100
- if (requestState.signal.aborted || !isRetryableError(error) || attempt >= MAX_RETRIES) {
101
- throw error;
102
- }
103
- await waitForRetry(undefined, attempt, requestState.signal);
104
- }
105
- }
106
- } finally {
107
- requestState.cleanup();
108
- }
109
-
110
- throw new Error("Codex compaction request failed");
111
- }
112
-
113
- export function resolveCodexResponsesUrl(baseUrl: string): string {
114
- const url = new URL(baseUrl);
115
- if (url.protocol !== "https:") {
116
- throw new Error("Codex compaction requires an HTTPS endpoint");
117
- }
118
- if (url.hostname.toLowerCase() !== TRUSTED_CODEX_HOST) {
119
- throw new Error("Codex compaction endpoint is not a trusted Codex origin");
120
- }
121
- if (url.username || url.password) {
122
- throw new Error("Codex compaction endpoint must not contain URL credentials");
123
- }
124
- if (url.port || url.search || url.hash) {
125
- throw new Error("Codex compaction endpoint must use the default port without query or fragment");
126
- }
127
-
128
- const path = url.pathname.replace(/\/+$/, "");
129
- if (!["/backend-api", "/backend-api/codex", "/backend-api/codex/responses"].includes(path)) {
130
- throw new Error("Codex compaction endpoint must use the Codex Responses path");
131
- }
132
- if (path.endsWith("/codex/responses")) {
133
- return url.toString();
134
- }
135
- url.pathname = path.endsWith("/codex") ? `${path}/responses` : `${path}/codex/responses`;
136
- return url.toString();
137
- }
138
-
139
- export function parseCompactionSse(sse: string): string {
140
- return parseCompactionSseResult(sse).encryptedContent;
141
- }
142
-
143
- export function parseCompactionSseResult(sse: string): CodexCompactionResult {
144
- if (new TextEncoder().encode(sse).byteLength > MAX_RESPONSE_BYTES) {
145
- throw new Error("Codex compaction response exceeded the size limit");
146
- }
147
- const parser = new CompactionSseParser();
148
- parser.push(sse);
149
- return parser.finish();
150
- }
151
-
152
- function buildHeaders(request: CodexCompactionRequest, accountId: string): Headers {
153
- const headers = new Headers();
154
- for (const [name, value] of Object.entries(request.model.headers ?? {})) {
155
- if (value !== null) headers.set(name, value);
156
- }
157
- for (const [name, value] of Object.entries(request.authHeaders ?? {})) {
158
- if (value !== null) headers.set(name, value);
159
- else headers.delete(name);
160
- }
161
-
162
- headers.set("Authorization", `Bearer ${request.apiKey}`);
163
- headers.set("chatgpt-account-id", accountId);
164
- headers.set("originator", "pi");
165
- headers.set("OpenAI-Beta", "responses=experimental");
166
- const features = headers.get("x-codex-beta-features")?.split(",").map((part) => part.trim()).filter(Boolean) ?? [];
167
- headers.set("x-codex-beta-features", [...new Set([...features, BETA_FEATURE])].join(","));
168
- headers.set("accept", "text/event-stream");
169
- headers.set("content-type", "application/json");
170
-
171
- const sessionId = safeSessionId(request.sessionId);
172
- if (sessionId) {
173
- headers.set("session-id", sessionId);
174
- headers.set("x-client-request-id", sessionId);
175
- }
176
- return headers;
177
- }
178
-
179
- export function getCodexAccountFingerprint(token: string): string {
180
- return `sha256:${createHash("sha256").update(extractAccountId(token)).digest("hex")}`;
181
- }
182
-
183
- function extractAccountId(token: string): string {
184
- const segment = token.split(".")[1];
185
- if (!segment) throw new Error("Codex authentication token has no account claim");
186
-
187
- try {
188
- const payload = JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as unknown;
189
- const claim = isRecord(payload) ? payload[ACCOUNT_ID_CLAIM] : undefined;
190
- const accountId = isRecord(claim) ? claim.chatgpt_account_id : undefined;
191
- if (typeof accountId !== "string" || !/^[A-Za-z0-9._-]{1,256}$/.test(accountId)) {
192
- throw new Error("missing account claim");
193
- }
194
- return accountId;
195
- } catch {
196
- throw new Error("Codex authentication token has no valid account claim");
197
- }
198
- }
199
-
200
- function safeSessionId(value: string | undefined): string | undefined {
201
- if (!value || !/^[A-Za-z0-9_-]{1,64}$/.test(value)) return undefined;
202
- return value;
203
- }
204
-
205
- async function readCompactionSse(
206
- response: Response,
207
- signal: AbortSignal,
208
- touch: () => void,
209
- ): Promise<CodexCompactionResult> {
210
- if (!response.body) {
211
- throw new CodexCompactionError("Codex compaction response has no body", true);
212
- }
213
-
214
- const reader = response.body.getReader();
215
- const decoder = new TextDecoder();
216
- const parser = new CompactionSseParser();
217
- let bytes = 0;
218
- try {
219
- while (true) {
220
- const { done, value } = await reader.read();
221
- if (done) break;
222
- touch();
223
- bytes += value.byteLength;
224
- if (bytes > MAX_RESPONSE_BYTES) {
225
- throw new CodexCompactionError("Codex compaction response exceeded the size limit", false);
226
- }
227
- parser.push(decoder.decode(value, { stream: true }));
228
- if (parser.isComplete) return parser.finish();
229
- }
230
- parser.push(decoder.decode());
231
- return parser.finish();
232
- } catch (error) {
233
- if (error instanceof CodexCompactionError) throw error;
234
- if (signal.aborted) throw error;
235
- throw new CodexCompactionError("Codex compaction response stream failed", true);
236
- } finally {
237
- await reader.cancel().catch(() => undefined);
238
- reader.releaseLock();
239
- }
240
- }
241
-
242
- class CompactionSseParser {
243
- private buffer = "";
244
- private completed = false;
245
- private status: string | undefined;
246
- private compactionItems: Array<string | undefined> = [];
247
- private usage: CodexCompactionUsage | undefined;
248
- private serviceTier: CodexCompactionResult["serviceTier"];
249
-
250
- get isComplete(): boolean {
251
- return this.completed;
252
- }
253
-
254
- push(chunk: string): void {
255
- this.buffer += chunk;
256
- this.buffer = this.buffer.replace(/\r\n/g, "\n");
257
- while (true) {
258
- const separator = this.buffer.indexOf("\n\n");
259
- if (separator < 0) break;
260
- const frame = this.buffer.slice(0, separator);
261
- this.buffer = this.buffer.slice(separator + 2);
262
- this.processFrame(frame);
263
- }
264
- }
265
-
266
- finish(): CodexCompactionResult {
267
- if (this.buffer.trim()) this.processFrame(this.buffer);
268
- if (!this.completed) {
269
- throw new CodexCompactionError("Codex compaction stream ended before response completion", true);
270
- }
271
- if (this.status && this.status !== "completed") {
272
- throw new CodexCompactionError("Codex compaction response failed", false);
273
- }
274
- if (this.compactionItems.length !== 1) {
275
- throw new CodexCompactionError(
276
- `Codex compaction returned ${this.compactionItems.length} checkpoint items`,
277
- false,
278
- );
279
- }
280
-
281
- const encryptedContent = this.compactionItems[0];
282
- if (!encryptedContent) {
283
- throw new CodexCompactionError("Codex compaction returned an invalid checkpoint", false);
284
- }
285
- return {
286
- encryptedContent,
287
- ...(this.usage ? { usage: this.usage } : {}),
288
- ...(this.serviceTier ? { serviceTier: this.serviceTier } : {}),
289
- };
290
- }
291
-
292
- private processFrame(frame: string): void {
293
- const data = frame
294
- .split("\n")
295
- .filter((line) => line.startsWith("data:"))
296
- .map((line) => line.slice(5).trim())
297
- .join("\n")
298
- .trim();
299
- if (!data || data === "[DONE]") return;
300
-
301
- let event: unknown;
302
- try {
303
- event = JSON.parse(data) as unknown;
304
- } catch {
305
- throw new CodexCompactionError("Codex compaction returned invalid SSE data", false);
306
- }
307
- if (!isRecord(event)) return;
308
-
309
- if (event.type === "error" || event.type === "response.failed") {
310
- throw new CodexCompactionError("Codex compaction response failed", false);
311
- }
312
-
313
- if (event.type === "response.output_item.done") {
314
- this.collectCompactionItem(event.item);
315
- }
316
-
317
- if (event.type === "response.completed" || event.type === "response.done") {
318
- const response = isRecord(event.response) ? event.response : undefined;
319
- this.status = typeof response?.status === "string" ? response.status : undefined;
320
- this.usage = parseUsage(response?.usage ?? event.usage);
321
- const tier = response?.service_tier;
322
- this.serviceTier = tier === "default" || tier === "priority" || tier === "flex" ? tier : undefined;
323
-
324
- // Codex emits output_item.done before response.completed. Retain this
325
- // fallback for compact Responses implementations that only return the
326
- // completed response's output array.
327
- if (this.compactionItems.length === 0) {
328
- collectOutputItems(response?.output, (item) => this.collectCompactionItem(item));
329
- }
330
- this.completed = true;
331
- }
332
- }
333
-
334
- private collectCompactionItem(value: unknown): void {
335
- if (!isRecord(value) || value.type !== "compaction") return;
336
- const encryptedContent = value.encrypted_content;
337
- this.compactionItems.push(
338
- typeof encryptedContent === "string" &&
339
- encryptedContent.length > 0 &&
340
- encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
341
- ? encryptedContent
342
- : undefined,
343
- );
344
- }
345
- }
346
-
347
- function collectOutputItems(value: unknown, collect: (item: unknown) => void): void {
348
- if (!Array.isArray(value)) return;
349
- for (const item of value) collect(item);
350
- }
351
-
352
- function parseUsage(value: unknown): CodexCompactionUsage | undefined {
353
- if (!isRecord(value)) return undefined;
354
- const inputTokens = finiteNonNegativeNumber(value.input_tokens);
355
- const outputTokens = finiteNonNegativeNumber(value.output_tokens);
356
- const totalTokens = finiteNonNegativeNumber(value.total_tokens);
357
- const inputDetails = isRecord(value.input_tokens_details) ? value.input_tokens_details : undefined;
358
- const outputDetails = isRecord(value.output_tokens_details) ? value.output_tokens_details : undefined;
359
- const cachedInputTokens = finiteNonNegativeNumber(inputDetails?.cached_tokens);
360
- const cacheWriteInputTokens = finiteNonNegativeNumber(inputDetails?.cache_write_tokens);
361
- const reasoningTokens = finiteNonNegativeNumber(outputDetails?.reasoning_tokens);
362
- if (
363
- inputTokens === undefined &&
364
- outputTokens === undefined &&
365
- totalTokens === undefined &&
366
- cachedInputTokens === undefined &&
367
- cacheWriteInputTokens === undefined &&
368
- reasoningTokens === undefined
369
- ) {
370
- return undefined;
371
- }
372
- return {
373
- ...(inputTokens !== undefined ? { inputTokens } : {}),
374
- ...(outputTokens !== undefined ? { outputTokens } : {}),
375
- ...(totalTokens !== undefined ? { totalTokens } : {}),
376
- ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
377
- ...(cacheWriteInputTokens !== undefined ? { cacheWriteInputTokens } : {}),
378
- ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
379
- };
380
- }
381
-
382
- function finiteNonNegativeNumber(value: unknown): number | undefined {
383
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
384
- }
385
-
386
- function isRetryableStatus(status: number): boolean {
387
- return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
388
- }
389
-
390
- async function waitForRetry(
391
- response: Response | undefined,
392
- attempt: number,
393
- signal: AbortSignal,
394
- ): Promise<void> {
395
- const retryAfter = response ? parseRetryAfter(response.headers) : undefined;
396
- const delay = Math.min(
397
- MAX_RETRY_DELAY_MS,
398
- retryAfter ?? RETRY_BASE_DELAY_MS * 2 ** attempt,
399
- );
400
- await new Promise<void>((resolve, reject) => {
401
- let settled = false;
402
- const onAbort = () => {
403
- if (settled) return;
404
- settled = true;
405
- clearTimeout(timer);
406
- signal.removeEventListener("abort", onAbort);
407
- reject(signal.reason ?? new Error("Codex compaction request aborted"));
408
- };
409
- const timer = setTimeout(() => {
410
- if (settled) return;
411
- settled = true;
412
- signal.removeEventListener("abort", onAbort);
413
- resolve();
414
- }, delay);
415
- signal.addEventListener("abort", onAbort, { once: true });
416
- if (signal.aborted) onAbort();
417
- });
418
- }
419
-
420
- function parseRetryAfter(headers: Headers): number | undefined {
421
- const milliseconds = headers.get("retry-after-ms");
422
- if (milliseconds) {
423
- const value = Number(milliseconds);
424
- if (Number.isFinite(value) && value >= 0) return value;
425
- }
426
- const seconds = headers.get("retry-after");
427
- if (!seconds) return undefined;
428
- const numeric = Number(seconds);
429
- if (Number.isFinite(numeric) && numeric >= 0) return numeric * 1_000;
430
- const date = Date.parse(seconds);
431
- return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
432
- }
433
-
434
- class CodexCompactionError extends Error {
435
- constructor(
436
- message: string,
437
- readonly retryable: boolean,
438
- ) {
439
- super(message);
440
- this.name = "CodexCompactionError";
441
- }
442
- }
443
-
444
- function isRetryableError(error: unknown): boolean {
445
- return error instanceof CodexCompactionError
446
- ? error.retryable
447
- : error instanceof Error;
448
- }
449
-
450
- interface RequestSignal {
451
- signal: AbortSignal;
452
- startAttempt: () => void;
453
- headersReceived: () => void;
454
- touch: () => void;
455
- cleanup: () => void;
456
- }
457
-
458
- function requestSignal(signal: AbortSignal | undefined): RequestSignal {
459
- const controller = new AbortController();
460
- const totalTimeout = setTimeout(
461
- () => controller.abort(new Error("Codex compaction request exceeded the total time limit")),
462
- REQUEST_TOTAL_TIMEOUT_MS,
463
- );
464
- let headerTimeout: ReturnType<typeof setTimeout> | undefined;
465
- let idleTimeout: ReturnType<typeof setTimeout> | undefined;
466
-
467
- const onAbort = () => controller.abort(signal?.reason);
468
- if (signal?.aborted) {
469
- controller.abort(signal.reason);
470
- } else {
471
- signal?.addEventListener("abort", onAbort, { once: true });
472
- }
473
-
474
- const clearTimers = () => {
475
- if (headerTimeout) clearTimeout(headerTimeout);
476
- if (idleTimeout) clearTimeout(idleTimeout);
477
- headerTimeout = undefined;
478
- idleTimeout = undefined;
479
- };
480
- const touch = () => {
481
- if (idleTimeout) clearTimeout(idleTimeout);
482
- idleTimeout = setTimeout(
483
- () => controller.abort(new Error("Codex compaction response idle timeout")),
484
- REQUEST_IDLE_TIMEOUT_MS,
485
- );
486
- };
487
-
488
- return {
489
- signal: controller.signal,
490
- startAttempt: () => {
491
- clearTimers();
492
- headerTimeout = setTimeout(
493
- () => controller.abort(new Error("Codex compaction request timed out waiting for response headers")),
494
- REQUEST_HEADER_TIMEOUT_MS,
495
- );
496
- },
497
- headersReceived: () => {
498
- if (headerTimeout) clearTimeout(headerTimeout);
499
- headerTimeout = undefined;
500
- touch();
501
- },
502
- touch,
503
- cleanup: () => {
504
- clearTimeout(totalTimeout);
505
- clearTimers();
506
- signal?.removeEventListener("abort", onAbort);
507
- },
508
- };
509
- }
510
-
511
- function isRecord(value: unknown): value is Record<string, unknown> {
512
- return typeof value === "object" && value !== null && !Array.isArray(value);
513
- }