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
@@ -25,6 +25,7 @@ import {
25
25
  } from "../../config.ts";
26
26
  import { URL_KILO_TOS } from "../../constants.ts";
27
27
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
28
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
28
29
  import { cleanModelName, logWarning } from "../../lib/util.ts";
29
30
  import {
30
31
  createCtxReRegister,
@@ -35,6 +36,119 @@ import {
35
36
  import { loginKilo, refreshKiloToken } from "./kilo-auth.ts";
36
37
  import { fetchKiloModels, KILO_GATEWAY_BASE } from "./kilo-models.ts";
37
38
 
39
+ /** Kilo Gateway compat overrides, borrowed from pi-kilo-provider. */
40
+ const KILO_COMPAT = {
41
+ supportsStore: false,
42
+ supportsDeveloperRole: false,
43
+ supportsReasoningEffort: false,
44
+ supportsUsageInStreaming: false,
45
+ supportsStrictMode: false,
46
+ thinkingFormat: "openrouter" as const,
47
+ maxTokensField: "max_tokens" as const,
48
+ };
49
+
50
+ // =============================================================================
51
+ // XML leak detection and auto-retry
52
+ // =============================================================================
53
+
54
+ /**
55
+ * Detect when a model outputs raw XML tool calls instead of using
56
+ * native function calling. This happens when gateways don't pass
57
+ * tool definitions to certain models (e.g., step-3.7-flash via Kilo).
58
+ *
59
+ * Pattern: <tool><name>tool_name</name><param>...</param></tool>
60
+ */
61
+ function detectXmlToolLeak(text: string): boolean {
62
+ // Use simple string searches instead of regex to avoid ReDoS risks.
63
+ const lower = text.toLowerCase();
64
+ return (
65
+ lower.includes("<tool>") ||
66
+ lower.includes("<tool_call>") ||
67
+ lower.includes("<function_call>") ||
68
+ lower.includes("<invoke") ||
69
+ lower.includes("<antml:tool_use>")
70
+ );
71
+ }
72
+
73
+ function findTag(
74
+ text: string,
75
+ tag: string,
76
+ start = 0,
77
+ ): { start: number; end: number; content: string } | null {
78
+ const open = `<${tag}>`;
79
+ const close = `</${tag}>`;
80
+ const openIdx = text.indexOf(open, start);
81
+ if (openIdx === -1) return null;
82
+ const contentStart = openIdx + open.length;
83
+ const closeIdx = text.indexOf(close, contentStart);
84
+ if (closeIdx === -1) return null;
85
+ return {
86
+ start: openIdx,
87
+ end: closeIdx + close.length,
88
+ content: text.slice(contentStart, closeIdx),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Parse XML tool calls and convert to pi's tool call format.
94
+ * Returns null if parsing fails.
95
+ *
96
+ * Uses simple string scanning instead of regex with backreferences
97
+ * to avoid super-linear backtracking (ReDoS).
98
+ */
99
+ function parseXmlToolCalls(
100
+ text: string,
101
+ ): Array<{ name: string; arguments: Record<string, unknown> }> | null {
102
+ try {
103
+ const calls: Array<{ name: string; arguments: Record<string, unknown> }> =
104
+ [];
105
+ let searchStart = 0;
106
+ while (true) {
107
+ const toolBlock = findTag(text, "tool", searchStart);
108
+ if (!toolBlock) break;
109
+ searchStart = toolBlock.end;
110
+
111
+ const nameTag = findTag(toolBlock.content, "name");
112
+ if (!nameTag) continue;
113
+ const name = nameTag.content.trim();
114
+ if (!name) continue;
115
+
116
+ const args: Record<string, unknown> = {};
117
+ // Skip the <name>...</name> block we already consumed.
118
+ let paramStart = nameTag.end;
119
+ const paramsText = toolBlock.content;
120
+ while (true) {
121
+ const openIdx = paramsText.indexOf("<", paramStart);
122
+ if (openIdx === -1) break;
123
+ const closeOpenIdx = paramsText.indexOf(">", openIdx);
124
+ if (closeOpenIdx === -1) break;
125
+ const tagName = paramsText.slice(openIdx + 1, closeOpenIdx).trim();
126
+ if (!tagName || tagName.startsWith("/")) {
127
+ paramStart = closeOpenIdx + 1;
128
+ continue;
129
+ }
130
+ const closeTag = `</${tagName}>`;
131
+ const closeIdx = paramsText.indexOf(closeTag, closeOpenIdx + 1);
132
+ if (closeIdx === -1) break;
133
+ const value = paramsText.slice(closeOpenIdx + 1, closeIdx).trim();
134
+ try {
135
+ args[tagName] = JSON.parse(value);
136
+ } catch {
137
+ args[tagName] = value;
138
+ }
139
+ paramStart = closeIdx + closeTag.length;
140
+ }
141
+ calls.push({ name, arguments: args });
142
+ }
143
+ return calls.length > 0 ? calls : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ // =============================================================================
150
+ // Extension entry point
151
+ // =============================================================================
38
152
  const KILO_PROVIDER_CONFIG = {
39
153
  providerId: PROVIDER_KILO,
40
154
  baseUrl: KILO_GATEWAY_BASE,
@@ -44,6 +158,19 @@ const KILO_PROVIDER_CONFIG = {
44
158
  },
45
159
  };
46
160
 
161
+ /** Apply Kilo-specific compat overrides while preserving provider/model values. */
162
+ function applyKiloCompat<T extends { compat?: ProviderModelConfig["compat"] }>(
163
+ models: T[],
164
+ ): T[] {
165
+ return models.map((m) => ({
166
+ ...m,
167
+ compat: {
168
+ ...KILO_COMPAT,
169
+ ...m.compat,
170
+ },
171
+ }));
172
+ }
173
+
47
174
  export default async function kiloProvider(pi: ExtensionAPI) {
48
175
  // Try to fetch ALL models at startup (like Cline/OpenRouter)
49
176
  // If no API key, this will return free models only
@@ -77,9 +204,11 @@ export default async function kiloProvider(pi: ExtensionAPI) {
77
204
  const stored: StoredModels = { free: freeModels, all: allModels };
78
205
 
79
206
  // Create re-register function
80
- const reRegister = createReRegister(pi, {
207
+ const baseReRegister = createReRegister(pi, {
81
208
  ...KILO_PROVIDER_CONFIG,
82
209
  });
210
+ const reRegister = (models: ProviderModelConfig[]) =>
211
+ baseReRegister(applyKiloCompat(models));
83
212
 
84
213
  // Register with global toggle system
85
214
  registerWithGlobalToggle(
@@ -108,9 +237,11 @@ export default async function kiloProvider(pi: ExtensionAPI) {
108
237
  stored.free = freeModels;
109
238
 
110
239
  // Update global toggle registration with new lists
111
- const globalReRegister = createReRegister(pi, {
240
+ const baseGlobalReRegister = createReRegister(pi, {
112
241
  ...KILO_PROVIDER_CONFIG,
113
242
  });
243
+ const globalReRegister = (models: ProviderModelConfig[]) =>
244
+ baseGlobalReRegister(applyKiloCompat(models));
114
245
  registerWithGlobalToggle(PROVIDER_KILO, stored, globalReRegister, true);
115
246
 
116
247
  // If paid mode is enabled, show all models
@@ -132,21 +263,24 @@ export default async function kiloProvider(pi: ExtensionAPI) {
132
263
  const template = models.find((m) => m.provider === PROVIDER_KILO);
133
264
  if (!template) return models;
134
265
  const nonKilo = models.filter((m) => m.provider !== PROVIDER_KILO);
135
- const fullModels = allModels.map((m) => ({
136
- ...template,
137
- id: m.id,
138
- name: cleanModelName(m.name),
139
- reasoning: m.reasoning,
140
- input: m.input,
141
- cost: m.cost,
142
- contextWindow: m.contextWindow,
143
- maxTokens: m.maxTokens,
144
- }));
145
- return [...nonKilo, ...fullModels];
266
+ const fullModels = applyKiloCompat(
267
+ allModels.map((m) => ({
268
+ ...template,
269
+ id: m.id,
270
+ name: cleanModelName(m.name),
271
+ reasoning: m.reasoning,
272
+ input: m.input,
273
+ cost: m.cost,
274
+ contextWindow: m.contextWindow,
275
+ maxTokens: m.maxTokens,
276
+ })),
277
+ );
278
+ return [...nonKilo, ...fullModels] as Model<"openai-completions">[];
146
279
  },
147
280
  };
148
281
 
149
282
  // Register initial provider (default to free models)
283
+ const modelsWithCompat = applyKiloCompat(currentModels);
150
284
  pi.registerProvider(PROVIDER_KILO, {
151
285
  baseUrl: KILO_GATEWAY_BASE,
152
286
  apiKey: "$KILO_API_KEY",
@@ -155,7 +289,7 @@ export default async function kiloProvider(pi: ExtensionAPI) {
155
289
  "X-KILOCODE-EDITORNAME": "Pi",
156
290
  "User-Agent": "pi-free-providers",
157
291
  },
158
- models: enhanceWithCI(currentModels),
292
+ models: enhanceWithCI(modelsWithCompat),
159
293
  oauth: oauthConfig,
160
294
  });
161
295
 
@@ -228,36 +362,129 @@ export default async function kiloProvider(pi: ExtensionAPI) {
228
362
  }
229
363
  });
230
364
 
231
- // Refresh models on session start if authenticated
232
- pi.on("session_start", async (_event, ctx) => {
233
- const cred = ctx.modelRegistry.authStorage.get(PROVIDER_KILO);
365
+ // ── XML leak detection and auto-retry ─────────────────────────
366
+ //
367
+ // When a model outputs raw XML tool calls (<tool><name>...</name></tool>)
368
+ // instead of native function calling, detect it and rewrite the message
369
+ // to force the model to use proper tool calling on the next turn.
234
370
 
235
- if (cred?.type === "oauth") {
236
- try {
237
- const newModels = await fetchKiloModels({
238
- token: cred.access,
239
- freeOnly: false,
240
- });
241
- allModels = newModels;
242
- stored.all = allModels;
243
- freeModels = allModels.filter((m) =>
244
- isFreeModel({ ...m, provider: PROVIDER_KILO }, allModels),
245
- );
246
- stored.free = freeModels;
371
+ let xmlLeakRetryCount = 0;
372
+ const MAX_XML_LEAK_RETRIES = 2;
247
373
 
248
- // Update global toggle registration
249
- const ctxReRegister = createCtxReRegister(ctx as any, {
250
- ...KILO_PROVIDER_CONFIG,
251
- });
252
- registerWithGlobalToggle(PROVIDER_KILO, stored, ctxReRegister, true);
374
+ (pi as any).on("message_end", (event: any, ctx: any) => {
375
+ if (ctx.model?.provider !== PROVIDER_KILO) return;
253
376
 
254
- // Apply current view mode
255
- if (showPaidModels && !getKiloFreeOnly()) {
256
- ctxReRegister(allModels);
257
- }
258
- } catch (error) {
259
- logWarning("kilo", "Failed to refresh models at session start", error);
260
- }
377
+ const msg = event.message;
378
+ if (msg.role !== "assistant") return;
379
+
380
+ // Extract text content from the message
381
+ let text = "";
382
+ if (typeof msg.content === "string") {
383
+ text = msg.content;
384
+ } else if (Array.isArray(msg.content)) {
385
+ text = msg.content
386
+ .filter((p: any) => p?.type === "text" && typeof p?.text === "string")
387
+ .map((p: any) => p.text)
388
+ .join("\n");
389
+ }
390
+
391
+ if (!text || !detectXmlToolLeak(text)) {
392
+ xmlLeakRetryCount = 0; // Reset on clean response
393
+ return;
261
394
  }
395
+
396
+ // XML leak detected
397
+ if (xmlLeakRetryCount >= MAX_XML_LEAK_RETRIES) {
398
+ xmlLeakRetryCount = 0;
399
+ logWarning("kilo", "XML tool leak persisted after retries, giving up");
400
+ return;
401
+ }
402
+
403
+ xmlLeakRetryCount++;
404
+ logWarning(
405
+ "kilo",
406
+ `XML tool leak detected (attempt ${xmlLeakRetryCount}/${MAX_XML_LEAK_RETRIES}), rewriting message`,
407
+ );
408
+
409
+ // Try to parse the XML tool calls
410
+ const parsedCalls = parseXmlToolCalls(text);
411
+ if (parsedCalls && parsedCalls.length > 0) {
412
+ // We parsed the tool calls - convert to proper toolCall format
413
+ const toolCalls = parsedCalls.map((call, i) => ({
414
+ type: "toolCall" as const,
415
+ id: `xml_leak_${Date.now()}_${i}`,
416
+ name: call.name,
417
+ arguments: call.arguments,
418
+ }));
419
+
420
+ return {
421
+ ...msg,
422
+ content: [
423
+ {
424
+ type: "text",
425
+ text:
426
+ text.replace(/<tool>[\s\S]*?<\/tool>/g, "").trim() ||
427
+ "(parsed tool calls)",
428
+ },
429
+ ...toolCalls,
430
+ ],
431
+ };
432
+ }
433
+
434
+ // Can't parse - add a correction message to force retry
435
+ // We rewrite the message to include a note about using proper tool calling
436
+ return {
437
+ ...msg,
438
+ content: [
439
+ {
440
+ type: "text",
441
+ text: `${text}\n\n---\n[SYSTEM: You outputted XML tool calls instead of using the function calling API. Please use the native tool/function calling format with JSON arguments, not XML tags like <tool>.]`,
442
+ },
443
+ ],
444
+ };
262
445
  });
446
+
447
+ // Refresh models on session start if authenticated
448
+ let refreshInFlight: Promise<void> | undefined;
449
+ pi.on(
450
+ "session_start",
451
+ wrapSessionStartHandler("kilo", (_event, ctx) => {
452
+ const cred = ctx.modelRegistry.authStorage.get(PROVIDER_KILO);
453
+ if (cred?.type !== "oauth" || refreshInFlight) return Promise.resolve();
454
+
455
+ refreshInFlight = fetchKiloModels({ token: cred.access, freeOnly: false })
456
+ .then((newModels) => {
457
+ allModels = newModels;
458
+ stored.all = allModels;
459
+ freeModels = allModels.filter((m) =>
460
+ isFreeModel({ ...m, provider: PROVIDER_KILO }, allModels),
461
+ );
462
+ stored.free = freeModels;
463
+
464
+ // Update global toggle registration
465
+ const baseCtxReRegister = createCtxReRegister(ctx as any, {
466
+ ...KILO_PROVIDER_CONFIG,
467
+ });
468
+ const ctxReRegister = (models: ProviderModelConfig[]) =>
469
+ baseCtxReRegister(applyKiloCompat(models));
470
+ registerWithGlobalToggle(PROVIDER_KILO, stored, ctxReRegister, true);
471
+
472
+ // Apply current view mode
473
+ if (showPaidModels && !getKiloFreeOnly()) {
474
+ ctxReRegister(allModels);
475
+ }
476
+ })
477
+ .catch((error) => {
478
+ logWarning(
479
+ "kilo",
480
+ "Failed to refresh models at session start",
481
+ error instanceof Error ? error.message : String(error),
482
+ );
483
+ })
484
+ .finally(() => {
485
+ refreshInFlight = undefined;
486
+ });
487
+ return Promise.resolve();
488
+ }),
489
+ );
263
490
  }
@@ -3,8 +3,9 @@
3
3
  * Consolidates duplicate logic from openrouter.ts and kilo-models.ts
4
4
  */
5
5
 
6
- import { DEFAULT_FETCH_TIMEOUT_MS, URL_MODELS_DEV } from "../constants.ts";
7
- import type { ModelsDevModel, ProviderModelConfig } from "../lib/types.ts";
6
+ import { DEFAULT_FETCH_TIMEOUT_MS } from "../constants.ts";
7
+ import { safeEnrichModelsWithModelsDev } from "../lib/model-metadata.ts";
8
+ import type { ProviderModelConfig } from "../lib/types.ts";
8
9
  import { fetchWithRetry, mapOpenRouterModel } from "../lib/util.ts";
9
10
 
10
11
  interface OpenRouterCompatibleModel {
@@ -22,12 +23,17 @@ interface OpenRouterCompatibleModel {
22
23
  input_modalities?: string[] | null;
23
24
  output_modalities?: string[] | null;
24
25
  };
25
- top_provider?: { max_completion_tokens?: number | null };
26
+ top_provider?: {
27
+ context_length?: number | null;
28
+ max_completion_tokens?: number | null;
29
+ };
26
30
  supported_parameters?: string[];
27
31
  isFree?: boolean;
28
32
  }
29
33
 
30
34
  interface FetchModelsOptions {
35
+ /** Provider id for scoped models.dev enrichment (e.g., openrouter, kilo). */
36
+ providerId?: string;
31
37
  /** Base URL for the API (e.g., https://api.openrouter.ai/api/v1) */
32
38
  baseUrl: string;
33
39
  /** API key for authentication (optional) */
@@ -93,11 +99,13 @@ export async function fetchOpenRouterCompatibleModels(
93
99
  throw new Error("Invalid models response: missing data array");
94
100
  }
95
101
 
96
- return json.data
102
+ const models = json.data
97
103
  .filter((m) => {
98
- // Filter out image generation models
104
+ // Filter out models that cannot produce text output (image/video/audio
105
+ // generation, embedding-only, etc.). Keep models with no output
106
+ // modality info to avoid over-filtering older endpoints.
99
107
  const outputMods = m.architecture?.output_modalities ?? [];
100
- if (outputMods.includes("image")) return false;
108
+ if (outputMods.length > 0 && !outputMods.includes("text")) return false;
101
109
 
102
110
  // Filter by provider flag when available, otherwise pricing.
103
111
  if (freeOnly) {
@@ -110,6 +118,10 @@ export async function fetchOpenRouterCompatibleModels(
110
118
  return true;
111
119
  })
112
120
  .map(mapOpenRouterModel);
121
+
122
+ return await safeEnrichModelsWithModelsDev(models, {
123
+ providerId: options.providerId,
124
+ });
113
125
  }
114
126
 
115
127
  /**
@@ -131,52 +143,3 @@ export async function fetchOpenRouterModelsWithFree(
131
143
 
132
144
  return { free, all };
133
145
  }
134
-
135
- // =============================================================================
136
- // Models.dev metadata fetching
137
- // =============================================================================
138
-
139
- interface ModelsDevResponse {
140
- [id: string]: {
141
- id?: string;
142
- models?: Record<string, ModelsDevModel>;
143
- };
144
- }
145
-
146
- /**
147
- * Fetch model metadata from models.dev.
148
- * @param providerId - If specified, only return models for that provider
149
- * @returns Map of model ID to model metadata
150
- */
151
- export async function fetchModelsDevMeta(
152
- providerId?: string,
153
- ): Promise<Record<string, ModelsDevModel>> {
154
- const response = await fetchWithRetry(
155
- URL_MODELS_DEV,
156
- {
157
- headers: { "User-Agent": "pi-free-providers" },
158
- },
159
- 3,
160
- 1000,
161
- DEFAULT_FETCH_TIMEOUT_MS,
162
- );
163
-
164
- if (!response.ok) return {};
165
-
166
- const json = (await response.json()) as ModelsDevResponse;
167
-
168
- // If providerId specified, return only that provider's models
169
- if (providerId) {
170
- const provider = Object.values(json).find((p) => p?.id === providerId);
171
- return provider?.models ?? {};
172
- }
173
-
174
- // Otherwise, return all models from all providers
175
- const allModels: Record<string, ModelsDevModel> = {};
176
- for (const provider of Object.values(json)) {
177
- if (provider?.models) {
178
- Object.assign(allModels, provider.models);
179
- }
180
- }
181
- return allModels;
182
- }
@@ -32,12 +32,15 @@ import {
32
32
  PROVIDER_NOVITA,
33
33
  } from "../../constants.ts";
34
34
  import { createLogger } from "../../lib/logger.ts";
35
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
35
36
  import {
36
37
  getProxyModelCompat,
37
38
  isLikelyReasoningModel,
38
39
  } from "../../lib/provider-compat.ts";
40
+ import { createProviderProbe } from "../../lib/provider-probe.ts";
39
41
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
40
- import { fetchWithRetry } from "../../lib/util.ts";
42
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
43
+ import { fetchWithRetry, fetchWithTimeout } from "../../lib/util.ts";
41
44
  import { createReRegister, setupProvider } from "../../provider-helper.ts";
42
45
 
43
46
  const _logger = createLogger("novita");
@@ -96,7 +99,7 @@ async function fetchNovitaModels(
96
99
 
97
100
  _logger.info(`[novita] Fetched ${models.length} models`);
98
101
 
99
- return models.map((m): ProviderModelConfig => {
102
+ const mapped = models.map((m): ProviderModelConfig => {
100
103
  const name = m.display_name || m.id.split("/").pop() || m.id;
101
104
  const reasoning =
102
105
  (m.features ?? []).includes("reasoning") ||
@@ -127,6 +130,10 @@ async function fetchNovitaModels(
127
130
  _pricingKnown: hasPricing,
128
131
  } as ProviderModelConfig & { _pricingKnown?: boolean };
129
132
  });
133
+
134
+ return await safeEnrichModelsWithModelsDev(mapped, {
135
+ providerId: PROVIDER_NOVITA,
136
+ });
130
137
  } catch (error) {
131
138
  _logger.error("[novita] Failed to fetch models:", {
132
139
  error: error instanceof Error ? error.message : String(error),
@@ -202,4 +209,64 @@ export default async function novitaProvider(pi: ExtensionAPI) {
202
209
  const initialModels =
203
210
  showPaid && stored.all.length > 0 ? stored.all : freeModels;
204
211
  reRegister(initialModels);
212
+
213
+ // ── Probe support ──────────────────────────────────────────────
214
+ const probe = createProviderProbe({
215
+ providerId: PROVIDER_NOVITA,
216
+ probeModel: async (_apiKey: string, modelId: string) => {
217
+ try {
218
+ const response = await fetchWithTimeout(
219
+ `${BASE_URL_NOVITA}/chat/completions`,
220
+ {
221
+ method: "POST",
222
+ headers: {
223
+ Authorization: `Bearer ${apiKey}`,
224
+ "Content-Type": "application/json",
225
+ "User-Agent": "pi-free-providers",
226
+ },
227
+ body: JSON.stringify({
228
+ model: modelId,
229
+ messages: [{ role: "user", content: "hi" }],
230
+ max_tokens: 1,
231
+ }),
232
+ },
233
+ 10_000,
234
+ );
235
+ if (response.status === 404 || response.status >= 500) return "broken";
236
+ if (response.status === 429) return "ok";
237
+ if (response.ok) return "ok";
238
+ return "ok";
239
+ } catch {
240
+ return "unknown";
241
+ }
242
+ },
243
+ });
244
+
245
+ // Probe command
246
+ pi.registerCommand(`probe-${PROVIDER_NOVITA}`, {
247
+ description: "Test all Novita AI models for availability",
248
+ handler: async (_args, ctx) => {
249
+ ctx.ui.notify(`Probing ${allModels.length} Novita AI models…`, "info");
250
+ const broken = await probe.run(apiKey, allModels, {
251
+ onBroken: (ids) => {
252
+ ctx.ui.notify(
253
+ `Found ${ids.length} broken models (auto-hidden):\n${ids.join("\n")}`,
254
+ "warning",
255
+ );
256
+ },
257
+ });
258
+ if (broken.length === 0) {
259
+ ctx.ui.notify("All Novita AI models are accessible ✅", "info");
260
+ }
261
+ },
262
+ });
263
+
264
+ // Lazy auto-probe on first session_start
265
+ pi.on(
266
+ "session_start",
267
+ wrapSessionStartHandler(
268
+ `${PROVIDER_NOVITA}-auto-probe`,
269
+ probe.autoProbeHandler(apiKey, freeModels),
270
+ ),
271
+ );
205
272
  }