pi-better-model-select 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,503 @@
1
+ // Add / update / delete flows against the effective model configuration.
2
+ //
3
+ // Each mutation asks which file to write, then re-reads that file immediately
4
+ // before saving so another pi session's edit is not silently clobbered.
5
+
6
+ import {
7
+ DEFAULT_CONTEXT_WINDOW,
8
+ DEFAULT_MAX_TOKENS,
9
+ fetchModelMeta,
10
+ listEndpointModels,
11
+ parsePositiveInt,
12
+ providerEndpoint,
13
+ resolveKey,
14
+ type ModelMeta,
15
+ } from "./meta.ts";
16
+ import {
17
+ configContainsModel,
18
+ loadConfig,
19
+ loadConfigFile,
20
+ saveConfig,
21
+ tombstoneModel,
22
+ upsertModel,
23
+ type ConfigTarget,
24
+ type ModelEntry,
25
+ type ModelsConfig,
26
+ } from "./store.ts";
27
+
28
+ // Minimal shape of the pi extension command context we rely on.
29
+ export interface FlowCtx {
30
+ ui: {
31
+ notify: (message: string, level: string) => void;
32
+ select: (message: string, options: string[]) => Promise<string | undefined>;
33
+ input: (message: string, initial?: string) => Promise<string | undefined>;
34
+ confirm: (title: string, message: string) => Promise<boolean>;
35
+ };
36
+ }
37
+
38
+ const INPUT_OPTIONS: string[][] = [["text"], ["text", "image"]];
39
+
40
+ const TARGET_LABELS = {
41
+ "models.local.json": "models.local.json (personal BMS overlay)",
42
+ "models.json": "models.json (Pi configuration)",
43
+ } satisfies Record<ConfigTarget, string>;
44
+
45
+ async function selectWriteTarget(
46
+ ctx: FlowCtx,
47
+ message = "Write changes to which file?",
48
+ ): Promise<ConfigTarget | undefined> {
49
+ const options = [TARGET_LABELS["models.local.json"], TARGET_LABELS["models.json"]];
50
+ const pick = await ctx.ui.select(message, options);
51
+ if (pick === TARGET_LABELS["models.local.json"]) return "models.local.json";
52
+ if (pick === TARGET_LABELS["models.json"]) return "models.json";
53
+ return undefined;
54
+ }
55
+
56
+ function providerOverlay(config: ModelsConfig, providerName: string): Record<string, unknown> {
57
+ const provider = config.providers?.[providerName] ?? {};
58
+ const { models: _models, ...settings } = provider;
59
+ return settings;
60
+ }
61
+
62
+ function completeModel(config: ModelsConfig, providerName: string, modelId: string): ModelEntry | undefined {
63
+ const model = config.providers?.[providerName]?.models?.find((entry) => entry.id === modelId);
64
+ return model ? structuredClone(model) : undefined;
65
+ }
66
+
67
+ function ensureProviderSettings(
68
+ targetConfig: ModelsConfig,
69
+ effectiveConfig: ModelsConfig,
70
+ providerName: string,
71
+ ): void {
72
+ targetConfig.providers = targetConfig.providers ?? {};
73
+ targetConfig.providers[providerName] = {
74
+ ...providerOverlay(effectiveConfig, providerName),
75
+ ...(targetConfig.providers[providerName] ?? {}),
76
+ };
77
+ }
78
+
79
+ /** Present a yes/no as a select so ESC can cancel — confirm() cannot. */
80
+ async function selectYesNo(
81
+ ctx: FlowCtx,
82
+ message: string,
83
+ defaultYes: boolean,
84
+ ): Promise<boolean | undefined> {
85
+ const first = defaultYes ? "yes" : "no";
86
+ const pick = await ctx.ui.select(message, [first, defaultYes ? "no" : "yes"]);
87
+ return pick === undefined ? undefined : pick === "yes";
88
+ }
89
+
90
+ async function selectInput(
91
+ ctx: FlowCtx,
92
+ message: string,
93
+ current: string[],
94
+ ): Promise<string[] | undefined> {
95
+ const labels = INPUT_OPTIONS.map((o) => o.join("+"));
96
+ const idx = labels.indexOf(current.join("+"));
97
+ const def = labels[Math.max(idx, 0)]!;
98
+ const pick = await ctx.ui.select(message, [def, ...labels.filter((l) => l !== def)]);
99
+ if (pick === undefined) return undefined;
100
+ return INPUT_OPTIONS[labels.indexOf(pick)] ?? ["text"];
101
+ }
102
+
103
+ // ----- add -----------------------------------------------------------------
104
+
105
+ export async function addFlow(ctx: FlowCtx): Promise<void> {
106
+ const config = loadConfig(ctx);
107
+ if (!config) return;
108
+ const providers = config.providers ?? {};
109
+
110
+ const names = Object.keys(providers).filter((n) => typeof providers[n]?.baseUrl === "string");
111
+ if (names.length === 0) {
112
+ ctx.ui.notify("No providers with a baseUrl in the effective model config", "warning");
113
+ return;
114
+ }
115
+
116
+ // Running breadcrumb of everything chosen so far, shown atop each prompt.
117
+ const crumbs: string[] = [];
118
+ const header = () =>
119
+ crumbs.length ? `Selected so far:\n${crumbs.map((c) => ` • ${c}`).join("\n")}\n\n` : "";
120
+
121
+ const providerName = await ctx.ui.select(`${header()}Select provider:`, names);
122
+ if (providerName === undefined) return;
123
+ const provider = providers[providerName]!;
124
+ crumbs.push(`Provider: ${providerName}`);
125
+
126
+ const base = String(provider.baseUrl).replace(/\/+$/, "");
127
+ const key = resolveKey(provider.apiKey);
128
+
129
+ let ids: string[];
130
+ try {
131
+ ids = await listEndpointModels(base, key);
132
+ } catch (e) {
133
+ ctx.ui.notify(`Failed to list models: ${e}`, "error");
134
+ return;
135
+ }
136
+
137
+ const existing = new Set((provider.models ?? []).map((m) => m.id));
138
+ const candidates = ids.filter((id) => !existing.has(id)).sort();
139
+ if (candidates.length === 0) {
140
+ ctx.ui.notify("All endpoint models are already configured", "info");
141
+ return;
142
+ }
143
+
144
+ const modelId = await ctx.ui.select(
145
+ `${header()}Add which model to "${providerName}"?`,
146
+ candidates,
147
+ );
148
+ if (modelId === undefined) return;
149
+ crumbs.push(`Model: ${modelId}`);
150
+
151
+ const meta = await fetchModelMeta(base, key, modelId);
152
+
153
+ const prefilled: string[] = [];
154
+ if (meta.contextWindow !== undefined) prefilled.push(`ctx=${meta.contextWindow}`);
155
+ if (meta.maxTokens !== undefined) prefilled.push(`max=${meta.maxTokens}`);
156
+ if (meta.reasoning !== undefined) prefilled.push(`reasoning=${meta.reasoning}`);
157
+ if (prefilled.length > 0) {
158
+ ctx.ui.notify(`Auto-filled from provider: ${prefilled.join(", ")}`, "info");
159
+ }
160
+
161
+ const nameRaw = await ctx.ui.input(`${header()}Display name:`, modelId);
162
+ if (nameRaw === undefined) return;
163
+ const name = nameRaw || modelId;
164
+ crumbs.push(`Name: ${name}`);
165
+
166
+ const cwDefault = meta.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
167
+ const cwRaw = await ctx.ui.input(`${header()}Context window:`, String(cwDefault));
168
+ if (cwRaw === undefined) return;
169
+ const contextWindow = parsePositiveInt(cwRaw, cwDefault);
170
+ crumbs.push(`Context window: ${contextWindow}`);
171
+
172
+ const mtDefault = meta.maxTokens ?? DEFAULT_MAX_TOKENS;
173
+ const mtRaw = await ctx.ui.input(`${header()}Max output tokens:`, String(mtDefault));
174
+ if (mtRaw === undefined) return;
175
+ const maxTokens = parsePositiveInt(mtRaw, mtDefault);
176
+ crumbs.push(`Max output tokens: ${maxTokens}`);
177
+
178
+ const reasoning = await selectYesNo(
179
+ ctx,
180
+ `${header()}Reasoning model?`,
181
+ Boolean(meta.reasoning),
182
+ );
183
+ if (reasoning === undefined) return;
184
+ crumbs.push(`Reasoning: ${reasoning ? "yes" : "no"}`);
185
+
186
+ const input = await selectInput(ctx, `${header()}Input modalities:`, meta.input ?? ["text"]);
187
+ if (input === undefined) return;
188
+ crumbs.push(`Input: ${input.join("+")}`);
189
+
190
+ const writeTarget = await selectWriteTarget(ctx);
191
+ if (!writeTarget) return;
192
+ const ok = await ctx.ui.confirm(
193
+ `${header()}Add this model to "${providerName}"?`,
194
+ `Write the entry to ${writeTarget}?`,
195
+ );
196
+ if (!ok) {
197
+ ctx.ui.notify("Cancelled. Nothing written.", "info");
198
+ return;
199
+ }
200
+
201
+ const freshEffective = loadConfig(ctx);
202
+ const freshTarget = loadConfigFile(ctx, writeTarget, writeTarget === "models.local.json");
203
+ if (!freshEffective || !freshTarget) return;
204
+ if (!freshEffective.providers?.[providerName]) {
205
+ ctx.ui.notify(`Provider "${providerName}" no longer exists.`, "error");
206
+ return;
207
+ }
208
+ if (configContainsModel(freshEffective, providerName, modelId)) {
209
+ ctx.ui.notify(`Model "${modelId}" is already configured in ${providerName}.`, "warning");
210
+ return;
211
+ }
212
+ ensureProviderSettings(freshTarget, freshEffective, providerName);
213
+ upsertModel(freshTarget, providerName, {
214
+ id: modelId,
215
+ name,
216
+ reasoning,
217
+ input,
218
+ contextWindow,
219
+ maxTokens,
220
+ });
221
+ saveConfig(freshTarget, writeTarget);
222
+ ctx.ui.notify(`Added "${modelId}" to ${providerName} in ${writeTarget}.`, "info");
223
+ }
224
+
225
+ // ----- update --------------------------------------------------------------
226
+
227
+ interface Field {
228
+ key: string;
229
+ label: string;
230
+ current: string;
231
+ suggest?: string;
232
+ edit: (header: () => string) => Promise<unknown | undefined>;
233
+ }
234
+
235
+ export async function updateFlow(
236
+ ctx: FlowCtx,
237
+ preselect?: { provider: string; modelId: string },
238
+ ): Promise<void> {
239
+ const config = loadConfig(ctx);
240
+ if (!config) return;
241
+ const providers = config.providers ?? {};
242
+
243
+ let providerName: string;
244
+ let existing: ModelEntry;
245
+
246
+ if (preselect && providers[preselect.provider]?.models?.some((m) => m.id === preselect.modelId)) {
247
+ providerName = preselect.provider;
248
+ existing = providers[preselect.provider]!.models!.find((m) => m.id === preselect.modelId)!;
249
+ } else {
250
+ const map = new Map<string, { provider: string; model: ModelEntry }>();
251
+ for (const p of Object.keys(providers)) {
252
+ for (const m of providers[p]!.models ?? []) {
253
+ const label = `${m.name ?? m.id} [${p}] (ctx: ${m.contextWindow ?? "?"}, max: ${m.maxTokens ?? "?"}, reasoning: ${m.reasoning ? "yes" : "no"})`;
254
+ map.set(label, { provider: p, model: m });
255
+ }
256
+ }
257
+ if (map.size === 0) {
258
+ ctx.ui.notify("No models configured to update.", "info");
259
+ return;
260
+ }
261
+ const label = await ctx.ui.select("Update which model?", [...map.keys()]);
262
+ if (label === undefined) return;
263
+ const hit = map.get(label)!;
264
+ providerName = hit.provider;
265
+ existing = hit.model;
266
+ }
267
+
268
+ // Try to refresh metadata from the provider.
269
+ const ep = providerEndpoint(config, providerName);
270
+ let meta: ModelMeta = {};
271
+ if (ep) {
272
+ meta = await fetchModelMeta(ep.base, ep.key, existing.id);
273
+ const diffs: string[] = [];
274
+ if (meta.contextWindow !== undefined && meta.contextWindow !== existing.contextWindow)
275
+ diffs.push(`ctx: ${existing.contextWindow ?? "?"} → ${meta.contextWindow}`);
276
+ if (meta.maxTokens !== undefined && meta.maxTokens !== existing.maxTokens)
277
+ diffs.push(`max: ${existing.maxTokens ?? "?"} → ${meta.maxTokens}`);
278
+ if (meta.reasoning !== undefined && meta.reasoning !== (existing.reasoning ?? false))
279
+ diffs.push(`reasoning: ${existing.reasoning ?? false} → ${meta.reasoning}`);
280
+ if (meta.input !== undefined && meta.input.join("+") !== (existing.input ?? ["text"]).join("+"))
281
+ diffs.push(`input: ${(existing.input ?? ["text"]).join("+")} → ${meta.input.join("+")}`);
282
+ ctx.ui.notify(
283
+ diffs.length > 0
284
+ ? `Provider suggests updates: ${diffs.join(", ")}`
285
+ : "Provider info matches current config — no changes suggested.",
286
+ "info",
287
+ );
288
+ }
289
+
290
+ const fields: Field[] = [
291
+ {
292
+ key: "name",
293
+ label: "Display name",
294
+ current: existing.name ?? existing.id,
295
+ edit: async (header) => {
296
+ const raw = await ctx.ui.input(`${header()}Display name:`, existing.name ?? existing.id);
297
+ return raw === undefined ? undefined : raw || existing.id;
298
+ },
299
+ },
300
+ {
301
+ key: "contextWindow",
302
+ label: "Context window",
303
+ current: String(existing.contextWindow ?? DEFAULT_CONTEXT_WINDOW),
304
+ suggest: meta.contextWindow !== undefined ? String(meta.contextWindow) : undefined,
305
+ edit: async (header) => {
306
+ const cur = existing.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
307
+ const def = meta.contextWindow ?? cur;
308
+ const hint =
309
+ meta.contextWindow !== undefined && meta.contextWindow !== cur
310
+ ? ` (provider: ${meta.contextWindow}, current: ${cur})`
311
+ : "";
312
+ const raw = await ctx.ui.input(`${header()}Context window${hint}:`, String(def));
313
+ return raw === undefined ? undefined : parsePositiveInt(raw, def);
314
+ },
315
+ },
316
+ {
317
+ key: "maxTokens",
318
+ label: "Max output tokens",
319
+ current: String(existing.maxTokens ?? DEFAULT_MAX_TOKENS),
320
+ suggest: meta.maxTokens !== undefined ? String(meta.maxTokens) : undefined,
321
+ edit: async (header) => {
322
+ const cur = existing.maxTokens ?? DEFAULT_MAX_TOKENS;
323
+ const def = meta.maxTokens ?? cur;
324
+ const hint =
325
+ meta.maxTokens !== undefined && meta.maxTokens !== cur
326
+ ? ` (provider: ${meta.maxTokens}, current: ${cur})`
327
+ : "";
328
+ const raw = await ctx.ui.input(`${header()}Max output tokens${hint}:`, String(def));
329
+ return raw === undefined ? undefined : parsePositiveInt(raw, def);
330
+ },
331
+ },
332
+ {
333
+ key: "reasoning",
334
+ label: "Reasoning model",
335
+ current: existing.reasoning ? "yes" : "no",
336
+ suggest: meta.reasoning !== undefined ? (meta.reasoning ? "yes" : "no") : undefined,
337
+ edit: async (header) =>
338
+ selectYesNo(
339
+ ctx,
340
+ `${header()}Reasoning model?`,
341
+ meta.reasoning ?? existing.reasoning ?? false,
342
+ ),
343
+ },
344
+ {
345
+ key: "input",
346
+ label: "Input modalities",
347
+ current: (existing.input ?? ["text"]).join("+"),
348
+ suggest: meta.input !== undefined ? meta.input.join("+") : undefined,
349
+ edit: async (header) =>
350
+ selectInput(
351
+ ctx,
352
+ `${header()}Input modalities:`,
353
+ meta.input ?? existing.input ?? ["text"],
354
+ ),
355
+ },
356
+ ];
357
+
358
+ const changes = new Map<string, unknown>();
359
+ for (;;) {
360
+ const options = fields.map(
361
+ (f) =>
362
+ `${changes.has(f.key) ? "•" : "·"} ${f.label}: ${f.current}${
363
+ f.suggest !== undefined && f.suggest !== f.current ? ` → provider: ${f.suggest}` : ""
364
+ }${changes.has(f.key) ? ` [changed: ${String(changes.get(f.key))}]` : ""}`,
365
+ );
366
+ const pick = await ctx.ui.select(
367
+ `Update which attribute of ${existing.id} [${providerName}]?\n(• = will be written, ESC to finish)`,
368
+ [...options, "Done — write changes"],
369
+ );
370
+ if (pick === undefined || pick.startsWith("Done")) break;
371
+ const idx = options.indexOf(pick);
372
+ const field = fields[idx];
373
+ if (!field) continue;
374
+ const value = await field.edit(() => `Model: ${existing.id} [${providerName}]\n`);
375
+ if (value === undefined) continue; // ESC in the field editor — back to list
376
+ changes.set(field.key, value);
377
+ }
378
+
379
+ if (changes.size === 0) {
380
+ ctx.ui.notify("No changes made.", "info");
381
+ return;
382
+ }
383
+
384
+ const writeTarget = await selectWriteTarget(ctx);
385
+ if (!writeTarget) return;
386
+ const ok = await ctx.ui.confirm(
387
+ `Update ${existing.id}?`,
388
+ `Write ${[...changes.keys()].join(", ")} to "${providerName}" in ${writeTarget}?`,
389
+ );
390
+ if (!ok) {
391
+ ctx.ui.notify("Cancelled. Nothing updated.", "info");
392
+ return;
393
+ }
394
+
395
+ const freshEffective = loadConfig(ctx);
396
+ const freshTarget = loadConfigFile(ctx, writeTarget, writeTarget === "models.local.json");
397
+ if (!freshEffective || !freshTarget) return;
398
+ const modelEntry = completeModel(freshEffective, providerName, existing.id);
399
+ if (!modelEntry) {
400
+ ctx.ui.notify(`Model "${existing.id}" no longer exists in ${providerName}.`, "error");
401
+ return;
402
+ }
403
+ Object.assign(modelEntry, Object.fromEntries(changes));
404
+ ensureProviderSettings(freshTarget, freshEffective, providerName);
405
+ upsertModel(freshTarget, providerName, modelEntry);
406
+ saveConfig(freshTarget, writeTarget);
407
+ ctx.ui.notify(
408
+ `Updated "${existing.id}" in ${providerName} via ${writeTarget}: ${[...changes.entries()]
409
+ .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join("+") : String(v)}`)
410
+ .join(", ")}.`,
411
+ "info",
412
+ );
413
+ }
414
+
415
+ // ----- delete --------------------------------------------------------------
416
+
417
+ export async function deleteFlow(
418
+ ctx: FlowCtx,
419
+ preselect?: { provider: string; modelId: string },
420
+ ): Promise<void> {
421
+ const config = loadConfig(ctx);
422
+ if (!config) return;
423
+ const providers = config.providers ?? {};
424
+
425
+ let provider: string;
426
+ let id: string;
427
+
428
+ if (preselect && providers[preselect.provider]?.models?.some((m) => m.id === preselect.modelId)) {
429
+ provider = preselect.provider;
430
+ id = preselect.modelId;
431
+ } else {
432
+ const map = new Map<string, { provider: string; id: string }>();
433
+ for (const p of Object.keys(providers)) {
434
+ for (const m of providers[p]!.models ?? []) {
435
+ map.set(`${m.id} [${p}]`, { provider: p, id: m.id });
436
+ }
437
+ }
438
+ if (map.size === 0) {
439
+ ctx.ui.notify("No models configured to delete.", "info");
440
+ return;
441
+ }
442
+ const label = await ctx.ui.select("Delete which model?", [...map.keys()]);
443
+ if (label === undefined) return;
444
+ ({ provider, id } = map.get(label)!);
445
+ }
446
+
447
+ const base = loadConfigFile(ctx, "models.json");
448
+ const local = loadConfigFile(ctx, "models.local.json", true);
449
+ if (!base || !local) return;
450
+
451
+ const candidates: ConfigTarget[] = [];
452
+ if (configContainsModel(local, provider, id)) candidates.push("models.local.json");
453
+ if (configContainsModel(base, provider, id)) candidates.push("models.json");
454
+ if (candidates.length === 0) {
455
+ ctx.ui.notify(`Model "${id}" is no longer present in either config file.`, "warning");
456
+ return;
457
+ }
458
+
459
+ if (candidates.length === 1 && candidates[0] === "models.json") {
460
+ const ok = await ctx.ui.confirm(
461
+ `Hide inherited model ${id}?`,
462
+ "Keep models.json unchanged and add a BMS tombstone to models.local.json?",
463
+ );
464
+ if (!ok) {
465
+ ctx.ui.notify("Cancelled. Nothing deleted.", "info");
466
+ return;
467
+ }
468
+
469
+ const freshLocal = loadConfigFile(ctx, "models.local.json", true);
470
+ if (!freshLocal) return;
471
+ tombstoneModel(freshLocal, provider, id);
472
+ saveConfig(freshLocal, "models.local.json");
473
+ ctx.ui.notify(`Hidden inherited model "${id}" via models.local.json.`, "info");
474
+ return;
475
+ }
476
+
477
+ let writeTarget: ConfigTarget;
478
+ if (candidates.length === 1) {
479
+ writeTarget = candidates[0]!;
480
+ } else {
481
+ const selected = await selectWriteTarget(ctx, "Remove the model from which file?");
482
+ if (!selected) return;
483
+ writeTarget = selected;
484
+ }
485
+
486
+ const ok = await ctx.ui.confirm(
487
+ `Delete ${id}?`,
488
+ `Remove it from "${provider}" in ${writeTarget}?`,
489
+ );
490
+ if (!ok) {
491
+ ctx.ui.notify("Cancelled. Nothing deleted.", "info");
492
+ return;
493
+ }
494
+
495
+ const fresh = loadConfigFile(ctx, writeTarget, writeTarget === "models.local.json");
496
+ if (!fresh) return;
497
+ const target = fresh.providers?.[provider];
498
+ if (target?.models) {
499
+ target.models = target.models.filter((m) => m.id !== id);
500
+ saveConfig(fresh, writeTarget);
501
+ ctx.ui.notify(`Deleted "${id}" from ${provider} in ${writeTarget}.`, "info");
502
+ }
503
+ }
@@ -0,0 +1,171 @@
1
+ // Provider metadata discovery.
2
+ //
3
+ // Tries, in order: LiteLLM /model/info, OpenAI-style /models/{id}, then the
4
+ // public LiteLLM cost map. Every step is best-effort — a plain OpenAI-compatible
5
+ // endpoint yields nothing and the caller falls back to explicit defaults.
6
+
7
+ import type { ModelsConfig } from "./store.ts";
8
+
9
+ export const DEFAULT_CONTEXT_WINDOW = 128000;
10
+ export const DEFAULT_MAX_TOKENS = 32768;
11
+
12
+ export interface ModelMeta {
13
+ contextWindow?: number;
14
+ maxTokens?: number;
15
+ reasoning?: boolean;
16
+ input?: string[];
17
+ }
18
+
19
+ /** Parse a positive integer; falls back instead of writing NaN on bad input. */
20
+ export function parsePositiveInt(raw: string | undefined, fallback: number): number {
21
+ const n = parseInt(raw ?? "", 10);
22
+ return Number.isFinite(n) && n > 0 ? n : fallback;
23
+ }
24
+
25
+ /** apiKey in models.json may be a literal ("sk-...") or an env ref ("$ENV_VAR"). */
26
+ export function resolveKey(apiKey?: string): string {
27
+ if (!apiKey) return "";
28
+ return apiKey.startsWith("$") ? process.env[apiKey.slice(1)] ?? "" : apiKey;
29
+ }
30
+
31
+ /** Resolve provider API key (literal or $ENV_REF) + base URL. */
32
+ export function providerEndpoint(
33
+ config: ModelsConfig,
34
+ providerName: string,
35
+ ): { base: string; key: string } | null {
36
+ const provider = config.providers?.[providerName];
37
+ if (!provider?.baseUrl) return null;
38
+ return {
39
+ base: String(provider.baseUrl).replace(/\/+$/, ""),
40
+ key: resolveKey(provider.apiKey),
41
+ };
42
+ }
43
+
44
+ /** List model ids advertised by an OpenAI-compatible /models endpoint. */
45
+ export async function listEndpointModels(base: string, key: string): Promise<string[]> {
46
+ const headers: Record<string, string> = key ? { Authorization: `Bearer ${key}` } : {};
47
+ const res = await fetch(`${base}/models`, { headers });
48
+ if (!res.ok) throw new Error(`GET ${base}/models -> ${res.status} ${res.statusText}`);
49
+ const body = (await res.json()) as { data?: Array<{ id?: string }> };
50
+ return (body.data ?? []).map((m) => m.id).filter((id): id is string => Boolean(id));
51
+ }
52
+
53
+ export async function fetchModelMeta(
54
+ base: string,
55
+ key: string,
56
+ modelId: string,
57
+ ): Promise<ModelMeta> {
58
+ let metaContext: number | undefined;
59
+ let metaMaxTokens: number | undefined;
60
+ let metaReasoning: boolean | undefined;
61
+ let metaInput: string[] | undefined;
62
+
63
+ // Heuristic: common reasoning-model name patterns.
64
+ const reasoningPattern = /o\d|reasoning|think|deepseek|r1|qwq|glm-5/i;
65
+ const heuristicReasoning = reasoningPattern.test(modelId);
66
+
67
+ const authHeaders: Record<string, string> = key ? { Authorization: `Bearer ${key}` } : {};
68
+
69
+ // 1) LiteLLM /model/info
70
+ try {
71
+ const infoRes = await fetch(`${base}/model/info`, {
72
+ headers: authHeaders,
73
+ signal: AbortSignal.timeout(5000),
74
+ });
75
+ if (infoRes.ok) {
76
+ const infoBody = (await infoRes.json()) as { data?: unknown[] } | unknown[];
77
+ const models = (Array.isArray(infoBody) ? infoBody : infoBody?.data ?? []) as Array<{
78
+ model_name?: string;
79
+ litellm_params?: { model?: string; tags?: string[] };
80
+ model_info?: Record<string, unknown> & { key?: string };
81
+ }>;
82
+ const match = models.find(
83
+ (m) =>
84
+ m.model_name === modelId ||
85
+ m.litellm_params?.model === modelId ||
86
+ m.model_info?.key === modelId,
87
+ );
88
+ if (match?.model_info) {
89
+ const mi = match.model_info as Record<string, any>;
90
+ metaContext = mi.max_input_tokens ?? mi.max_tokens;
91
+ // Only trust an explicit output cap — LiteLLM's generic `max_tokens`
92
+ // is often a default (e.g. 4096/128000), not the model's real cap.
93
+ if (typeof mi.max_output_tokens === "number") metaMaxTokens = mi.max_output_tokens;
94
+
95
+ let vision = Boolean(mi.supports_vision);
96
+ const tags = match.litellm_params?.tags ?? [];
97
+ if (tags.includes("visual") || tags.includes("vision")) vision = true;
98
+ if (typeof mi.litellm_provider === "string" && mi.litellm_provider.length > 0) {
99
+ metaInput = mi.litellm_provider.includes("image") ? ["text", "image"] : ["text"];
100
+ } else if (vision) {
101
+ metaInput = ["text", "image"];
102
+ }
103
+ }
104
+ }
105
+ } catch {
106
+ // Not a LiteLLM proxy or endpoint unavailable — ignore.
107
+ }
108
+
109
+ // 2) OpenAI-style per-model GET
110
+ if (metaContext === undefined) {
111
+ try {
112
+ const modelRes = await fetch(`${base}/models/${encodeURIComponent(modelId)}`, {
113
+ headers: authHeaders,
114
+ signal: AbortSignal.timeout(5000),
115
+ });
116
+ if (modelRes.ok) {
117
+ const b = (await modelRes.json()) as Record<string, any>;
118
+ // OpenAI /v1/models/{id} does not return token limits, but some proxies
119
+ // add custom fields like max_context_length or max_model_tokens.
120
+ metaContext =
121
+ b.max_context_length ?? b.max_input_tokens ?? b.context_window ?? b.max_tokens;
122
+ metaMaxTokens = b.max_output_tokens ?? b.max_completion_tokens ?? metaContext;
123
+ }
124
+ } catch {
125
+ // Endpoint not supported — ignore.
126
+ }
127
+ }
128
+
129
+ // 3) LiteLLM public model cost map
130
+ if (metaContext === undefined) {
131
+ try {
132
+ const costRes = await fetch(
133
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
134
+ { signal: AbortSignal.timeout(5000) },
135
+ );
136
+ if (costRes.ok) {
137
+ const costMap = (await costRes.json()) as Record<string, any>;
138
+ for (const candidate of [modelId, ...modelId.split("/").slice(1)]) {
139
+ const entry = costMap[candidate];
140
+ if (!entry) continue;
141
+ metaContext = entry.max_input_tokens ?? entry.max_tokens;
142
+ metaMaxTokens = entry.max_output_tokens ?? entry.max_tokens;
143
+ metaReasoning =
144
+ entry.mode === "reasoning" || entry.reasoning === true || heuristicReasoning;
145
+ if (entry.input_cost_per_token > 0 || entry.output_cost_per_token > 0) {
146
+ metaInput = entry.supports_vision ? ["text", "image"] : ["text"];
147
+ }
148
+ break;
149
+ }
150
+ }
151
+ } catch {
152
+ // No internet or fetch failed — ignore.
153
+ }
154
+ }
155
+
156
+ // Heuristic defaults for anything still undetermined. Note: no default is
157
+ // invented for maxTokens — callers fall back to a constant explicitly so we
158
+ // never write NaN into models.json.
159
+ if (metaReasoning === undefined) metaReasoning = heuristicReasoning;
160
+ if (metaInput === undefined) {
161
+ const visionPattern = /vision|claude-3-opus|gpt-4o|gpt-5|gemini|llava/i;
162
+ metaInput = visionPattern.test(modelId) ? ["text", "image"] : ["text"];
163
+ }
164
+
165
+ return {
166
+ contextWindow: metaContext,
167
+ maxTokens: metaMaxTokens,
168
+ reasoning: metaReasoning,
169
+ input: metaInput,
170
+ };
171
+ }