@luxass/pi-personal 0.0.0 → 0.1.1

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.
@@ -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
+ }