@luxass/pi-personal 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +14 -2
- package/package.json +45 -3
- package/src/continue-after-compaction.ts +59 -0
- package/src/crow/auto-compact.ts +318 -0
- package/src/crow/codex-stream.ts +742 -0
- package/src/crow/index.ts +826 -0
- package/src/crow/lib.ts +744 -0
- package/src/crow/retry.ts +38 -0
- package/src/custom-header.ts +220 -0
- package/src/git-interceptor.ts +39 -0
- package/src/package-manager-interceptor.ts +24 -0
- package/src/worker-configuration-guard.ts +191 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load a patched openai-codex-responses implementation for CLIProxyAPI.
|
|
3
|
+
*
|
|
4
|
+
* Differences from stock pi-ai:
|
|
5
|
+
* - extractAccountId never throws; plain API keys are allowed
|
|
6
|
+
* - chatgpt-account-id header is omitted when account id is unavailable
|
|
7
|
+
* - provider id(s) are added to CODEX_TOOL_CALL_PROVIDERS for tool-call id handling
|
|
8
|
+
* - model/message api id uses cliproxyapi-codex-responses
|
|
9
|
+
*
|
|
10
|
+
* The patched module is derived at runtime from the installed
|
|
11
|
+
* @earendil-works/pi-ai openai-codex-responses implementation so we track
|
|
12
|
+
* upstream protocol fixes without vendoring 1200+ lines.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
realpathSync,
|
|
21
|
+
statSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { createRequire, isBuiltin } from "node:module";
|
|
25
|
+
import { homedir, tmpdir } from "node:os";
|
|
26
|
+
import { dirname, join, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
Api,
|
|
31
|
+
AssistantMessageEventStream,
|
|
32
|
+
Context,
|
|
33
|
+
Model,
|
|
34
|
+
SimpleStreamOptions,
|
|
35
|
+
} from "@earendil-works/pi-ai";
|
|
36
|
+
|
|
37
|
+
export const CLIPROXYAPI_CODEX_API = "cliproxyapi-codex-responses" as const;
|
|
38
|
+
|
|
39
|
+
export type CliproxyCodexStreamSimple = (
|
|
40
|
+
model: Model<Api>,
|
|
41
|
+
context: Context,
|
|
42
|
+
options?: SimpleStreamOptions,
|
|
43
|
+
) => AssistantMessageEventStream;
|
|
44
|
+
|
|
45
|
+
export type CloseCodexWebSocketSessions = (sessionId?: string) => void;
|
|
46
|
+
|
|
47
|
+
export type CliproxyCodexStreams = {
|
|
48
|
+
streamSimple: CliproxyCodexStreamSimple;
|
|
49
|
+
stream: CliproxyCodexStreamSimple;
|
|
50
|
+
rawStreamSimple: CliproxyCodexStreamSimple;
|
|
51
|
+
rawStream: CliproxyCodexStreamSimple;
|
|
52
|
+
api: typeof CLIPROXYAPI_CODEX_API;
|
|
53
|
+
closeOpenAICodexWebSocketSessions: CloseCodexWebSocketSessions;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
interface TranscriptMessage {
|
|
57
|
+
role?: string;
|
|
58
|
+
content?: unknown;
|
|
59
|
+
sections?: Record<string, string | null | undefined>;
|
|
60
|
+
toolsAdded?: unknown[];
|
|
61
|
+
toolsRemoved?: Array<{ name: string }>;
|
|
62
|
+
[key: string]: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extractContentText(content: unknown): string {
|
|
66
|
+
if (typeof content === "string") return content.trim();
|
|
67
|
+
if (Array.isArray(content)) {
|
|
68
|
+
const blocks: readonly unknown[] = content;
|
|
69
|
+
const text: string[] = [];
|
|
70
|
+
for (const block of blocks) {
|
|
71
|
+
if (
|
|
72
|
+
typeof block !== "object" ||
|
|
73
|
+
block === null ||
|
|
74
|
+
!("type" in block) ||
|
|
75
|
+
block.type !== "text" ||
|
|
76
|
+
!("text" in block) ||
|
|
77
|
+
typeof block.text !== "string"
|
|
78
|
+
)
|
|
79
|
+
continue;
|
|
80
|
+
const value = block.text.trim();
|
|
81
|
+
if (value) text.push(value);
|
|
82
|
+
}
|
|
83
|
+
return text.join("\n");
|
|
84
|
+
}
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Adapt a pi >=0.86.0 TranscriptContext into a classic Context with top-level systemPrompt and tools.
|
|
90
|
+
*
|
|
91
|
+
* In pi >=0.86.0, custom provider stream handlers receive a normalized TranscriptContext where
|
|
92
|
+
* system prompt sections and tool declarations are encoded into system messages inside context.messages.
|
|
93
|
+
* Classic openai-codex-responses expects context.systemPrompt and context.tools, and fails when
|
|
94
|
+
* encountering system messages inside context.messages during token estimation or payload generation.
|
|
95
|
+
*/
|
|
96
|
+
export function adaptTranscriptContext(
|
|
97
|
+
context: Context | { messages?: unknown[]; [key: string]: unknown },
|
|
98
|
+
): Context {
|
|
99
|
+
if (!context || !Array.isArray(context.messages)) {
|
|
100
|
+
return context as Context;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rawMessages = context.messages as TranscriptMessage[];
|
|
104
|
+
const hasSystemMessage = rawMessages.some((m) => m && m.role === "system");
|
|
105
|
+
|
|
106
|
+
if (!hasSystemMessage) {
|
|
107
|
+
return context as Context;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let leadingText = "";
|
|
111
|
+
const sections = new Map<string, string>();
|
|
112
|
+
const toolsMap = new Map<string, unknown>();
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(context.tools)) {
|
|
115
|
+
const initialTools: readonly unknown[] = context.tools;
|
|
116
|
+
for (const tool of initialTools) {
|
|
117
|
+
if (tool && typeof tool === "object" && "name" in tool && typeof tool.name === "string") {
|
|
118
|
+
toolsMap.set(tool.name, tool);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const nonSystemMessages: unknown[] = [];
|
|
124
|
+
|
|
125
|
+
for (const msg of rawMessages) {
|
|
126
|
+
if (!msg || typeof msg !== "object") continue;
|
|
127
|
+
|
|
128
|
+
if (msg.role === "system") {
|
|
129
|
+
const text = extractContentText(msg.content);
|
|
130
|
+
if (text) {
|
|
131
|
+
leadingText = leadingText ? `${leadingText}\n\n${text}` : text;
|
|
132
|
+
}
|
|
133
|
+
if (msg.sections && typeof msg.sections === "object") {
|
|
134
|
+
for (const [name, sectionText] of Object.entries(msg.sections)) {
|
|
135
|
+
if (sectionText === null || sectionText === undefined) {
|
|
136
|
+
sections.delete(name);
|
|
137
|
+
} else if (typeof sectionText === "string" && sectionText.trim()) {
|
|
138
|
+
sections.set(name, sectionText.trim());
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(msg.toolsRemoved)) {
|
|
143
|
+
for (const tool of msg.toolsRemoved) {
|
|
144
|
+
if (tool && typeof tool.name === "string") {
|
|
145
|
+
toolsMap.delete(tool.name);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(msg.toolsAdded)) {
|
|
150
|
+
for (const tool of msg.toolsAdded) {
|
|
151
|
+
if (tool && typeof tool === "object" && "name" in tool && typeof tool.name === "string") {
|
|
152
|
+
toolsMap.set(tool.name, tool);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
nonSystemMessages.push(msg);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sectionParts = [...sections.values()];
|
|
162
|
+
const extractedSystemPrompt = [leadingText, ...sectionParts].filter(Boolean).join("\n\n");
|
|
163
|
+
const finalSystemPrompt =
|
|
164
|
+
extractedSystemPrompt ||
|
|
165
|
+
(typeof context.systemPrompt === "string" ? context.systemPrompt : undefined);
|
|
166
|
+
const finalTools =
|
|
167
|
+
toolsMap.size > 0
|
|
168
|
+
? [...toolsMap.values()]
|
|
169
|
+
: Array.isArray(context.tools) && context.tools.length > 0
|
|
170
|
+
? context.tools
|
|
171
|
+
: undefined;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
...(context as Record<string, unknown>),
|
|
175
|
+
systemPrompt: finalSystemPrompt,
|
|
176
|
+
tools: finalTools as Context["tools"],
|
|
177
|
+
messages: nonSystemMessages as Context["messages"],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Ensure a classic context with top-level systemPrompt/tools is represented as a TranscriptContext.
|
|
183
|
+
* If the context already contains a system message in context.messages, it is returned untouched.
|
|
184
|
+
*/
|
|
185
|
+
export function ensureTranscriptContext(
|
|
186
|
+
context: Context | { messages?: unknown[]; [key: string]: unknown },
|
|
187
|
+
): Context {
|
|
188
|
+
if (!context || !Array.isArray(context.messages)) return context as Context;
|
|
189
|
+
const hasSystemMessage = (context.messages as Array<{ role?: string }>).some(
|
|
190
|
+
(m) => m && m.role === "system",
|
|
191
|
+
);
|
|
192
|
+
if (hasSystemMessage) return context as Context;
|
|
193
|
+
|
|
194
|
+
const hasSystemPrompt =
|
|
195
|
+
typeof context.systemPrompt === "string" && context.systemPrompt.length > 0;
|
|
196
|
+
const hasTools = Array.isArray(context.tools) && context.tools.length > 0;
|
|
197
|
+
if (!hasSystemPrompt && !hasTools) return context as Context;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
...(context as Record<string, unknown>),
|
|
201
|
+
messages: [
|
|
202
|
+
{
|
|
203
|
+
role: "system",
|
|
204
|
+
content: context.systemPrompt ?? "",
|
|
205
|
+
...(hasTools ? { toolsAdded: context.tools } : {}),
|
|
206
|
+
timestamp: 0,
|
|
207
|
+
},
|
|
208
|
+
...context.messages,
|
|
209
|
+
] as Context["messages"],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function supportsTranscriptSource(source: string): boolean {
|
|
214
|
+
return source.includes("resolveTranscriptTools") || source.includes("resolveTranscript");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function adaptContextForModule(
|
|
218
|
+
context: Context | { messages?: unknown[]; [key: string]: unknown },
|
|
219
|
+
supportsTranscript: boolean,
|
|
220
|
+
): Context {
|
|
221
|
+
return supportsTranscript ? ensureTranscriptContext(context) : adaptTranscriptContext(context);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const EXTRACT_ACCOUNT_ID_PATCH = `function extractAccountId(token) {
|
|
225
|
+
// CLIProxyAPI accepts plain API keys as well as ChatGPT JWTs.
|
|
226
|
+
// Never throw: missing account id simply means no chatgpt-account-id header.
|
|
227
|
+
try {
|
|
228
|
+
const parts = token.split(".");
|
|
229
|
+
if (parts.length !== 3)
|
|
230
|
+
return "";
|
|
231
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
232
|
+
const accountId = payload?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
|
|
233
|
+
return typeof accountId === "string" && accountId.trim() ? accountId : "";
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return "";
|
|
237
|
+
}
|
|
238
|
+
}`;
|
|
239
|
+
|
|
240
|
+
const IMPORT_FROM_SPECIFIER = /from\s+(["'])([^"']+)\1/g;
|
|
241
|
+
|
|
242
|
+
function stripSourceMappingUrl(source: string): string {
|
|
243
|
+
return source.replace(/^\/\/# sourceMappingURL=.*$/gm, "");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const CODEX_MODULE_RELATIVE = join(
|
|
247
|
+
"node_modules",
|
|
248
|
+
"@earendil-works",
|
|
249
|
+
"pi-ai",
|
|
250
|
+
"dist",
|
|
251
|
+
"api",
|
|
252
|
+
"openai-codex-responses.js",
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Well-known filesystem locations for openai-codex-responses.js on bundled hosts.
|
|
257
|
+
*
|
|
258
|
+
* @param homeDirectory - User home used as the root for `.omp` and `.pi` plugin trees
|
|
259
|
+
* @returns Candidate absolute paths in probe order
|
|
260
|
+
*/
|
|
261
|
+
export function wellKnownCodexModuleCandidates(homeDirectory: string): string[] {
|
|
262
|
+
return [
|
|
263
|
+
join(homeDirectory, ".omp", "plugins", CODEX_MODULE_RELATIVE),
|
|
264
|
+
join(
|
|
265
|
+
homeDirectory,
|
|
266
|
+
".omp",
|
|
267
|
+
"plugins",
|
|
268
|
+
"node_modules",
|
|
269
|
+
"@earendil-works",
|
|
270
|
+
"pi-coding-agent",
|
|
271
|
+
CODEX_MODULE_RELATIVE,
|
|
272
|
+
),
|
|
273
|
+
join(homeDirectory, ".pi", "agent", "npm", CODEX_MODULE_RELATIVE),
|
|
274
|
+
join(
|
|
275
|
+
homeDirectory,
|
|
276
|
+
".pi",
|
|
277
|
+
"agent",
|
|
278
|
+
"npm",
|
|
279
|
+
"node_modules",
|
|
280
|
+
"@earendil-works",
|
|
281
|
+
"pi-coding-agent",
|
|
282
|
+
CODEX_MODULE_RELATIVE,
|
|
283
|
+
),
|
|
284
|
+
join(homeDirectory, ".bun", "install", "global", CODEX_MODULE_RELATIVE),
|
|
285
|
+
join(
|
|
286
|
+
homeDirectory,
|
|
287
|
+
".bun",
|
|
288
|
+
"install",
|
|
289
|
+
"global",
|
|
290
|
+
"node_modules",
|
|
291
|
+
"@earendil-works",
|
|
292
|
+
"pi-coding-agent",
|
|
293
|
+
CODEX_MODULE_RELATIVE,
|
|
294
|
+
),
|
|
295
|
+
join(homeDirectory, ".npm-global", CODEX_MODULE_RELATIVE),
|
|
296
|
+
join(homeDirectory, ".npm-global", "lib", CODEX_MODULE_RELATIVE),
|
|
297
|
+
join(
|
|
298
|
+
homeDirectory,
|
|
299
|
+
".npm-global",
|
|
300
|
+
"lib",
|
|
301
|
+
"node_modules",
|
|
302
|
+
"@earendil-works",
|
|
303
|
+
"pi-coding-agent",
|
|
304
|
+
CODEX_MODULE_RELATIVE,
|
|
305
|
+
),
|
|
306
|
+
join(homeDirectory, "Library", "pnpm", "global", "5", CODEX_MODULE_RELATIVE),
|
|
307
|
+
join(
|
|
308
|
+
homeDirectory,
|
|
309
|
+
"Library",
|
|
310
|
+
"pnpm",
|
|
311
|
+
"global",
|
|
312
|
+
"5",
|
|
313
|
+
"node_modules",
|
|
314
|
+
"@earendil-works",
|
|
315
|
+
"pi-coding-agent",
|
|
316
|
+
CODEX_MODULE_RELATIVE,
|
|
317
|
+
),
|
|
318
|
+
join(homeDirectory, ".local", "share", "pnpm", "global", "5", CODEX_MODULE_RELATIVE),
|
|
319
|
+
join(
|
|
320
|
+
homeDirectory,
|
|
321
|
+
".local",
|
|
322
|
+
"share",
|
|
323
|
+
"pnpm",
|
|
324
|
+
"global",
|
|
325
|
+
"5",
|
|
326
|
+
"node_modules",
|
|
327
|
+
"@earendil-works",
|
|
328
|
+
"pi-coding-agent",
|
|
329
|
+
CODEX_MODULE_RELATIVE,
|
|
330
|
+
),
|
|
331
|
+
join("/usr", "local", "lib", CODEX_MODULE_RELATIVE),
|
|
332
|
+
join(
|
|
333
|
+
"/usr",
|
|
334
|
+
"local",
|
|
335
|
+
"lib",
|
|
336
|
+
"node_modules",
|
|
337
|
+
"@earendil-works",
|
|
338
|
+
"pi-coding-agent",
|
|
339
|
+
CODEX_MODULE_RELATIVE,
|
|
340
|
+
),
|
|
341
|
+
join("/opt", "homebrew", "lib", CODEX_MODULE_RELATIVE),
|
|
342
|
+
join(
|
|
343
|
+
"/opt",
|
|
344
|
+
"homebrew",
|
|
345
|
+
"lib",
|
|
346
|
+
"node_modules",
|
|
347
|
+
"@earendil-works",
|
|
348
|
+
"pi-coding-agent",
|
|
349
|
+
CODEX_MODULE_RELATIVE,
|
|
350
|
+
),
|
|
351
|
+
];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Rewrite one module so relative and bare imports become absolute file URLs
|
|
356
|
+
* resolved from `originalPath`'s package graph.
|
|
357
|
+
*
|
|
358
|
+
* @param source - Module source containing `from "..."` specifiers
|
|
359
|
+
* @param originalPath - Physical file used as the resolve base
|
|
360
|
+
* @returns Source with resolvable file URL imports
|
|
361
|
+
*/
|
|
362
|
+
export function rewritePatchedModuleImports(source: string, originalPath: string): string {
|
|
363
|
+
const requireFromOriginal = createRequire(pathToFileURL(originalPath));
|
|
364
|
+
const originalDir = dirname(originalPath);
|
|
365
|
+
return stripSourceMappingUrl(
|
|
366
|
+
source.replace(IMPORT_FROM_SPECIFIER, (full, _quote: string, specifier: string) => {
|
|
367
|
+
if (specifier.startsWith("node:") || isBuiltin(specifier)) {
|
|
368
|
+
return full;
|
|
369
|
+
}
|
|
370
|
+
if (specifier.startsWith(".")) {
|
|
371
|
+
return `from ${JSON.stringify(pathToFileURL(join(originalDir, specifier)).href)}`;
|
|
372
|
+
}
|
|
373
|
+
const resolved = requireFromOriginal.resolve(specifier);
|
|
374
|
+
return `from ${JSON.stringify(pathToFileURL(resolved).href)}`;
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Write a patched module so a cache file outside the original tree
|
|
381
|
+
* can still resolve bare and relative dependencies.
|
|
382
|
+
*
|
|
383
|
+
* @param outputPath - Destination `.mjs` path, typically under tmpdir
|
|
384
|
+
* @param source - Patched entry source
|
|
385
|
+
* @param originalPath - Physical openai-codex-responses.js used for resolution
|
|
386
|
+
*/
|
|
387
|
+
export function writePatchedModuleCache(
|
|
388
|
+
outputPath: string,
|
|
389
|
+
source: string,
|
|
390
|
+
originalPath: string,
|
|
391
|
+
): void {
|
|
392
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
393
|
+
const rewritten = rewritePatchedModuleImports(source, originalPath);
|
|
394
|
+
writeFileSync(outputPath, rewritten, "utf8");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function patchWebSocketTransport(source: string): string {
|
|
398
|
+
const sessionIdExpression = String.raw`(?:options\?\.sessionId|cacheSessionId)`;
|
|
399
|
+
const retryVariables = /let retriedWebSocketConnectionLimit\s*=\s*false;/;
|
|
400
|
+
const connectionLimitRetry =
|
|
401
|
+
/if \(!aborted && connectionLimitBeforeStart && !retriedWebSocketConnectionLimit\) \{\s*retriedWebSocketConnectionLimit = true;\s*continue;\s*\}/;
|
|
402
|
+
const websocketFailureHandling = new RegExp(
|
|
403
|
+
String.raw`if \(aborted \|\| \(isCodexNonTransportError\(error\) && !connectionLimitBeforeStart\)\) \{[\s\S]*?recordWebSocketFailure\((${sessionIdExpression}), error\);[\s\S]*?recordWebSocketSseFallback\(\1\);\s*break;`,
|
|
404
|
+
);
|
|
405
|
+
const idleTtlDefinition = "const SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1000;";
|
|
406
|
+
const transportDefinition = 'const transport = options?.transport || "auto";';
|
|
407
|
+
|
|
408
|
+
if (!source.includes(idleTtlDefinition)) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
"openai-codex-responses source no longer defines SESSION_WEBSOCKET_CACHE_TTL_MS",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (!source.includes(transportDefinition)) {
|
|
414
|
+
throw new Error("openai-codex-responses source no longer defines transport");
|
|
415
|
+
}
|
|
416
|
+
for (const pattern of [retryVariables, connectionLimitRetry, websocketFailureHandling]) {
|
|
417
|
+
if (!pattern.test(source)) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
"openai-codex-responses source no longer supports the WebSocket transport patch",
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return source
|
|
425
|
+
.replace(
|
|
426
|
+
transportDefinition,
|
|
427
|
+
'const transport = (process.env.CLIPROXYAPI_TRANSPORT || options?.transport || "auto");\n options = options ? { ...options, transport } : { transport };',
|
|
428
|
+
)
|
|
429
|
+
.replace(
|
|
430
|
+
retryVariables,
|
|
431
|
+
`let websocketRetries = 0;
|
|
432
|
+
const maxWebSocketRetries = Number.isFinite(options?.maxRetries)
|
|
433
|
+
? Math.min(Math.max(0, Math.floor(options.maxRetries)), 5)
|
|
434
|
+
: 3;`,
|
|
435
|
+
)
|
|
436
|
+
.replace(connectionLimitRetry, "")
|
|
437
|
+
.replace(
|
|
438
|
+
websocketFailureHandling,
|
|
439
|
+
(
|
|
440
|
+
_match,
|
|
441
|
+
activeSessionId: string,
|
|
442
|
+
) => `if (aborted || (isCodexNonTransportError(error) && !connectionLimitBeforeStart)) {
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
if (!websocketStarted && websocketRetries < maxWebSocketRetries) {
|
|
446
|
+
websocketRetries++;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
appendAssistantMessageDiagnostic(output, createAssistantMessageDiagnostic("provider_transport_failure", error, {
|
|
450
|
+
configuredTransport: transport,
|
|
451
|
+
fallbackTransport: websocketStarted ? undefined : "sse",
|
|
452
|
+
eventsEmitted: websocketStarted,
|
|
453
|
+
phase: websocketStarted ? "after_message_stream_start" : "before_message_stream_start",
|
|
454
|
+
requestBytes: new TextEncoder().encode(bodyJson).byteLength,
|
|
455
|
+
}));
|
|
456
|
+
recordWebSocketFailure(${activeSessionId}, error);
|
|
457
|
+
if (websocketStarted) {
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
recordWebSocketSseFallback(${activeSessionId});
|
|
461
|
+
break;`,
|
|
462
|
+
)
|
|
463
|
+
.replace(
|
|
464
|
+
idleTtlDefinition,
|
|
465
|
+
'const SESSION_WEBSOCKET_CACHE_TTL_MS = (() => { const raw = Number.parseInt(process.env.CLIPROXYAPI_WS_IDLE_TTL_MS || "", 10); return Number.isFinite(raw) && raw > 0 && raw <= 2147483647 ? raw : 30 * 60 * 1000; })();',
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function patchCodexSource(source: string, providerIds: string[]): string {
|
|
470
|
+
let src = source;
|
|
471
|
+
|
|
472
|
+
if (!/function extractAccountId\(token\) \{/.test(src)) {
|
|
473
|
+
throw new Error("openai-codex-responses source no longer contains extractAccountId(token)");
|
|
474
|
+
}
|
|
475
|
+
src = src.replace(/function extractAccountId\(token\) \{[\s\S]*?\n\}/, EXTRACT_ACCOUNT_ID_PATCH);
|
|
476
|
+
|
|
477
|
+
if (!src.includes(`headers.set("chatgpt-account-id", accountId);`)) {
|
|
478
|
+
throw new Error("openai-codex-responses source no longer sets chatgpt-account-id");
|
|
479
|
+
}
|
|
480
|
+
src = src.replace(
|
|
481
|
+
`headers.set("chatgpt-account-id", accountId);`,
|
|
482
|
+
`if (accountId) {\n headers.set("chatgpt-account-id", accountId);\n }`,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const providersMatch = src.match(/const CODEX_TOOL_CALL_PROVIDERS = new Set\(\[([^\]]*)\]\);/);
|
|
486
|
+
if (!providersMatch) {
|
|
487
|
+
throw new Error("openai-codex-responses source no longer defines CODEX_TOOL_CALL_PROVIDERS");
|
|
488
|
+
}
|
|
489
|
+
const existing = providersMatch[1];
|
|
490
|
+
const extras = providerIds
|
|
491
|
+
.filter((id) => id.trim())
|
|
492
|
+
.map((id) => JSON.stringify(id.trim()))
|
|
493
|
+
.join(", ");
|
|
494
|
+
src = src.replace(
|
|
495
|
+
/const CODEX_TOOL_CALL_PROVIDERS = new Set\(\[([^\]]*)\]\);/,
|
|
496
|
+
`const CODEX_TOOL_CALL_PROVIDERS = new Set([${existing}${extras ? `, ${extras}` : ""}]);`,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
// Keep assistant message api metadata aligned with the registered custom api id.
|
|
500
|
+
src = src.replaceAll(
|
|
501
|
+
`api: "openai-codex-responses"`,
|
|
502
|
+
`api: ${JSON.stringify(CLIPROXYAPI_CODEX_API)}`,
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// CLIProxyAPI prefers a persistent WebSocket transport. Reconnect before the
|
|
506
|
+
// response starts, and gracefully fall back to SSE if retries are exhausted.
|
|
507
|
+
src = patchWebSocketTransport(src);
|
|
508
|
+
|
|
509
|
+
// The generated module lives outside the original source map directory.
|
|
510
|
+
src = src.replace(/^\/\/# sourceMappingURL=.*$/gm, "");
|
|
511
|
+
|
|
512
|
+
return src;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function resolveCodexModuleFromNodeEntry(entryPath: string): string | undefined {
|
|
516
|
+
const visited = new Set<string>();
|
|
517
|
+
const queue = [entryPath];
|
|
518
|
+
|
|
519
|
+
while (queue.length > 0) {
|
|
520
|
+
const current = queue.shift();
|
|
521
|
+
if (!current || visited.has(current)) continue;
|
|
522
|
+
visited.add(current);
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
if (!existsSync(current)) continue;
|
|
526
|
+
const real = realpathSync(current);
|
|
527
|
+
const require = createRequire(pathToFileURL(real));
|
|
528
|
+
for (const nodeModulesDir of require.resolve.paths("@earendil-works/pi-ai") ?? []) {
|
|
529
|
+
const candidate = join(
|
|
530
|
+
nodeModulesDir,
|
|
531
|
+
"@earendil-works",
|
|
532
|
+
"pi-ai",
|
|
533
|
+
"dist",
|
|
534
|
+
"api",
|
|
535
|
+
"openai-codex-responses.js",
|
|
536
|
+
);
|
|
537
|
+
if (existsSync(candidate)) {
|
|
538
|
+
return candidate;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// If current is a small text script (wrapper / trampoline), inspect it for referenced entry paths or imports
|
|
543
|
+
if (statSync(real).size < 8192) {
|
|
544
|
+
const content = readFileSync(real, "utf8");
|
|
545
|
+
// Look for path-like string literals (e.g. "/foo/bar", "./cli.js", "../dist/cli.js")
|
|
546
|
+
const pathMatches = content.matchAll(
|
|
547
|
+
/["'`]((\/|[A-Za-z]:[\\/]|\.{1,2}[\\/])[^"'`\r\n]+)["'`]/g,
|
|
548
|
+
);
|
|
549
|
+
for (const match of pathMatches) {
|
|
550
|
+
const ref = match[1]?.trim();
|
|
551
|
+
if (ref) {
|
|
552
|
+
queue.push(resolve(dirname(real), ref));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const homeJoinMatch = content.match(
|
|
556
|
+
/join\s*\(\s*(?:homedir\(\)|process\.env\.HOME)[^)]*["']([^"']+)["']\s*\)/,
|
|
557
|
+
);
|
|
558
|
+
const homeJoinPath = homeJoinMatch?.[1];
|
|
559
|
+
if (homeJoinPath) {
|
|
560
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
561
|
+
queue.push(join(home, homeJoinPath));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
// Ignore invalid or unavailable runtime entrypoints.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function computePatchedModuleCachePath(
|
|
572
|
+
originalPath: string,
|
|
573
|
+
patchedSource: string,
|
|
574
|
+
cacheDir: string = join(tmpdir(), "pi-cliproxyapi-provider"),
|
|
575
|
+
): string {
|
|
576
|
+
const hash = createHash("sha1")
|
|
577
|
+
.update("resolved-imports-v2\n")
|
|
578
|
+
.update(originalPath)
|
|
579
|
+
.update("\n")
|
|
580
|
+
.update(patchedSource)
|
|
581
|
+
.digest("hex")
|
|
582
|
+
.slice(0, 16);
|
|
583
|
+
return join(cacheDir, `openai-codex-responses-cpa-${hash}.mjs`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export interface ResolveOriginalCodexModulePathOptions {
|
|
587
|
+
/** Override `import.meta.resolve`. Throw to simulate a bundled host with no package graph. */
|
|
588
|
+
resolveSpecifier?: (specifier: string) => string;
|
|
589
|
+
/** Override `process.argv[1]`. Pass a non-Node host path to skip createRequire success. */
|
|
590
|
+
nodeEntry?: string | undefined;
|
|
591
|
+
/** Override `HOME` / `USERPROFILE` for well-known filesystem fallbacks. */
|
|
592
|
+
homeDirectory?: string | undefined;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Locate the physical openai-codex-responses.js used as the patch source.
|
|
597
|
+
*
|
|
598
|
+
* Probe order: host nodeEntry, import.meta.resolve, then HOME/well-known paths.
|
|
599
|
+
*
|
|
600
|
+
* @param options - Optional resolve/argv/home overrides for tests and bundled hosts
|
|
601
|
+
* @returns Existing module path and its directory
|
|
602
|
+
* @throws If no candidate exists on disk
|
|
603
|
+
*/
|
|
604
|
+
export function resolveOriginalCodexModulePath(
|
|
605
|
+
options: ResolveOriginalCodexModulePathOptions = {},
|
|
606
|
+
): {
|
|
607
|
+
path: string;
|
|
608
|
+
dir: string;
|
|
609
|
+
} {
|
|
610
|
+
// Under pi's extension loader, `@earendil-works/pi-ai` may resolve to dist/compat.js
|
|
611
|
+
// and package subpath resolve for `/api/*` can fail. Prefer locating the physical
|
|
612
|
+
// dist file next to the resolved package entry.
|
|
613
|
+
const resolveSpecifier =
|
|
614
|
+
options.resolveSpecifier ?? ((specifier: string) => import.meta.resolve(specifier));
|
|
615
|
+
const nodeEntry = "nodeEntry" in options ? options.nodeEntry : process.argv[1];
|
|
616
|
+
const homeDirectory =
|
|
617
|
+
"homeDirectory" in options
|
|
618
|
+
? options.homeDirectory
|
|
619
|
+
: process.env.HOME || process.env.USERPROFILE;
|
|
620
|
+
const candidates: string[] = [];
|
|
621
|
+
|
|
622
|
+
// pi bundled Node CLI exposes pi-ai as a virtual module to
|
|
623
|
+
// extensions. Resolve its physical nested dependency from the CLI entry first so
|
|
624
|
+
// the extension shares the exact same module instances (e.g. session resources) with the host.
|
|
625
|
+
// Oh My Pi Mach-O hosts do not have a Node entry at argv[1]; skip quietly.
|
|
626
|
+
if (nodeEntry) {
|
|
627
|
+
const bundledHostModule = resolveCodexModuleFromNodeEntry(nodeEntry);
|
|
628
|
+
if (bundledHostModule && existsSync(bundledHostModule)) {
|
|
629
|
+
return { path: bundledHostModule, dir: dirname(bundledHostModule) };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Also check well-known CLI binaries if nodeEntry wasn't explicitly provided in options
|
|
634
|
+
if (!("nodeEntry" in options) && homeDirectory) {
|
|
635
|
+
const wellKnownEntries = [
|
|
636
|
+
join(homeDirectory, ".bun", "bin", "pi"),
|
|
637
|
+
join(homeDirectory, ".local", "bin", "pi"),
|
|
638
|
+
join(homeDirectory, ".npm-global", "bin", "pi"),
|
|
639
|
+
"/usr/local/bin/pi",
|
|
640
|
+
"/opt/homebrew/bin/pi",
|
|
641
|
+
];
|
|
642
|
+
for (const entry of wellKnownEntries) {
|
|
643
|
+
if (entry !== nodeEntry && existsSync(entry)) {
|
|
644
|
+
const bundledHostModule = resolveCodexModuleFromNodeEntry(entry);
|
|
645
|
+
if (bundledHostModule && existsSync(bundledHostModule)) {
|
|
646
|
+
return { path: bundledHostModule, dir: dirname(bundledHostModule) };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
const subpath = resolveSpecifier("@earendil-works/pi-ai/api/openai-codex-responses");
|
|
654
|
+
candidates.push(fileURLToPath(subpath));
|
|
655
|
+
} catch {
|
|
656
|
+
// ignore and try filesystem candidates
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
const main = fileURLToPath(resolveSpecifier("@earendil-works/pi-ai"));
|
|
661
|
+
const distDir = dirname(main);
|
|
662
|
+
candidates.push(join(distDir, "api/openai-codex-responses.js"));
|
|
663
|
+
candidates.push(join(distDir, "openai-codex-responses.js"));
|
|
664
|
+
} catch {
|
|
665
|
+
// ignore
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (homeDirectory) {
|
|
669
|
+
candidates.push(...wellKnownCodexModuleCandidates(homeDirectory));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
for (const path of candidates) {
|
|
673
|
+
if (path && existsSync(path)) {
|
|
674
|
+
return { path, dir: dirname(path) };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
throw new Error(
|
|
679
|
+
`Cannot resolve openai-codex-responses.js (tried: ${candidates.join(", ") || "none"})`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export async function loadCliproxyCodexStreams(
|
|
684
|
+
providerIds: string[] = ["cliproxyapi"],
|
|
685
|
+
): Promise<CliproxyCodexStreams> {
|
|
686
|
+
const { path: originalPath } = resolveOriginalCodexModulePath();
|
|
687
|
+
const originalSource = readFileSync(originalPath, "utf8");
|
|
688
|
+
const patched = patchCodexSource(originalSource, providerIds);
|
|
689
|
+
|
|
690
|
+
const outPath = computePatchedModuleCachePath(originalPath, patched);
|
|
691
|
+
const cacheDir = dirname(outPath);
|
|
692
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
693
|
+
if (!existsSync(outPath)) {
|
|
694
|
+
writePatchedModuleCache(outPath, patched, originalPath);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const mod = (await import(pathToFileURL(outPath).href)) as {
|
|
698
|
+
streamSimple: CliproxyCodexStreamSimple;
|
|
699
|
+
stream: CliproxyCodexStreamSimple;
|
|
700
|
+
closeOpenAICodexWebSocketSessions?: CloseCodexWebSocketSessions;
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
if (typeof mod.streamSimple !== "function" || typeof mod.stream !== "function") {
|
|
704
|
+
throw new Error("patched openai-codex-responses module missing streamSimple/stream exports");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Must come from the patched module: its WebSocket cache is a separate Map
|
|
708
|
+
// from the stock @earendil-works/pi-ai openai-codex-responses instance.
|
|
709
|
+
const closeOpenAICodexWebSocketSessions =
|
|
710
|
+
typeof mod.closeOpenAICodexWebSocketSessions === "function"
|
|
711
|
+
? mod.closeOpenAICodexWebSocketSessions
|
|
712
|
+
: () => {
|
|
713
|
+
process.stderr.write(
|
|
714
|
+
`${JSON.stringify({
|
|
715
|
+
source: "pi-cliproxyapi-provider",
|
|
716
|
+
level: "warning",
|
|
717
|
+
message:
|
|
718
|
+
"Patched openai-codex-responses module is missing closeOpenAICodexWebSocketSessions export",
|
|
719
|
+
})}\n`,
|
|
720
|
+
);
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const supportsTranscript = supportsTranscriptSource(originalSource);
|
|
724
|
+
const adaptContext = (context: Context): Context =>
|
|
725
|
+
adaptContextForModule(context, supportsTranscript);
|
|
726
|
+
|
|
727
|
+
const adaptedStreamSimple: CliproxyCodexStreamSimple = (model, context, streamOptions) => {
|
|
728
|
+
return mod.streamSimple(model, adaptContext(context), streamOptions);
|
|
729
|
+
};
|
|
730
|
+
const adaptedStream: CliproxyCodexStreamSimple = (model, context, streamOptions) => {
|
|
731
|
+
return mod.stream(model, adaptContext(context), streamOptions);
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
api: CLIPROXYAPI_CODEX_API,
|
|
736
|
+
streamSimple: adaptedStreamSimple,
|
|
737
|
+
stream: adaptedStream,
|
|
738
|
+
rawStreamSimple: adaptedStreamSimple,
|
|
739
|
+
rawStream: adaptedStream,
|
|
740
|
+
closeOpenAICodexWebSocketSessions,
|
|
741
|
+
};
|
|
742
|
+
}
|