@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,826 @@
1
+ /**
2
+ * CLIProxyAPI dynamic model provider for pi.
3
+ *
4
+ * Supports login-style setup via `/login`:
5
+ * 1. Provider is registered as OAuth-only so `/login Crow` / `/login crow`
6
+ * skip the API-key vs account selector and go straight to multi-field prompts
7
+ * (pi only supports multi-field prompts on the account/OAuth path).
8
+ * 2. Preferred shortcuts: `/login Crow` or `/login crow`.
9
+ * 3. Setup prompts for baseUrl + apiKey.
10
+ * 4. Final login step validates credentials via /v1/models?client_version=pi
11
+ * (HTTP 200 = success even if the catalog is empty; otherwise re-prompt).
12
+ * 5. On success, models are registered immediately; Pi saves credentials in auth.json.
13
+
14
+ *
15
+ * Uses a patched openai-codex-responses implementation that does not require
16
+ * extracting chatgpt_account_id from the API key (plain CPA keys work).
17
+ *
18
+ * Credentials live in auth.json. Non-interactive setup uses upstream env vars.
19
+ */
20
+
21
+ import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
22
+ import {
23
+ type ExtensionAPI,
24
+ type ProviderModelConfig,
25
+ getAgentDir,
26
+ } from "@earendil-works/pi-coding-agent";
27
+
28
+ import { ProactiveCompactionController } from "./auto-compact.ts";
29
+ import {
30
+ CLIPROXYAPI_CODEX_API,
31
+ type CliproxyCodexStreamSimple,
32
+ loadCliproxyCodexStreams,
33
+ } from "./codex-stream.ts";
34
+ import {
35
+ CREDENTIAL_TTL_MS,
36
+ DEFAULT_BASE_URL,
37
+ decodeRefreshMeta,
38
+ encodeRefreshMeta,
39
+ firstNonEmpty,
40
+ loadAuthConnection,
41
+ loadMappedModels,
42
+ type MappedModels,
43
+ resolveConnection,
44
+ resolveEndpoints,
45
+ resolveIdentity,
46
+ } from "./lib.ts";
47
+ import { registerTransientNetworkErrorRetry } from "./retry.ts";
48
+
49
+ interface RefreshResult {
50
+ modelCount: number;
51
+ modelsUrl: string;
52
+ }
53
+
54
+ export const DEFAULT_AUTO_RECOVERY_DELAY_MS = 60_000;
55
+ export const MAX_AUTO_RECOVERY_DELAY_MS = 300_000;
56
+
57
+ export interface RecoverySnapshot {
58
+ action: () => Promise<unknown>;
59
+ attempt: number;
60
+ delayMs: number;
61
+ }
62
+
63
+ class ModelRefreshCoordinator {
64
+ private generation = 0;
65
+ private activeController: AbortController | undefined;
66
+ private recoveryTimer: NodeJS.Timeout | undefined;
67
+ private recoveryAttempt = 0;
68
+ private activeRecovery: RecoverySnapshot | undefined;
69
+ private stopped = false;
70
+
71
+ begin(): { generation: number; signal: AbortSignal } {
72
+ this.clearRecoveryTimer();
73
+ this.activeController?.abort();
74
+ const controller = new AbortController();
75
+ this.activeController = controller;
76
+ this.generation += 1;
77
+ return { generation: this.generation, signal: controller.signal };
78
+ }
79
+
80
+ isCurrent(generation: number): boolean {
81
+ return !this.stopped && this.generation === generation;
82
+ }
83
+
84
+ scheduleRecovery(
85
+ action: () => Promise<unknown>,
86
+ baseDelayMs = DEFAULT_AUTO_RECOVERY_DELAY_MS,
87
+ maxDelayMs = MAX_AUTO_RECOVERY_DELAY_MS,
88
+ ): void {
89
+ if (this.stopped) return;
90
+ this.clearRecoveryTimer();
91
+ const delay = Math.min(baseDelayMs * 1.5 ** this.recoveryAttempt, maxDelayMs);
92
+ this.recoveryAttempt += 1;
93
+ this.activeRecovery = { action, attempt: this.recoveryAttempt, delayMs: delay };
94
+
95
+ this.recoveryTimer = setTimeout(() => {
96
+ this.recoveryTimer = undefined;
97
+ if (this.stopped) return;
98
+ void action().catch((error) => {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ if (message.includes("is stale after session replacement or reload")) {
101
+ this.clearRecovery();
102
+ return;
103
+ }
104
+ // Suppressed: logging this into the TUI corrupts the display.
105
+ // The refresh path already reschedules the next attempt.
106
+ });
107
+ }, delay);
108
+ this.recoveryTimer.unref?.();
109
+ }
110
+
111
+ snapshotRecovery(): RecoverySnapshot | undefined {
112
+ if (!this.stopped && this.activeRecovery) {
113
+ return { ...this.activeRecovery };
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ restoreRecovery(snapshot: RecoverySnapshot): void {
119
+ if (this.stopped || !snapshot) return;
120
+ this.clearRecoveryTimer();
121
+ this.recoveryAttempt = snapshot.attempt;
122
+ this.activeRecovery = snapshot;
123
+ this.recoveryTimer = setTimeout(() => {
124
+ this.recoveryTimer = undefined;
125
+ if (this.stopped) return;
126
+ void snapshot.action().catch((error) => {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ if (message.includes("is stale after session replacement or reload")) {
129
+ this.clearRecovery();
130
+ return;
131
+ }
132
+ // Suppressed: logging this into the TUI corrupts the display.
133
+ // The refresh path already reschedules the next attempt.
134
+ });
135
+ }, snapshot.delayMs);
136
+ this.recoveryTimer.unref?.();
137
+ }
138
+
139
+ clearRecoveryTimer(): void {
140
+ if (this.recoveryTimer) {
141
+ clearTimeout(this.recoveryTimer);
142
+ this.recoveryTimer = undefined;
143
+ }
144
+ }
145
+
146
+ clearRecovery(): void {
147
+ this.clearRecoveryTimer();
148
+ this.recoveryAttempt = 0;
149
+ this.activeRecovery = undefined;
150
+ }
151
+
152
+ stop(): void {
153
+ this.stopped = true;
154
+ this.clearRecovery();
155
+ this.activeController?.abort();
156
+ this.activeController = undefined;
157
+ this.generation += 1;
158
+ }
159
+ }
160
+
161
+ const COMPAT_COORDINATOR_KEY = Symbol.for("pi-cliproxyapi-provider.compat-coordinator");
162
+ export const COMPAT_SOURCE_ID = "pi-cliproxyapi-provider-global";
163
+
164
+ interface CompatRegistrationEntry {
165
+ instanceId: string;
166
+ providerId: string;
167
+ rawStream: CliproxyCodexStreamSimple;
168
+ rawStreamSimple: CliproxyCodexStreamSimple;
169
+ }
170
+
171
+ function getCompatCoordinator() {
172
+ const globalState = globalThis as typeof globalThis & {
173
+ [COMPAT_COORDINATOR_KEY]?: { stack: CompatRegistrationEntry[] };
174
+ };
175
+ if (!globalState[COMPAT_COORDINATOR_KEY]) {
176
+ globalState[COMPAT_COORDINATOR_KEY] = { stack: [] };
177
+ }
178
+ return globalState[COMPAT_COORDINATOR_KEY];
179
+ }
180
+
181
+ export function resetCompatCoordinator(): void {
182
+ const coordinator = getCompatCoordinator();
183
+ coordinator.stack = [];
184
+ }
185
+
186
+ function findActiveCompatEntry(
187
+ stack: CompatRegistrationEntry[],
188
+ providerId: string,
189
+ ): CompatRegistrationEntry | undefined {
190
+ for (let i = stack.length - 1; i >= 0; i--) {
191
+ const entry = stack[i];
192
+ if (entry?.providerId === providerId) {
193
+ return entry;
194
+ }
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ function hasLoginCredential(agentDir: string, providerId: string): boolean {
200
+ try {
201
+ return Boolean(loadAuthConnection(agentDir, providerId)?.apiKey);
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ function buildOAuthCredentials(baseUrlInput: string, apiKey: string): OAuthCredentials {
208
+ return {
209
+ refresh: encodeRefreshMeta(baseUrlInput),
210
+ access: apiKey,
211
+ expires: Date.now() + CREDENTIAL_TTL_MS,
212
+ };
213
+ }
214
+
215
+ function resolveDefaultBaseUrl(agentDir: string, providerId: string): string {
216
+ const authBaseUrl = loadAuthConnection(agentDir, providerId)?.baseUrl;
217
+ return firstNonEmpty(process.env.CLIPROXYAPI_BASE_URL, authBaseUrl, DEFAULT_BASE_URL)!;
218
+ }
219
+
220
+ async function promptConnection(
221
+ callbacks: OAuthLoginCallbacks,
222
+ defaults: { baseUrl: string },
223
+ ): Promise<{ baseUrlInput: string; apiKey: string }> {
224
+ callbacks.onProgress?.(
225
+ "Configure CLIProxyAPI. Preferred baseUrl form: host:port (e.g. http://127.0.0.1:8317).",
226
+ );
227
+
228
+ const baseUrlRaw = await callbacks.onPrompt({
229
+ message: `Crow base URL [${defaults.baseUrl}]:`,
230
+ placeholder: defaults.baseUrl,
231
+ allowEmpty: true,
232
+ });
233
+ const baseUrlInput = firstNonEmpty(baseUrlRaw, defaults.baseUrl)!;
234
+
235
+ // Validate early so users get a clear error before typing the API key.
236
+ resolveEndpoints(baseUrlInput);
237
+
238
+ const apiKey = (
239
+ await callbacks.onPrompt({
240
+ message: "Crow API key:",
241
+ placeholder: "sk-...",
242
+ allowEmpty: false,
243
+ })
244
+ ).trim();
245
+
246
+ if (!apiKey) {
247
+ throw new Error("API key cannot be empty.");
248
+ }
249
+
250
+ return { baseUrlInput, apiKey };
251
+ }
252
+
253
+ async function configureAndRegister(options: {
254
+ pi: ExtensionAPI;
255
+ agentDir: string;
256
+ providerId: string;
257
+ providerName: string;
258
+ baseUrlInput: string;
259
+ apiKey: string;
260
+ defaultBaseUrl: string;
261
+ streamSimple: CliproxyCodexStreamSimple;
262
+ refreshCoordinator: ModelRefreshCoordinator;
263
+ onRefreshOutcome?: (loaded: MappedModels) => void;
264
+ }): Promise<RefreshResult> {
265
+ const {
266
+ pi,
267
+ agentDir,
268
+ providerId,
269
+ providerName,
270
+ baseUrlInput,
271
+ apiKey,
272
+ defaultBaseUrl,
273
+ streamSimple,
274
+ refreshCoordinator,
275
+ onRefreshOutcome,
276
+ } = options;
277
+
278
+ const refresh = refreshCoordinator.begin();
279
+ const loaded = await loadMappedModels(baseUrlInput, apiKey, undefined, agentDir, refresh.signal);
280
+ if (!refreshCoordinator.isCurrent(refresh.generation)) {
281
+ throw new Error("Model refresh was superseded by a newer request.");
282
+ }
283
+
284
+ // /login stores oauth credentials itself; keep the provider OAuth-only so
285
+ // `/login <provider>` skips the API-key vs account selector.
286
+ registerProvider(pi, {
287
+ providerId,
288
+ providerName,
289
+ baseUrlInput,
290
+ models: loaded.models,
291
+ defaultBaseUrl: baseUrlInput || defaultBaseUrl,
292
+ agentDir,
293
+ streamSimple,
294
+ refreshCoordinator,
295
+ onRefreshOutcome,
296
+ });
297
+
298
+ onRefreshOutcome?.(loaded);
299
+
300
+ return {
301
+ modelCount: loaded.models.length,
302
+ modelsUrl: loaded.modelsUrl,
303
+ };
304
+ }
305
+
306
+ function createOAuthHandlers(options: {
307
+ pi: ExtensionAPI;
308
+ agentDir: string;
309
+ providerId: string;
310
+ providerName: string;
311
+ defaultBaseUrl: string;
312
+ streamSimple: CliproxyCodexStreamSimple;
313
+ refreshCoordinator: ModelRefreshCoordinator;
314
+ onRefreshOutcome?: (loaded: MappedModels) => void;
315
+ }) {
316
+ const {
317
+ pi,
318
+ agentDir,
319
+ providerId,
320
+ providerName,
321
+ defaultBaseUrl,
322
+ streamSimple,
323
+ refreshCoordinator,
324
+ onRefreshOutcome,
325
+ } = options;
326
+
327
+ return {
328
+ name: providerName,
329
+
330
+ async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
331
+ let promptDefaultBaseUrl = resolveDefaultBaseUrl(agentDir, providerId) || defaultBaseUrl;
332
+
333
+ // Final login step: validate by calling /v1/models.
334
+ // HTTP 200 (even with an empty catalog) means success; otherwise re-prompt.
335
+ while (true) {
336
+ const { baseUrlInput, apiKey } = await promptConnection(callbacks, {
337
+ baseUrl: promptDefaultBaseUrl,
338
+ });
339
+
340
+ callbacks.onProgress?.("Validating credentials via models endpoint...");
341
+ const previousRecovery = refreshCoordinator.snapshotRecovery();
342
+ try {
343
+ const result = await configureAndRegister({
344
+ pi,
345
+ agentDir,
346
+ providerId,
347
+ providerName,
348
+ baseUrlInput,
349
+ apiKey,
350
+ defaultBaseUrl,
351
+ streamSimple,
352
+ refreshCoordinator,
353
+ onRefreshOutcome,
354
+ });
355
+
356
+ process.stderr.write(
357
+ `${JSON.stringify({
358
+ source: "pi-cliproxyapi-provider",
359
+ level: "info",
360
+ message: `login ok: registered ${result.modelCount} models from ${result.modelsUrl}`,
361
+ })}\n`,
362
+ );
363
+ return buildOAuthCredentials(baseUrlInput, apiKey);
364
+ } catch (error) {
365
+ if (previousRecovery) {
366
+ refreshCoordinator.restoreRecovery(previousRecovery);
367
+ }
368
+ const message = error instanceof Error ? error.message : String(error);
369
+ process.stderr.write(
370
+ `${JSON.stringify({ source: "pi-cliproxyapi-provider", level: "warning", message: `login validation failed: ${message}` })}\n`,
371
+ );
372
+ callbacks.onProgress?.(
373
+ `Login validation failed: ${message}\nPlease re-enter base URL and API key.`,
374
+ );
375
+ // Keep last baseUrl as the next default so retyping is easier.
376
+ promptDefaultBaseUrl = baseUrlInput || promptDefaultBaseUrl;
377
+ }
378
+ }
379
+ },
380
+
381
+ async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
382
+ // API keys do not expire; keep the stored payload as-is.
383
+ return {
384
+ ...credentials,
385
+ expires: Date.now() + CREDENTIAL_TTL_MS,
386
+ };
387
+ },
388
+
389
+ getApiKey(credentials: OAuthCredentials): string {
390
+ return credentials.access;
391
+ },
392
+
393
+ modifyModels(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[] {
394
+ const meta = decodeRefreshMeta(credentials.refresh);
395
+ if (!meta?.baseUrl) {
396
+ return models;
397
+ }
398
+ try {
399
+ const { inferenceBaseUrl } = resolveEndpoints(meta.baseUrl);
400
+ return models.map((model) =>
401
+ model.provider === providerId ? { ...model, baseUrl: inferenceBaseUrl } : model,
402
+ );
403
+ } catch {
404
+ return models;
405
+ }
406
+ },
407
+ };
408
+ }
409
+
410
+ function registerProvider(
411
+ pi: ExtensionAPI,
412
+ options: {
413
+ providerId: string;
414
+ providerName: string;
415
+ baseUrlInput: string;
416
+ apiKey?: string;
417
+ models?: ProviderModelConfig[];
418
+ defaultBaseUrl: string;
419
+ agentDir: string;
420
+ streamSimple: CliproxyCodexStreamSimple;
421
+ refreshCoordinator?: ModelRefreshCoordinator;
422
+ onRefreshOutcome?: (loaded: MappedModels) => void;
423
+ },
424
+ ): void {
425
+ const {
426
+ providerId,
427
+ providerName,
428
+ baseUrlInput,
429
+ apiKey,
430
+ models,
431
+ defaultBaseUrl,
432
+ agentDir,
433
+ streamSimple,
434
+
435
+ onRefreshOutcome,
436
+ } = options;
437
+ const refreshCoordinator = options.refreshCoordinator ?? new ModelRefreshCoordinator();
438
+
439
+ const endpoints = resolveEndpoints(baseUrlInput);
440
+ let currentModels = models;
441
+ let checkedAt = models ? Date.now() : undefined;
442
+ const oauth = createOAuthHandlers({
443
+ pi,
444
+ agentDir,
445
+ providerId,
446
+ providerName,
447
+ defaultBaseUrl,
448
+ streamSimple,
449
+ refreshCoordinator,
450
+ onRefreshOutcome,
451
+ });
452
+
453
+ // Replace any previous registration so an earlier ambient apiKey does not linger
454
+ // via registerProvider merge semantics and reintroduce the auth-type selector.
455
+ pi.unregisterProvider(providerId);
456
+
457
+ pi.registerProvider(providerId, {
458
+ name: providerName,
459
+ baseUrl: endpoints.inferenceBaseUrl,
460
+ api: CLIPROXYAPI_CODEX_API,
461
+ streamSimple,
462
+ // OAuth-only keeps `/login <provider>` on the multi-field account path.
463
+ // Pass apiKey only for ambient request auth when no /login credential exists
464
+ // (env). Never pass both for /login flows.
465
+ oauth,
466
+ ...(apiKey ? { apiKey } : {}),
467
+ ...(models ? { models } : {}),
468
+ async refreshModels(context) {
469
+ let catalog =
470
+ currentModels ??
471
+ context.stored?.models.filter(
472
+ (model): model is Model<Api> =>
473
+ "reasoning" in model &&
474
+ model.provider === providerId &&
475
+ model.baseUrl === endpoints.inferenceBaseUrl,
476
+ );
477
+ let catalogCheckedAt = checkedAt ?? context.stored?.checkedAt;
478
+ if (context.allowNetwork) {
479
+ const connection = resolveConnection(agentDir, providerId);
480
+ if (connection) {
481
+ const loaded = await loadMappedModels(
482
+ connection.baseUrlInput,
483
+ connection.apiKey,
484
+ undefined,
485
+ agentDir,
486
+ context.signal,
487
+ );
488
+ catalog = loaded.models;
489
+ catalogCheckedAt = Date.now();
490
+ }
491
+ }
492
+ if (!catalog || context.signal.aborted) return [];
493
+ const published = await context.publish({
494
+ persist: {
495
+ models: catalog.map((model) => ({
496
+ ...model,
497
+ api: CLIPROXYAPI_CODEX_API,
498
+ provider: providerId,
499
+ baseUrl: endpoints.inferenceBaseUrl,
500
+ })),
501
+ checkedAt: catalogCheckedAt,
502
+ },
503
+ });
504
+ if (published) {
505
+ currentModels = catalog;
506
+ checkedAt = catalogCheckedAt;
507
+ }
508
+ return catalog;
509
+ },
510
+ });
511
+ }
512
+
513
+ export function registerRefreshCommand(options: {
514
+ pi: ExtensionAPI;
515
+ agentDir: string;
516
+ providerId: string;
517
+ providerName: string;
518
+ defaultBaseUrl: string;
519
+ streamSimple: CliproxyCodexStreamSimple;
520
+ refreshCoordinator?: ModelRefreshCoordinator;
521
+ onRefresh?: (
522
+ connection: NonNullable<ReturnType<typeof resolveConnection>>,
523
+ ) => Promise<RefreshResult | undefined>;
524
+ }): void {
525
+ const {
526
+ pi,
527
+ agentDir,
528
+ providerId,
529
+ providerName,
530
+ defaultBaseUrl,
531
+ streamSimple,
532
+ refreshCoordinator,
533
+ onRefresh,
534
+ } = options;
535
+
536
+ pi.registerCommand("cliproxyapi-refresh", {
537
+ description: "Force refresh CLIProxyAPI models from the remote catalog.",
538
+ handler: async (args, ctx) => {
539
+ if (args.trim()) {
540
+ ctx.ui.notify("Usage: /cliproxyapi-refresh", "error");
541
+ return;
542
+ }
543
+
544
+ const connection = resolveConnection(agentDir, providerId);
545
+ if (!connection) {
546
+ ctx.ui.notify(
547
+ `CLIProxyAPI is not configured. Use /login ${providerName} or /login ${providerId}.`,
548
+ "error",
549
+ );
550
+ return;
551
+ }
552
+
553
+ try {
554
+ let result: RefreshResult | undefined;
555
+ if (onRefresh) {
556
+ result = await onRefresh(connection);
557
+ if (!result) return;
558
+ } else {
559
+ const refresh = refreshCoordinator?.begin();
560
+ const loaded = await loadMappedModels(
561
+ connection.baseUrlInput,
562
+ connection.apiKey,
563
+ undefined,
564
+ agentDir,
565
+ refresh?.signal,
566
+ );
567
+ if (refresh && !refreshCoordinator?.isCurrent(refresh.generation)) return;
568
+
569
+ const hasStoredLogin = hasLoginCredential(agentDir, providerId);
570
+ registerProvider(pi, {
571
+ providerId,
572
+ providerName,
573
+ baseUrlInput: connection.baseUrlInput,
574
+ apiKey: hasStoredLogin ? undefined : connection.apiKey,
575
+ models: loaded.models,
576
+ defaultBaseUrl,
577
+ agentDir,
578
+ streamSimple,
579
+ refreshCoordinator,
580
+ });
581
+
582
+ result = {
583
+ modelCount: loaded.models.length,
584
+ modelsUrl: loaded.modelsUrl,
585
+ };
586
+ }
587
+
588
+ ctx.ui.notify(
589
+ `Refreshed ${result.modelCount} CLIProxyAPI models from ${result.modelsUrl}.`,
590
+ "info",
591
+ );
592
+ } catch (error) {
593
+ const message = error instanceof Error ? error.message : String(error);
594
+ ctx.ui.notify(`Failed to refresh CLIProxyAPI models: ${message}`, "error");
595
+ }
596
+ },
597
+ });
598
+ }
599
+
600
+ export { CLIPROXYAPI_CODEX_API } from "./codex-stream.ts";
601
+ export { resolveEndpoints, toPiModel } from "./lib.ts";
602
+
603
+ export default async function crow(pi: ExtensionAPI): Promise<void> {
604
+ const agentDir = getAgentDir();
605
+ const identity = resolveIdentity();
606
+ const defaultBaseUrl = resolveDefaultBaseUrl(agentDir, identity.providerId);
607
+
608
+ const proactiveCompaction = new ProactiveCompactionController(agentDir, identity.providerId);
609
+ proactiveCompaction.register(pi);
610
+
611
+ const modelRefreshCoordinator = new ModelRefreshCoordinator();
612
+
613
+ let streamSimple: CliproxyCodexStreamSimple = () => {
614
+ throw new Error(
615
+ `Codex protocol stream is unavailable for provider: ${identity.providerId} (api: ${CLIPROXYAPI_CODEX_API})`,
616
+ );
617
+ };
618
+ try {
619
+ const streams = await loadCliproxyCodexStreams([identity.providerId, "cliproxyapi"]);
620
+ proactiveCompaction.setCloseWebSocketSessions(streams.closeOpenAICodexWebSocketSessions);
621
+ streamSimple = proactiveCompaction.wrapStreamSimple(streams.streamSimple);
622
+
623
+ pi.on("session_shutdown", () => {
624
+ try {
625
+ streams.closeOpenAICodexWebSocketSessions();
626
+ } catch (error) {
627
+ const message = error instanceof Error ? error.message : String(error);
628
+ process.stderr.write(
629
+ `${JSON.stringify({
630
+ source: "pi-cliproxyapi-provider",
631
+ level: "warning",
632
+ message: `failed to close Codex WebSocket sessions on shutdown: ${message}`,
633
+ })}\n`,
634
+ );
635
+ }
636
+ });
637
+
638
+ try {
639
+ const { registerApiProvider, unregisterApiProviders } =
640
+ await import("@earendil-works/pi-ai/compat");
641
+
642
+ const coordinator = getCompatCoordinator();
643
+ const instanceId = `${identity.providerId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
644
+
645
+ const entry: CompatRegistrationEntry = {
646
+ instanceId,
647
+ providerId: identity.providerId,
648
+ rawStream: streams.rawStream,
649
+ rawStreamSimple: streams.rawStreamSimple,
650
+ };
651
+
652
+ coordinator.stack.push(entry);
653
+
654
+ const dispatchStream: CliproxyCodexStreamSimple = (model, context, options) => {
655
+ const active = findActiveCompatEntry(coordinator.stack, model.provider);
656
+ if (!active) {
657
+ throw new Error(
658
+ `No active provider stream handler registered for provider: ${model.provider} (api: ${CLIPROXYAPI_CODEX_API})`,
659
+ );
660
+ }
661
+
662
+ return active.rawStream(model, context, options);
663
+ };
664
+
665
+ const dispatchStreamSimple: CliproxyCodexStreamSimple = (model, context, options) => {
666
+ const active = findActiveCompatEntry(coordinator.stack, model.provider);
667
+ if (!active) {
668
+ throw new Error(
669
+ `No active provider streamSimple handler registered for provider: ${model.provider} (api: ${CLIPROXYAPI_CODEX_API})`,
670
+ );
671
+ }
672
+
673
+ return active.rawStreamSimple(model, context, options);
674
+ };
675
+
676
+ unregisterApiProviders(COMPAT_SOURCE_ID);
677
+ registerApiProvider(
678
+ {
679
+ api: CLIPROXYAPI_CODEX_API,
680
+ stream: dispatchStream,
681
+ streamSimple: dispatchStreamSimple,
682
+ },
683
+ COMPAT_SOURCE_ID,
684
+ );
685
+
686
+ pi.on("session_shutdown", () => {
687
+ const index = coordinator.stack.findIndex((item) => item.instanceId === instanceId);
688
+ if (index !== -1) {
689
+ coordinator.stack.splice(index, 1);
690
+ }
691
+ if (coordinator.stack.length === 0) {
692
+ unregisterApiProviders(COMPAT_SOURCE_ID);
693
+ }
694
+ });
695
+ } catch (error) {
696
+ const message = error instanceof Error ? error.message : String(error);
697
+ process.stderr.write(
698
+ `${JSON.stringify({
699
+ source: "pi-cliproxyapi-provider",
700
+ level: "warning",
701
+ message: `failed to register compat API provider: ${message}`,
702
+ })}\n`,
703
+ );
704
+ }
705
+ } catch (error) {
706
+ const message = error instanceof Error ? error.message : String(error);
707
+ process.stderr.write(
708
+ `${JSON.stringify({
709
+ source: "pi-cliproxyapi-provider",
710
+ level: "warning",
711
+ message: `failed to load patched codex protocol: ${message}`,
712
+ })}\n`,
713
+ );
714
+ }
715
+
716
+ const scheduleActiveRecovery = (): void => {
717
+ modelRefreshCoordinator.scheduleRecovery(async () => {
718
+ const activeConn = resolveConnection(agentDir, identity.providerId);
719
+ if (!activeConn) {
720
+ modelRefreshCoordinator.clearRecovery();
721
+ return;
722
+ }
723
+ await registerConfiguredProvider(activeConn, { forceRefresh: true });
724
+ }, DEFAULT_AUTO_RECOVERY_DELAY_MS);
725
+ };
726
+
727
+ const handleRefreshOutcome = (): void => modelRefreshCoordinator.clearRecovery();
728
+
729
+ // Always register oauth so the provider is visible in /login immediately after install.
730
+ registerProvider(pi, {
731
+ providerId: identity.providerId,
732
+ providerName: identity.providerName,
733
+ baseUrlInput: defaultBaseUrl,
734
+ defaultBaseUrl,
735
+ agentDir,
736
+ streamSimple,
737
+ refreshCoordinator: modelRefreshCoordinator,
738
+ onRefreshOutcome: handleRefreshOutcome,
739
+ });
740
+ registerTransientNetworkErrorRetry(pi, identity.providerId);
741
+
742
+ pi.on("session_start", async (_event, ctx) => {
743
+ const result = await ctx.modelRegistry.refresh({
744
+ allowNetwork: true,
745
+ providers: [identity.providerId],
746
+ });
747
+ const error = result.errors.get(identity.providerId);
748
+ if (error) {
749
+ process.stderr.write(
750
+ `${JSON.stringify({
751
+ source: "pi-cliproxyapi-provider",
752
+ level: "warning",
753
+ message: `failed to refresh models (${error.message}); keeping Pi's stored catalog.`,
754
+ })}\n`,
755
+ );
756
+ scheduleActiveRecovery();
757
+ }
758
+ });
759
+ pi.on("session_shutdown", () => {
760
+ modelRefreshCoordinator.stop();
761
+ });
762
+
763
+ const registerConfiguredProvider = async (
764
+ currentConnection: { baseUrlInput: string; apiKey: string },
765
+ options: { forceRefresh?: boolean } = {},
766
+ ): Promise<RefreshResult | undefined> => {
767
+ const refresh = modelRefreshCoordinator.begin();
768
+ try {
769
+ const loaded = await loadMappedModels(
770
+ currentConnection.baseUrlInput,
771
+ currentConnection.apiKey,
772
+ undefined,
773
+ agentDir,
774
+ refresh.signal,
775
+ );
776
+ if (!modelRefreshCoordinator.isCurrent(refresh.generation)) return undefined;
777
+
778
+ // Prefer OAuth-only registration when /login already stored credentials so
779
+ // `/login <provider>` jumps straight into the multi-field flow. Fall back to
780
+ // ambient apiKey only for env setups without auth.json.
781
+ const hasStoredLogin = hasLoginCredential(agentDir, identity.providerId);
782
+ registerProvider(pi, {
783
+ providerId: identity.providerId,
784
+ providerName: identity.providerName,
785
+ baseUrlInput: currentConnection.baseUrlInput,
786
+ apiKey: hasStoredLogin ? undefined : currentConnection.apiKey,
787
+ models: loaded.models,
788
+ defaultBaseUrl,
789
+ agentDir,
790
+ streamSimple,
791
+ refreshCoordinator: modelRefreshCoordinator,
792
+ onRefreshOutcome: handleRefreshOutcome,
793
+ });
794
+
795
+ handleRefreshOutcome();
796
+
797
+ return {
798
+ modelCount: loaded.models.length,
799
+ modelsUrl: loaded.modelsUrl,
800
+ };
801
+ } catch (error) {
802
+ if (!modelRefreshCoordinator.isCurrent(refresh.generation)) return undefined;
803
+ const message = error instanceof Error ? error.message : String(error);
804
+ if (message.includes("is stale after session replacement or reload")) {
805
+ modelRefreshCoordinator.clearRecovery();
806
+ return undefined;
807
+ }
808
+ if (options.forceRefresh) {
809
+ scheduleActiveRecovery();
810
+ }
811
+ throw error;
812
+ }
813
+ };
814
+
815
+ registerRefreshCommand({
816
+ pi,
817
+ agentDir,
818
+ providerId: identity.providerId,
819
+ providerName: identity.providerName,
820
+ defaultBaseUrl,
821
+ streamSimple,
822
+ refreshCoordinator: modelRefreshCoordinator,
823
+ onRefresh: (currentConnection) =>
824
+ registerConfiguredProvider(currentConnection, { forceRefresh: true }),
825
+ });
826
+ }