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