pi-free 2.0.14 → 2.1.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +64 -78
  3. package/banner.svg +21 -36
  4. package/config.ts +123 -9
  5. package/constants.ts +3 -9
  6. package/index.ts +14 -15
  7. package/lib/built-in-toggle.ts +29 -16
  8. package/lib/json-persistence.ts +90 -22
  9. package/lib/logger.ts +21 -12
  10. package/lib/model-detection.ts +2 -12
  11. package/lib/model-enhancer.ts +11 -2
  12. package/lib/model-metadata.ts +387 -0
  13. package/lib/open-browser.ts +74 -24
  14. package/lib/paths.ts +90 -0
  15. package/lib/probe-cache.ts +19 -19
  16. package/lib/provider-cache.ts +74 -28
  17. package/lib/provider-compat.ts +58 -9
  18. package/lib/provider-probe.ts +188 -0
  19. package/lib/registry.ts +1 -5
  20. package/lib/session-start-metrics.ts +46 -0
  21. package/lib/telemetry.ts +115 -86
  22. package/lib/types.ts +22 -2
  23. package/lib/util.ts +80 -21
  24. package/package.json +7 -2
  25. package/provider-failover/benchmark-lookup.ts +17 -5
  26. package/provider-helper.ts +11 -2
  27. package/providers/cline/cline-models.ts +12 -2
  28. package/providers/cline/cline-xml-bridge.ts +974 -0
  29. package/providers/cline/cline.ts +67 -176
  30. package/providers/crofai/crofai.ts +6 -1
  31. package/providers/deepinfra/deepinfra.ts +69 -2
  32. package/providers/dynamic-built-in/index.ts +237 -2
  33. package/providers/kilo/kilo-models.ts +3 -1
  34. package/providers/kilo/kilo.ts +268 -41
  35. package/providers/model-fetcher.ts +18 -55
  36. package/providers/novita/novita.ts +69 -2
  37. package/providers/ollama/ollama.ts +48 -24
  38. package/providers/opencode-session.ts +67 -2
  39. package/providers/routeway/routeway.ts +188 -2
  40. package/providers/sambanova/sambanova.ts +67 -1
  41. package/providers/together/together.ts +69 -2
  42. package/providers/tokenrouter/tokenrouter.ts +378 -0
  43. package/providers/zenmux/zenmux.ts +6 -1
  44. package/scripts/check-extensions.mjs +32 -16
  45. package/providers/nvidia/nvidia.ts +0 -504
