pi-openai-codex-compat 0.0.1-alpha.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/CHANGELOG.md +64 -0
- package/LICENSE +20 -0
- package/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/pi-ai-MIT.txt +21 -0
- package/README.md +331 -0
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/extensions/openai-codex-compat/apply-patch-diff-render.ts +436 -0
- package/extensions/openai-codex-compat/apply-patch-engine.ts +1004 -0
- package/extensions/openai-codex-compat/apply-patch-render.ts +133 -0
- package/extensions/openai-codex-compat/apply-patch.ts +142 -0
- package/extensions/openai-codex-compat/codex-protocol.ts +598 -0
- package/extensions/openai-codex-compat/codex-provider.ts +740 -0
- package/extensions/openai-codex-compat/codex-stream.ts +444 -0
- package/extensions/openai-codex-compat/codex-tool-surface.ts +186 -0
- package/extensions/openai-codex-compat/codex-transport.ts +855 -0
- package/extensions/openai-codex-compat/compaction-checkpoint.ts +304 -0
- package/extensions/openai-codex-compat/config.ts +268 -0
- package/extensions/openai-codex-compat/footer.ts +99 -0
- package/extensions/openai-codex-compat/image-generation-render.ts +166 -0
- package/extensions/openai-codex-compat/image-generation.ts +355 -0
- package/extensions/openai-codex-compat/index.ts +65 -0
- package/extensions/openai-codex-compat/model-policy.ts +67 -0
- package/extensions/openai-codex-compat/namespaced-tools.ts +43 -0
- package/extensions/openai-codex-compat/native-history.ts +78 -0
- package/extensions/openai-codex-compat/remote-compaction.ts +198 -0
- package/extensions/openai-codex-compat/request-options.ts +121 -0
- package/extensions/openai-codex-compat/responses-replay.ts +33 -0
- package/extensions/openai-codex-compat/settings-pane.ts +298 -0
- package/extensions/openai-codex-compat/tool-runtime.ts +32 -0
- package/extensions/openai-codex-compat/tools.ts +70 -0
- package/extensions/openai-codex-compat/vendor/pi-ai/README.md +15 -0
- package/extensions/openai-codex-compat/vendor/pi-ai/openai-responses-serialization.ts +660 -0
- package/extensions/openai-codex-compat/web-run-description.txt +105 -0
- package/extensions/openai-codex-compat/web-run-output.ts +172 -0
- package/extensions/openai-codex-compat/web-run-render.ts +681 -0
- package/extensions/openai-codex-compat/web-run-schema.ts +301 -0
- package/extensions/openai-codex-compat/web-run.ts +164 -0
- package/package.json +63 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { calculateCost, type Model, type Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export const REMOTE_COMPACTION_BETA = "remote_compaction_v2";
|
|
4
|
+
export const RETAINED_CONTEXT_BUDGET = 64_000;
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BASE_URL = "https://chatgpt.com/backend-api";
|
|
7
|
+
const REQUEST_RETRIES = 2;
|
|
8
|
+
const UTF8_BYTES_PER_TOKEN = 4;
|
|
9
|
+
|
|
10
|
+
export interface JsonRecord {
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
type?: unknown;
|
|
13
|
+
role?: unknown;
|
|
14
|
+
content?: unknown;
|
|
15
|
+
text?: unknown;
|
|
16
|
+
verbosity?: unknown;
|
|
17
|
+
encrypted_content?: unknown;
|
|
18
|
+
input?: unknown;
|
|
19
|
+
messages?: unknown;
|
|
20
|
+
previous_response_id?: unknown;
|
|
21
|
+
include?: unknown;
|
|
22
|
+
model?: unknown;
|
|
23
|
+
store?: unknown;
|
|
24
|
+
stream?: unknown;
|
|
25
|
+
instructions?: unknown;
|
|
26
|
+
parallel_tool_calls?: unknown;
|
|
27
|
+
tool_choice?: unknown;
|
|
28
|
+
prompt_cache_key?: unknown;
|
|
29
|
+
tools?: unknown;
|
|
30
|
+
service_tier?: unknown;
|
|
31
|
+
chatgpt_account_id?: unknown;
|
|
32
|
+
message?: unknown;
|
|
33
|
+
item?: unknown;
|
|
34
|
+
response?: unknown;
|
|
35
|
+
usage?: unknown;
|
|
36
|
+
input_tokens?: unknown;
|
|
37
|
+
output_tokens?: unknown;
|
|
38
|
+
input_tokens_details?: unknown;
|
|
39
|
+
cached_tokens?: unknown;
|
|
40
|
+
cache_write_tokens?: unknown;
|
|
41
|
+
total_tokens?: unknown;
|
|
42
|
+
v?: unknown;
|
|
43
|
+
id?: unknown;
|
|
44
|
+
phase?: unknown;
|
|
45
|
+
data?: unknown;
|
|
46
|
+
mimeType?: unknown;
|
|
47
|
+
stopReason?: unknown;
|
|
48
|
+
thinkingSignature?: unknown;
|
|
49
|
+
textSignature?: unknown;
|
|
50
|
+
name?: unknown;
|
|
51
|
+
arguments?: unknown;
|
|
52
|
+
toolCallId?: unknown;
|
|
53
|
+
addedToolNames?: unknown;
|
|
54
|
+
kind?: unknown;
|
|
55
|
+
version?: unknown;
|
|
56
|
+
modelId?: unknown;
|
|
57
|
+
history?: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ResponsesItem extends JsonRecord {
|
|
61
|
+
type?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type RemoteCompactionResponse = {
|
|
65
|
+
item: ResponsesItem;
|
|
66
|
+
usage?: Usage;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function isObject(value: unknown): value is JsonRecord {
|
|
70
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isResponsesItem(value: unknown): value is ResponsesItem {
|
|
74
|
+
if (!isObject(value)) return false;
|
|
75
|
+
return (
|
|
76
|
+
typeof value.type === "string" ||
|
|
77
|
+
(typeof value.role === "string" &&
|
|
78
|
+
(typeof value.content === "string" || Array.isArray(value.content)))
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function approximateTokens(text: string): number {
|
|
83
|
+
return Math.ceil(new TextEncoder().encode(text).byteLength / UTF8_BYTES_PER_TOKEN);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function messageTextTokens(item: ResponsesItem): number {
|
|
87
|
+
if (item.type !== undefined && item.type !== "message") return 0;
|
|
88
|
+
if (typeof item.content === "string") return approximateTokens(item.content);
|
|
89
|
+
if (!Array.isArray(item.content)) return 0;
|
|
90
|
+
|
|
91
|
+
let tokens = 0;
|
|
92
|
+
for (const part of item.content) {
|
|
93
|
+
if (!isObject(part) || typeof part.text !== "string") continue;
|
|
94
|
+
if (part.type === "input_text" || part.type === "output_text") {
|
|
95
|
+
tokens += approximateTokens(part.text);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return tokens;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function splitUtf8Bytes(
|
|
102
|
+
text: string,
|
|
103
|
+
beginningBytes: number,
|
|
104
|
+
endBytes: number,
|
|
105
|
+
): { removedCharacters: number; prefix: string; suffix: string } {
|
|
106
|
+
const encodedLength = new TextEncoder().encode(text).byteLength;
|
|
107
|
+
const tailStartTarget = Math.max(0, encodedLength - endBytes);
|
|
108
|
+
let currentByte = 0;
|
|
109
|
+
let prefix = "";
|
|
110
|
+
let suffix = "";
|
|
111
|
+
let removedCharacters = 0;
|
|
112
|
+
let suffixStarted = false;
|
|
113
|
+
|
|
114
|
+
for (const character of text) {
|
|
115
|
+
const width = new TextEncoder().encode(character).byteLength;
|
|
116
|
+
const characterStart = currentByte;
|
|
117
|
+
const characterEnd = currentByte + width;
|
|
118
|
+
currentByte = characterEnd;
|
|
119
|
+
|
|
120
|
+
if (characterEnd <= beginningBytes) {
|
|
121
|
+
prefix += character;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (characterStart >= tailStartTarget) {
|
|
125
|
+
suffixStarted = true;
|
|
126
|
+
suffix += character;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!suffixStarted) removedCharacters += 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const prefixBytes = new TextEncoder().encode(prefix).byteLength;
|
|
133
|
+
const suffixBytes = new TextEncoder().encode(suffix).byteLength;
|
|
134
|
+
if (prefixBytes + suffixBytes > encodedLength) {
|
|
135
|
+
suffix = text.slice(prefix.length);
|
|
136
|
+
removedCharacters = 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { removedCharacters, prefix, suffix };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function truncateMiddleWithTokenBudget(text: string, tokenLimit: number): string {
|
|
143
|
+
const originalBytes = new TextEncoder().encode(text).byteLength;
|
|
144
|
+
const byteLimit = Math.max(0, tokenLimit) * UTF8_BYTES_PER_TOKEN;
|
|
145
|
+
if (originalBytes <= byteLimit) return text;
|
|
146
|
+
|
|
147
|
+
const leftBytes = Math.floor(byteLimit / 2);
|
|
148
|
+
const rightBytes = byteLimit - leftBytes;
|
|
149
|
+
const { prefix, suffix } = splitUtf8Bytes(text, leftBytes, rightBytes);
|
|
150
|
+
const removedTokens = Math.ceil(Math.max(0, originalBytes - byteLimit) / UTF8_BYTES_PER_TOKEN);
|
|
151
|
+
return `${prefix}…${removedTokens} tokens truncated…${suffix}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function shortenMessage(item: ResponsesItem, tokenLimit: number): ResponsesItem | undefined {
|
|
155
|
+
if (tokenLimit <= 0 || (item.type !== undefined && item.type !== "message")) return undefined;
|
|
156
|
+
const result = structuredClone(item);
|
|
157
|
+
|
|
158
|
+
if (typeof result.content === "string") {
|
|
159
|
+
result.content = truncateMiddleWithTokenBudget(result.content, tokenLimit);
|
|
160
|
+
return result.content ? result : undefined;
|
|
161
|
+
}
|
|
162
|
+
if (!Array.isArray(result.content)) return result;
|
|
163
|
+
|
|
164
|
+
let remaining = tokenLimit;
|
|
165
|
+
const content: unknown[] = [];
|
|
166
|
+
for (const part of result.content) {
|
|
167
|
+
if (
|
|
168
|
+
!isObject(part) ||
|
|
169
|
+
typeof part.text !== "string" ||
|
|
170
|
+
(part.type !== "input_text" && part.type !== "output_text")
|
|
171
|
+
) {
|
|
172
|
+
content.push(part);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (remaining === 0) continue;
|
|
177
|
+
const tokens = approximateTokens(part.text);
|
|
178
|
+
if (tokens <= remaining) {
|
|
179
|
+
content.push(part);
|
|
180
|
+
remaining -= tokens;
|
|
181
|
+
} else {
|
|
182
|
+
const text = truncateMiddleWithTokenBudget(part.text, remaining);
|
|
183
|
+
if (text) content.push({ ...part, text });
|
|
184
|
+
remaining = 0;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
result.content = content;
|
|
189
|
+
return content.length > 0 ? result : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function retainedRole(item: ResponsesItem): boolean {
|
|
193
|
+
return (
|
|
194
|
+
(item.type === undefined || item.type === "message") &&
|
|
195
|
+
(item.role === "user" || item.role === "developer" || item.role === "system")
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Reproduce Codex remote-compaction-v2 history installation: select recent
|
|
201
|
+
* user/developer/system messages from newest to oldest, then restore their
|
|
202
|
+
* chronological order. The oldest selected message may be truncated.
|
|
203
|
+
*/
|
|
204
|
+
export function selectRetainedContext(
|
|
205
|
+
history: readonly ResponsesItem[],
|
|
206
|
+
budget = RETAINED_CONTEXT_BUDGET,
|
|
207
|
+
): ResponsesItem[] {
|
|
208
|
+
let remaining = budget;
|
|
209
|
+
const newestFirst: ResponsesItem[] = [];
|
|
210
|
+
|
|
211
|
+
for (let index = history.length - 1; index >= 0 && remaining > 0; index--) {
|
|
212
|
+
const item = history[index]!;
|
|
213
|
+
if (!retainedRole(item)) continue;
|
|
214
|
+
|
|
215
|
+
const tokens = Math.max(1, messageTextTokens(item));
|
|
216
|
+
if (tokens <= remaining) {
|
|
217
|
+
newestFirst.push(structuredClone(item));
|
|
218
|
+
remaining -= tokens;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const partial = shortenMessage(item, remaining);
|
|
223
|
+
if (partial) newestFirst.push(partial);
|
|
224
|
+
remaining = 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return newestFirst.reverse();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function installCompactionItem(
|
|
231
|
+
previousHistory: readonly ResponsesItem[],
|
|
232
|
+
compactionItem: ResponsesItem,
|
|
233
|
+
): ResponsesItem[] {
|
|
234
|
+
if (
|
|
235
|
+
compactionItem.type !== "compaction" ||
|
|
236
|
+
typeof compactionItem.encrypted_content !== "string"
|
|
237
|
+
) {
|
|
238
|
+
throw new Error("Codex returned an invalid remote compaction item.");
|
|
239
|
+
}
|
|
240
|
+
return [...selectRetainedContext(previousHistory), structuredClone(compactionItem)];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function responsesEndpoint(baseUrl?: string): string {
|
|
244
|
+
const base = (baseUrl?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
245
|
+
if (base.endsWith("/codex/responses")) return base;
|
|
246
|
+
if (base.endsWith("/codex")) return `${base}/responses`;
|
|
247
|
+
return `${base}/codex/responses`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function addRemoteCompactionFeature(current: string | null | undefined): string {
|
|
251
|
+
const features = (current ?? "")
|
|
252
|
+
.split(",")
|
|
253
|
+
.map((feature) => feature.trim())
|
|
254
|
+
.filter(Boolean);
|
|
255
|
+
return [...new Set([...features, REMOTE_COMPACTION_BETA])].join(",");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function withoutConversationInput(payload: JsonRecord): JsonRecord {
|
|
259
|
+
const template = structuredClone(payload);
|
|
260
|
+
delete template.input;
|
|
261
|
+
delete template.messages;
|
|
262
|
+
delete template.previous_response_id;
|
|
263
|
+
return template;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Construct the same ordinary Responses request shape used by Codex v2. */
|
|
267
|
+
export function remoteCompactionPayload(options: {
|
|
268
|
+
template?: JsonRecord | undefined;
|
|
269
|
+
modelId: string;
|
|
270
|
+
history: readonly ResponsesItem[];
|
|
271
|
+
instructions: string;
|
|
272
|
+
sessionId: string;
|
|
273
|
+
fallbackTools?: unknown[] | undefined;
|
|
274
|
+
priority: boolean;
|
|
275
|
+
}): JsonRecord {
|
|
276
|
+
const payload = options.template ? structuredClone(options.template) : {};
|
|
277
|
+
const include = Array.isArray(payload.include)
|
|
278
|
+
? payload.include.filter((entry): entry is string => typeof entry === "string")
|
|
279
|
+
: [];
|
|
280
|
+
|
|
281
|
+
payload.model = options.modelId;
|
|
282
|
+
payload.store = false;
|
|
283
|
+
payload.stream = true;
|
|
284
|
+
payload.instructions = options.instructions;
|
|
285
|
+
payload.input = [
|
|
286
|
+
...options.history.map((item) => structuredClone(item)),
|
|
287
|
+
{ type: "compaction_trigger" },
|
|
288
|
+
];
|
|
289
|
+
payload.parallel_tool_calls =
|
|
290
|
+
typeof payload.parallel_tool_calls === "boolean" ? payload.parallel_tool_calls : true;
|
|
291
|
+
payload.tool_choice ??= "auto";
|
|
292
|
+
payload.include = [...new Set([...include, "reasoning.encrypted_content"])];
|
|
293
|
+
payload.prompt_cache_key = options.sessionId;
|
|
294
|
+
payload.text =
|
|
295
|
+
isObject(payload.text) && typeof payload.text.verbosity === "string"
|
|
296
|
+
? { verbosity: payload.text.verbosity }
|
|
297
|
+
: { verbosity: "low" };
|
|
298
|
+
|
|
299
|
+
if (!Array.isArray(payload.tools) && options.fallbackTools) payload.tools = options.fallbackTools;
|
|
300
|
+
if (options.priority) {
|
|
301
|
+
payload.service_tier = "priority";
|
|
302
|
+
} else if (payload.service_tier === "priority") {
|
|
303
|
+
// A cached request template may still contain this extension's old fast-mode value.
|
|
304
|
+
delete payload.service_tier;
|
|
305
|
+
}
|
|
306
|
+
delete payload.messages;
|
|
307
|
+
delete payload.previous_response_id;
|
|
308
|
+
return payload;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function accountIdFromToken(token: string): string {
|
|
312
|
+
try {
|
|
313
|
+
const pieces = token.split(".");
|
|
314
|
+
if (pieces.length !== 3) throw new Error("not a JWT");
|
|
315
|
+
const claims = JSON.parse(Buffer.from(pieces[1]!, "base64url").toString("utf8")) as JsonRecord;
|
|
316
|
+
const openAIClaims = claims["https://api.openai.com/auth"];
|
|
317
|
+
if (!isObject(openAIClaims) || typeof openAIClaims.chatgpt_account_id !== "string") {
|
|
318
|
+
throw new Error("account id missing");
|
|
319
|
+
}
|
|
320
|
+
return openAIClaims.chatgpt_account_id;
|
|
321
|
+
} catch {
|
|
322
|
+
throw new Error("Could not read the ChatGPT account id from OpenAI Codex authentication.");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function remoteCompactionHeaders(options: {
|
|
327
|
+
token: string;
|
|
328
|
+
providerHeaders?: Record<string, string> | undefined;
|
|
329
|
+
sessionId: string;
|
|
330
|
+
}): Headers {
|
|
331
|
+
const headers = new Headers(options.providerHeaders);
|
|
332
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
333
|
+
headers.set("chatgpt-account-id", accountIdFromToken(options.token));
|
|
334
|
+
headers.set("originator", "pi");
|
|
335
|
+
headers.set("user-agent", "pi-openai-codex-compat");
|
|
336
|
+
headers.set("openai-beta", "responses=experimental");
|
|
337
|
+
headers.set("accept", "text/event-stream");
|
|
338
|
+
headers.set("content-type", "application/json");
|
|
339
|
+
headers.set("session-id", options.sessionId);
|
|
340
|
+
headers.set("x-client-request-id", options.sessionId);
|
|
341
|
+
headers.set(
|
|
342
|
+
"x-codex-beta-features",
|
|
343
|
+
addRemoteCompactionFeature(headers.get("x-codex-beta-features")),
|
|
344
|
+
);
|
|
345
|
+
return headers;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
class PermanentRemoteError extends Error {}
|
|
349
|
+
class IncompleteRemoteStream extends Error {}
|
|
350
|
+
|
|
351
|
+
function retryableStatus(status: number): boolean {
|
|
352
|
+
return status === 408 || status === 409 || status === 429 || status >= 500;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function serverRetryDelay(response: Response): number | undefined {
|
|
356
|
+
const milliseconds = Number(response.headers.get("retry-after-ms"));
|
|
357
|
+
if (Number.isFinite(milliseconds) && milliseconds >= 0) return milliseconds;
|
|
358
|
+
|
|
359
|
+
const retryAfter = response.headers.get("retry-after");
|
|
360
|
+
if (!retryAfter) return undefined;
|
|
361
|
+
const seconds = Number(retryAfter);
|
|
362
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
363
|
+
const date = Date.parse(retryAfter);
|
|
364
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function wait(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
|
368
|
+
if (signal?.aborted) throw new Error("Compaction aborted");
|
|
369
|
+
if (milliseconds <= 0) return;
|
|
370
|
+
await new Promise<void>((resolve, reject) => {
|
|
371
|
+
const onAbort = () => {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
reject(new Error("Compaction aborted"));
|
|
374
|
+
};
|
|
375
|
+
const timer = setTimeout(() => {
|
|
376
|
+
signal?.removeEventListener("abort", onAbort);
|
|
377
|
+
resolve();
|
|
378
|
+
}, milliseconds);
|
|
379
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function readCompactionStream(
|
|
384
|
+
response: Response,
|
|
385
|
+
): Promise<{ item: ResponsesItem; usage?: unknown }> {
|
|
386
|
+
if (!response.body)
|
|
387
|
+
throw new IncompleteRemoteStream("Codex returned an empty compaction stream.");
|
|
388
|
+
|
|
389
|
+
const reader = response.body.getReader();
|
|
390
|
+
const decoder = new TextDecoder();
|
|
391
|
+
const compacted: ResponsesItem[] = [];
|
|
392
|
+
let pending = "";
|
|
393
|
+
let finished = false;
|
|
394
|
+
let usage: unknown;
|
|
395
|
+
|
|
396
|
+
const consumeEvent = (block: string) => {
|
|
397
|
+
const encoded = block
|
|
398
|
+
.split("\n")
|
|
399
|
+
.filter((line) => line.startsWith("data:"))
|
|
400
|
+
.map((line) => line.slice(5).trimStart())
|
|
401
|
+
.join("\n")
|
|
402
|
+
.trim();
|
|
403
|
+
if (!encoded || encoded === "[DONE]") return;
|
|
404
|
+
|
|
405
|
+
let event: unknown;
|
|
406
|
+
try {
|
|
407
|
+
event = JSON.parse(encoded);
|
|
408
|
+
} catch {
|
|
409
|
+
throw new PermanentRemoteError("Codex returned malformed compaction stream data.");
|
|
410
|
+
}
|
|
411
|
+
if (!isObject(event)) return;
|
|
412
|
+
|
|
413
|
+
if (event.type === "error") {
|
|
414
|
+
if (typeof event.message === "string" && event.message.trim()) {
|
|
415
|
+
throw new PermanentRemoteError(event.message);
|
|
416
|
+
}
|
|
417
|
+
throw new IncompleteRemoteStream("Codex reported an unspecified compaction error.");
|
|
418
|
+
}
|
|
419
|
+
if (event.type === "response.failed") {
|
|
420
|
+
throw new PermanentRemoteError("Codex remote compaction failed.");
|
|
421
|
+
}
|
|
422
|
+
if (event.type === "response.incomplete") {
|
|
423
|
+
throw new IncompleteRemoteStream("Codex remote compaction was incomplete.");
|
|
424
|
+
}
|
|
425
|
+
if (event.type === "response.output_item.done" && isResponsesItem(event.item)) {
|
|
426
|
+
if (event.item.type === "compaction") compacted.push(event.item);
|
|
427
|
+
}
|
|
428
|
+
if (event.type === "response.completed" || event.type === "response.done") {
|
|
429
|
+
finished = true;
|
|
430
|
+
usage = isObject(event.response) ? event.response.usage : undefined;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
while (true) {
|
|
435
|
+
const chunk = await reader.read();
|
|
436
|
+
pending += decoder.decode(chunk.value, { stream: !chunk.done }).replace(/\r\n/g, "\n");
|
|
437
|
+
let separator = pending.indexOf("\n\n");
|
|
438
|
+
while (separator !== -1) {
|
|
439
|
+
consumeEvent(pending.slice(0, separator));
|
|
440
|
+
pending = pending.slice(separator + 2);
|
|
441
|
+
separator = pending.indexOf("\n\n");
|
|
442
|
+
}
|
|
443
|
+
if (chunk.done) break;
|
|
444
|
+
}
|
|
445
|
+
if (pending.trim()) consumeEvent(pending);
|
|
446
|
+
|
|
447
|
+
if (!finished) throw new IncompleteRemoteStream("Codex stream ended before response.completed.");
|
|
448
|
+
if (compacted.length !== 1) {
|
|
449
|
+
throw new PermanentRemoteError(
|
|
450
|
+
`Codex returned ${compacted.length} compaction items; exactly one is required.`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (typeof compacted[0]!.encrypted_content !== "string") {
|
|
454
|
+
throw new PermanentRemoteError("Codex compaction output did not contain encrypted_content.");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return { item: compacted[0]!, usage };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function responseUsage(
|
|
461
|
+
model: Model<any>,
|
|
462
|
+
value: unknown,
|
|
463
|
+
priority: boolean,
|
|
464
|
+
): Usage | undefined {
|
|
465
|
+
if (!isObject(value)) return undefined;
|
|
466
|
+
const totalInput = typeof value.input_tokens === "number" ? value.input_tokens : 0;
|
|
467
|
+
const output = typeof value.output_tokens === "number" ? value.output_tokens : 0;
|
|
468
|
+
const inputDetails = isObject(value.input_tokens_details)
|
|
469
|
+
? value.input_tokens_details
|
|
470
|
+
: undefined;
|
|
471
|
+
const cacheRead =
|
|
472
|
+
typeof inputDetails?.cached_tokens === "number" ? inputDetails.cached_tokens : 0;
|
|
473
|
+
const cacheWrite =
|
|
474
|
+
typeof inputDetails?.cache_write_tokens === "number" ? inputDetails.cache_write_tokens : 0;
|
|
475
|
+
const usage: Usage = {
|
|
476
|
+
input: Math.max(0, totalInput - cacheRead - cacheWrite),
|
|
477
|
+
output,
|
|
478
|
+
cacheRead,
|
|
479
|
+
cacheWrite,
|
|
480
|
+
totalTokens: typeof value.total_tokens === "number" ? value.total_tokens : totalInput + output,
|
|
481
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
482
|
+
};
|
|
483
|
+
calculateCost(model, usage);
|
|
484
|
+
|
|
485
|
+
if (priority) {
|
|
486
|
+
const multiplier = model.id === "gpt-5.5" ? 2.5 : 2;
|
|
487
|
+
usage.cost.input *= multiplier;
|
|
488
|
+
usage.cost.output *= multiplier;
|
|
489
|
+
usage.cost.cacheRead *= multiplier;
|
|
490
|
+
usage.cost.cacheWrite *= multiplier;
|
|
491
|
+
usage.cost.total =
|
|
492
|
+
usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
|
493
|
+
}
|
|
494
|
+
return usage;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function collectRemoteCompaction(
|
|
498
|
+
events: AsyncIterable<JsonRecord>,
|
|
499
|
+
accountingModel: Model<any>,
|
|
500
|
+
priority: boolean,
|
|
501
|
+
): Promise<RemoteCompactionResponse> {
|
|
502
|
+
const compacted: ResponsesItem[] = [];
|
|
503
|
+
let finished = false;
|
|
504
|
+
let usageValue: unknown;
|
|
505
|
+
|
|
506
|
+
for await (const event of events) {
|
|
507
|
+
if (event.type === "response.output_item.done" && isResponsesItem(event.item)) {
|
|
508
|
+
if (event.item.type === "compaction") compacted.push(structuredClone(event.item));
|
|
509
|
+
}
|
|
510
|
+
if (event.type === "response.completed" || event.type === "response.done") {
|
|
511
|
+
finished = true;
|
|
512
|
+
if (isObject(event.response)) {
|
|
513
|
+
usageValue = event.response.usage;
|
|
514
|
+
if (Array.isArray(event.response["output"])) {
|
|
515
|
+
for (const item of event.response["output"]) {
|
|
516
|
+
if (
|
|
517
|
+
isResponsesItem(item) &&
|
|
518
|
+
item.type === "compaction" &&
|
|
519
|
+
!compacted.some(
|
|
520
|
+
(existing) =>
|
|
521
|
+
(typeof existing.id === "string" &&
|
|
522
|
+
typeof item.id === "string" &&
|
|
523
|
+
existing.id === item.id) ||
|
|
524
|
+
JSON.stringify(existing) === JSON.stringify(item),
|
|
525
|
+
)
|
|
526
|
+
) {
|
|
527
|
+
compacted.push(structuredClone(item));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (!finished) throw new Error("Codex stream ended before response.completed.");
|
|
536
|
+
if (compacted.length !== 1) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`Codex returned ${compacted.length} compaction items; exactly one is required.`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
if (typeof compacted[0]!.encrypted_content !== "string") {
|
|
542
|
+
throw new Error("Codex compaction output did not contain encrypted_content.");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const usage = responseUsage(accountingModel, usageValue, priority);
|
|
546
|
+
return {
|
|
547
|
+
item: compacted[0]!,
|
|
548
|
+
...(usage ? { usage } : {}),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export async function requestRemoteCompaction(options: {
|
|
553
|
+
endpoint: string;
|
|
554
|
+
headers: Headers;
|
|
555
|
+
payload: JsonRecord;
|
|
556
|
+
accountingModel: Model<any>;
|
|
557
|
+
priority: boolean;
|
|
558
|
+
signal?: AbortSignal | undefined;
|
|
559
|
+
fetcher?: typeof fetch | undefined;
|
|
560
|
+
}): Promise<RemoteCompactionResponse> {
|
|
561
|
+
const fetcher = options.fetcher ?? fetch;
|
|
562
|
+
let lastFailure: unknown;
|
|
563
|
+
|
|
564
|
+
for (let attempt = 0; attempt <= REQUEST_RETRIES; attempt++) {
|
|
565
|
+
try {
|
|
566
|
+
const response = await fetcher(options.endpoint, {
|
|
567
|
+
method: "POST",
|
|
568
|
+
headers: options.headers,
|
|
569
|
+
body: JSON.stringify(options.payload),
|
|
570
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
if (!response.ok) {
|
|
574
|
+
const body = await response.text().catch(() => "");
|
|
575
|
+
const message = `Codex remote compaction failed (${response.status}): ${body || response.statusText}`;
|
|
576
|
+
if (!retryableStatus(response.status)) throw new PermanentRemoteError(message);
|
|
577
|
+
if (attempt === REQUEST_RETRIES) throw new Error(message);
|
|
578
|
+
lastFailure = new Error(message);
|
|
579
|
+
await wait(serverRetryDelay(response) ?? 1000 * 2 ** attempt, options.signal);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const result = await readCompactionStream(response);
|
|
584
|
+
const usage = responseUsage(options.accountingModel, result.usage, options.priority);
|
|
585
|
+
return {
|
|
586
|
+
item: result.item,
|
|
587
|
+
...(usage ? { usage } : {}),
|
|
588
|
+
};
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (options.signal?.aborted || error instanceof PermanentRemoteError) throw error;
|
|
591
|
+
lastFailure = error;
|
|
592
|
+
if (attempt === REQUEST_RETRIES) throw error;
|
|
593
|
+
await wait(1000 * 2 ** attempt, options.signal);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
throw lastFailure instanceof Error ? lastFailure : new Error("Codex remote compaction failed.");
|
|
598
|
+
}
|