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
@@ -15,15 +15,26 @@
15
15
  */
16
16
 
17
17
  import type { OAuthCredentials } from "@earendil-works/pi-ai";
18
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import type {
19
+ ExtensionAPI,
20
+ ProviderModelConfig,
21
+ } from "@earendil-works/pi-coding-agent";
19
22
  import { getClineShowPaid } from "../../config.ts";
20
23
  import { BASE_URL_CLINE, PROVIDER_CLINE } from "../../constants.ts";
24
+ import {
25
+ DEFAULT_PROVIDER_CACHE_TTL_MS,
26
+ isProviderCacheFresh,
27
+ loadProviderCache,
28
+ saveProviderCache,
29
+ } from "../../lib/provider-cache.ts";
21
30
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
31
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
22
32
  import { createToggleState } from "../../lib/toggle-state.ts";
23
33
  import { logWarning } from "../../lib/util.ts";
24
34
  import { enhanceWithCI } from "../../provider-helper.ts";
25
35
  import { loginCline, refreshClineToken } from "./cline-auth.ts";
26
36
  import { fetchClineModels } from "./cline-models.ts";
37
+ import { streamClineXml } from "./cline-xml-bridge.ts";
27
38
 
28
39
  // =============================================================================
29
40
  // Cline API headers (must match real Cline VS Code extension exactly)
@@ -68,153 +79,26 @@ function toApiKey(credentials: OAuthCredentials): string {
68
79
  return token.startsWith("workos:") ? token : `workos:${token}`;
69
80
  }
70
81
 
71
- // =============================================================================
72
- // Context shaping — Cline's API requires a specific message envelope
73
- // =============================================================================
74
-
75
- const TASK_PROGRESS_BLOCK = `
76
- # task_progress List (Optional - Plan Mode)
77
-
78
- While in PLAN MODE, if you've outlined concrete steps or requirements for the user, you may include a preliminary todo list using the task_progress parameter.
79
-
80
- 1. To create or update a todo list, include the task_progress parameter in the next tool call
81
- 2. Review each item and update its status:
82
- - Mark completed items with: - [x]
83
- - Keep incomplete items as: - [ ]
84
- 3. Modify the list as needed
85
- 4. Ensure the list accurately reflects the current state`;
86
-
87
- function buildEnvironmentDetails(): string {
88
- const cwd = process.cwd();
89
- return `<environmentDetails>
90
- # Visual Studio Code Visible Files
91
- (No visible files)
92
-
93
- # Visual Studio Code Open Tabs
94
- (No open tabs)
95
-
96
- # Current Working Directory (${cwd}) Files
97
- (No files)
98
-
99
- # Context Window Usage
100
- 0 / 204.8K tokens used (0%)
101
-
102
- # Current Mode
103
- PLAN MODE
104
- </environmentDetails>`;
105
- }
106
-
107
- function extractText(content: unknown): string {
108
- if (typeof content === "string") return content.trim();
109
- if (Array.isArray(content)) {
110
- return (content as any[])
111
- .filter((p: any) => p?.type === "text" && typeof p?.text === "string")
112
- .map((p: any) => p.text)
113
- .join("\n\n")
114
- .trim();
115
- }
116
- return "";
117
- }
118
-
119
- function isClineWrapped(content: unknown): boolean {
120
- if (!Array.isArray(content)) return false;
121
- const texts = (content as any[])
122
- .filter((p: any) => p?.type === "text" && typeof p?.text === "string")
123
- .map((p: any) => p.text as string);
124
- return (
125
- texts.some((t) => /<task>[\s\S]*<\/task>/.test(t)) &&
126
- texts.some((t) => t.includes("task_progress List")) &&
127
- texts.some((t) => t.includes("<environmentDetails>"))
128
- );
129
- }
130
-
131
- function extractTaskBody(content: unknown): string {
132
- if (!Array.isArray(content)) return "";
133
- for (const p of content as any[]) {
134
- if (p?.type !== "text" || typeof p?.text !== "string") continue;
135
- const m = p.text.match(/<task>([\s\S]*?)<\/task>/);
136
- if (m?.[1]) return m[1].trim();
137
- }
138
- return "";
139
- }
140
-
141
- function findLastClineWrappedMessage(messages: any[]): {
142
- index: number;
143
- transcript: string;
144
- } {
145
- for (let i = messages.length - 1; i >= 0; i--) {
146
- if (messages[i]?.role !== "user") continue;
147
- if (!isClineWrapped(messages[i]?.content)) continue;
148
- return { index: i, transcript: extractTaskBody(messages[i].content) };
149
- }
150
- return { index: -1, transcript: "" };
151
- }
152
-
153
- function buildTranscriptParts(
154
- messages: any[],
155
- startIdx: number,
156
- baseTranscript: string,
157
- ): string[] {
158
- const parts: string[] = baseTranscript ? [baseTranscript] : [];
159
-
160
- for (let i = startIdx; i < messages.length; i++) {
161
- const msg = messages[i];
162
- const role = msg?.role ?? "user";
163
- if (role === "system") continue;
164
- if (role === "user" && isClineWrapped(msg?.content)) continue;
165
- const text = extractText(msg?.content).trim();
166
- if (!text) continue;
167
-
168
- if (role === "tool") {
169
- parts.push(`<tool_result>\n${text}\n</tool_result>`);
170
- } else if (role !== "assistant") {
171
- parts.push(`[${role}]\n${text}`);
172
- }
173
- }
174
-
175
- return parts;
176
- }
177
-
178
- function buildCollapsedMessage(messages: any[], transcript: string): any[] {
179
- const collapsed: any[] = [];
180
- const systemMsg = messages.find((m: any) => m?.role === "system");
181
- if (systemMsg) {
182
- const systemText = extractText(systemMsg.content);
183
- if (systemText) collapsed.push({ role: "system", content: systemText });
184
- }
185
-
186
- collapsed.push({
187
- role: "user",
188
- content: [
189
- { type: "text", text: `<task>\n${transcript}\n</task>` },
190
- { type: "text", text: TASK_PROGRESS_BLOCK },
191
- { type: "text", text: buildEnvironmentDetails() },
192
- ],
193
- });
194
-
195
- return collapsed;
196
- }
197
-
198
- function shapeMessagesForCline(messages: any[]): any[] {
199
- const { index: lastWrappedIdx, transcript: baseTranscript } =
200
- findLastClineWrappedMessage(messages);
201
-
202
- const startIdx = lastWrappedIdx >= 0 ? lastWrappedIdx + 1 : 0;
203
- const parts = buildTranscriptParts(messages, startIdx, baseTranscript);
204
- const transcript = parts.join("\n\n").trim() || "(no conversation yet)";
205
-
206
- return buildCollapsedMessage(messages, transcript);
207
- }
208
-
209
82
  // =============================================================================
