@2tle/pi-provider-manager 0.1.1 → 0.1.2

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.
package/index.ts CHANGED
@@ -1,395 +1,47 @@
1
- import { homedir } from "node:os";
2
- import { dirname, join } from "node:path";
3
- import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
- import type { RefreshModelsContext } from "@earendil-works/pi-ai";
5
- import { DynamicBorder, keyHint, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
- import { Container, CURSOR_MARKER, getKeybindings, Input, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { loadSecrets, loadState, saveSecrets, saveState, validateBaseUrl, validateProviderId } from "./src/config.js";
4
+ import { createManagedProvider } from "./src/provider.js";
5
+ import { SecretInputDialog } from "./src/ui.js";
6
+ import { REFRESH_TIMEOUT_MS, type StoredProvider, type StoredSecrets, type StoredState } from "./src/types.js";
7
7
 
8
- const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-provider-manager.json");
9
- const SECRETS_PATH = join(homedir(), ".pi", "agent", "pi-provider-manager-secrets.json");
10
- const API = "openai-completions" as const;
11
- const DEFAULT_CONTEXT_WINDOW = 128_000;
12
- const DEFAULT_MAX_TOKENS = 16_384;
13
- const REFRESH_TIMEOUT_MS = 30_000;
14
- const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
15
- type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number];
8
+ // Preserve the helper's existing public export for consumers and tests.
9
+ export { maskInputLine } from "./src/ui.js";
16
10
 
17
- interface StoredProvider {
18
- id: string;
19
- name: string;
20
- baseUrl: string;
21
- }
22
-
23
- interface StoredState {
24
- providers: StoredProvider[];
25
- }
26
-
27
- interface StoredSecrets {
28
- apiKeys: Record<string, string>;
29
- }
30
-
31
- interface ProviderModelConfig {
32
- id: string;
33
- name: string;
34
- api: typeof API;
35
- reasoning: boolean;
36
- thinkingLevelMap?: Partial<Record<PiThinkingLevel, string | null>>;
37
- compat?: {
38
- supportsReasoningEffort: boolean;
39
- };
40
- input: ("text" | "image")[];
41
- cost: {
42
- input: number;
43
- output: number;
44
- cacheRead: number;
45
- cacheWrite: number;
46
- };
47
- contextWindow: number;
48
- maxTokens: number;
49
- }
50
-
51
- interface ManagedProviderConfig {
52
- name: string;
53
- baseUrl: string;
54
- api: typeof API;
55
- apiKey: string;
56
- models: ProviderModelConfig[];
57
- refreshModels(context: RefreshModelsContext): Promise<ProviderModelConfig[]>;
58
- }
59
-
60
- interface OpenAIModelPayload {
61
- id?: unknown;
62
- name?: unknown;
63
- context_window?: unknown;
64
- contextWindow?: unknown;
65
- max_tokens?: unknown;
66
- maxTokens?: unknown;
67
- reasoning?: unknown;
68
- supports_reasoning?: unknown;
69
- supports_reasoning_effort?: unknown;
70
- reasoning_efforts?: unknown;
71
- input?: unknown;
72
- cost?: unknown;
73
- capabilities?: unknown;
74
- }
75
-
76
- interface OpenAIModelsPayload {
77
- data?: unknown;
78
- }
79
-
80
- function asRecord(value: unknown): Record<string, unknown> | undefined {
81
- return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
82
- }
83
-
84
- function asNonEmptyString(value: unknown): string | undefined {
85
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
86
- }
87
-
88
- function asPositiveNumber(value: unknown, fallback: number): number {
89
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
90
- }
91
-
92
- function getReasoningEfforts(value: unknown): Set<string> {
93
- if (!Array.isArray(value)) return new Set();
94
- return new Set(value.flatMap((item) => {
95
- if (typeof item === "string") return [item.toLowerCase()];
96
- const effort = asNonEmptyString(asRecord(item)?.value);
97
- return effort ? [effort.toLowerCase()] : [];
98
- }));
99
- }
100
-
101
- function thinkingLevelMap(efforts: Set<string>): Partial<Record<PiThinkingLevel, string | null>> | undefined {
102
- if (efforts.size === 0) return undefined;
103
-
104
- const map: Partial<Record<PiThinkingLevel, string | null>> = {};
105
- for (const level of PI_THINKING_LEVELS) {
106
- if (efforts.has(level)) map[level] = level;
107
- else if (level === "max" && efforts.has("ultra")) map[level] = "ultra";
108
- else map[level] = null;
109
- }
110
- return map;
111
- }
112
-
113
- function normalizeBaseUrl(value: string): string {
114
- return value.trim().replace(/\/+$/, "");
115
- }
116
-
117
- function validateProviderId(id: string): void {
118
- if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) {
119
- throw new Error("Provider ID may contain only lowercase letters, numbers, '.', '_' or '-'.");
120
- }
121
- }
122
-
123
- function validateBaseUrl(value: string): string {
124
- const baseUrl = normalizeBaseUrl(value);
125
- let parsed: URL;
126
- try {
127
- parsed = new URL(baseUrl);
128
- } catch {
129
- throw new Error("Base URL is not a valid URL.");
130
- }
131
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
132
- throw new Error("Base URL must use the http or https protocol.");
133
- }
134
- return baseUrl;
135
- }
136
-
137
- function modelsUrl(baseUrl: string): string {
138
- return new URL("models", `${normalizeBaseUrl(baseUrl)}/`).toString();
139
- }
140
-
141
- async function loadState(): Promise<StoredState> {
142
- try {
143
- const raw = await readFile(CONFIG_PATH, "utf8");
144
- const parsed = asRecord(JSON.parse(raw));
145
- const providers = Array.isArray(parsed?.providers) ? parsed.providers : [];
146
- const normalized: StoredProvider[] = [];
147
- for (const item of providers) {
148
- const record = asRecord(item);
149
- const id = asNonEmptyString(record?.id);
150
- const name = asNonEmptyString(record?.name);
151
- const baseUrl = asNonEmptyString(record?.baseUrl);
152
- if (!id || !name || !baseUrl) continue;
153
- try {
154
- validateProviderId(id);
155
- normalized.push({ id, name, baseUrl: validateBaseUrl(baseUrl) });
156
- } catch {
157
- // Ignore malformed entries so one broken provider does not prevent startup.
158
- }
159
- }
160
- return { providers: normalized };
161
- } catch (error) {
162
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
163
- return { providers: [] };
164
- }
165
- throw new Error(`Unable to read provider configuration: ${error instanceof Error ? error.message : String(error)}`);
166
- }
167
- }
168
-
169
- async function loadSecrets(): Promise<StoredSecrets> {
170
- try {
171
- const raw = await readFile(SECRETS_PATH, "utf8");
172
- const parsed = asRecord(JSON.parse(raw));
173
- const apiKeys: Record<string, string> = {};
174
- const rawApiKeys = asRecord(parsed?.apiKeys);
175
- for (const [id, value] of Object.entries(rawApiKeys ?? {})) {
176
- if (typeof value === "string" && value.trim()) apiKeys[id] = value;
177
- }
178
- return { apiKeys };
179
- } catch (error) {
180
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
181
- return { apiKeys: {} };
182
- }
183
- throw new Error(`Unable to read provider API keys: ${error instanceof Error ? error.message : String(error)}`);
184
- }
185
- }
186
-
187
- async function saveJsonFile(path: string, value: object): Promise<void> {
188
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
189
- const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
190
- const content = `${JSON.stringify(value, null, 2)}\n`;
191
- try {
192
- await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
193
- await chmod(temporaryPath, 0o600);
194
- await rename(temporaryPath, path);
195
- } finally {
196
- await unlink(temporaryPath).catch(() => undefined);
197
- }
198
- }
199
-
200
- async function saveState(state: StoredState): Promise<void> {
201
- await saveJsonFile(CONFIG_PATH, state);
202
- }
203
-
204
- async function saveSecrets(secrets: StoredSecrets): Promise<void> {
205
- await saveJsonFile(SECRETS_PATH, secrets);
206
- }
207
-
208
- function modelFromPayload(
209
- provider: StoredProvider,
210
- payload: OpenAIModelPayload,
211
- ): ProviderModelConfig | undefined {
212
- const id = asNonEmptyString(payload.id);
213
- if (!id) return undefined;
214
-
215
- const capabilities = asRecord(payload.capabilities);
216
- const cost = asRecord(payload.cost);
217
- const inputModalities = Array.isArray(payload.input)
218
- ? payload.input
219
- : capabilities?.input_modalities;
220
- const input: ("text" | "image")[] =
221
- Array.isArray(inputModalities) && inputModalities.includes("image") ? ["text", "image"] : ["text"];
222
- const efforts = getReasoningEfforts(payload.reasoning_efforts ?? capabilities?.reasoning_effort);
223
- const reasoning =
224
- payload.reasoning === true ||
225
- payload.supports_reasoning === true ||
226
- payload.supports_reasoning_effort === true ||
227
- capabilities?.supports_reasoning === true ||
228
- efforts.size > 0;
229
-
230
- return {
231
- id,
232
- name: asNonEmptyString(payload.name) ?? id,
233
- api: API,
234
- reasoning,
235
- ...(reasoning ? {
236
- thinkingLevelMap: thinkingLevelMap(efforts),
237
- compat: { supportsReasoningEffort: true },
238
- } : {}),
239
- input,
240
- cost: {
241
- input: asPositiveNumber(cost?.input, 0),
242
- output: asPositiveNumber(cost?.output, 0),
243
- cacheRead: asPositiveNumber(cost?.cacheRead, 0),
244
- cacheWrite: asPositiveNumber(cost?.cacheWrite, 0),
245
- },
246
- contextWindow: asPositiveNumber(
247
- payload.context_window ?? payload.contextWindow ?? capabilities?.context_length,
248
- DEFAULT_CONTEXT_WINDOW,
249
- ),
250
- maxTokens: asPositiveNumber(
251
- payload.max_tokens ?? payload.maxTokens ?? capabilities?.max_output_tokens,
252
- DEFAULT_MAX_TOKENS,
253
- ),
254
- };
255
- }
256
-
257
- async function fetchProviderModels(
258
- provider: StoredProvider,
259
- context: RefreshModelsContext,
260
- getApiKey: () => string | undefined,
261
- ): Promise<ProviderModelConfig[]> {
262
- const apiKey = getApiKey();
263
- if (!apiKey) {
264
- throw new Error(`API key for provider '${provider.id}' is not configured.`);
265
- }
266
-
267
- const response = await fetch(modelsUrl(provider.baseUrl), {
268
- signal: context.signal,
269
- headers: {
270
- Accept: "application/json",
271
- Authorization: `Bearer ${apiKey}`,
272
- },
273
- });
274
-
275
- if (!response.ok) {
276
- throw new Error(`Failed to fetch model list (${response.status} ${response.statusText})`);
277
- }
278
-
279
- const payload = (await response.json()) as OpenAIModelsPayload | unknown[];
280
- const data = Array.isArray(payload) ? payload : asRecord(payload)?.data;
281
- if (!Array.isArray(data)) {
282
- throw new Error("Model list response does not contain a data array.");
283
- }
284
-
285
- return data
286
- .map((item) => {
287
- const record = asRecord(item);
288
- return record ? modelFromPayload(provider, record as OpenAIModelPayload) : undefined;
289
- })
290
- .filter((model): model is ProviderModelConfig => model !== undefined);
11
+ function parseArgs(args: string): string[] {
12
+ return args.trim().split(/\s+/).filter(Boolean);
291
13
  }
292
14
 
293
- function createManagedProvider(
294
- config: StoredProvider,
295
- getApiKey: () => string | undefined,
296
- ): ManagedProviderConfig {
297
- return {
298
- name: config.name,
299
- baseUrl: config.baseUrl,
300
- api: API,
301
- // The API key is held outside the regular provider config and is only
302
- // read when the provider is registered or refreshed.
303
- apiKey: getApiKey() ?? "local",
304
- models: [],
305
- refreshModels: (context) => fetchProviderModels(config, context, getApiKey),
306
- };
15
+ function formatTokenCount(value: number | undefined): string {
16
+ return value === undefined ? "API/default" : value.toLocaleString();
307
17
  }
308
18
 
309
- export function maskInputLine(line: string): string {
310
- const prompt = line.startsWith("> ") ? "> " : "";
311
- let result = "";
312
- for (let index = prompt.length; index < line.length; ) {
313
- // Keep Input's zero-width APC cursor marker intact. Masking only CSI/SGR
314
- // codes previously turned the marker bytes into visible bullets.
315
- if (line.startsWith(CURSOR_MARKER, index)) {
316
- result += CURSOR_MARKER;
317
- index += CURSOR_MARKER.length;
318
- continue;
319
- }
320
-
321
- if (line[index] === "\x1b") {
322
- const ansi = line.slice(index).match(/^\x1b\[[0-9;?]*[ -/]*[@-~]/)?.[0];
323
- if (ansi) {
324
- result += ansi;
325
- index += ansi.length;
326
- continue;
327
- }
328
- }
329
-
330
- const character = line[index++];
331
- result += /\s/u.test(character) ? character : "*";
332
- }
333
- return prompt + result;
19
+ function parseTokenCount(value: string): number | undefined {
20
+ const match = value.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(k|m|b)?$/);
21
+ if (!match) return undefined;
22
+ const multiplier = match[2] === "k" ? 1_000 : match[2] === "m" ? 1_000_000 : match[2] === "b" ? 1_000_000_000 : 1;
23
+ const parsed = Number(match[1]) * multiplier;
24
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
334
25
  }
335
26
 
336
- class MaskedInput extends Input {
337
- override render(width: number): string[] {
338
- const terminalWidth = process.stdout.columns;
339
- const safeWidth = Math.max(1, Number.isFinite(terminalWidth) ? Math.min(width, terminalWidth) : width);
340
- return super.render(safeWidth).map((line) => truncateToWidth(maskInputLine(line), safeWidth, "", false));
341
- }
27
+ function providerChoiceLabel(provider: StoredProvider, ctx: ExtensionCommandContext): string {
28
+ const modelCount = ctx.modelRegistry.getProvider(provider.id)?.getModels().length ?? 0;
29
+ const overrideCount = Object.keys(provider.modelOverrides ?? {}).length;
30
+ return `${provider.id} ${provider.name} · ${modelCount} models · ${overrideCount} overrides`;
342
31
  }
343
32
 
344
- class SecretInputDialog extends Container {
345
- private readonly input = new MaskedInput();
346
- private _focused = false;
347
-
348
- constructor(
349
- done: (value: string | undefined) => void,
350
- title: string,
351
- helpText: string,
352
- border: (text: string) => string,
353
- ) {
354
- super();
355
- // Match Pi's built-in ctx.ui.input() layout while keeping the secret masked.
356
- this.addChild(new DynamicBorder(border));
357
- this.addChild(new Spacer(1));
358
- this.addChild(new Text(title, 1, 0));
359
- this.addChild(new Spacer(1));
360
- this.addChild(this.input);
361
- this.addChild(new Spacer(1));
362
- this.addChild(new Text(helpText, 1, 0));
363
- this.addChild(new Spacer(1));
364
- this.addChild(new DynamicBorder(border));
365
- this.done = done;
366
- }
367
-
368
- private readonly done: (value: string | undefined) => void;
369
-
370
- get focused(): boolean {
371
- return this._focused;
372
- }
373
-
374
- set focused(value: boolean) {
375
- this._focused = value;
376
- this.input.focused = value;
377
- }
378
-
379
- handleInput(data: string): void {
380
- const keybindings = getKeybindings();
381
- if (keybindings.matches(data, "tui.select.confirm") || data === "\n") {
382
- this.done(this.input.getValue());
383
- } else if (keybindings.matches(data, "tui.select.cancel")) {
384
- this.done(undefined);
385
- } else {
386
- this.input.handleInput(data);
387
- }
388
- }
389
- }
390
-
391
- function parseArgs(args: string): string[] {
392
- return args.trim().split(/\s+/).filter(Boolean);
33
+ async function chooseManagedProvider(
34
+ ctx: ExtensionCommandContext,
35
+ managed: ReadonlyMap<string, StoredProvider>,
36
+ action: string,
37
+ ): Promise<StoredProvider | undefined> {
38
+ if (!ctx.hasUI) throw new Error(`${action} requires an interactive UI.`);
39
+ const providers = [...managed.values()].sort((a, b) => a.id.localeCompare(b.id));
40
+ if (providers.length === 0) throw new Error("No managed providers.");
41
+ const labels = providers.map((provider) => providerChoiceLabel(provider, ctx));
42
+ const selected = await ctx.ui.select(action, labels);
43
+ if (selected === undefined) return undefined;
44
+ return providers[labels.indexOf(selected)];
393
45
  }
394
46
 
395
47
  function usage(): string {
@@ -397,7 +49,8 @@ function usage(): string {
397
49
  "Usage:",
398
50
  " /provider add",
399
51
  " /provider list",
400
- " /provider edit <provider_name>",
52
+ " /provider edit [provider_name]",
53
+ " /provider model [provider_name] [model_id]",
401
54
  " /provider reload --all",
402
55
  " /provider reload <provider_name>",
403
56
  " /provider delete <provider_name>",
@@ -408,145 +61,172 @@ function commandNotify(ctx: ExtensionCommandContext, message: string, type: "inf
408
61
  if (ctx.hasUI) ctx.ui.notify(message, type);
409
62
  }
410
63
 
411
- async function refreshProviders(
412
- ctx: ExtensionCommandContext,
413
- providerIds: readonly string[],
414
- ): Promise<void> {
64
+ async function refreshProviders(ctx: ExtensionCommandContext, providerIds: readonly string[]): Promise<void> {
415
65
  if (providerIds.length === 0) {
416
66
  commandNotify(ctx, "No providers to refresh.", "warning");
417
67
  return;
418
68
  }
419
-
420
- const signal = AbortSignal.timeout(REFRESH_TIMEOUT_MS);
421
69
  const result = await ctx.modelRegistry.refresh({
422
70
  providers: providerIds,
423
71
  allowNetwork: true,
424
72
  force: true,
425
- signal,
73
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
426
74
  });
75
+ if (result.aborted) throw new Error("Model list refresh was cancelled or timed out.");
427
76
 
428
- if (result.aborted) {
429
- throw new Error("Model list refresh was cancelled or timed out.");
430
- }
431
-
432
- const messages: string[] = [];
433
- for (const providerId of providerIds) {
77
+ const messages = providerIds.map((providerId) => {
434
78
  const error = result.errors.get(providerId);
435
- if (error) {
436
- messages.push(`${providerId}: failed — ${error.message}`);
437
- continue;
438
- }
439
- const provider = ctx.modelRegistry.getProvider(providerId);
440
- const count = provider?.getModels().length ?? 0;
441
- messages.push(`${providerId}: ${count} model${count === 1 ? "" : "s"}`);
442
- }
443
-
444
- const failed = result.errors.size > 0;
445
- commandNotify(ctx, `Model list refresh complete\n${messages.join("\n")}`, failed ? "warning" : "info");
79
+ if (error) return `${providerId}: failed — ${error.message}`;
80
+ const count = ctx.modelRegistry.getProvider(providerId)?.getModels().length ?? 0;
81
+ return `${providerId}: ${count} model${count === 1 ? "" : "s"}`;
82
+ });
83
+ commandNotify(ctx, `Model list refresh complete\n${messages.join("\n")}`, result.errors.size > 0 ? "warning" : "info");
446
84
  }
447
85
 
448
86
  async function promptForProvider(ctx: ExtensionCommandContext): Promise<StoredProvider | undefined> {
449
87
  if (!ctx.hasUI) throw new Error("/provider add requires an interactive UI.");
450
-
451
88
  const id = (await ctx.ui.input("Provider ID", "e.g. lmstudio"))?.trim();
452
89
  if (!id) return undefined;
453
90
  validateProviderId(id);
454
-
455
91
  const name = (await ctx.ui.input("Provider display name", id))?.trim() || id;
456
- const baseUrlInput = await ctx.ui.input("OpenAI-compatible Base URL", "e.g. http://localhost:1234/v1");
457
- if (!baseUrlInput?.trim()) return undefined;
458
-
459
- return { id, name, baseUrl: validateBaseUrl(baseUrlInput) };
92
+ const baseUrl = await ctx.ui.input("OpenAI-compatible Base URL", "e.g. http://localhost:1234/v1");
93
+ if (!baseUrl?.trim()) return undefined;
94
+ return { id, name, baseUrl: validateBaseUrl(baseUrl) };
460
95
  }
461
96
 
462
97
  async function promptForApiKey(ctx: ExtensionCommandContext): Promise<string | undefined> {
463
98
  if (!ctx.hasUI) throw new Error("API key entry requires an interactive UI.");
464
-
465
- const apiKey =
466
- ctx.mode === "tui"
467
- ? await ctx.ui.custom<string | undefined>((_tui, theme, _keybindings, done) =>
468
- new SecretInputDialog(
469
- done,
470
- theme.fg("accent", "API key"),
471
- `${keyHint("tui.select.confirm", "submit")} ${keyHint("tui.select.cancel", "cancel")}`,
472
- (text: string) => theme.fg("accent", text),
473
- ),
474
- )
475
- : await ctx.ui.input("API key", "sk-... or local");
476
-
99
+ const apiKey = ctx.mode === "tui"
100
+ ? await ctx.ui.custom<string | undefined>((_tui, theme, _keybindings, done) => new SecretInputDialog(
101
+ done,
102
+ theme.fg("accent", "API key"),
103
+ `${keyHint("tui.select.confirm", "submit")} ${keyHint("tui.select.cancel", "cancel")}`,
104
+ (text: string) => theme.fg("accent", text),
105
+ ))
106
+ : await ctx.ui.input("API key", "sk-... or local");
477
107
  if (apiKey === undefined) return undefined;
478
108
  if (!apiKey.trim()) throw new Error("API key cannot be empty.");
479
109
  return apiKey;
480
110
  }
481
111
 
482
- interface ProviderEdit {
483
- provider: StoredProvider;
484
- apiKey?: string;
485
- }
486
-
487
- async function promptForProviderEdit(
488
- ctx: ExtensionCommandContext,
489
- provider: StoredProvider,
490
- ): Promise<ProviderEdit | undefined> {
112
+ async function promptForProviderEdit(ctx: ExtensionCommandContext, provider: StoredProvider): Promise<{ provider: StoredProvider; apiKey?: string } | undefined> {
491
113
  if (!ctx.hasUI) throw new Error("/provider edit requires an interactive UI.");
492
-
493
114
  let updatedProvider = { ...provider };
494
115
  let updatedApiKey: string | undefined;
495
-
496
116
  while (true) {
497
117
  const choice = await ctx.ui.select(`Edit provider: ${provider.id}`, [
498
118
  `Change display name (${updatedProvider.name})`,
499
119
  `Change Base URL (${updatedProvider.baseUrl})`,
500
120
  "Change API key",
121
+ "Edit model limits",
122
+ `Reset all model limits (${Object.keys(updatedProvider.modelOverrides ?? {}).length})`,
501
123
  "Save changes",
502
124
  ]);
503
125
  if (choice === undefined) return undefined;
504
126
  if (choice === "Save changes") return { provider: updatedProvider, apiKey: updatedApiKey };
505
-
506
127
  if (choice.startsWith("Change display name")) {
507
128
  const name = await ctx.ui.input("Provider display name", `Current value: ${updatedProvider.name}`);
508
129
  if (name?.trim()) updatedProvider = { ...updatedProvider, name: name.trim() };
509
130
  continue;
510
131
  }
511
-
512
132
  if (choice.startsWith("Change Base URL")) {
513
- const baseUrlInput = await ctx.ui.input("OpenAI-compatible Base URL", `Current value: ${updatedProvider.baseUrl}`);
514
- if (!baseUrlInput?.trim()) continue;
515
- try {
516
- updatedProvider = { ...updatedProvider, baseUrl: validateBaseUrl(baseUrlInput) };
517
- } catch (error) {
518
- commandNotify(ctx, error instanceof Error ? error.message : String(error), "error");
133
+ const baseUrl = await ctx.ui.input("OpenAI-compatible Base URL", `Current value: ${updatedProvider.baseUrl}`);
134
+ if (!baseUrl?.trim()) continue;
135
+ try { updatedProvider = { ...updatedProvider, baseUrl: validateBaseUrl(baseUrl) }; }
136
+ catch (error) { commandNotify(ctx, error instanceof Error ? error.message : String(error), "error"); }
137
+ continue;
138
+ }
139
+ if (choice === "Edit model limits") {
140
+ const edited = await promptForModelOverride(ctx, updatedProvider);
141
+ if (edited) updatedProvider = edited;
142
+ continue;
143
+ }
144
+ if (choice.startsWith("Reset all model limits")) {
145
+ if (Object.keys(updatedProvider.modelOverrides ?? {}).length === 0) continue;
146
+ if (await ctx.ui.confirm("Reset model limits", `Remove all model overrides for '${provider.id}'?`)) {
147
+ const { modelOverrides: _old, ...withoutOverrides } = updatedProvider;
148
+ updatedProvider = withoutOverrides;
519
149
  }
520
150
  continue;
521
151
  }
522
-
523
152
  const apiKey = await promptForApiKey(ctx);
524
153
  if (apiKey !== undefined) updatedApiKey = apiKey;
525
154
  }
526
155
  }
527
156
 
528
- function findManagedProvider(managed: ReadonlyMap<string, StoredProvider>, name: string): StoredProvider | undefined {
529
- const providerById = managed.get(name);
530
- if (providerById) return providerById;
157
+ async function promptForModelOverride(
158
+ ctx: ExtensionCommandContext,
159
+ provider: StoredProvider,
160
+ requestedModelId?: string,
161
+ ): Promise<StoredProvider | undefined> {
162
+ if (!ctx.hasUI) throw new Error("/provider model requires an interactive UI.");
163
+ const loadedModels = ctx.modelRegistry.getProvider(provider.id)?.getModels() ?? [];
164
+ const modelIds = new Set([...loadedModels.map((model) => model.id), ...Object.keys(provider.modelOverrides ?? {})]);
165
+ let modelId = requestedModelId;
166
+ if (!modelId) {
167
+ const modelChoices = [...modelIds].sort().map((id) => {
168
+ const model = loadedModels.find((item) => item.id === id);
169
+ const override = provider.modelOverrides?.[id];
170
+ const context = override?.contextWindow ?? model?.contextWindow;
171
+ const output = override?.maxTokens ?? model?.maxTokens;
172
+ const marker = override ? " · override" : "";
173
+ return `${id} · context ${formatTokenCount(context)} · output ${formatTokenCount(output)}${marker}`;
174
+ });
175
+ const enterModelId = "Enter a model ID…";
176
+ const selected = await ctx.ui.select(`Update model limits: ${provider.id}`, [...modelChoices, enterModelId]);
177
+ if (selected === undefined) return undefined;
178
+ modelId = selected === enterModelId
179
+ ? (await ctx.ui.input("Model ID", "e.g. opencode-go/deepseek-v4.1-flash"))?.trim()
180
+ : [...modelIds].sort()[modelChoices.indexOf(selected)];
181
+ }
182
+ if (!modelId) return undefined;
531
183
 
532
- const matches = [...managed.values()].filter((provider) => provider.name === name);
533
- if (matches.length > 1) {
534
- throw new Error(`Multiple providers have the display name '${name}'. Use a provider ID instead.`);
184
+ const modelOverrides = { ...provider.modelOverrides };
185
+ let override = { ...modelOverrides[modelId] };
186
+ while (true) {
187
+ const choice = await ctx.ui.select(`Model limits: ${provider.id}/${modelId}`, [
188
+ `Context window: ${formatTokenCount(override.contextWindow)}`,
189
+ `Max output tokens: ${formatTokenCount(override.maxTokens)}`,
190
+ "Clear context window override",
191
+ "Clear max output override",
192
+ "Remove all overrides for this model",
193
+ "Save changes",
194
+ ]);
195
+ if (choice === undefined) return undefined;
196
+ if (choice === "Save changes") {
197
+ if (override.contextWindow === undefined && override.maxTokens === undefined) delete modelOverrides[modelId];
198
+ else modelOverrides[modelId] = override;
199
+ const { modelOverrides: _old, ...providerWithoutOverrides } = provider;
200
+ return { ...providerWithoutOverrides, ...(Object.keys(modelOverrides).length ? { modelOverrides } : {}) };
201
+ }
202
+ if (choice === "Clear context window override") { const { contextWindow: _old, ...rest } = override; override = rest; continue; }
203
+ if (choice === "Clear max output override") { const { maxTokens: _old, ...rest } = override; override = rest; continue; }
204
+ if (choice === "Remove all overrides for this model") { override = {}; continue; }
205
+ const field = choice.startsWith("Context window") ? "contextWindow" : "maxTokens";
206
+ const label = field === "contextWindow" ? "Context window" : "Max output tokens";
207
+ const input = await ctx.ui.input(label, "Examples: 128k, 1m, 1048576");
208
+ if (input === undefined) continue;
209
+ const value = parseTokenCount(input);
210
+ if (value === undefined) { commandNotify(ctx, `${label} must be a positive whole token count (e.g. 128k or 1m).`, "error"); continue; }
211
+ override = { ...override, [field]: value };
535
212
  }
213
+ }
214
+
215
+ function findManagedProvider(managed: ReadonlyMap<string, StoredProvider>, name: string): StoredProvider | undefined {
216
+ const provider = managed.get(name);
217
+ if (provider) return provider;
218
+ const matches = [...managed.values()].filter((item) => item.name === name);
219
+ if (matches.length > 1) throw new Error(`Multiple providers have the display name '${name}'. Use a provider ID instead.`);
536
220
  return matches[0];
537
221
  }
538
222
 
539
- function providerListMessage(
540
- providers: readonly StoredProvider[],
541
- secrets: StoredSecrets,
542
- ctx: ExtensionCommandContext,
543
- ): string {
223
+ function providerListMessage(providers: readonly StoredProvider[], secrets: StoredSecrets, ctx: ExtensionCommandContext): string {
544
224
  if (providers.length === 0) return "No managed providers.";
545
-
546
225
  return ["Managed providers:", ...providers.map((provider) => {
547
226
  const modelCount = ctx.modelRegistry.getProvider(provider.id)?.getModels().length ?? 0;
548
227
  const apiKeyStatus = secrets.apiKeys[provider.id] ? "configured" : "missing";
549
- return `- ${provider.id} (${provider.name})\n Base URL: ${provider.baseUrl}\n API key: ${apiKeyStatus} · Models: ${modelCount}`;
228
+ const overrideCount = Object.keys(provider.modelOverrides ?? {}).length;
229
+ return `- ${provider.id} (${provider.name})\n Base URL: ${provider.baseUrl}\n API key: ${apiKeyStatus} · Models: ${modelCount} · Overrides: ${overrideCount}`;
550
230
  })].join("\n");
551
231
  }
552
232
 
@@ -554,49 +234,38 @@ export default async function (pi: ExtensionAPI) {
554
234
  const state = await loadState();
555
235
  const secrets = await loadSecrets();
556
236
  const managed = new Map<string, StoredProvider>();
557
-
558
- for (const provider of state.providers) {
559
- if (managed.has(provider.id)) continue;
237
+ const register = (provider: StoredProvider) => {
560
238
  managed.set(provider.id, provider);
561
239
  pi.registerProvider(provider.id, createManagedProvider(provider, () => secrets.apiKeys[provider.id]));
562
- }
240
+ };
241
+ for (const provider of state.providers) if (!managed.has(provider.id)) register(provider);
563
242
 
564
243
  pi.registerCommand("provider", {
565
- description: "Add, list, edit, refresh, and delete OpenAI-compatible providers",
244
+ description: "Add, configure models, refresh, and delete OpenAI-compatible providers",
566
245
  handler: async (args, ctx) => {
567
246
  try {
568
247
  const [subcommand, ...rest] = parseArgs(args);
569
-
570
- if (!subcommand) {
571
- commandNotify(ctx, usage(), "warning");
572
- return;
573
- }
574
-
248
+ if (!subcommand) { commandNotify(ctx, usage(), "warning"); return; }
575
249
  if (subcommand === "list") {
576
- if (rest.length > 0) throw new Error("/provider list does not accept arguments.");
250
+ if (rest.length) throw new Error("/provider list does not accept arguments.");
577
251
  commandNotify(ctx, providerListMessage([...managed.values()], secrets, ctx));
578
252
  return;
579
253
  }
580
-
581
254
  if (subcommand === "add") {
582
- if (rest.length > 0) throw new Error("/provider add does not accept arguments.");
255
+ if (rest.length) throw new Error("/provider add does not accept arguments.");
583
256
  const provider = await promptForProvider(ctx);
584
257
  if (!provider) return;
585
- if (managed.has(provider.id) || ctx.modelRegistry.getProvider(provider.id)) {
586
- throw new Error(`Provider '${provider.id}' is already registered.`);
587
- }
258
+ if (managed.has(provider.id) || ctx.modelRegistry.getProvider(provider.id)) throw new Error(`Provider '${provider.id}' is already registered.`);
588
259
  const apiKey = await promptForApiKey(ctx);
589
260
  if (!apiKey) return;
590
-
591
261
  const nextState: StoredState = { providers: [...state.providers, provider] };
592
262
  const previousApiKey = secrets.apiKeys[provider.id];
593
263
  secrets.apiKeys[provider.id] = apiKey;
594
264
  try {
595
265
  await saveState(nextState);
596
266
  await saveSecrets(secrets);
597
- pi.registerProvider(provider.id, createManagedProvider(provider, () => secrets.apiKeys[provider.id]));
598
267
  state.providers = nextState.providers;
599
- managed.set(provider.id, provider);
268
+ register(provider);
600
269
  } catch (error) {
601
270
  if (previousApiKey === undefined) delete secrets.apiKeys[provider.id];
602
271
  else secrets.apiKeys[provider.id] = previousApiKey;
@@ -605,87 +274,76 @@ export default async function (pi: ExtensionAPI) {
605
274
  pi.unregisterProvider(provider.id);
606
275
  throw error;
607
276
  }
608
-
609
277
  commandNotify(ctx, `Provider '${provider.id}' was added. Fetching its model list.`);
610
278
  await refreshProviders(ctx, [provider.id]);
611
279
  return;
612
280
  }
613
-
614
281
  if (subcommand === "edit") {
615
- if (rest.length !== 1) throw new Error("Usage: /provider edit <provider_name>");
616
- const currentProvider = findManagedProvider(managed, rest[0]);
617
- if (!currentProvider) throw new Error(`Managed provider '${rest[0]}' was not found.`);
618
-
619
- const edit = await promptForProviderEdit(ctx, currentProvider);
620
- if (!edit) return;
621
- const providerChanged =
622
- edit.provider.name !== currentProvider.name || edit.provider.baseUrl !== currentProvider.baseUrl;
623
- if (!providerChanged && edit.apiKey === undefined) {
624
- commandNotify(ctx, "No provider settings were changed.", "warning");
625
- return;
626
- }
627
-
282
+ if (rest.length > 1) throw new Error("Usage: /provider edit [provider_name]");
283
+ const current = rest.length === 1
284
+ ? findManagedProvider(managed, rest[0])
285
+ : await chooseManagedProvider(ctx, managed, "Edit provider");
286
+ if (!current) return;
287
+ const edit = await promptForProviderEdit(ctx, current); if (!edit) return;
288
+ if (edit.provider.name === current.name && edit.provider.baseUrl === current.baseUrl && edit.apiKey === undefined) { commandNotify(ctx, "No provider settings were changed.", "warning"); return; }
628
289
  const previousState: StoredState = { providers: [...state.providers] };
629
- const previousApiKey = secrets.apiKeys[currentProvider.id];
630
- const nextState: StoredState = {
631
- providers: state.providers.map((provider) =>
632
- provider.id === currentProvider.id ? edit.provider : provider,
633
- ),
634
- };
635
- if (edit.apiKey !== undefined) secrets.apiKeys[currentProvider.id] = edit.apiKey;
636
-
290
+ const previousApiKey = secrets.apiKeys[current.id];
291
+ const nextState: StoredState = { providers: state.providers.map((item) => item.id === current.id ? edit.provider : item) };
292
+ if (edit.apiKey !== undefined) secrets.apiKeys[current.id] = edit.apiKey;
637
293
  try {
638
294
  await saveState(nextState);
639
295
  if (edit.apiKey !== undefined) await saveSecrets(secrets);
640
- pi.registerProvider(
641
- currentProvider.id,
642
- createManagedProvider(edit.provider, () => secrets.apiKeys[currentProvider.id]),
643
- );
644
296
  state.providers = nextState.providers;
645
- managed.set(currentProvider.id, edit.provider);
297
+ register(edit.provider);
646
298
  } catch (error) {
647
- if (previousApiKey === undefined) delete secrets.apiKeys[currentProvider.id];
648
- else secrets.apiKeys[currentProvider.id] = previousApiKey;
299
+ if (previousApiKey === undefined) delete secrets.apiKeys[current.id];
300
+ else secrets.apiKeys[current.id] = previousApiKey;
649
301
  await saveState(previousState).catch(() => undefined);
650
302
  if (edit.apiKey !== undefined) await saveSecrets(secrets).catch(() => undefined);
651
- pi.registerProvider(
652
- currentProvider.id,
653
- createManagedProvider(currentProvider, () => secrets.apiKeys[currentProvider.id]),
654
- );
303
+ register(current);
655
304
  throw error;
656
305
  }
657
-
658
- commandNotify(ctx, `Saved settings for provider '${currentProvider.id}'. Fetching its model list.`);
659
- await refreshProviders(ctx, [currentProvider.id]);
306
+ commandNotify(ctx, `Saved settings for provider '${current.id}'. Fetching its model list.`);
307
+ await refreshProviders(ctx, [current.id]);
660
308
  return;
661
309
  }
662
-
663
- if (subcommand === "reload") {
664
- if (rest.length !== 1) throw new Error("Usage: /provider reload --all | <provider_name>");
665
- const providerIds = rest[0] === "--all" ? [...managed.keys()] : [rest[0]];
666
- for (const providerId of providerIds) {
667
- if (!managed.has(providerId)) throw new Error(`Managed provider '${providerId}' was not found.`);
310
+ if (subcommand === "model") {
311
+ if (rest.length > 2) throw new Error("Usage: /provider model [provider_name] [model_id]");
312
+ const current = rest.length > 0
313
+ ? findManagedProvider(managed, rest[0])
314
+ : await chooseManagedProvider(ctx, managed, "Edit model limits");
315
+ if (!current) return;
316
+ const updated = await promptForModelOverride(ctx, current, rest[1]); if (!updated) return;
317
+ const previousState: StoredState = { providers: [...state.providers] };
318
+ const nextState: StoredState = { providers: state.providers.map((item) => item.id === current.id ? updated : item) };
319
+ try {
320
+ await saveState(nextState);
321
+ state.providers = nextState.providers;
322
+ register(updated);
323
+ } catch (error) {
324
+ await saveState(previousState).catch(() => undefined);
325
+ register(current);
326
+ throw error;
668
327
  }
669
- await refreshProviders(ctx, providerIds);
328
+ commandNotify(ctx, `Saved model config overrides for provider '${current.id}'. Fetching its model list.`);
329
+ await refreshProviders(ctx, [current.id]);
670
330
  return;
671
331
  }
672
-
332
+ if (subcommand === "reload") {
333
+ if (rest.length !== 1) throw new Error("Usage: /provider reload --all | <provider_name>");
334
+ const ids = rest[0] === "--all" ? [...managed.keys()] : [rest[0]];
335
+ for (const id of ids) if (!managed.has(id)) throw new Error(`Managed provider '${id}' was not found.`);
336
+ await refreshProviders(ctx, ids); return;
337
+ }
673
338
  if (subcommand === "delete") {
674
339
  if (rest.length !== 1) throw new Error("Usage: /provider delete <provider_name>");
675
340
  const providerId = rest[0];
676
341
  if (!managed.has(providerId)) throw new Error(`Managed provider '${providerId}' was not found.`);
677
342
  if (!ctx.hasUI) throw new Error("/provider delete requires an interactive UI.");
678
- const shouldDelete = await ctx.ui.confirm(
679
- "Delete provider",
680
- `Delete provider '${providerId}' and its stored API key?`,
681
- );
682
- if (!shouldDelete) return;
683
-
343
+ if (!await ctx.ui.confirm("Delete provider", `Delete provider '${providerId}' and its stored API key?`)) return;
684
344
  const previousState: StoredState = { providers: [...state.providers] };
685
345
  const previousApiKey = secrets.apiKeys[providerId];
686
- const nextState: StoredState = {
687
- providers: state.providers.filter((provider) => provider.id !== providerId),
688
- };
346
+ const nextState: StoredState = { providers: state.providers.filter((item) => item.id !== providerId) };
689
347
  delete secrets.apiKeys[providerId];
690
348
  try {
691
349
  await saveState(nextState);
@@ -702,23 +360,14 @@ export default async function (pi: ExtensionAPI) {
702
360
  commandNotify(ctx, `Provider '${providerId}' was deleted.`);
703
361
  return;
704
362
  }
705
-
706
363
  throw new Error(`Unknown provider command: ${subcommand}\n\n${usage()}`);
707
- } catch (error) {
708
- commandNotify(ctx, error instanceof Error ? error.message : String(error), "error");
709
- }
364
+ } catch (error) { commandNotify(ctx, error instanceof Error ? error.message : String(error), "error"); }
710
365
  },
711
366
  });
712
367
 
713
368
  pi.on("session_start", async (_event, ctx) => {
714
369
  if (managed.size === 0) return;
715
- const result = await ctx.modelRegistry.refresh({
716
- providers: [...managed.keys()],
717
- allowNetwork: true,
718
- signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
719
- });
720
- if (result.errors.size > 0 && ctx.hasUI) {
721
- ctx.ui.notify(`Could not refresh model lists for: ${[...result.errors.keys()].join(", ")}`, "warning");
722
- }
370
+ const result = await ctx.modelRegistry.refresh({ providers: [...managed.keys()], allowNetwork: true, signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) });
371
+ if (result.errors.size > 0 && ctx.hasUI) ctx.ui.notify(`Could not refresh model lists for: ${[...result.errors.keys()].join(", ")}`, "warning");
723
372
  });
724
373
  }