pi-cliproxyapi-autoconnect 0.0.0-stage → 1.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +336 -2
  3. package/index.ts +1313 -0
  4. package/package.json +41 -4
package/index.ts ADDED
@@ -0,0 +1,1313 @@
1
+ /**
2
+ * pi-cliproxyapi-autoconnect — CLIProxyAPI provider for pi
3
+ *
4
+ * Adds a "CLIProxy" entry to pi's /login flow (under "Sign in with an API key"):
5
+ * 1. ask for the bound domain (for example proxy.example.com)
6
+ * 2. ask for the API key
7
+ * 3. derive every compatible base URL and try them automatically,
8
+ * OpenAI first, then Gemini, then Anthropic
9
+ * 4. register every returned model in /model
10
+ *
11
+ * If model discovery fails everywhere, the user picks one of the formatted
12
+ * compatible addresses (OpenAI / Anthropic / Gemini) or enters a custom base
13
+ * URL, and can type model IDs manually.
14
+ *
15
+ * The API key is stored by pi in ~/.pi/agent/auth.json. This extension only
16
+ * stores non-secret connection metadata in ~/.pi/agent/cliproxy.json.
17
+ *
18
+ * CLIProxyAPI reference: https://github.com/router-for-me/CLIProxyAPI
19
+ */
20
+
21
+ import {
22
+ copyFileSync,
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ renameSync,
27
+ statSync,
28
+ unlinkSync,
29
+ writeFileSync,
30
+ } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+ import {
33
+ anthropicMessagesApi,
34
+ createProvider,
35
+ googleGenerativeAIApi,
36
+ openAICompletionsApi,
37
+ type Api,
38
+ type ApiKeyCredential,
39
+ type Model,
40
+ type Provider,
41
+ type ProviderAuthInteraction,
42
+ type ProviderStreams,
43
+ type RefreshModelsContext,
44
+ } from "@earendil-works/pi-ai/compat";
45
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
46
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
47
+
48
+ // =============================================================================
49
+ // Constants
50
+ // =============================================================================
51
+
52
+ const PROVIDER_ID = "cliproxy";
53
+ const PROVIDER_NAME = "CLIProxy";
54
+ const AUTH_NAME = "CLIProxy (domain + API key)";
55
+ const CONFIG_FILE_NAME = "cliproxy.json";
56
+ const CONFIG_VERSION = 2;
57
+ const REQUEST_TIMEOUT_MS = 10_000;
58
+ const PROBE_TIMEOUT_MS = 4_000;
59
+ /** Reuse a fresh catalog for a few minutes unless a refresh is forced. */
60
+ const MODEL_REFRESH_TTL_MS = 5 * 60_000;
61
+
62
+ /** Last non-fatal error from a background model refresh, used by /cliproxy. */
63
+ let lastRefreshError: string | undefined;
64
+
65
+ type SupportedApi = "openai-completions" | "anthropic-messages" | "google-generative-ai";
66
+
67
+ const PROVIDER_APIS: Readonly<Record<SupportedApi, string>> = {
68
+ "openai-completions": "OpenAI compatible",
69
+ "anthropic-messages": "Anthropic compatible",
70
+ "google-generative-ai": "Gemini compatible",
71
+ };
72
+
73
+ // =============================================================================
74
+ // Persisted configuration (never contains the API key)
75
+ // =============================================================================
76
+
77
+ interface StoredModel {
78
+ /** Upstream model ID exactly as returned by the proxy. */
79
+ id: string;
80
+ /** Optional display name. */
81
+ name?: string;
82
+ /** Optional upstream owner reported by /v1/models. */
83
+ ownedBy?: string;
84
+ /** Persisted capability metadata, so cliproxy.json is self-contained. */
85
+ reasoning?: boolean;
86
+ input?: ("text" | "image")[];
87
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
88
+ contextWindow?: number;
89
+ maxTokens?: number;
90
+ }
91
+
92
+ interface CliproxyConfig {
93
+ version: number;
94
+ /** Domain exactly as the user typed it. */
95
+ domain: string;
96
+ /** Normalized root URL without a compatibility suffix, e.g. https://proxy.example.com */
97
+ rootUrl: string;
98
+ /** Base URL used by pi for every model, e.g. https://proxy.example.com/v1 */
99
+ baseUrl: string;
100
+ /** Wire protocol used by the configured base URL. */
101
+ api: SupportedApi;
102
+ models: StoredModel[];
103
+ /** Whether models came from automatic discovery or from manual IDs. */
104
+ source: "auto" | "manual";
105
+ updatedAt: number;
106
+ lastError?: string;
107
+ }
108
+
109
+ interface NormalizedDomain {
110
+ rootUrl: string;
111
+ implicitScheme: boolean;
112
+ }
113
+
114
+ /** One derivable compatible address, in the order it should be probed. */
115
+ interface DiscoveryCandidate {
116
+ api: SupportedApi;
117
+ rootUrl: string;
118
+ baseUrl: string;
119
+ }
120
+
121
+ interface DiscoveryAttempt {
122
+ candidate: DiscoveryCandidate;
123
+ failure: FetchFailure;
124
+ }
125
+
126
+ type DiscoveryOutcome =
127
+ | { ok: true; candidate: DiscoveryCandidate; models: StoredModel[] }
128
+ | { ok: false; attempts: DiscoveryAttempt[]; empty?: DiscoveryCandidate };
129
+
130
+ interface FetchedModels {
131
+ ok: true;
132
+ models: StoredModel[];
133
+ }
134
+
135
+ interface FetchFailure {
136
+ ok: false;
137
+ error: string;
138
+ kind: "auth" | "forbidden" | "not-found" | "server" | "network" | "format" | "http" | "unsupported";
139
+ status?: number;
140
+ }
141
+
142
+ type FetchResult = FetchedModels | FetchFailure;
143
+
144
+ /** Capability metadata borrowed from an existing models.json entry with the same model ID. */
145
+ interface ModelMetadata {
146
+ name?: string;
147
+ reasoning?: boolean;
148
+ input?: ("text" | "image")[];
149
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
150
+ contextWindow?: number;
151
+ maxTokens?: number;
152
+ }
153
+
154
+ let metadataCache: { path: string; mtimeMs: number; metadata: Map<string, ModelMetadata> } | undefined;
155
+ let storeMetadataCache: { path: string; mtimeMs: number; metadata: Map<string, ModelMetadata> } | undefined;
156
+ let builtinMetadataCache: Map<string, ModelMetadata> | undefined;
157
+
158
+ // =============================================================================
159
+ // Config file
160
+ // =============================================================================
161
+
162
+ function configPath(): string {
163
+ return join(getAgentDir(), CONFIG_FILE_NAME);
164
+ }
165
+
166
+ function isSupportedApi(value: unknown): value is SupportedApi {
167
+ return value === "openai-completions" || value === "anthropic-messages" || value === "google-generative-ai";
168
+ }
169
+
170
+ function sanitizeModels(value: unknown): StoredModel[] {
171
+ if (!Array.isArray(value)) return [];
172
+ const seen = new Set<string>();
173
+ const models: StoredModel[] = [];
174
+ for (const entry of value) {
175
+ if (!entry || typeof entry !== "object") continue;
176
+ const record = entry as Record<string, unknown>;
177
+ const id = typeof record.id === "string" ? record.id.trim() : "";
178
+ if (!id || seen.has(id)) continue;
179
+ seen.add(id);
180
+ const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : undefined;
181
+ const ownedBy = typeof record.ownedBy === "string" && record.ownedBy.trim() ? record.ownedBy.trim() : undefined;
182
+ const reasoning = typeof record.reasoning === "boolean" ? record.reasoning : undefined;
183
+ const input = Array.isArray(record.input)
184
+ ? record.input.filter((value): value is "text" | "image" => value === "text" || value === "image")
185
+ : undefined;
186
+ const cost = readMetadataCost(record.cost);
187
+ const contextWindow =
188
+ typeof record.contextWindow === "number" && record.contextWindow > 0 ? record.contextWindow : undefined;
189
+ const maxTokens =
190
+ typeof record.maxTokens === "number" && record.maxTokens > 0 ? record.maxTokens : undefined;
191
+ models.push({
192
+ id,
193
+ ...(name ? { name } : {}),
194
+ ...(ownedBy ? { ownedBy } : {}),
195
+ ...(reasoning !== undefined ? { reasoning } : {}),
196
+ ...(input && input.length > 0 ? { input } : {}),
197
+ ...(cost ? { cost } : {}),
198
+ ...(contextWindow ? { contextWindow } : {}),
199
+ ...(maxTokens ? { maxTokens } : {}),
200
+ });
201
+ }
202
+ return models;
203
+ }
204
+
205
+ function readConfig(): CliproxyConfig | undefined {
206
+ const path = configPath();
207
+ if (!existsSync(path)) return undefined;
208
+ try {
209
+ let raw = readFileSync(path, "utf8");
210
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
211
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
212
+ if (!parsed || typeof parsed !== "object") return undefined;
213
+ if (typeof parsed.rootUrl !== "string" || typeof parsed.baseUrl !== "string") return undefined;
214
+ if (!isSupportedApi(parsed.api)) return undefined;
215
+ const source = parsed.source === "manual" ? "manual" : "auto";
216
+ const lastError = typeof parsed.lastError === "string" && parsed.lastError ? parsed.lastError : undefined;
217
+ return {
218
+ version: typeof parsed.version === "number" ? parsed.version : CONFIG_VERSION,
219
+ domain: typeof parsed.domain === "string" ? parsed.domain : parsed.rootUrl,
220
+ rootUrl: parsed.rootUrl,
221
+ baseUrl: parsed.baseUrl,
222
+ api: parsed.api,
223
+ models: sanitizeModels(parsed.models),
224
+ source,
225
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : 0,
226
+ ...(lastError ? { lastError } : {}),
227
+ };
228
+ } catch {
229
+ return undefined;
230
+ }
231
+ }
232
+
233
+ function writeConfig(config: CliproxyConfig): void {
234
+ const path = configPath();
235
+ mkdirSync(dirname(path), { recursive: true });
236
+ // Keep one previous generation for manual recovery.
237
+ if (existsSync(path)) {
238
+ try {
239
+ copyFileSync(path, `${path}.bak`);
240
+ } catch {
241
+ // Backups are best effort; never fail the actual write because of one.
242
+ }
243
+ }
244
+ const data = `${JSON.stringify(config, null, 2)}\n`;
245
+ const temporaryPath = `${path}.${process.pid}.tmp`;
246
+ writeFileSync(temporaryPath, data, { encoding: "utf8", mode: 0o600 });
247
+ try {
248
+ renameSync(temporaryPath, path);
249
+ } catch {
250
+ // Windows can refuse to replace an existing file while another process holds it.
251
+ writeFileSync(path, data, { encoding: "utf8", mode: 0o600 });
252
+ try {
253
+ unlinkSync(temporaryPath);
254
+ } catch {
255
+ // Best effort only.
256
+ }
257
+ }
258
+ }
259
+
260
+ // =============================================================================
261
+ // Optional metadata inheritance from models.json
262
+ // =============================================================================
263
+
264
+ function readMetadataCost(value: unknown): ModelMetadata["cost"] | undefined {
265
+ if (!value || typeof value !== "object") return undefined;
266
+ const record = value as Record<string, unknown>;
267
+ const input = record.input;
268
+ const output = record.output;
269
+ const cacheRead = record.cacheRead;
270
+ const cacheWrite = record.cacheWrite;
271
+ if (typeof input !== "number" || typeof output !== "number") return undefined;
272
+ return {
273
+ input,
274
+ output,
275
+ cacheRead: typeof cacheRead === "number" ? cacheRead : 0,
276
+ cacheWrite: typeof cacheWrite === "number" ? cacheWrite : 0,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * CLIProxyAPI's /v1/models only returns model IDs. If the user already has a
282
+ * models.json entry for the same ID (for example a hand-written provider that
283
+ * points at the same proxy), reuse its capability metadata instead of the
284
+ * conservative guesses below.
285
+ */
286
+ function readModelsJsonMetadata(): Map<string, ModelMetadata> {
287
+ const path = join(getAgentDir(), "models.json");
288
+ try {
289
+ const mtimeMs = statSync(path).mtimeMs;
290
+ if (metadataCache && metadataCache.path === path && metadataCache.mtimeMs === mtimeMs) {
291
+ return metadataCache.metadata;
292
+ }
293
+ let raw = readFileSync(path, "utf8");
294
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
295
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
296
+ const providers =
297
+ parsed.providers && typeof parsed.providers === "object"
298
+ ? (parsed.providers as Record<string, unknown>)
299
+ : parsed;
300
+ const metadata = new Map<string, ModelMetadata>();
301
+ for (const provider of Object.values(providers)) {
302
+ if (!provider || typeof provider !== "object") continue;
303
+ const models = (provider as Record<string, unknown>).models;
304
+ if (!Array.isArray(models)) continue;
305
+ for (const value of models) {
306
+ if (!value || typeof value !== "object") continue;
307
+ const model = value as Record<string, unknown>;
308
+ if (typeof model.id !== "string" || !model.id.trim()) continue;
309
+ if (typeof model.type === "string" && model.type !== "chat") continue;
310
+ const id = model.id.trim();
311
+ if (metadata.has(id)) continue;
312
+ const entry: ModelMetadata = {};
313
+ if (typeof model.name === "string" && model.name.trim()) entry.name = model.name.trim();
314
+ if (typeof model.reasoning === "boolean") entry.reasoning = model.reasoning;
315
+ if (Array.isArray(model.input)) {
316
+ const input = model.input.filter(
317
+ (value): value is "text" | "image" => value === "text" || value === "image",
318
+ );
319
+ if (input.length > 0) entry.input = input;
320
+ }
321
+ const cost = readMetadataCost(model.cost);
322
+ if (cost) entry.cost = cost;
323
+ if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
324
+ entry.contextWindow = model.contextWindow;
325
+ }
326
+ if (typeof model.maxTokens === "number" && model.maxTokens > 0) {
327
+ entry.maxTokens = model.maxTokens;
328
+ }
329
+ if (Object.keys(entry).length > 0) metadata.set(id, entry);
330
+ }
331
+ }
332
+ metadataCache = { path, mtimeMs, metadata };
333
+ return metadata;
334
+ } catch {
335
+ if (metadataCache?.path === path) return metadataCache.metadata;
336
+ return new Map();
337
+ }
338
+ }
339
+
340
+ // =============================================================================
341
+ // Optional metadata inheritance from pi's models-store cache
342
+ // =============================================================================
343
+
344
+ function readModelsStoreMetadata(): Map<string, ModelMetadata> {
345
+ const path = join(getAgentDir(), "models-store.json");
346
+ try {
347
+ const mtimeMs = statSync(path).mtimeMs;
348
+ if (storeMetadataCache && storeMetadataCache.path === path && storeMetadataCache.mtimeMs === mtimeMs) {
349
+ return storeMetadataCache.metadata;
350
+ }
351
+ let raw = readFileSync(path, "utf8");
352
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
353
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
354
+ const providerEntry = parsed[PROVIDER_ID];
355
+ const models =
356
+ providerEntry && typeof providerEntry === "object"
357
+ ? (providerEntry as Record<string, unknown>).models
358
+ : undefined;
359
+ const metadata = new Map<string, ModelMetadata>();
360
+ if (Array.isArray(models)) {
361
+ for (const value of models) {
362
+ if (!value || typeof value !== "object") continue;
363
+ const model = value as Record<string, unknown>;
364
+ if (typeof model.id !== "string" || !model.id.trim()) continue;
365
+ if (typeof model.type === "string" && model.type !== "chat") continue;
366
+ const id = model.id.trim();
367
+ const entry: ModelMetadata = {};
368
+ if (typeof model.name === "string" && model.name.trim()) entry.name = model.name.trim();
369
+ if (typeof model.reasoning === "boolean") entry.reasoning = model.reasoning;
370
+ if (Array.isArray(model.input)) {
371
+ const input = model.input.filter(
372
+ (value): value is "text" | "image" => value === "text" || value === "image",
373
+ );
374
+ if (input.length > 0) entry.input = input;
375
+ }
376
+ const cost = readMetadataCost(model.cost);
377
+ if (cost) entry.cost = cost;
378
+ if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
379
+ entry.contextWindow = model.contextWindow;
380
+ }
381
+ if (typeof model.maxTokens === "number" && model.maxTokens > 0) {
382
+ entry.maxTokens = model.maxTokens;
383
+ }
384
+ if (Object.keys(entry).length > 0) metadata.set(id, entry);
385
+ }
386
+ }
387
+ storeMetadataCache = { path, mtimeMs, metadata };
388
+ return metadata;
389
+ } catch {
390
+ if (storeMetadataCache?.path === path) return storeMetadataCache.metadata;
391
+ return new Map();
392
+ }
393
+ }
394
+
395
+ // =============================================================================
396
+ // Optional metadata inheritance from pi's built-in catalog
397
+ // =============================================================================
398
+
399
+ function normalizeModelId(id: string): string {
400
+ // CLIProxy often exposes prefixes such as "global:", "cn:" or "models/".
401
+ return id
402
+ .trim()
403
+ .replace(/^models\//iu, "")
404
+ .replace(/^[a-z\d._-]+:/iu, "");
405
+ }
406
+
407
+ function readBuiltinMetadata(): Map<string, ModelMetadata> {
408
+ if (builtinMetadataCache) return builtinMetadataCache;
409
+ const metadata = new Map<string, ModelMetadata>();
410
+ try {
411
+ for (const provider of builtinProviders()) {
412
+ for (const model of provider.getModels()) {
413
+ if (model.type && model.type !== "chat") continue;
414
+ const entry: ModelMetadata = {
415
+ name: model.name,
416
+ reasoning: model.reasoning,
417
+ input: model.input,
418
+ cost: model.cost,
419
+ contextWindow: model.contextWindow,
420
+ maxTokens: model.maxTokens,
421
+ };
422
+ const normalized = normalizeModelId(model.id);
423
+ if (!metadata.has(model.id)) metadata.set(model.id, entry);
424
+ if (normalized && !metadata.has(normalized)) metadata.set(normalized, entry);
425
+ }
426
+ }
427
+ } catch {
428
+ // Built-in catalog is optional; heuristics remain available.
429
+ }
430
+ builtinMetadataCache = metadata;
431
+ return metadata;
432
+ }
433
+
434
+ // =============================================================================
435
+ // Domain and URL formatting
436
+ // =============================================================================
437
+
438
+ function trimTrailingSlashes(value: string): string {
439
+ return value.replace(/\/+$/u, "");
440
+ }
441
+
442
+ function swapScheme(url: string): string | undefined {
443
+ try {
444
+ const parsed = new URL(url);
445
+ if (parsed.protocol === "https:") {
446
+ parsed.protocol = "http:";
447
+ return trimTrailingSlashes(parsed.toString());
448
+ }
449
+ if (parsed.protocol === "http:") {
450
+ parsed.protocol = "https:";
451
+ return trimTrailingSlashes(parsed.toString());
452
+ }
453
+ } catch {
454
+ return undefined;
455
+ }
456
+ return undefined;
457
+ }
458
+
459
+ function isLocalHostname(hostname: string): boolean {
460
+ const host = hostname.toLowerCase();
461
+ return (
462
+ host === "localhost" ||
463
+ host === "127.0.0.1" ||
464
+ host === "::1" ||
465
+ host === "[::1]" ||
466
+ host.endsWith(".local") ||
467
+ host.endsWith(".lan") ||
468
+ /^127\./u.test(host) ||
469
+ /^10\./u.test(host) ||
470
+ /^192\.168\./u.test(host) ||
471
+ /^172\.(1[6-9]|2\d|3[01])\./u.test(host)
472
+ );
473
+ }
474
+
475
+ /**
476
+ * Accepts `proxy.example.com`, `https://proxy.example.com/`, a pasted
477
+ * `https://proxy.example.com/v1` or `.../v1/models`, and even a domain with a
478
+ * reverse-proxy path prefix. Returns the root URL used to derive compatible
479
+ * addresses.
480
+ */
481
+ function normalizeDomain(input: string): NormalizedDomain {
482
+ const original = input.trim();
483
+ if (!original) throw new Error("Domain is required.");
484
+ const implicitScheme = !/^[a-z][a-z\d+.-]*:\/\//iu.test(original);
485
+ const withScheme = implicitScheme
486
+ ? `${isLocalHostname(original.split("/")[0]?.split(":")[0] ?? "") ? "http" : "https"}://${original}`
487
+ : original;
488
+ let parsed: URL;
489
+ try {
490
+ parsed = new URL(withScheme);
491
+ } catch {
492
+ throw new Error(`Could not parse domain: "${input}". Example: proxy.example.com or https://proxy.example.com`);
493
+ }
494
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
495
+ throw new Error(`Only http/https URLs are supported; received: ${parsed.protocol}`);
496
+ }
497
+ parsed.username = "";
498
+ parsed.password = "";
499
+ parsed.hash = "";
500
+ parsed.search = "";
501
+ let pathname = parsed.pathname.replace(/\/+$/u, "");
502
+ // Users often paste the complete compatible URL instead of the domain.
503
+ pathname = pathname.replace(/\/v1(?:beta|alpha)?\/models$/iu, "");
504
+ pathname = pathname.replace(/\/models$/iu, "");
505
+ pathname = pathname.replace(/\/v1(?:beta|alpha)?$/iu, "");
506
+ parsed.pathname = pathname;
507
+ const rootUrl = trimTrailingSlashes(parsed.toString());
508
+ return { rootUrl, implicitScheme };
509
+ }
510
+
511
+ function openAIBaseUrl(rootUrl: string): string {
512
+ return `${trimTrailingSlashes(rootUrl)}/v1`;
513
+ }
514
+
515
+ function anthropicBaseUrl(rootUrl: string): string {
516
+ // The Anthropic SDK appends /v1/messages to this value.
517
+ return trimTrailingSlashes(rootUrl);
518
+ }
519
+
520
+ function geminiBaseUrl(rootUrl: string): string {
521
+ return `${trimTrailingSlashes(rootUrl)}/v1beta`;
522
+ }
523
+
524
+ // =============================================================================
525
+ // Network helpers
526
+ // =============================================================================
527
+
528
+ function abortSignalWithTimeout(signal: AbortSignal, timeoutMs: number): { signal: AbortSignal; cleanup: () => void } {
529
+ const controller = new AbortController();
530
+ const timer = setTimeout(() => controller.abort(new Error("Request timed out")), timeoutMs);
531
+ const onAbort = () => controller.abort(signal.reason);
532
+ if (signal.aborted) controller.abort(signal.reason);
533
+ else signal.addEventListener("abort", onAbort, { once: true });
534
+ return {
535
+ signal: controller.signal,
536
+ cleanup: () => {
537
+ clearTimeout(timer);
538
+ signal.removeEventListener("abort", onAbort);
539
+ },
540
+ };
541
+ }
542
+
543
+ async function safeText(response: Response): Promise<string> {
544
+ try {
545
+ return (await response.text()).trim().slice(0, 500);
546
+ } catch {
547
+ return "";
548
+ }
549
+ }
550
+
551
+ function describeFetchError(error: unknown, ownSignal: AbortSignal, outerSignal: AbortSignal): string {
552
+ if (outerSignal.aborted) return "Cancelled";
553
+ if (ownSignal.aborted) return "Request timed out";
554
+ if (error instanceof Error) {
555
+ const cause = error.cause;
556
+ if (cause instanceof Error && cause.message && cause.message !== error.message) {
557
+ return `${error.message} (${cause.message})`;
558
+ }
559
+ return error.message;
560
+ }
561
+ return String(error);
562
+ }
563
+
564
+ function failureKindForStatus(status: number): FetchFailure["kind"] {
565
+ if (status === 401) return "auth";
566
+ if (status === 403) return "forbidden";
567
+ if (status === 404) return "not-found";
568
+ if (status === 429 || status >= 500) return "server";
569
+ return "http";
570
+ }
571
+
572
+ function makeHttpFailure(status: number, body: string): FetchFailure {
573
+ return {
574
+ ok: false,
575
+ error: `HTTP ${status}${body ? `: ${body}` : ""}`,
576
+ kind: failureKindForStatus(status),
577
+ status,
578
+ };
579
+ }
580
+
581
+ async function probeCliproxy(rootUrl: string, signal: AbortSignal): Promise<string | undefined> {
582
+ const { signal: requestSignal, cleanup } = abortSignalWithTimeout(signal, PROBE_TIMEOUT_MS);
583
+ try {
584
+ const response = await fetch(`${trimTrailingSlashes(rootUrl)}/healthz`, {
585
+ headers: { Accept: "application/json" },
586
+ signal: requestSignal,
587
+ });
588
+ if (!response.ok) return undefined;
589
+ const body = (await response.json().catch(() => undefined)) as { status?: unknown } | undefined;
590
+ if (body?.status === "ok") return "Detected CLIProxyAPI (/healthz OK)";
591
+ return "Detected /healthz, but the response is not CLIProxyAPI format";
592
+ } catch {
593
+ return undefined;
594
+ } finally {
595
+ cleanup();
596
+ }
597
+ }
598
+
599
+ function parseOpenAIModelList(payload: unknown): StoredModel[] | undefined {
600
+ if (!payload || typeof payload !== "object") return undefined;
601
+ const record = payload as Record<string, unknown>;
602
+ const entries = Array.isArray(record.data) ? record.data : Array.isArray(record.models) ? record.models : undefined;
603
+ if (!entries) return undefined;
604
+ const seen = new Set<string>();
605
+ const models: StoredModel[] = [];
606
+ for (const entry of entries) {
607
+ if (typeof entry === "string") {
608
+ const id = entry.trim();
609
+ if (id && !seen.has(id)) {
610
+ seen.add(id);
611
+ models.push({ id });
612
+ }
613
+ continue;
614
+ }
615
+ if (!entry || typeof entry !== "object") continue;
616
+ const item = entry as Record<string, unknown>;
617
+ const rawId = typeof item.id === "string" ? item.id : typeof item.name === "string" ? item.name : "";
618
+ const id = rawId.trim().replace(/^models\//iu, "");
619
+ if (!id || seen.has(id)) continue;
620
+ seen.add(id);
621
+ const name =
622
+ typeof item.display_name === "string" && item.display_name.trim()
623
+ ? item.display_name.trim()
624
+ : typeof item.displayName === "string" && item.displayName.trim()
625
+ ? item.displayName.trim()
626
+ : undefined;
627
+ const ownedBy = typeof item.owned_by === "string" && item.owned_by.trim() ? item.owned_by.trim() : undefined;
628
+ models.push({ id, ...(name ? { name } : {}), ...(ownedBy ? { ownedBy } : {}) });
629
+ }
630
+ return models;
631
+ }
632
+
633
+ async function fetchOpenAIModels(baseUrl: string, apiKey: string, signal: AbortSignal): Promise<FetchResult> {
634
+ const url = `${trimTrailingSlashes(baseUrl)}/models`;
635
+ const { signal: requestSignal, cleanup } = abortSignalWithTimeout(signal, REQUEST_TIMEOUT_MS);
636
+ try {
637
+ const response = await fetch(url, {
638
+ headers: {
639
+ Accept: "application/json",
640
+ Authorization: `Bearer ${apiKey}`,
641
+ "User-Agent": "pi-cliproxyapi-autoconnect/1.2.1",
642
+ },
643
+ signal: requestSignal,
644
+ });
645
+ if (!response.ok) {
646
+ const body = await safeText(response);
647
+ return makeHttpFailure(response.status, body);
648
+ }
649
+ const payload = await response.json().catch(() => undefined);
650
+ const models = parseOpenAIModelList(payload);
651
+ if (!models) return { ok: false, error: "Response is not an OpenAI-compatible model list (missing data array)", kind: "format" };
652
+ return { ok: true, models };
653
+ } catch (error) {
654
+ return { ok: false, error: describeFetchError(error, requestSignal, signal), kind: "network" };
655
+ } finally {
656
+ cleanup();
657
+ }
658
+ }
659
+
660
+ async function fetchGeminiModels(baseUrl: string, apiKey: string, signal: AbortSignal): Promise<FetchResult> {
661
+ const url = `${trimTrailingSlashes(baseUrl)}/models?key=${encodeURIComponent(apiKey)}`;
662
+ const { signal: requestSignal, cleanup } = abortSignalWithTimeout(signal, REQUEST_TIMEOUT_MS);
663
+ try {
664
+ const response = await fetch(url, {
665
+ headers: { Accept: "application/json", "User-Agent": "pi-cliproxyapi-autoconnect/1.2.1" },
666
+ signal: requestSignal,
667
+ });
668
+ if (!response.ok) {
669
+ const body = await safeText(response);
670
+ return makeHttpFailure(response.status, body);
671
+ }
672
+ const payload = await response.json().catch(() => undefined);
673
+ const models = parseOpenAIModelList(payload);
674
+ if (!models) return { ok: false, error: "Response is not a Gemini-compatible model list (missing models array)", kind: "format" };
675
+ return { ok: true, models };
676
+ } catch (error) {
677
+ return { ok: false, error: describeFetchError(error, requestSignal, signal), kind: "network" };
678
+ } finally {
679
+ cleanup();
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Anthropic's public API does expose GET /v1/models, but CLIProxyAPI's
685
+ * Anthropic-compatible endpoint does not always proxy it. Try anyway so the
686
+ * automatic discovery can use Anthropic when it is available; callers fall
687
+ * back to manual model IDs when it is not.
688
+ */
689
+ function anthropicModelsUrl(baseUrl: string): string {
690
+ const root = trimTrailingSlashes(baseUrl);
691
+ // Anthropic's list endpoint paginates (default 20); ask for the largest page.
692
+ return /\/v1$/u.test(root) ? `${root}/models?limit=1000` : `${root}/v1/models?limit=1000`;
693
+ }
694
+
695
+ async function fetchAnthropicModels(baseUrl: string, apiKey: string, signal: AbortSignal): Promise<FetchResult> {
696
+ const { signal: requestSignal, cleanup } = abortSignalWithTimeout(signal, REQUEST_TIMEOUT_MS);
697
+ try {
698
+ const response = await fetch(anthropicModelsUrl(baseUrl), {
699
+ headers: {
700
+ Accept: "application/json",
701
+ "x-api-key": apiKey,
702
+ // Some reverse proxies accept the key as a bearer token instead of
703
+ // Anthropic's x-api-key header; sending both is harmless upstream.
704
+ Authorization: `Bearer ${apiKey}`,
705
+ "anthropic-version": "2023-06-01",
706
+ "User-Agent": "pi-cliproxyapi-autoconnect/1.2.1",
707
+ },
708
+ signal: requestSignal,
709
+ });
710
+ if (!response.ok) {
711
+ const body = await safeText(response);
712
+ if (response.status === 404 || response.status === 405 || response.status === 501) {
713
+ return {
714
+ ok: false,
715
+ error: `The Anthropic-compatible endpoint does not expose a usable model list (HTTP ${response.status})`,
716
+ kind: "unsupported",
717
+ status: response.status,
718
+ };
719
+ }
720
+ return makeHttpFailure(response.status, body);
721
+ }
722
+ const payload = await response.json().catch(() => undefined);
723
+ const models = parseOpenAIModelList(payload);
724
+ if (!models) return { ok: false, error: "Response is not an Anthropic-compatible model list (missing data array)", kind: "format" };
725
+ return { ok: true, models };
726
+ } catch (error) {
727
+ return { ok: false, error: describeFetchError(error, requestSignal, signal), kind: "network" };
728
+ } finally {
729
+ cleanup();
730
+ }
731
+ }
732
+
733
+ async function fetchModelsForApi(api: SupportedApi, baseUrl: string, apiKey: string, signal: AbortSignal): Promise<FetchResult> {
734
+ if (api === "google-generative-ai") return fetchGeminiModels(baseUrl, apiKey, signal);
735
+ if (api === "anthropic-messages") return fetchAnthropicModels(baseUrl, apiKey, signal);
736
+ return fetchOpenAIModels(baseUrl, apiKey, signal);
737
+ }
738
+
739
+ // =============================================================================
740
+ // Model conversion
741
+ // =============================================================================
742
+
743
+ function guessLimits(api: SupportedApi, modelId: string): { contextWindow: number; maxTokens: number } {
744
+ const id = modelId.toLowerCase();
745
+ if (api === "anthropic-messages" || id.includes("claude")) return { contextWindow: 200_000, maxTokens: 64_000 };
746
+ if (api === "google-generative-ai" || id.includes("gemini")) return { contextWindow: 1_000_000, maxTokens: 65_536 };
747
+ if (/gpt-5|gpt-6|gpt-7/u.test(id)) return { contextWindow: 272_000, maxTokens: 128_000 };
748
+ if (/(^|[-_/])o[1-9](-|$)/u.test(id)) return { contextWindow: 200_000, maxTokens: 100_000 };
749
+ return { contextWindow: 131_072, maxTokens: 32_768 };
750
+ }
751
+
752
+ function toModel(
753
+ entry: StoredModel,
754
+ config: CliproxyConfig,
755
+ metadata: ReadonlyMap<string, ModelMetadata> = new Map(),
756
+ storeMetadata: ReadonlyMap<string, ModelMetadata> = new Map(),
757
+ builtin: ReadonlyMap<string, ModelMetadata> = new Map(),
758
+ ): Model<Api> {
759
+ const normalizedId = normalizeModelId(entry.id);
760
+ const meta = metadata.get(entry.id) ?? metadata.get(normalizedId);
761
+ const stored = storeMetadata.get(entry.id) ?? storeMetadata.get(normalizedId);
762
+ const builtinMeta = builtin.get(entry.id) ?? builtin.get(normalizedId);
763
+ const limits = guessLimits(config.api, entry.id);
764
+ return {
765
+ id: entry.id,
766
+ name: meta?.name ?? entry.name?.trim() ?? stored?.name ?? builtinMeta?.name ?? entry.id,
767
+ api: config.api,
768
+ provider: PROVIDER_ID,
769
+ baseUrl: config.baseUrl,
770
+ reasoning: meta?.reasoning ?? entry.reasoning ?? stored?.reasoning ?? builtinMeta?.reasoning ?? false,
771
+ input: meta?.input ?? entry.input ?? stored?.input ?? builtinMeta?.input ?? ["text"],
772
+ cost: meta?.cost ??
773
+ entry.cost ??
774
+ stored?.cost ??
775
+ builtinMeta?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
776
+ contextWindow:
777
+ meta?.contextWindow ??
778
+ entry.contextWindow ??
779
+ stored?.contextWindow ??
780
+ builtinMeta?.contextWindow ??
781
+ limits.contextWindow,
782
+ maxTokens:
783
+ meta?.maxTokens ?? entry.maxTokens ?? stored?.maxTokens ?? builtinMeta?.maxTokens ?? limits.maxTokens,
784
+ type: "chat",
785
+ };
786
+ }
787
+
788
+ /** Freeze the effective capability metadata into cliproxy.json. */
789
+ function enrichModel(
790
+ entry: StoredModel,
791
+ config: CliproxyConfig,
792
+ metadata: ReadonlyMap<string, ModelMetadata>,
793
+ storeMetadata: ReadonlyMap<string, ModelMetadata>,
794
+ builtin: ReadonlyMap<string, ModelMetadata>,
795
+ ): StoredModel {
796
+ const model = toModel(entry, config, metadata, storeMetadata, builtin);
797
+ return {
798
+ id: model.id,
799
+ name: model.name,
800
+ ...(entry.ownedBy ? { ownedBy: entry.ownedBy } : {}),
801
+ reasoning: model.reasoning,
802
+ input: model.input,
803
+ cost: model.cost,
804
+ contextWindow: model.contextWindow,
805
+ maxTokens: model.maxTokens,
806
+ };
807
+ }
808
+
809
+ function parseManualModelIds(input: string): StoredModel[] {
810
+ const seen = new Set<string>();
811
+ const models: StoredModel[] = [];
812
+ for (const part of input.split(/[,\s;,、]+/u)) {
813
+ const id = part.trim();
814
+ if (!id || seen.has(id)) continue;
815
+ seen.add(id);
816
+ models.push({ id });
817
+ }
818
+ return models;
819
+ }
820
+
821
+ function credentialKey(credential: RefreshModelsContext["credential"]): string | undefined {
822
+ if (credential?.type === "api_key" && credential.key) return credential.key;
823
+ return undefined;
824
+ }
825
+
826
+ // =============================================================================
827
+ // Login flow
828
+ // =============================================================================
829
+
830
+ function isAuthFailure(failure: FetchFailure | undefined): boolean {
831
+ return failure?.kind === "auth" || failure?.kind === "forbidden";
832
+ }
833
+
834
+ function authFailureMessage(failure: FetchFailure): string {
835
+ if (failure.error.includes("unsafe_example_api_key")) {
836
+ return "CLIProxyAPI is still using the example API key (unsafe_example_api_key). Update access.api-keys on the server and restart CLIProxyAPI.";
837
+ }
838
+ return `API key verification failed: ${failure.error}`;
839
+ }
840
+
841
+ function normalizeApiKey(value: string): string {
842
+ return value.trim().replace(/^Bearer\s+/iu, "").trim();
843
+ }
844
+
845
+ function compactMessage(value: string, maxLength = 180): string {
846
+ const singleLine = value.replace(/\s+/gu, " ").trim();
847
+ return singleLine.length > maxLength ? `${singleLine.slice(0, maxLength)}...` : singleLine;
848
+ }
849
+
850
+ /**
851
+ * Probe every compatible address, but keep OpenAI first: all OpenAI candidates
852
+ * (primary scheme, then the implicit http/https alternative) run before Gemini,
853
+ * and Gemini runs before Anthropic. The first non-empty model list wins.
854
+ */
855
+ function buildDiscoveryCandidates(roots: string[]): DiscoveryCandidate[] {
856
+ const candidates: DiscoveryCandidate[] = [];
857
+ for (const rootUrl of roots) {
858
+ candidates.push({ api: "openai-completions", rootUrl, baseUrl: openAIBaseUrl(rootUrl) });
859
+ }
860
+ for (const rootUrl of roots) {
861
+ candidates.push({ api: "google-generative-ai", rootUrl, baseUrl: geminiBaseUrl(rootUrl) });
862
+ }
863
+ for (const rootUrl of roots) {
864
+ candidates.push({ api: "anthropic-messages", rootUrl, baseUrl: anthropicBaseUrl(rootUrl) });
865
+ }
866
+ return candidates;
867
+ }
868
+
869
+ function summarizeDiscoveryAttempts(attempts: DiscoveryAttempt[]): string {
870
+ return attempts
871
+ .map(
872
+ ({ candidate, failure }) =>
873
+ `${PROVIDER_APIS[candidate.api]} ${candidate.baseUrl}: ${compactMessage(failure.error)}`,
874
+ )
875
+ .join("\n");
876
+ }
877
+
878
+ function firstAuthFailure(attempts: DiscoveryAttempt[]): FetchFailure | undefined {
879
+ return attempts.find(({ failure }) => isAuthFailure(failure))?.failure;
880
+ }
881
+
882
+ async function autoDiscoverModels(
883
+ candidates: DiscoveryCandidate[],
884
+ apiKey: string,
885
+ signal: AbortSignal,
886
+ onAttempt?: (candidate: DiscoveryCandidate, index: number, total: number) => void,
887
+ ): Promise<DiscoveryOutcome> {
888
+ const attempts: DiscoveryAttempt[] = [];
889
+ let emptySuccess: DiscoveryCandidate | undefined;
890
+
891
+ for (const [index, candidate] of candidates.entries()) {
892
+ onAttempt?.(candidate, index, candidates.length);
893
+ const result = await fetchModelsForApi(candidate.api, candidate.baseUrl, apiKey, signal);
894
+ if (result.ok) {
895
+ if (result.models.length > 0) return { ok: true, candidate, models: result.models };
896
+ emptySuccess ??= candidate;
897
+ continue;
898
+ }
899
+ attempts.push({ candidate, failure: result });
900
+ }
901
+
902
+ return { ok: false, attempts, ...(emptySuccess ? { empty: emptySuccess } : {}) };
903
+ }
904
+
905
+ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCredential> {
906
+ const domainInput = await interaction.prompt({
907
+ type: "text",
908
+ message: "CLIProxy bound domain (for example proxy.example.com or https://proxy.example.com)",
909
+ placeholder: "proxy.example.com",
910
+ });
911
+ if (!domainInput.trim()) throw new Error("Domain is required.");
912
+
913
+ const { rootUrl: initialRootUrl, implicitScheme } = normalizeDomain(domainInput);
914
+ const promptForApiKey = async (message: string): Promise<string> => {
915
+ const value = normalizeApiKey(
916
+ await interaction.prompt({
917
+ type: "secret",
918
+ message,
919
+ placeholder: "sk-...",
920
+ }),
921
+ );
922
+ if (!value) throw new Error("API key is required.");
923
+ return value;
924
+ };
925
+ let apiKey = await promptForApiKey("CLIProxy API key (stored in pi's auth.json)");
926
+
927
+ // A bare host may be either http or https; only try the alternate scheme
928
+ // automatically when the user did not spell one out.
929
+ const roots = [initialRootUrl];
930
+ if (implicitScheme) {
931
+ const alternate = swapScheme(initialRootUrl);
932
+ if (alternate) roots.push(alternate);
933
+ }
934
+
935
+ let activeRoot = initialRootUrl;
936
+ let api: SupportedApi = "openai-completions";
937
+ let baseUrl = openAIBaseUrl(initialRootUrl);
938
+ let models: StoredModel[] = [];
939
+ let source: "auto" | "manual" = "auto";
940
+ let lastError: string | undefined;
941
+
942
+ const candidates = buildDiscoveryCandidates(roots);
943
+ const discover = async (): Promise<DiscoveryOutcome> =>
944
+ autoDiscoverModels(candidates, apiKey, interaction.signal, (candidate, index, total) => {
945
+ interaction.notify({
946
+ type: "progress",
947
+ message: `Trying ${PROVIDER_APIS[candidate.api]}: ${candidate.baseUrl} (${index + 1}/${total})...`,
948
+ });
949
+ });
950
+
951
+ let outcome = await discover();
952
+
953
+ // A rejected key applies to every protocol, so only ask again after all
954
+ // compatible addresses have been tried. This keeps the login flow automatic
955
+ // while still surfacing a real credential problem instead of hiding it in
956
+ // the address picker.
957
+ if (!outcome.ok) {
958
+ let authFailure = firstAuthFailure(outcome.attempts);
959
+ if (authFailure?.error.includes("unsafe_example_api_key")) {
960
+ throw new Error(authFailureMessage(authFailure));
961
+ }
962
+ if (authFailure) {
963
+ interaction.notify({
964
+ type: "info",
965
+ message: `All compatible endpoints failed authentication: ${compactMessage(authFailure.error)}\nEnter the API key again.`,
966
+ });
967
+ apiKey = await promptForApiKey("Re-enter the CLIProxy API key");
968
+ outcome = await discover();
969
+ authFailure = outcome.ok ? undefined : firstAuthFailure(outcome.attempts);
970
+ if (authFailure) throw new Error(authFailureMessage(authFailure));
971
+ }
972
+ }
973
+
974
+ if (outcome.ok) {
975
+ activeRoot = outcome.candidate.rootUrl;
976
+ baseUrl = outcome.candidate.baseUrl;
977
+ api = outcome.candidate.api;
978
+ models = outcome.models;
979
+ lastError = undefined;
980
+ interaction.notify({
981
+ type: "info",
982
+ message: `Selected ${PROVIDER_APIS[api]}: ${baseUrl} (${models.length} models).`,
983
+ });
984
+ } else if (outcome.empty) {
985
+ // Every address answered, but no upstream credentials are available.
986
+ // Picking another address would not help; go straight to manual IDs.
987
+ activeRoot = outcome.empty.rootUrl;
988
+ baseUrl = outcome.empty.baseUrl;
989
+ api = outcome.empty.api;
990
+ models = [];
991
+ lastError = "All compatible endpoints returned an empty model list";
992
+ } else {
993
+ const responding = outcome.attempts.find(({ failure }) => failure.kind !== "network");
994
+ activeRoot = responding?.candidate.rootUrl ?? initialRootUrl;
995
+ lastError = summarizeDiscoveryAttempts(outcome.attempts) || "Unknown error";
996
+
997
+ const diagnostic = await probeCliproxy(activeRoot, interaction.signal);
998
+ interaction.notify({
999
+ type: "info",
1000
+ message: `Tried ${outcome.attempts.length} compatible endpoints, but none returned a model list:\n${lastError}${diagnostic ? `\n${diagnostic}` : ""}`,
1001
+ });
1002
+
1003
+ const choice = await interaction.prompt({
1004
+ type: "select",
1005
+ message: "Select a CLIProxy-compatible endpoint",
1006
+ options: [
1007
+ { id: "retry-openai", label: `Retry OpenAI compatible: ${openAIBaseUrl(activeRoot)}` },
1008
+ { id: "anthropic", label: `Anthropic compatible: ${anthropicBaseUrl(activeRoot)}` },
1009
+ { id: "gemini", label: `Gemini compatible: ${geminiBaseUrl(activeRoot)}` },
1010
+ { id: "custom", label: "Custom compatible endpoint..." },
1011
+ ],
1012
+ });
1013
+
1014
+ if (choice === "retry-openai") {
1015
+ api = "openai-completions";
1016
+ baseUrl = openAIBaseUrl(activeRoot);
1017
+ const retry = await fetchOpenAIModels(baseUrl, apiKey, interaction.signal);
1018
+ if (!retry.ok && isAuthFailure(retry)) throw new Error(authFailureMessage(retry));
1019
+ if (retry.ok) {
1020
+ models = retry.models;
1021
+ lastError = undefined;
1022
+ } else {
1023
+ lastError = retry.error;
1024
+ }
1025
+ } else if (choice === "anthropic") {
1026
+ api = "anthropic-messages";
1027
+ baseUrl = anthropicBaseUrl(activeRoot);
1028
+ const anthropic = await fetchAnthropicModels(baseUrl, apiKey, interaction.signal);
1029
+ if (!anthropic.ok && isAuthFailure(anthropic)) throw new Error(authFailureMessage(anthropic));
1030
+ if (anthropic.ok) {
1031
+ models = anthropic.models;
1032
+ lastError = undefined;
1033
+ } else {
1034
+ lastError = anthropic.error;
1035
+ if (anthropic.kind === "unsupported") {
1036
+ interaction.notify({
1037
+ type: "info",
1038
+ message: "This Anthropic-compatible endpoint has no model list endpoint. Enter model IDs manually in the next step.",
1039
+ });
1040
+ }
1041
+ }
1042
+ } else if (choice === "gemini") {
1043
+ api = "google-generative-ai";
1044
+ baseUrl = geminiBaseUrl(activeRoot);
1045
+ const gemini = await fetchGeminiModels(baseUrl, apiKey, interaction.signal);
1046
+ if (!gemini.ok && isAuthFailure(gemini)) throw new Error(authFailureMessage(gemini));
1047
+ if (gemini.ok) {
1048
+ models = gemini.models;
1049
+ lastError = undefined;
1050
+ } else {
1051
+ lastError = gemini.error;
1052
+ }
1053
+ } else {
1054
+ const customInput = (
1055
+ await interaction.prompt({
1056
+ type: "text",
1057
+ message: "Custom compatible endpoint (full base URL, for example https://proxy.example.com/v1)",
1058
+ placeholder: "https://proxy.example.com/v1",
1059
+ })
1060
+ ).trim();
1061
+ if (!customInput) throw new Error("A custom endpoint is required.");
1062
+ const protocolChoice = await interaction.prompt({
1063
+ type: "select",
1064
+ message: "Compatible protocol for this endpoint",
1065
+ options: [
1066
+ { id: "openai", label: "OpenAI compatible (/v1/chat/completions)" },
1067
+ { id: "anthropic", label: "Anthropic compatible (/v1/messages)" },
1068
+ { id: "gemini", label: "Gemini compatible (/v1beta)" },
1069
+ ],
1070
+ });
1071
+ api =
1072
+ protocolChoice === "anthropic"
1073
+ ? "anthropic-messages"
1074
+ : protocolChoice === "gemini"
1075
+ ? "google-generative-ai"
1076
+ : "openai-completions";
1077
+ baseUrl = trimTrailingSlashes(customInput);
1078
+ const retry = await fetchModelsForApi(api, baseUrl, apiKey, interaction.signal);
1079
+ if (!retry.ok && isAuthFailure(retry)) throw new Error(authFailureMessage(retry));
1080
+ if (retry.ok) {
1081
+ models = retry.models;
1082
+ lastError = undefined;
1083
+ } else {
1084
+ lastError = retry.error;
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ if (models.length === 0) {
1090
+ const manual = await interaction.prompt({
1091
+ type: "text",
1092
+ message: "No model list was retrieved. Enter model IDs manually (comma or space separated; leave empty to add none for now)",
1093
+ placeholder: "claude-sonnet-4-5, gemini-2.5-pro",
1094
+ });
1095
+ models = parseManualModelIds(manual);
1096
+ source = "manual";
1097
+ }
1098
+
1099
+ const configBase: CliproxyConfig = {
1100
+ version: CONFIG_VERSION,
1101
+ domain: domainInput.trim(),
1102
+ rootUrl: activeRoot,
1103
+ baseUrl,
1104
+ api,
1105
+ models: [],
1106
+ source,
1107
+ updatedAt: 0,
1108
+ };
1109
+ const metadata = readModelsJsonMetadata();
1110
+ const storeMetadata = readModelsStoreMetadata();
1111
+ const builtin = readBuiltinMetadata();
1112
+ const enriched = models.map((entry) => enrichModel(entry, configBase, metadata, storeMetadata, builtin));
1113
+ const config: CliproxyConfig = {
1114
+ ...configBase,
1115
+ models: enriched,
1116
+ updatedAt: Date.now(),
1117
+ ...(lastError && enriched.length === 0 ? { lastError } : {}),
1118
+ };
1119
+ try {
1120
+ writeConfig(config);
1121
+ } catch (error) {
1122
+ throw new Error(
1123
+ `Failed to write CLIProxy config: ${error instanceof Error ? error.message : String(error)} (${configPath()})`,
1124
+ );
1125
+ }
1126
+
1127
+ if (models.length > 0) {
1128
+ interaction.notify({
1129
+ type: "info",
1130
+ message: `CLIProxy config saved: ${baseUrl} (${models.length} models, ${PROVIDER_APIS[api]}).`,
1131
+ });
1132
+ } else {
1133
+ interaction.notify({
1134
+ type: "info",
1135
+ message: `CLIProxy config saved: ${baseUrl}, but no models were imported. Use /cliproxy refresh to retry.`,
1136
+ });
1137
+ }
1138
+
1139
+ return { type: "api_key", key: apiKey };
1140
+ }
1141
+
1142
+ // =============================================================================
1143
+ // Dynamic model discovery
1144
+ // =============================================================================
1145
+
1146
+ async function refreshProviderModels(context: RefreshModelsContext): Promise<readonly Model<Api>[]> {
1147
+ const config = readConfig();
1148
+ if (!config) return [];
1149
+ const metadata = readModelsJsonMetadata();
1150
+ const storeMetadata = readModelsStoreMetadata();
1151
+ const builtin = readBuiltinMetadata();
1152
+ const cached = config.models.map((entry) => toModel(entry, config, metadata, storeMetadata, builtin));
1153
+ const apiKey = credentialKey(context.credential);
1154
+ if (!apiKey) return cached;
1155
+
1156
+ // Opening /model asks for a refresh every time. Reuse a recent snapshot
1157
+ // unless the caller explicitly forces it (/cliproxy refresh) or it is stale.
1158
+ const fresh = config.updatedAt > 0 && Date.now() - config.updatedAt < MODEL_REFRESH_TTL_MS;
1159
+ if (!context.force && fresh) return cached;
1160
+
1161
+ const result = await fetchModelsForApi(config.api, config.baseUrl, apiKey, context.signal);
1162
+ if (context.signal.aborted) return cached;
1163
+ if (!result.ok) {
1164
+ // CLIProxyAPI's Anthropic endpoint may not expose /v1/models at all.
1165
+ // That is expected, not a refresh error; keep the cached catalog.
1166
+ if (result.kind === "unsupported") return cached;
1167
+ lastRefreshError = result.error;
1168
+ if (config.lastError !== result.error) {
1169
+ try {
1170
+ writeConfig({ ...config, lastError: result.error });
1171
+ } catch {
1172
+ // Keep serving the cached catalog even if the error cannot be persisted.
1173
+ }
1174
+ }
1175
+ return cached;
1176
+ }
1177
+ lastRefreshError = undefined;
1178
+ const fetched = result.models;
1179
+ // An empty upstream list is legitimate (no credentials/cooldown). Keep
1180
+ // manually entered models but do honor real automatic empties.
1181
+ if (fetched.length === 0 && config.source === "manual") {
1182
+ try {
1183
+ writeConfig({
1184
+ ...config,
1185
+ updatedAt: Date.now(),
1186
+ lastError: "Upstream returned an empty model list (manual models kept)",
1187
+ });
1188
+ } catch {
1189
+ // Best effort.
1190
+ }
1191
+ return cached;
1192
+ }
1193
+
1194
+ const enriched = fetched.map((entry) => enrichModel(entry, config, metadata, storeMetadata, builtin));
1195
+ const updated: CliproxyConfig = {
1196
+ ...config,
1197
+ models: enriched,
1198
+ source: "auto",
1199
+ updatedAt: Date.now(),
1200
+ ...(enriched.length === 0 ? { lastError: "Upstream returned an empty model list" } : {}),
1201
+ };
1202
+ if (enriched.length > 0) delete updated.lastError;
1203
+ try {
1204
+ writeConfig(updated);
1205
+ } catch {
1206
+ // Model discovery still works in memory even if the cache cannot be persisted.
1207
+ }
1208
+ return enriched.map((entry) => toModel(entry, updated, metadata, storeMetadata, builtin));
1209
+ }
1210
+
1211
+ // =============================================================================
1212
+ // Provider registration
1213
+ // =============================================================================
1214
+
1215
+ function buildProvider(config: CliproxyConfig | undefined): Provider {
1216
+ const metadata = readModelsJsonMetadata();
1217
+ const storeMetadata = readModelsStoreMetadata();
1218
+ const builtin = readBuiltinMetadata();
1219
+ const models = config
1220
+ ? config.models.map((entry) => toModel(entry, config, metadata, storeMetadata, builtin))
1221
+ : [];
1222
+ const api: Partial<Record<Api, ProviderStreams>> = {
1223
+ "openai-completions": openAICompletionsApi(),
1224
+ "anthropic-messages": anthropicMessagesApi(),
1225
+ "google-generative-ai": googleGenerativeAIApi(),
1226
+ };
1227
+ return createProvider({
1228
+ id: PROVIDER_ID,
1229
+ name: PROVIDER_NAME,
1230
+ auth: {
1231
+ apiKey: {
1232
+ name: AUTH_NAME,
1233
+ login: runLogin,
1234
+ resolve: async ({ credential }) =>
1235
+ credential?.key
1236
+ ? { auth: { apiKey: credential.key }, source: "stored credential" }
1237
+ : undefined,
1238
+ },
1239
+ },
1240
+ // Baseline models come from cliproxy.json so offline startup and
1241
+ // `pi --list-models` work without a models-store entry. Dynamic
1242
+ // fetchModels() refreshes them; model-level baseUrl is authoritative.
1243
+ models,
1244
+ fetchModels: refreshProviderModels,
1245
+ api,
1246
+ });
1247
+ }
1248
+
1249
+ // =============================================================================
1250
+ // Extension entry point
1251
+ // =============================================================================
1252
+
1253
+ export default function (pi: ExtensionAPI): void {
1254
+ pi.registerProvider(buildProvider(readConfig()));
1255
+
1256
+ pi.registerCommand("cliproxy", {
1257
+ description: "Show CLIProxy config, or run /cliproxy refresh to refresh models",
1258
+ handler: async (args, ctx) => {
1259
+ const subcommand = args.trim().toLowerCase();
1260
+ const config = readConfig();
1261
+
1262
+ if (subcommand === "refresh") {
1263
+ if (!config) {
1264
+ ctx.ui.notify("CLIProxy is not configured. Run /login and choose \"Sign in with an API key\" -> CLIProxy.", "warning");
1265
+ return;
1266
+ }
1267
+ let refreshError: string | undefined;
1268
+ try {
1269
+ const result = await ctx.modelRegistry.refresh({
1270
+ providers: [PROVIDER_ID],
1271
+ force: true,
1272
+ signal: AbortSignal.timeout(20_000),
1273
+ });
1274
+ refreshError = result.errors.get(PROVIDER_ID)?.message;
1275
+ } catch (error) {
1276
+ refreshError = error instanceof Error ? error.message : String(error);
1277
+ }
1278
+ const count = ctx.modelRegistry.getAll().filter((model) => model.provider === PROVIDER_ID).length;
1279
+ if (refreshError) {
1280
+ ctx.ui.notify(`CLIProxy model refresh failed: ${refreshError}`, "error");
1281
+ } else if (lastRefreshError) {
1282
+ ctx.ui.notify(`CLIProxy model refresh failed; using cached models (${count}): ${lastRefreshError}`, "warning");
1283
+ } else if (count === 0) {
1284
+ ctx.ui.notify("CLIProxy refreshed, but no models were found. Check the API key or run /login again.", "warning");
1285
+ } else {
1286
+ ctx.ui.notify(`CLIProxy models refreshed: ${count}.`, "info");
1287
+ }
1288
+ return;
1289
+ }
1290
+
1291
+ if (!config) {
1292
+ ctx.ui.notify(
1293
+ `CLIProxy is not configured.\nRun /login -> "Sign in with an API key" -> CLIProxy and enter the bound domain and API key.\nConfig will be written to: ${configPath()}`,
1294
+ "info",
1295
+ );
1296
+ return;
1297
+ }
1298
+
1299
+ const count = ctx.modelRegistry.getAll().filter((model) => model.provider === PROVIDER_ID).length;
1300
+ const lastError = lastRefreshError ?? config.lastError;
1301
+ const lines = [
1302
+ `CLIProxy: ${config.baseUrl}`,
1303
+ `Protocol: ${PROVIDER_APIS[config.api]} (${config.api})`,
1304
+ `Models: ${count}${config.models.length !== count ? ` (${config.models.length} cached)` : ""}`,
1305
+ `Config: ${configPath()}`,
1306
+ lastError ? `Last error: ${lastError}` : undefined,
1307
+ "Reconfigure: /login -> \"Sign in with an API key\" -> CLIProxy",
1308
+ "Refresh models: /cliproxy refresh",
1309
+ ].filter((line): line is string => Boolean(line));
1310
+ ctx.ui.notify(lines.join("\n"), "info");
1311
+ },
1312
+ });
1313
+ }