210
83
  // Extension entry point
211
84
  // =============================================================================
212
85
 
213
86
  export default async function clineProvider(pi: ExtensionAPI) {
214
- let allModels = await fetchClineModels(false).catch((err) => {
215
- logWarning("cline", "Failed to fetch models at startup", err);
216
- return [];
217
- });
87
+ let allModels: ProviderModelConfig[];
88
+ const cachedModels = loadProviderCache(PROVIDER_CLINE);
89
+ if (cachedModels && cachedModels.length > 0) {
90
+ allModels = cachedModels;
91
+ } else {
92
+ allModels = await fetchClineModels(false).catch((err) => {
93
+ logWarning("cline", "Failed to fetch models at startup", err);
94
+ return [];
95
+ });
96
+ if (allModels.length > 0) {
97
+ saveProviderCache(PROVIDER_CLINE, allModels).catch((err) => {
98
+ logWarning("cline", "Failed to save model cache", err);
99
+ });
100
+ }
101
+ }
218
102
  let freeModels = allModels.filter((m) =>
219
103
  isFreeModel({ ...m, provider: PROVIDER_CLINE }, allModels),
220
104
  );
@@ -228,9 +112,11 @@ export default async function clineProvider(pi: ExtensionAPI) {
228
112
  const reRegister = (m: typeof allModels) => {
229
113
  pi.registerProvider(PROVIDER_CLINE, {
230
114
  baseUrl: BASE_URL_CLINE,
231
- api: "openai-completions" as const,
115
+ api: "cline-xml-tools" as const,
232
116
  authHeader: false,
233
117
  headers: buildClineHeaders(),
118
+ streamSimple: (model, context, options) =>
119
+ streamClineXml(model as any, context, options, buildClineHeaders()),
234
120
  models: enhanceWithCI(m),
235
121
  oauth: {
236
122
  name: "Cline",
@@ -241,12 +127,23 @@ export default async function clineProvider(pi: ExtensionAPI) {
241
127
  });
242
128
  };
243
129
 
130
+ const applyModelList = (models: ProviderModelConfig[]) => {
131
+ allModels = models;
132
+ freeModels = allModels.filter((m) =>
133
+ isFreeModel({ ...m, provider: PROVIDER_CLINE }, allModels),
134
+ );
135
+ stored.all = allModels;
136
+ stored.free = freeModels;
137
+ toggleState.setModels(stored);
138
+ toggleState.applyCurrent(reRegister);
139
+ };
140
+
244
141
  registerWithGlobalToggle(PROVIDER_CLINE, stored, (m) => reRegister(m), false);
245
142
  toggleState.applyCurrent(reRegister);
246
143
 
247
144
  pi.registerCommand("toggle-cline", {
248
145
  description: "Toggle between free and all Cline models",
249
- handler: async (_args, ctx) => {
146
+ handler: (_args, ctx) => {
250
147
  const applied = toggleState.toggle(reRegister);
251
148
  const freeCount = stored.free.length;
252
149
  const paidCount = stored.all.length - freeCount;
@@ -262,6 +159,7 @@ export default async function clineProvider(pi: ExtensionAPI) {
262
159
  "info",
263
160
  );
264
161
  }
162
+ return Promise.resolve();
265
163
  },
266
164
  });
267
165
 
@@ -288,41 +186,34 @@ export default async function clineProvider(pi: ExtensionAPI) {
288
186
  ctx.ui.setStatus(`${PROVIDER_CLINE}-status`, status);
289
187
  });
290
188
 
291
- pi.on("before_agent_start", async (_event, ctx) => {
189
+ pi.on("before_agent_start", (_event, ctx) => {
292
190
  if (ctx.model?.provider !== PROVIDER_CLINE) return;
293
191
  _currentTaskId = generateUlid();
294
192
  toggleState.applyCurrent(reRegister);
295
193
  });
296
194
 
297
- pi.on("context", async (event, ctx) => {
298
- if (ctx.model?.provider !== PROVIDER_CLINE) return;
299
- const sourceMessages = Array.isArray(event.messages) ? event.messages : [];
300
- return { messages: shapeMessagesForCline(sourceMessages) };
301
- });
302
-
303
- pi.on("session_start", async (_event, ctx) => {
304
- try {
305
- const fresh = await fetchClineModels(false);
306
- if (fresh.length > 0) {
307
- allModels = fresh;
308
- freeModels = allModels.filter((m) =>
309
- isFreeModel({ ...m, provider: PROVIDER_CLINE }, allModels),
310
- );
311
- stored.all = allModels;
312
- stored.free = freeModels;
313
- toggleState.setModels(stored);
314
- toggleState.applyCurrent(reRegister);
315
- if (ctx.model?.provider === PROVIDER_CLINE) {
316
- const freeCount = stored.free.length;
317
- const paidCount = stored.all.length - freeCount;
318
- ctx.ui.notify(
319
- `Cline: ${freeCount} free, ${paidCount} paid models available`,
320
- "info",
321
- );
322
- }
195
+ let refreshInFlight: Promise<void> | undefined;
196
+ pi.on(
197
+ "session_start",
198
+ wrapSessionStartHandler("cline", () => {
199
+ if (refreshInFlight) return Promise.resolve();
200
+ if (isProviderCacheFresh(PROVIDER_CLINE, DEFAULT_PROVIDER_CACHE_TTL_MS)) {
201
+ return Promise.resolve();
323
202
  }
324
- } catch (err) {
325
- logWarning("cline", "Failed to refresh models at session start", err);
326
- }
327
- });
203
+
204
+ refreshInFlight = fetchClineModels(false)
205
+ .then(async (fresh) => {
206
+ if (fresh.length === 0) return;
207
+ await saveProviderCache(PROVIDER_CLINE, fresh);
208
+ applyModelList(fresh);
209
+ })
210
+ .catch((err) => {
211
+ logWarning("cline", "Failed to refresh models at session start", err);
212
+ })
213
+ .finally(() => {
214
+ refreshInFlight = undefined;
215
+ });
216
+ return Promise.resolve();
217
+ }),
218
+ );
328
219
  }
@@ -28,6 +28,7 @@ import {
28
28
  PROVIDER_CROFAI,
29
29
  } from "../../constants.ts";
30
30
  import { createLogger } from "../../lib/logger.ts";
31
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
31
32
  import {
32
33
  getProxyModelCompat,
33
34
  isLikelyReasoningModel,
@@ -98,7 +99,7 @@ async function fetchCrofaiModels(
98
99
 
99
100
  _logger.info(`[crofai] Fetched ${models.length} models`);
100
101
 
101
- return models
102
+ const mapped = models
102
103
  .filter((m) => m.id)
103
104
  .map((m): ProviderModelConfig => {
104
105
  const name = m.name || m.id;
@@ -125,6 +126,10 @@ async function fetchCrofaiModels(
125
126
  m.pricing?.cache_prompt !== undefined,
126
127
  } as ProviderModelConfig & { _pricingKnown?: boolean };
127
128
  });
129
+
130
+ return await safeEnrichModelsWithModelsDev(mapped, {
131
+ providerId: PROVIDER_CROFAI,
132
+ });
128
133
  }
129
134
 
130
135
  // =============================================================================
@@ -40,12 +40,15 @@ import {
40
40
  PROVIDER_DEEPINFRA,
41
41
  } from "../../constants.ts";
42
42
  import { createLogger } from "../../lib/logger.ts";
43
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
43
44
  import {
44
45
  getProxyModelCompat,
45
46
  isLikelyReasoningModel,
46
47
  } from "../../lib/provider-compat.ts";
48
+ import { createProviderProbe } from "../../lib/provider-probe.ts";
47
49
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
48
- import { fetchWithRetry } from "../../lib/util.ts";
50
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
51
+ import { fetchWithRetry, fetchWithTimeout } from "../../lib/util.ts";
49
52
  import { createReRegister, setupProvider } from "../../provider-helper.ts";
50
53
 
51
54
  const _logger = createLogger("deepinfra");
@@ -99,7 +102,7 @@ async function fetchDeepinfraModels(
99
102
 
100
103
  _logger.info(`[deepinfra] Fetched ${models.length} models`);
101
104
 
102
- return models
105
+ const mapped = models
103
106
  .filter((m) => {
104
107
  const id = m.id.toLowerCase();
105
108
  // Filter out non-chat models
@@ -139,6 +142,10 @@ async function fetchDeepinfraModels(
139
142
  _pricingKnown: meta?.pricing !== undefined,
140
143
  } as ProviderModelConfig & { _pricingKnown?: boolean };
141
144
  });
145
+
146
+ return await safeEnrichModelsWithModelsDev(mapped, {
147
+ providerId: PROVIDER_DEEPINFRA,
148
+ });
142
149
  }
143
150
 
144
151
  // =============================================================================
@@ -205,4 +212,64 @@ export default async function deepinfraProvider(pi: ExtensionAPI) {
205
212
  // Initial registration — DeepInfra is a trial-credit provider,
206
213
  // so always show all models. Users see them immediately on setup.
207
214
  reRegister(allModels);
215
+
216
+ // ── Probe support ──────────────────────────────────────────────
217
+ const probe = createProviderProbe({
218
+ providerId: PROVIDER_DEEPINFRA,
219
+ probeModel: async (_apiKey: string, modelId: string) => {
220
+ try {
221
+ const response = await fetchWithTimeout(
222
+ `${BASE_URL_DEEPINFRA}/chat/completions`,
223
+ {
224
+ method: "POST",
225
+ headers: {
226
+ Authorization: `Bearer ${apiKey}`,
227
+ "Content-Type": "application/json",
228
+ "User-Agent": "pi-free-providers",
229
+ },
230
+ body: JSON.stringify({
231
+ model: modelId,
232
+ messages: [{ role: "user", content: "hi" }],
233
+ max_tokens: 1,
234
+ }),
235
+ },
236
+ 10_000,
237
+ );
238
+ if (response.status === 404 || response.status >= 500) return "broken";
239
+ if (response.status === 429) return "ok";
240
+ if (response.ok) return "ok";
241
+ return "ok";
242
+ } catch {
243
+ return "unknown";
244
+ }
245
+ },
246
+ });
247
+
248
+ // Probe command
249
+ pi.registerCommand(`probe-${PROVIDER_DEEPINFRA}`, {
250
+ description: "Test all DeepInfra models for availability",
251
+ handler: async (_args, ctx) => {
252
+ ctx.ui.notify(`Probing ${allModels.length} DeepInfra models…`, "info");
253
+ const broken = await probe.run(apiKey, allModels, {
254
+ onBroken: (ids) => {
255
+ ctx.ui.notify(
256
+ `Found ${ids.length} broken models (auto-hidden):\n${ids.join("\n")}`,
257
+ "warning",
258
+ );
259
+ },
260
+ });
261
+ if (broken.length === 0) {
262
+ ctx.ui.notify("All DeepInfra models are accessible ✅", "info");
263
+ }
264
+ },
265
+ });
266
+
267
+ // Lazy auto-probe on first session_start
268
+ pi.on(
269
+ "session_start",
270
+ wrapSessionStartHandler(
271
+ `${PROVIDER_DEEPINFRA}-auto-probe`,
272
+ probe.autoProbeHandler(apiKey, freeModels),
273
+ ),
274
+ );
208
275
  }