pi-cliproxyapi-autoconnect 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +331 -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(域名 + 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 兼容",
69
+ "anthropic-messages": "Anthropic 兼容",
70
+ "google-generative-ai": "Gemini 兼容",
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("未输入绑定域名");
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(`无法解析域名:"${input}"。示例:proxy.example.com 或 https://proxy.example.com`);
493
+ }
494
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
495
+ throw new Error(`仅支持 http/https 地址,收到:${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("请求超时")), 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 "已取消";
553
+ if (ownSignal.aborted) return "请求超时";
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 "已探测到 CLIProxyAPI(/healthz 正常)";
591
+ return "已探测到 /healthz,但响应不是 CLIProxyAPI 格式";
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",
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: "响应不是 OpenAI 兼容的模型列表(缺少 data 数组)", 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" },
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: "响应不是 Gemini 兼容的模型列表(缺少 models 数组)", 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",
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: `Anthropic 兼容接口没有可用的模型列表(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: "响应不是 Anthropic 兼容的模型列表(缺少 data 数组)", 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 "CLIProxy 服务端仍在使用示例 API Key(unsafe_example_api_key),请先修改服务端 access.api-keys 并重启 CLIProxyAPI。";
837
+ }
838
+ return `API Key 验证失败:${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 绑定域名(例如 proxy.example.com 或 https://proxy.example.com)",
909
+ placeholder: "proxy.example.com",
910
+ });
911
+ if (!domainInput.trim()) throw new Error("未输入绑定域名");
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");
923
+ return value;
924
+ };
925
+ let apiKey = await promptForApiKey("CLIProxy API Key(保存在 pi 的 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: `正在尝试 ${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: `所有兼容地址都未能通过鉴权:${compactMessage(authFailure.error)}\n请重新输入一次 API Key。`,
966
+ });
967
+ apiKey = await promptForApiKey("重新输入 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: `自动选择 ${PROVIDER_APIS[api]}:${baseUrl}(${models.length} 个模型)。`,
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 = "所有兼容地址均返回空模型列表";
992
+ } else {
993
+ const responding = outcome.attempts.find(({ failure }) => failure.kind !== "network");
994
+ activeRoot = responding?.candidate.rootUrl ?? initialRootUrl;
995
+ lastError = summarizeDiscoveryAttempts(outcome.attempts) || "未知错误";
996
+
997
+ const diagnostic = await probeCliproxy(activeRoot, interaction.signal);
998
+ interaction.notify({
999
+ type: "info",
1000
+ message: `已自动尝试 ${outcome.attempts.length} 个兼容地址,均未获取到模型列表:\n${lastError}${diagnostic ? `\n${diagnostic}` : ""}`,
1001
+ });
1002
+
1003
+ const choice = await interaction.prompt({
1004
+ type: "select",
1005
+ message: "请选择 CLIProxy 兼容地址",
1006
+ options: [
1007
+ { id: "retry-openai", label: `重试 OpenAI 兼容地址:${openAIBaseUrl(activeRoot)}` },
1008
+ { id: "anthropic", label: `Anthropic 兼容地址:${anthropicBaseUrl(activeRoot)}` },
1009
+ { id: "gemini", label: `Gemini 兼容地址:${geminiBaseUrl(activeRoot)}` },
1010
+ { id: "custom", label: "自定义兼容地址…" },
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: "该 Anthropic 兼容地址没有模型列表端点,请在下一步手动输入模型 ID。",
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: "自定义兼容地址(完整 base URL,例如 https://proxy.example.com/v1)",
1058
+ placeholder: "https://proxy.example.com/v1",
1059
+ })
1060
+ ).trim();
1061
+ if (!customInput) throw new Error("未输入自定义地址");
1062
+ const protocolChoice = await interaction.prompt({
1063
+ type: "select",
1064
+ message: "该地址使用的兼容协议",
1065
+ options: [
1066
+ { id: "openai", label: "OpenAI 兼容(/v1/chat/completions)" },
1067
+ { id: "anthropic", label: "Anthropic 兼容(/v1/messages)" },
1068
+ { id: "gemini", label: "Gemini 兼容(/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: "未获取到模型列表。可手动输入模型 ID(逗号或空格分隔,留空则暂不添加模型)",
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
+ `CLIProxy 配置写入失败:${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 配置已保存:${baseUrl}(${models.length} 个模型,${PROVIDER_APIS[api]})。`,
1131
+ });
1132
+ } else {
1133
+ interaction.notify({
1134
+ type: "info",
1135
+ message: `CLIProxy 配置已保存:${baseUrl},但暂未导入任何模型。可使用 /cliproxy refresh 重试。`,
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: "上游返回的模型列表为空(保留手动模型)",
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: "上游返回的模型列表为空" } : {}),
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: "查看 CLIProxy 配置,或执行 /cliproxy refresh 刷新模型列表",
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。请运行 /login 并选择“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 模型刷新失败:${refreshError}`, "error");
1281
+ } else if (lastRefreshError) {
1282
+ ctx.ui.notify(`CLIProxy 模型刷新失败,继续使用缓存(共 ${count} 个):${lastRefreshError}`, "warning");
1283
+ } else if (count === 0) {
1284
+ ctx.ui.notify("CLIProxy 已刷新,但仍未获取到模型。请检查 API Key 或运行 /login 重新配置。", "warning");
1285
+ } else {
1286
+ ctx.ui.notify(`CLIProxy 模型已刷新,共 ${count} 个。`, "info");
1287
+ }
1288
+ return;
1289
+ }
1290
+
1291
+ if (!config) {
1292
+ ctx.ui.notify(
1293
+ `尚未配置 CLIProxy。\n请运行 /login → “Sign in with an API key” → CLIProxy,输入绑定域名和 API Key。\n配置将写入:${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
+ `协议:${PROVIDER_APIS[config.api]}(${config.api})`,
1304
+ `模型:${count} 个${config.models.length !== count ? `(缓存 ${config.models.length} 个)` : ""}`,
1305
+ `配置:${configPath()}`,
1306
+ lastError ? `最近错误:${lastError}` : undefined,
1307
+ "重新配置:/login → “Sign in with an API key” → CLIProxy",
1308
+ "刷新模型:/cliproxy refresh",
1309
+ ].filter((line): line is string => Boolean(line));
1310
+ ctx.ui.notify(lines.join("\n"), "info");
1311
+ },
1312
+ });
1313
+ }