pi-free 2.2.6 → 2.2.8
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/CHANGELOG.md +31 -4
- package/README.md +3 -2
- package/config.ts +815 -803
- package/constants.ts +113 -121
- package/index.ts +6 -7
- package/lib/built-in-toggle.ts +426 -426
- package/lib/model-detection.ts +1 -1
- package/package.json +74 -74
- package/provider-helper.ts +29 -7
- package/providers/anyapi/anyapi.ts +270 -0
- package/providers/cline/cline-auth.ts +473 -473
- package/providers/cline/cline-xml-bridge.ts +1506 -1506
- package/providers/cline/cline.ts +205 -205
- package/providers/dynamic-built-in/index.ts +718 -718
- package/providers/kilo/kilo-auth.ts +152 -155
- package/providers/kilo/kilo.ts +14 -13
- package/providers/opencode-session.ts +514 -463
- package/providers/qoder/auth.ts +548 -548
- package/providers/qoder/qoder.ts +119 -119
- package/providers/qoder/stream.ts +585 -585
- package/providers/qoder/thinking-parser.ts +251 -251
- package/providers/qoder/transform.ts +192 -192
- package/providers/tokenrouter/tokenrouter.ts +636 -637
- package/providers/qwen/qwen-auth.ts +0 -416
- package/providers/qwen/qwen-models.ts +0 -101
- package/providers/qwen/qwen.ts +0 -200
package/lib/built-in-toggle.ts
CHANGED
|
@@ -1,426 +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((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
|
-
}
|
|
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/compat";
|
|
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
|
+
}
|