@@ -0,0 +1,378 @@
1
+ /**
2
+ * TokenRouter Provider Extension
3
+ *
4
+ * TokenRouter is an OpenAI-compatible API gateway routing to 90+ models
5
+ * across multiple providers (OpenAI, Anthropic, Google, DeepSeek, Qwen, etc.).
6
+ *
7
+ * API: https://api.tokenrouter.com/v1
8
+ * Models: /v1/models
9
+ *
10
+ * Setup:
11
+ * TOKENROUTER_API_KEY=sk-...
12
+ * # or add tokenrouter_api_key to ~/.pi/free.json
13
+ */
14
+
15
+ import type {
16
+ ExtensionAPI,
17
+ ProviderModelConfig,
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import type { AssistantMessage, ThinkingContent } from "@earendil-works/pi-ai";
20
+ import {
21
+ getTokenrouterApiKey,
22
+ getTokenrouterShowPaid,
23
+ applyHidden,
24
+ } from "../../config.ts";
25
+ import {
26
+ BASE_URL_TOKENROUTER,
27
+ DEFAULT_FETCH_TIMEOUT_MS,
28
+ PROVIDER_TOKENROUTER,
29
+ } from "../../constants.ts";
30
+ import { createLogger } from "../../lib/logger.ts";
31
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
32
+ import {
33
+ DEEPSEEK_PROXY_COMPAT,
34
+ getProxyModelCompat,
35
+ isLikelyReasoningModel,
36
+ } from "../../lib/provider-compat.ts";
37
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
38
+ import { cleanModelName, fetchWithRetry } from "../../lib/util.ts";
39
+ import { createReRegister, setupProvider } from "../../provider-helper.ts";
40
+
41
+ const _logger = createLogger("tokenrouter");
42
+
43
+ // =============================================================================
44
+ // Reasoning cleanup
45
+ // TokenRouter's MiniMax-M3 model sometimes emits DeepSeek-style `<think>`
46
+ // reasoning tags inline in the assistant text. Pi does not strip them, so we
47
+ // extract them into proper ThinkingContent blocks on message_end.
48
+ // =============================================================================
49
+
50
+ interface ExtractedThinking {
51
+ text: string;
52
+ thinking: string;
53
+ }
54
+
55
+ function collapseWhitespace(text: string): string {
56
+ return text
57
+ .replace(/\r\n/g, "\n")
58
+ .replace(/\n{3,}/g, "\n\n")
59
+ .replace(/[ \t]+/g, " ")
60
+ .trim();
61
+ }
62
+
63
+ function extractThinkBlocks(text: string): ExtractedThinking {
64
+ const openTag = "<think>";
65
+ const closeTag = "</think>";
66
+ const thinkingParts: string[] = [];
67
+ const textParts: string[] = [];
68
+ let cursor = 0;
69
+
70
+ while (cursor < text.length) {
71
+ const openStart = text.indexOf(openTag, cursor);
72
+ if (openStart === -1) {
73
+ textParts.push(text.slice(cursor));
74
+ break;
75
+ }
76
+
77
+ textParts.push(text.slice(cursor, openStart));
78
+ const valueStart = openStart + openTag.length;
79
+ const closeStart = text.indexOf(closeTag, valueStart);
80
+ if (closeStart === -1) {
81
+ // Unclosed think tag: treat remainder as thinking.
82
+ thinkingParts.push(text.slice(valueStart));
83
+ break;
84
+ }
85
+
86
+ thinkingParts.push(text.slice(valueStart, closeStart));
87
+ cursor = closeStart + closeTag.length;
88
+ }
89
+
90
+ return {
91
+ text: collapseWhitespace(textParts.join("")),
92
+ thinking: collapseWhitespace(thinkingParts.join("\n\n")),
93
+ };
94
+ }
95
+
96
+ function isTokenRouterModel(model: { provider?: string }): boolean {
97
+ return model.provider === PROVIDER_TOKENROUTER;
98
+ }
99
+
100
+ // =============================================================================
101
+ // Known Free Models
102
+ // TokenRouter doesn't expose pricing via /v1/models, so known-free models
103
+ // are hardcoded. Detected via name suffix also catches `:free`-tagged models.
104
+ // =============================================================================
105
+
106
+ const MINIMAX_M3_ID = "MiniMax-M3";
107
+ const KNOWN_FREE_MODELS = new Set([MINIMAX_M3_ID]);
108
+ const MINIMAX_ADAPTIVE_COMPAT: NonNullable<ProviderModelConfig["compat"]> = {
109
+ ...DEEPSEEK_PROXY_COMPAT,
110
+ thinkingFormat: "deepseek",
111
+ };
112
+
113
+ // =============================================================================
114
+ // Types
115
+ // =============================================================================
116
+
117
+ interface TokenRouterModel {
118
+ id: string;
119
+ object: string;
120
+ created: number;
121
+ owned_by: string;
122
+ supported_endpoint_types: string[];
123
+ tags?: string;
124
+ }
125
+
126
+ // =============================================================================
127
+ // Helpers
128
+ // =============================================================================
129
+
130
+ /** Text-capable chat endpoints (excludes image/video/audio-only types) */
131
+ const CHAT_ENDPOINT_TYPES = new Set([
132
+ "openai",
133
+ "openai-response",
134
+ "anthropic",
135
+ "anthropic-compatible",
136
+ "gemini",
137
+ ]);
138
+
139
+ function isTextChatModel(model: TokenRouterModel): boolean {
140
+ const tags = (model.tags ?? "").toLowerCase();
141
+ // Exclude models whose only tags are non-text
142
+ const nonTextTags = ["image", "video", "audio"];
143
+ const hasNonTextTag = nonTextTags.some((t) => tags.includes(t));
144
+ const hasTextTag = tags.includes("text");
145
+ // If it has a text tag, include it. If only non-text tags, exclude.
146
+ if (hasTextTag) return true;
147
+ if (hasNonTextTag && !hasTextTag) return false;
148
+ // No tags or empty tags: check endpoint types
149
+ return model.supported_endpoint_types.some((t) => CHAT_ENDPOINT_TYPES.has(t));
150
+ }
151
+
152
+ function isTokenRouterMinimaxModel(modelId: string): boolean {
153
+ return modelId.toLowerCase().includes("minimax");
154
+ }
155
+
156
+ export function finalizeTokenRouterModel(
157
+ model: ProviderModelConfig,
158
+ ): ProviderModelConfig {
159
+ if (!isTokenRouterMinimaxModel(model.id)) return model;
160
+
161
+ return {
162
+ ...model,
163
+ reasoning: true,
164
+ compat: {
165
+ ...MINIMAX_ADAPTIVE_COMPAT,
166
+ ...(model.compat ?? {}),
167
+ thinkingFormat: "deepseek",
168
+ supportsReasoningEffort: true,
169
+ },
170
+ };
171
+ }
172
+
173
+ export function normalizeAssistantMessage(
174
+ message: AssistantMessage,
175
+ ): AssistantMessage {
176
+ const newContent: AssistantMessage["content"] = [];
177
+ let extractedThinking = "";
178
+
179
+ for (const block of message.content) {
180
+ if (block.type !== "text") {
181
+ newContent.push(block);
182
+ continue;
183
+ }
184
+
185
+ const extracted = extractThinkBlocks(block.text);
186
+ if (extracted.thinking) {
187
+ extractedThinking = extractedThinking
188
+ ? `${extractedThinking}\n\n${extracted.thinking}`
189
+ : extracted.thinking;
190
+ }
191
+ if (extracted.text) {
192
+ newContent.push({ ...block, text: extracted.text });
193
+ }
194
+ }
195
+
196
+ if (extractedThinking) {
197
+ newContent.push({
198
+ type: "thinking",
199
+ thinking: extractedThinking,
200
+ } as ThinkingContent);
201
+ }
202
+
203
+ return { ...message, content: newContent };
204
+ }
205
+
206
+ export function patchTokenRouterMinimaxThinkingPayload(
207
+ payload: unknown,
208
+ ): unknown {
209
+ if (typeof payload !== "object" || payload === null) return payload;
210
+ const body = payload as {
211
+ model?: unknown;
212
+ thinking?: { type?: unknown };
213
+ };
214
+ if (!isTokenRouterMinimaxModel(String(body.model ?? ""))) return payload;
215
+ if (body.thinking?.type !== "enabled") return payload;
216
+
217
+ return {
218
+ ...body,
219
+ thinking: {
220
+ ...body.thinking,
221
+ type: "adaptive",
222
+ },
223
+ };
224
+ }
225
+
226
+ export function mapTokenRouterModel(
227
+ model: TokenRouterModel,
228
+ ): ProviderModelConfig & {
229
+ _pricingKnown?: boolean;
230
+ _freeKnown?: boolean;
231
+ _isFree?: boolean;
232
+ } {
233
+ const name = cleanModelName(model.id);
234
+ const isMinimax = isTokenRouterMinimaxModel(model.id);
235
+ const reasoning = isMinimax || isLikelyReasoningModel({ id: model.id, name });
236
+ const isResponseApi =
237
+ model.supported_endpoint_types.includes("openai-response");
238
+ const isKnownFree = KNOWN_FREE_MODELS.has(model.id);
239
+
240
+ return {
241
+ id: model.id,
242
+ name,
243
+ reasoning,
244
+ input: ["text"],
245
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
246
+ contextWindow: 128_000,
247
+ maxTokens: 16_384,
248
+ compat: {
249
+ ...(isMinimax
250
+ ? MINIMAX_ADAPTIVE_COMPAT
251
+ : getProxyModelCompat({ id: model.id, name })),
252
+ // openai-response models use a different API shape
253
+ ...(isResponseApi ? { apiType: "openai-response" as const } : {}),
254
+ },
255
+ // Known-free models bypass pricing detection entirely
256
+ _freeKnown: isKnownFree,
257
+ _isFree: isKnownFree,
258
+ // Non-free models signal no pricing data (name-based detection only)
259
+ _pricingKnown: false,
260
+ } as ProviderModelConfig & { _pricingKnown?: boolean };
261
+ }
262
+
263
+ // =============================================================================
264
+ // Fetch Models
265
+ // =============================================================================
266
+
267
+ async function fetchTokenRouterModels(
268
+ apiKey: string,
269
+ ): Promise<ProviderModelConfig[]> {
270
+ _logger.info("[tokenrouter] Fetching models from TokenRouter API...");
271
+
272
+ try {
273
+ const response = await fetchWithRetry(
274
+ `${BASE_URL_TOKENROUTER}/models`,
275
+ {
276
+ headers: {
277
+ Authorization: `Bearer ${apiKey}`,
278
+ Accept: "application/json",
279
+ "Content-Type": "application/json",
280
+ },
281
+ },
282
+ 3,
283
+ 1000,
284
+ DEFAULT_FETCH_TIMEOUT_MS,
285
+ );
286
+
287
+ if (!response.ok) {
288
+ throw new Error(`TokenRouter API error: ${response.status}`);
289
+ }
290
+
291
+ const json = (await response.json()) as { data?: TokenRouterModel[] };
292
+ const models = (json.data ?? []).filter(isTextChatModel);
293
+
294
+ _logger.info(`[tokenrouter] Fetched ${models.length} text chat models`);
295
+ const enriched = await safeEnrichModelsWithModelsDev(
296
+ models.map(mapTokenRouterModel),
297
+ { providerId: PROVIDER_TOKENROUTER },
298
+ );
299
+ return applyHidden(
300
+ enriched.map(finalizeTokenRouterModel),
301
+ PROVIDER_TOKENROUTER,
302
+ );
303
+ } catch (error) {
304
+ _logger.error("[tokenrouter] Failed to fetch models", {
305
+ error: error instanceof Error ? error.message : String(error),
306
+ });
307
+ return [];
308
+ }
309
+ }
310
+
311
+ // =============================================================================
312
+ // Extension Entry Point
313
+ // =============================================================================
314
+
315
+ export default async function tokenRouterProvider(pi: ExtensionAPI) {
316
+ const apiKey = getTokenrouterApiKey();
317
+
318
+ if (!apiKey) {
319
+ _logger.info("[tokenrouter] Skipping — TOKENROUTER_API_KEY not set.");
320
+ return;
321
+ }
322
+
323
+ const allModels = await fetchTokenRouterModels(apiKey);
324
+
325
+ if (allModels.length === 0) {
326
+ _logger.warn("[tokenrouter] No text chat models available");
327
+ return;
328
+ }
329
+
330
+ const freeModels = allModels.filter((m) =>
331
+ isFreeModel({ ...m, provider: PROVIDER_TOKENROUTER }, allModels),
332
+ );
333
+ const stored = { free: freeModels, all: allModels };
334
+
335
+ _logger.info(
336
+ `[tokenrouter] Registered ${allModels.length} models (${freeModels.length} free)`,
337
+ );
338
+
339
+ const reRegister = createReRegister(pi, {
340
+ providerId: PROVIDER_TOKENROUTER,
341
+ baseUrl: BASE_URL_TOKENROUTER,
342
+ apiKey,
343
+ });
344
+
345
+ registerWithGlobalToggle(PROVIDER_TOKENROUTER, stored, reRegister, true);
346
+
347
+ pi.on("before_provider_request", (event) =>
348
+ patchTokenRouterMinimaxThinkingPayload(event.payload),
349
+ );
350
+
351
+ pi.on("message_end", (event, ctx) => {
352
+ if (!isTokenRouterModel(ctx.model ?? {})) return;
353
+ if (event.message.role !== "assistant") return;
354
+ return { message: normalizeAssistantMessage(event.message) };
355
+ });
356
+
357
+ setupProvider(
358
+ pi,
359
+ {
360
+ providerId: PROVIDER_TOKENROUTER,
361
+ initialShowPaid: getTokenrouterShowPaid(),
362
+ tosUrl: "https://tokenrouter.com/terms",
363
+ reRegister: (models, _stored) => {
364
+ if (_stored) {
365
+ stored.free = _stored.free;
366
+ stored.all = _stored.all;
367
+ }
368
+ reRegister(models);
369
+ },
370
+ },
371
+ stored,
372
+ );
373
+
374
+ const showPaid = getTokenrouterShowPaid();
375
+ const initialModels =
376
+ showPaid && stored.all.length > 0 ? stored.all : freeModels;
377
+ reRegister(initialModels);
378
+ }
@@ -27,6 +27,7 @@ import {
27
27
  PROVIDER_ZENMUX,
28
28
  } from "../../constants.ts";
29
29
  import { createLogger } from "../../lib/logger.ts";
30
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
30
31
  import { getProxyModelCompat } from "../../lib/provider-compat.ts";
31
32
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
32
33
  import { fetchWithRetry } from "../../lib/util.ts";
@@ -97,7 +98,7 @@ async function fetchZenmuxModels(
97
98
 
98
99
  _logger.info(`[zenmux] Fetched ${models.length} models`);
99
100
 
100
- return models.map((m) => {
101
+ const mapped = models.map((m) => {
101
102
  const hasPricings = m.pricings !== undefined;
102
103
  return {
103
104
  id: m.id,
@@ -118,6 +119,10 @@ async function fetchZenmuxModels(
118
119
  _pricingKnown: hasPricings,
119
120
  } as ProviderModelConfig & { _pricingKnown?: boolean };
120
121
  });
122
+
123
+ return await safeEnrichModelsWithModelsDev(mapped, {
124
+ providerId: PROVIDER_ZENMUX,
125
+ });
121
126
  } catch (error) {
122
127
  _logger.error("[zenmux] Failed to fetch models:", {
123
128
  error: error instanceof Error ? error.message : String(error),
@@ -14,29 +14,45 @@ import { dirname, join, resolve } from "node:path";
14
14
  const installDir = resolve(process.argv[2] ?? ".");
15
15
  const fromSource = process.argv[2] == null;
16
16
 
17
- /** Resolve npm to an absolute path to avoid S4036 PATH-lookup flags. */
18
- function resolveNpm() {
17
+ function resolveNpmCli() {
19
18
  for (const p of [
20
- "/usr/bin/npm",
21
- "/usr/local/bin/npm",
22
- process.platform === "win32"
23
- ? String.raw`C:\Program Files\nodejs\npm.cmd`
24
- : "",
19
+ join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
20
+ "/usr/lib/node_modules/npm/bin/npm-cli.js",
21
+ "/usr/local/lib/node_modules/npm/bin/npm-cli.js",
22
+ "/usr/share/nodejs/npm/bin/npm-cli.js",
25
23
  ]) {
26
- if (p && existsSync(p)) return p;
24
+ if (existsSync(p)) return p;
27
25
  }
28
- return "npm"; // fallback
26
+ throw new Error("Could not find npm-cli.js in known Node/npm locations");
29
27
  }
30
28
 
31
- function getFiles() {
32
- if (fromSource) {
33
- // Use npm pack --dry-run with an absolute executable path.
34
- const out = execFileSync(resolveNpm(), ["pack", "--dry-run"], {
35
- encoding: "utf8",
36
- });
29
+ function runNpmPackDryRun() {
30
+ return execFileSync(
31
+ process.execPath,
32
+ [resolveNpmCli(), "pack", "--dry-run", "--json"],
33
+ { encoding: "utf8" },
34
+ );
35
+ }
36
+
37
+ function parsePackFileList(out) {
38
+ try {
39
+ const packed = JSON.parse(out);
40
+ return packed.flatMap((entry) =>
41
+ (entry.files ?? []).map((file) => file.path).filter(Boolean),
42
+ );
43
+ } catch {
37
44
  return out
38
45
  .split("\n")
39
- .map((l) => l.match(/npm notice \S+\s+(.+)/)?.[1]?.trim())
46
+ .map((line) => line.match(/npm notice \S+\s+(.+)/)?.[1]?.trim())
47
+ .filter(Boolean);
48
+ }
49
+ }
50
+
51
+ function getFiles() {
52
+ if (fromSource) {
53
+ // Use npm pack --dry-run to inspect exactly what would be published.
54
+ const out = runNpmPackDryRun();
55
+ return parsePackFileList(out)
40
56
  .filter((f) => f && (f.endsWith(".ts") || f.endsWith(".mjs")))
41
57
  .map((f) => join(installDir, f));
42
58
  }