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.
- package/CHANGELOG.md +90 -0
- package/README.md +64 -78
- package/banner.svg +21 -36
- package/config.ts +123 -9
- package/constants.ts +3 -9
- package/index.ts +14 -15
- package/lib/built-in-toggle.ts +29 -16
- package/lib/json-persistence.ts +90 -22
- package/lib/logger.ts +21 -12
- package/lib/model-detection.ts +2 -12
- package/lib/model-enhancer.ts +11 -2
- package/lib/model-metadata.ts +387 -0
- package/lib/open-browser.ts +74 -24
- package/lib/paths.ts +90 -0
- package/lib/probe-cache.ts +19 -19
- package/lib/provider-cache.ts +74 -28
- package/lib/provider-compat.ts +58 -9
- package/lib/provider-probe.ts +188 -0
- package/lib/registry.ts +1 -5
- package/lib/session-start-metrics.ts +46 -0
- package/lib/telemetry.ts +115 -86
- package/lib/types.ts +22 -2
- package/lib/util.ts +80 -21
- package/package.json +7 -2
- package/provider-failover/benchmark-lookup.ts +17 -5
- package/provider-helper.ts +11 -2
- package/providers/cline/cline-models.ts +12 -2
- package/providers/cline/cline-xml-bridge.ts +974 -0
- package/providers/cline/cline.ts +67 -176
- package/providers/crofai/crofai.ts +6 -1
- package/providers/deepinfra/deepinfra.ts +69 -2
- package/providers/dynamic-built-in/index.ts +237 -2
- package/providers/kilo/kilo-models.ts +3 -1
- package/providers/kilo/kilo.ts +268 -41
- package/providers/model-fetcher.ts +18 -55
- package/providers/novita/novita.ts +69 -2
- package/providers/ollama/ollama.ts +48 -24
- package/providers/opencode-session.ts +67 -2
- package/providers/routeway/routeway.ts +188 -2
- package/providers/sambanova/sambanova.ts +67 -1
- package/providers/together/together.ts +69 -2
- package/providers/tokenrouter/tokenrouter.ts +378 -0
- package/providers/zenmux/zenmux.ts +6 -1
- package/scripts/check-extensions.mjs +32 -16
- package/providers/nvidia/nvidia.ts +0 -504
package/providers/kilo/kilo.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
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
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
7
|
-
import
|
|
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?: {
|
|
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
|
-
|
|
102
|
+
const models = json.data
|
|
97
103
|
.filter((m) => {
|
|
98
|
-
// Filter out
|
|
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("
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|