pi-free 2.2.5 → 2.2.7

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.
@@ -1,427 +1,426 @@
1
- /**
2
- * Built-in Provider Toggle Support
3
- *
4
- * Captures pi's built-in providers after session start and enables
5
- * free/paid toggling for them via the global registry.
6
- *
7
- * Currently supports:
8
- * - opencode (OpenCode / Zen gateway)
9
- * - openrouter (OpenRouter)
10
- *
11
- * Usage: /toggle-opencode
12
- */
13
-
14
- import type { Api, Model } from "@earendil-works/pi-ai";
15
- import type {
16
- ExtensionAPI,
17
- ProviderModelConfig,
18
- } from "@earendil-works/pi-coding-agent";
19
- import {
20
- getOpencodeApiKey,
21
- getOpencodeShowPaid,
22
- getOpenrouterApiKey,
23
- getOpenrouterShowPaid,
24
- } from "../config.ts";
25
- import { createLogger } from "./logger.ts";
26
- import {
27
- getProviderRegistry,
28
- isFreeModel,
29
- registerWithGlobalToggle,
30
- } from "./registry.ts";
31
- import { wrapSessionStartHandler } from "./session-start-metrics.ts";
32
- import { createToggleState } from "./toggle-state.ts";
33
- import { fetchWithTimeout } from "./util.ts";
34
- import {
35
- OPENCODE_DYNAMIC_API,
36
- createOpenCodeSessionTracker,
37
- createOpenCodeStreamSimple,
38
- isOpenCodeProvider,
39
- } from "../providers/opencode-session.ts";
40
-
41
- const _logger = createLogger("built-in-toggle");
42
-
43
- // OpenCode requires per-request ids; see createOpenCodeStreamSimple().
44
- // Lazy-initialised because the OpenCode dynamic fetcher in
45
- // providers/dynamic-built-in/ usually wins the race for `opencode`,
46
- // leaving this fallback capture unused — no point allocating the
47
- // session tracker on every module import.
48
- let _opencodeSession: ReturnType<typeof createOpenCodeSessionTracker> | null =
49
- null;
50
- function getOpenCodeSession() {
51
- if (!_opencodeSession) _opencodeSession = createOpenCodeSessionTracker();
52
- return _opencodeSession;
53
- }
54
-
55
- // =============================================================================
56
- // Configuration
57
- // =============================================================================
58
-
59
- interface BuiltInToggleConfig {
60
- id: string;
61
- getShowPaid: () => boolean;
62
- baseUrl: string;
63
- api: Api;
64
- getApiKey: () => string | undefined;
65
- }
66
-
67
- const BUILT_IN_TOGGLE_PROVIDERS: BuiltInToggleConfig[] = [
68
- {
69
- id: "opencode",
70
- getShowPaid: getOpencodeShowPaid,
71
- baseUrl: "https://opencode.ai/zen/v1",
72
- api: OPENCODE_DYNAMIC_API,
73
- getApiKey: getOpencodeApiKey,
74
- },
75
- {
76
- id: "opencode-go",
77
- getShowPaid: getOpencodeShowPaid,
78
- baseUrl: "https://opencode.ai/zen/go/v1",
79
- api: OPENCODE_DYNAMIC_API,
80
- getApiKey: getOpencodeApiKey,
81
- },
82
- {
83
- id: "openrouter",
84
- getShowPaid: getOpenrouterShowPaid,
85
- baseUrl: "https://openrouter.ai/api/v1",
86
- api: "openai-completions",
87
- getApiKey: getOpenrouterApiKey,
88
- },
89
- ];
90
-
91
- // =============================================================================
92
- // State
93
- // =============================================================================
94
-
95
- interface BuiltInProviderState {
96
- stored: { free: ProviderModelConfig[]; all: ProviderModelConfig[] };
97
- reRegister: (models: ProviderModelConfig[]) => void;
98
- toggleState: ReturnType<typeof createToggleState<ProviderModelConfig>>;
99
- }
100
-
101
- const providerStates = new Map<string, BuiltInProviderState>();
102
- let commandsRegistered = false;
103
-
104
- const ZERO_COST = Object.freeze({
105
- input: 0,
106
- output: 0,
107
- cacheRead: 0,
108
- cacheWrite: 0,
109
- });
110
-
111
- // OpenCode's /models endpoint does not expose real pricing. Use a tiny
112
- // non-zero sentinel for known non-free models so the model picker does not
113
- // label every OpenCode model as free. Authoritative free detection still uses
114
- // _freeKnown/_isFree, not the sentinel amount.
115
- const UNKNOWN_PAID_COST = Object.freeze({
116
- input: 0.000001,
117
- output: 0.000001,
118
- cacheRead: 0,
119
- cacheWrite: 0,
120
- });
121
-
122
- // =============================================================================
123
- // Setup
124
- // =============================================================================
125
-
126
- export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
127
- const activeConfigs = BUILT_IN_TOGGLE_PROVIDERS.filter(
128
- (config) => !getProviderRegistry().has(config.id),
129
- );
130
-
131
- if (activeConfigs.length === 0) {
132
- _logger.info(
133
- "[built-in-toggle] OpenCode/OpenRouter already registered dynamically; skipping fallback capture",
134
- );
135
- return;
136
- }
137
-
138
- // Register toggle commands once (available even before models load)
139
- if (!commandsRegistered) {
140
- for (const config of activeConfigs) {
141
- registerToggleCommand(pi, config);
142
- }
143
- commandsRegistered = true;
144
- }
145
-
146
- // Capture built-in models on session start and apply initial filter
147
- pi.on(
148
- "session_start",
149
- wrapSessionStartHandler("built-in-toggle", async (_event, ctx) => {
150
- for (const config of activeConfigs) {
151
- if (providerStates.has(config.id)) {
152
- // Already captured — skip to avoid re-registering
153
- continue;
154
- }
155
-
156
- const state = tryCaptureProvider(pi, config, ctx);
157
- if (!state) continue;
158
-
159
- const applied = state.toggleState.applyCurrent(state.reRegister);
160
- _logger.info(
161
- `[built-in-toggle] ${config.id}: applied ${applied.mode} mode with ${applied.models.length} models`,
162
- );
163
- }
164
- }),
165
- );
166
- }
167
-
168
- // =============================================================================
169
- // On-demand model capture (called by toggle command when state is missing)
170
- // =============================================================================
171
-
172
- function tryCaptureProvider(
173
- pi: ExtensionAPI,
174
- config: BuiltInToggleConfig,
175
- ctx: any,
176
- ): BuiltInProviderState | undefined {
177
- const available = ctx.modelRegistry.getAvailable();
178
- const providerModels = available.filter(
179
- (m: Model<Api>) => m.provider === config.id,
180
- );
181
- if (providerModels.length === 0) return undefined;
182
-
183
- const allModels = providerModels.map((m: Model<Api>) =>
184
- modelToProviderConfig(m, config.id),
185
- );
186
-
187
- return createProviderState(pi, config, {
188
- allModels,
189
- baseUrl: providerModels[0].baseUrl,
190
- api: providerModels[0].api,
191
- apiKey: getApiKeyEnvForProvider(config.id),
192
- source: "captured",
193
- });
194
- }
195
-
196
- async function tryDiscoverProvider(
197
- pi: ExtensionAPI,
198
- config: BuiltInToggleConfig,
199
- ): Promise<BuiltInProviderState | undefined> {
200
- const apiKey = config.getApiKey();
201
- if (!apiKey) return undefined;
202
-
203
- try {
204
- const response = await fetchWithTimeout(
205
- `${config.baseUrl}/models`,
206
- {
207
- headers: {
208
- Accept: "application/json",
209
- Authorization: `Bearer ${apiKey}`,
210
- },
211
- },
212
- 30_000,
213
- );
214
-
215
- if (!response.ok) {
216
- throw new Error(`HTTP ${response.status} ${response.statusText}`);
217
- }
218
-
219
- const body = (await response.json()) as
220
- | Array<Record<string, unknown>>
221
- | { data?: Array<Record<string, unknown>> };
222
- const rawModels = Array.isArray(body) ? body : (body.data ?? []);
223
- const mappedModels = rawModels
224
- .map((m) => rawModelToProviderConfig(m, config))
225
- .filter(
226
- (m): m is ProviderModelConfig & { _pricingKnown?: boolean } =>
227
- m !== undefined,
228
- );
229
- const allModels = applyAuthoritativeFreeFlags(mappedModels, config.id);
230
-
231
- if (allModels.length === 0) return undefined;
232
-
233
- return createProviderState(pi, config, {
234
- allModels,
235
- baseUrl: config.baseUrl,
236
- api: config.api,
237
- apiKey,
238
- source: "discovered",
239
- });
240
- } catch (err) {
241
- _logger.warn(`[built-in-toggle] ${config.id}: on-demand discovery failed`, {
242
- error: err instanceof Error ? err.message : String(err),
243
- });
244
- return undefined;
245
- }
246
- }
247
-
248
- function createProviderState(
249
- pi: ExtensionAPI,
250
- config: BuiltInToggleConfig,
251
- options: {
252
- allModels: ProviderModelConfig[];
253
- baseUrl: string;
254
- api: Api;
255
- apiKey: string;
256
- source: "captured" | "discovered";
257
- },
258
- ): BuiltInProviderState {
259
- const { allModels, baseUrl, api, apiKey, source } = options;
260
- const freeModels = allModels.filter((m: ProviderModelConfig) =>
261
- isFreeModel({ ...m, provider: config.id }, allModels),
262
- );
263
-
264
- const reRegister = (models: ProviderModelConfig[]) => {
265
- pi.registerProvider(config.id, {
266
- baseUrl,
267
- apiKey,
268
- api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : api,
269
- ...(isOpenCodeProvider(config.id)
270
- ? { streamSimple: createOpenCodeStreamSimple(getOpenCodeSession()) }
271
- : {}),
272
- models,
273
- });
274
- };
275
-
276
- const stored = { free: freeModels, all: allModels };
277
- const toggleState = createToggleState<ProviderModelConfig>({
278
- providerId: config.id,
279
- initialShowPaid: config.getShowPaid(),
280
- initialModels: stored,
281
- });
282
-
283
- const state: BuiltInProviderState = { stored, reRegister, toggleState };
284
- providerStates.set(config.id, state);
285
-
286
- registerWithGlobalToggle(config.id, stored, reRegister, true);
287
-
288
- _logger.info(
289
- `[built-in-toggle] ${config.id}: ${source} ${allModels.length} models (${freeModels.length} free)`,
290
- );
291
-
292
- return state;
293
- }
294
-
295
- // =============================================================================
296
- // Per-provider toggle command
297
- // =============================================================================
298
-
299
- function registerToggleCommand(
300
- pi: ExtensionAPI,
301
- config: BuiltInToggleConfig,
302
- ): void {
303
- const commandName = `toggle-${config.id}`;
304
- pi.registerCommand(commandName, {
305
- description: `Toggle free/paid ${config.id} models`,
306
- handler: async (_args, ctx) => {
307
- let state = providerStates.get(config.id);
308
- if (!state) {
309
- // Models may have loaded after session_start — try on-demand capture.
310
- state = tryCaptureProvider(pi, config, ctx);
311
- }
312
- if (!state) {
313
- // If Pi has not exposed built-in models yet, fetch the provider's
314
- // /models endpoint directly so /toggle-opencode works before the
315
- // first chat session has populated the model registry.
316
- state = await tryDiscoverProvider(pi, config);
317
- }
318
- if (!state) {
319
- ctx.ui.notify(
320
- `${config.id}: models not loaded yet and on-demand discovery failed. Check your API key, then try again.`,
321
- "warning",
322
- );
323
- return;
324
- }
325
-
326
- const applied = state.toggleState.toggle(state.reRegister);
327
-
328
- if (applied.mode === "all") {
329
- ctx.ui.notify(
330
- `${config.id}: showing all ${state.stored.all.length} models`,
331
- "info",
332
- );
333
- } else {
334
- ctx.ui.notify(
335
- `${config.id}: showing ${state.stored.free.length} free models`,
336
- "info",
337
- );
338
- }
339
- },
340
- });
341
- }
342
-
343
- // =============================================================================
344
- // Helpers
345
- // =============================================================================
346
-
347
- function modelToProviderConfig(
348
- m: Model<Api>,
349
- providerId?: string,
350
- ): ProviderModelConfig {
351
- const base: ProviderModelConfig = {
352
- id: m.id,
353
- name: m.name,
354
- api: m.api,
355
- reasoning: m.reasoning,
356
- input: m.input,
357
- cost: m.cost,
358
- contextWindow: m.contextWindow,
359
- maxTokens: m.maxTokens,
360
- headers: m.headers,
361
- compat: (m as any).compat,
362
- };
363
-
364
- // Use a custom OpenCode API wrapper so per-request headers are regenerated
365
- // for every LLM call instead of being frozen at registration time.
366
- if (providerId && isOpenCodeProvider(providerId)) {
367
- base.api = OPENCODE_DYNAMIC_API;
368
- }
369
-
370
- return base;
371
- }
372
-
373
- function rawModelToProviderConfig(
374
- m: Record<string, unknown>,
375
- config: BuiltInToggleConfig,
376
- ): (ProviderModelConfig & { _pricingKnown?: boolean }) | undefined {
377
- const id = String(m.id ?? "").trim();
378
- if (!id) return undefined;
379
- const inputModalities = Array.isArray(m.input_modalities)
380
- ? m.input_modalities
381
- : undefined;
382
- const supportsImage = inputModalities?.includes("image") === true;
383
- return {
384
- id,
385
- name: String(m.name ?? m.model ?? id),
386
- api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : config.api,
387
- reasoning: Boolean(m.reasoning ?? false),
388
- input: supportsImage ? ["text", "image"] : ["text"],
389
- cost: ZERO_COST,
390
- contextWindow:
391
- ((m.context_length ??
392
- m.max_context_length ??
393
- m.context_window) as number) ?? 128_000,
394
- maxTokens: ((m.max_tokens ?? m.max_completion_tokens) as number) ?? 16_384,
395
- _pricingKnown: false,
396
- };
397
- }
398
-
399
- function applyAuthoritativeFreeFlags(
400
- models: Array<ProviderModelConfig & { _pricingKnown?: boolean }>,
401
- providerId: string,
402
- ): Array<
403
- ProviderModelConfig & {
404
- _pricingKnown?: boolean;
405
- _freeKnown?: boolean;
406
- _isFree?: boolean;
407
- }
408
- > {
409
- return models.map((model) => {
410
- const isFree = isFreeModel({ ...model, provider: providerId }, models);
411
- return {
412
- ...model,
413
- cost: isFree ? ZERO_COST : UNKNOWN_PAID_COST,
414
- _freeKnown: true,
415
- _isFree: isFree,
416
- };
417
- });
418
- }
419
-
420
- function getApiKeyEnvForProvider(providerId: string): string {
421
- const envMap: Record<string, string> = {
422
- opencode: "$OPENCODE_API_KEY",
423
- "opencode-go": "$OPENCODE_API_KEY",
424
- openrouter: "$OPENROUTER_API_KEY",
425
- };
426
- return envMap[providerId] || `$${providerId.toUpperCase()}_API_KEY`;
427
- }
1
+ /**
2
+ * Built-in Provider Toggle Support
3
+ *
4
+ * Captures pi's built-in providers after session start and enables
5
+ * free/paid toggling for them via the global registry.
6
+ *
7
+ * Currently supports:
8
+ * - opencode (OpenCode / Zen gateway)
9
+ * - openrouter (OpenRouter)
10
+ *
11
+ * Usage: /toggle-opencode
12
+ */
13
+
14
+ import type { Api, Model } from "@earendil-works/pi-ai";
15
+ import type {
16
+ ExtensionAPI,
17
+ ProviderModelConfig,
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import {
20
+ getOpencodeApiKey,
21
+ getOpencodeShowPaid,
22
+ getOpenrouterApiKey,
23
+ getOpenrouterShowPaid,
24
+ } from "../config.ts";
25
+ import { createLogger } from "./logger.ts";
26
+ import {
27
+ getProviderRegistry,
28
+ isFreeModel,
29
+ registerWithGlobalToggle,
30
+ } from "./registry.ts";
31
+ import { wrapSessionStartHandler } from "./session-start-metrics.ts";
32
+ import { createToggleState } from "./toggle-state.ts";
33
+ import { fetchWithTimeout } from "./util.ts";
34
+ import {
35
+ OPENCODE_DYNAMIC_API,
36
+ createOpenCodeSessionTracker,
37
+ createOpenCodeStreamSimple,
38
+ isOpenCodeProvider,
39
+ } from "../providers/opencode-session.ts";
40
+
41
+ const _logger = createLogger("built-in-toggle");
42
+
43
+ // OpenCode requires per-request ids; see createOpenCodeStreamSimple().
44
+ // Lazy-initialised because the OpenCode dynamic fetcher in
45
+ // providers/dynamic-built-in/ usually wins the race for `opencode`,
46
+ // leaving this fallback capture unused — no point allocating the
47
+ // session tracker on every module import.
48
+ let _opencodeSession: ReturnType<typeof createOpenCodeSessionTracker> | null =
49
+ null;
50
+ function getOpenCodeSession() {
51
+ if (!_opencodeSession) _opencodeSession = createOpenCodeSessionTracker();
52
+ return _opencodeSession;
53
+ }
54
+
55
+ // =============================================================================
56
+ // Configuration
57
+ // =============================================================================
58
+
59
+ interface BuiltInToggleConfig {
60
+ id: string;
61
+ getShowPaid: () => boolean;
62
+ baseUrl: string;
63
+ api: Api;
64
+ getApiKey: () => string | undefined;
65
+ }
66
+
67
+ const BUILT_IN_TOGGLE_PROVIDERS: BuiltInToggleConfig[] = [
68
+ {
69
+ id: "opencode",
70
+ getShowPaid: getOpencodeShowPaid,
71
+ baseUrl: "https://opencode.ai/zen/v1",
72
+ api: OPENCODE_DYNAMIC_API,
73
+ getApiKey: getOpencodeApiKey,
74
+ },
75
+ {
76
+ id: "opencode-go",
77
+ getShowPaid: getOpencodeShowPaid,
78
+ baseUrl: "https://opencode.ai/zen/go/v1",
79
+ api: OPENCODE_DYNAMIC_API,
80
+ getApiKey: getOpencodeApiKey,
81
+ },
82
+ {
83
+ id: "openrouter",
84
+ getShowPaid: getOpenrouterShowPaid,
85
+ baseUrl: "https://openrouter.ai/api/v1",
86
+ api: "openai-completions",
87
+ getApiKey: getOpenrouterApiKey,
88
+ },
89
+ ];
90
+
91
+ // =============================================================================
92
+ // State
93
+ // =============================================================================
94
+
95
+ interface BuiltInProviderState {
96
+ stored: { free: ProviderModelConfig[]; all: ProviderModelConfig[] };
97
+ reRegister: (models: ProviderModelConfig[]) => void;
98
+ toggleState: ReturnType<typeof createToggleState<ProviderModelConfig>>;
99
+ }
100
+
101
+ const providerStates = new Map<string, BuiltInProviderState>();
102
+ let commandsRegistered = false;
103
+
104
+ const ZERO_COST = Object.freeze({
105
+ input: 0,
106
+ output: 0,
107
+ cacheRead: 0,
108
+ cacheWrite: 0,
109
+ });
110
+
111
+ // OpenCode's /models endpoint does not expose real pricing. Use a tiny
112
+ // non-zero sentinel for known non-free models so the model picker does not
113
+ // label every OpenCode model as free. Authoritative free detection still uses
114
+ // _freeKnown/_isFree, not the sentinel amount.
115
+ const UNKNOWN_PAID_COST = Object.freeze({
116
+ input: 0.000001,
117
+ output: 0.000001,
118
+ cacheRead: 0,
119
+ cacheWrite: 0,
120
+ });
121
+
122
+ // =============================================================================
123
+ // Setup
124
+ // =============================================================================
125
+
126
+ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
127
+ const activeConfigs = BUILT_IN_TOGGLE_PROVIDERS.filter(
128
+ (config) => !getProviderRegistry().has(config.id),
129
+ );
130
+
131
+ if (activeConfigs.length === 0) {
132
+ _logger.info(
133
+ "[built-in-toggle] OpenCode/OpenRouter already registered dynamically; skipping fallback capture",
134
+ );
135
+ return;
136
+ }
137
+
138
+ // Register toggle commands once (available even before models load)
139
+ if (!commandsRegistered) {
140
+ for (const config of activeConfigs) {
141
+ registerToggleCommand(pi, config);
142
+ }
143
+ commandsRegistered = true;
144
+ }
145
+
146
+ // Capture built-in models on session start and apply initial filter
147
+ pi.on(
148
+ "session_start",
149
+ wrapSessionStartHandler("built-in-toggle", async (_event, ctx) => {
150
+ for (const config of activeConfigs) {
151
+ if (providerStates.has(config.id)) {
152
+ // Already captured — skip to avoid re-registering
153
+ continue;
154
+ }
155
+
156
+ const state = tryCaptureProvider(pi, config, ctx);
157
+ if (!state) continue;
158
+
159
+ const applied = state.toggleState.applyCurrent(state.reRegister);
160
+ _logger.info(
161
+ `[built-in-toggle] ${config.id}: applied ${applied.mode} mode with ${applied.models.length} models`,
162
+ );
163
+ }
164
+ }),
165
+ );
166
+ }
167
+
168
+ // =============================================================================
169
+ // On-demand model capture (called by toggle command when state is missing)
170
+ // =============================================================================
171
+
172
+ function tryCaptureProvider(
173
+ pi: ExtensionAPI,
174
+ config: BuiltInToggleConfig,
175
+ ctx: any,
176
+ ): BuiltInProviderState | undefined {
177
+ const available = ctx.modelRegistry.getAvailable();
178
+ const providerModels = available.filter(
179
+ (m: Model<Api>) => m.provider === config.id,
180
+ );
181
+ if (providerModels.length === 0) return undefined;
182
+
183
+ const allModels = providerModels.map((m: Model<Api>) =>
184
+ modelToProviderConfig(m, config.id),
185
+ );
186
+
187
+ return createProviderState(pi, config, {
188
+ allModels,
189
+ baseUrl: providerModels[0].baseUrl,
190
+ api: providerModels[0].api,
191
+ apiKey: getApiKeyEnvForProvider(config.id),
192
+ source: "captured",
193
+ });
194
+ }
195
+
196
+ async function tryDiscoverProvider(
197
+ pi: ExtensionAPI,
198
+ config: BuiltInToggleConfig,
199
+ ): Promise<BuiltInProviderState | undefined> {
200
+ const apiKey = config.getApiKey();
201
+ if (!apiKey) return undefined;
202
+
203
+ try {
204
+ const response = await fetchWithTimeout(
205
+ `${config.baseUrl}/models`,
206
+ {
207
+ headers: {
208
+ Accept: "application/json",
209
+ Authorization: `Bearer ${apiKey}`,
210
+ },
211
+ },
212
+ 30_000,
213
+ );
214
+
215
+ if (!response.ok) {
216
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
217
+ }
218
+
219
+ const body = (await response.json()) as
220
+ | Array<Record<string, unknown>>
221
+ | { data?: Array<Record<string, unknown>> };
222
+ const rawModels = Array.isArray(body) ? body : (body.data ?? []);
223
+ const mappedModels = rawModels
224
+ .map((m) => rawModelToProviderConfig(m, config))
225
+ .filter((m): m is ProviderModelConfig & { _pricingKnown?: boolean } =>
226
+ m !== undefined,
227
+ );
228
+ const allModels = applyAuthoritativeFreeFlags(mappedModels, config.id);
229
+
230
+ if (allModels.length === 0) return undefined;
231
+
232
+ return createProviderState(pi, config, {
233
+ allModels,
234
+ baseUrl: config.baseUrl,
235
+ api: config.api,
236
+ apiKey,
237
+ source: "discovered",
238
+ });
239
+ } catch (err) {
240
+ _logger.warn(`[built-in-toggle] ${config.id}: on-demand discovery failed`, {
241
+ error: err instanceof Error ? err.message : String(err),
242
+ });
243
+ return undefined;
244
+ }
245
+ }
246
+
247
+ function createProviderState(
248
+ pi: ExtensionAPI,
249
+ config: BuiltInToggleConfig,
250
+ options: {
251
+ allModels: ProviderModelConfig[];
252
+ baseUrl: string;
253
+ api: Api;
254
+ apiKey: string;
255
+ source: "captured" | "discovered";
256
+ },
257
+ ): BuiltInProviderState {
258
+ const { allModels, baseUrl, api, apiKey, source } = options;
259
+ const freeModels = allModels.filter((m: ProviderModelConfig) =>
260
+ isFreeModel({ ...m, provider: config.id }, allModels),
261
+ );
262
+
263
+ const reRegister = (models: ProviderModelConfig[]) => {
264
+ pi.registerProvider(config.id, {
265
+ baseUrl,
266
+ apiKey,
267
+ api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : api,
268
+ ...(isOpenCodeProvider(config.id)
269
+ ? { streamSimple: createOpenCodeStreamSimple(getOpenCodeSession()) }
270
+ : {}),
271
+ models,
272
+ });
273
+ };
274
+
275
+ const stored = { free: freeModels, all: allModels };
276
+ const toggleState = createToggleState<ProviderModelConfig>({
277
+ providerId: config.id,
278
+ initialShowPaid: config.getShowPaid(),
279
+ initialModels: stored,
280
+ });
281
+
282
+ const state: BuiltInProviderState = { stored, reRegister, toggleState };
283
+ providerStates.set(config.id, state);
284
+
285
+ registerWithGlobalToggle(config.id, stored, reRegister, true);
286
+
287
+ _logger.info(
288
+ `[built-in-toggle] ${config.id}: ${source} ${allModels.length} models (${freeModels.length} free)`,
289
+ );
290
+
291
+ return state;
292
+ }
293
+
294
+ // =============================================================================
295
+ // Per-provider toggle command
296
+ // =============================================================================
297
+
298
+ function registerToggleCommand(
299
+ pi: ExtensionAPI,
300
+ config: BuiltInToggleConfig,
301
+ ): void {
302
+ const commandName = `toggle-${config.id}`;
303
+ pi.registerCommand(commandName, {
304
+ description: `Toggle free/paid ${config.id} models`,
305
+ handler: async (_args, ctx) => {
306
+ let state = providerStates.get(config.id);
307
+ if (!state) {
308
+ // Models may have loaded after session_start — try on-demand capture.
309
+ state = tryCaptureProvider(pi, config, ctx);
310
+ }
311
+ if (!state) {
312
+ // If Pi has not exposed built-in models yet, fetch the provider's
313
+ // /models endpoint directly so /toggle-opencode works before the
314
+ // first chat session has populated the model registry.
315
+ state = await tryDiscoverProvider(pi, config);
316
+ }
317
+ if (!state) {
318
+ ctx.ui.notify(
319
+ `${config.id}: models not loaded yet and on-demand discovery failed. Check your API key, then try again.`,
320
+ "warning",
321
+ );
322
+ return;
323
+ }
324
+
325
+ const applied = state.toggleState.toggle(state.reRegister);
326
+
327
+ if (applied.mode === "all") {
328
+ ctx.ui.notify(
329
+ `${config.id}: showing all ${state.stored.all.length} models`,
330
+ "info",
331
+ );
332
+ } else {
333
+ ctx.ui.notify(
334
+ `${config.id}: showing ${state.stored.free.length} free models`,
335
+ "info",
336
+ );
337
+ }
338
+ },
339
+ });
340
+ }
341
+
342
+ // =============================================================================
343
+ // Helpers
344
+ // =============================================================================
345
+
346
+ function modelToProviderConfig(
347
+ m: Model<Api>,
348
+ providerId?: string,
349
+ ): ProviderModelConfig {
350
+ const base: ProviderModelConfig = {
351
+ id: m.id,
352
+ name: m.name,
353
+ api: m.api,
354
+ reasoning: m.reasoning,
355
+ input: m.input,
356
+ cost: m.cost,
357
+ contextWindow: m.contextWindow,
358
+ maxTokens: m.maxTokens,
359
+ headers: m.headers,
360
+ compat: (m as any).compat,
361
+ };
362
+
363
+ // Use a custom OpenCode API wrapper so per-request headers are regenerated
364
+ // for every LLM call instead of being frozen at registration time.
365
+ if (providerId && isOpenCodeProvider(providerId)) {
366
+ base.api = OPENCODE_DYNAMIC_API;
367
+ }
368
+
369
+ return base;
370
+ }
371
+
372
+ function rawModelToProviderConfig(
373
+ m: Record<string, unknown>,
374
+ config: BuiltInToggleConfig,
375
+ ): (ProviderModelConfig & { _pricingKnown?: boolean }) | undefined {
376
+ const id = String(m.id ?? "").trim();
377
+ if (!id) return undefined;
378
+ const inputModalities = Array.isArray(m.input_modalities)
379
+ ? m.input_modalities
380
+ : undefined;
381
+ const supportsImage = inputModalities?.includes("image") === true;
382
+ return {
383
+ id,
384
+ name: String(m.name ?? m.model ?? id),
385
+ api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : config.api,
386
+ reasoning: Boolean(m.reasoning ?? false),
387
+ input: supportsImage ? ["text", "image"] : ["text"],
388
+ cost: ZERO_COST,
389
+ contextWindow:
390
+ ((m.context_length ??
391
+ m.max_context_length ??
392
+ m.context_window) as number) ?? 128_000,
393
+ maxTokens: ((m.max_tokens ?? m.max_completion_tokens) as number) ?? 16_384,
394
+ _pricingKnown: false,
395
+ };
396
+ }
397
+
398
+ function applyAuthoritativeFreeFlags(
399
+ models: Array<ProviderModelConfig & { _pricingKnown?: boolean }>,
400
+ providerId: string,
401
+ ): Array<
402
+ ProviderModelConfig & {
403
+ _pricingKnown?: boolean;
404
+ _freeKnown?: boolean;
405
+ _isFree?: boolean;
406
+ }
407
+ > {
408
+ return models.map((model) => {
409
+ const isFree = isFreeModel({ ...model, provider: providerId }, models);
410
+ return {
411
+ ...model,
412
+ cost: isFree ? ZERO_COST : UNKNOWN_PAID_COST,
413
+ _freeKnown: true,
414
+ _isFree: isFree,
415
+ };
416
+ });
417
+ }
418
+
419
+ function getApiKeyEnvForProvider(providerId: string): string {
420
+ const envMap: Record<string, string> = {
421
+ opencode: "$OPENCODE_API_KEY",
422
+ "opencode-go": "$OPENCODE_API_KEY",
423
+ openrouter: "$OPENROUTER_API_KEY",
424
+ };
425
+ return envMap[providerId] || `$${providerId.toUpperCase()}_API_KEY`;
426
+ }