pi-free 2.0.5 → 2.0.6
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/README.md +495 -495
- package/lib/open-browser.ts +31 -4
- package/lib/util.ts +351 -262
- package/package.json +1 -1
- package/provider-failover/benchmark-lookup.ts +702 -688
- package/scripts/check-extensions.mjs +18 -11
package/lib/open-browser.ts
CHANGED
|
@@ -5,7 +5,28 @@
|
|
|
5
5
|
* on Windows by using PowerShell's Start-Process instead of cmd.exe.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
8
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve an executable path, preferring the known absolute path if it exists,
|
|
13
|
+
* falling back to PATH lookup. This avoids relying on an untrusted PATH variable.
|
|
14
|
+
*/
|
|
15
|
+
function resolveExe(name: string, absolutePath: string): string {
|
|
16
|
+
if (absolutePath && existsSync(absolutePath)) {
|
|
17
|
+
return absolutePath;
|
|
18
|
+
}
|
|
19
|
+
// Fallback: try to resolve via PATH (may still be manipulated)
|
|
20
|
+
try {
|
|
21
|
+
const which = process.platform === "win32" ? "where" : "which";
|
|
22
|
+
// Use execFileSync with separate args — no shell injection vector
|
|
23
|
+
return execFileSync(which, [name], { encoding: "utf8" })
|
|
24
|
+
.trim()
|
|
25
|
+
.split("\n")[0];
|
|
26
|
+
} catch {
|
|
27
|
+
return name; // Last-resort fallback
|
|
28
|
+
}
|
|
29
|
+
}
|
|
9
30
|
|
|
10
31
|
/**
|
|
11
32
|
* Open a URL in the user's default browser.
|
|
@@ -19,8 +40,12 @@ export function openBrowser(url: string): void {
|
|
|
19
40
|
if (process.platform === "win32") {
|
|
20
41
|
// PowerShell's Start-Process treats the URL as a literal string,
|
|
21
42
|
// unlike cmd.exe which interprets & as a command separator.
|
|
22
|
-
|
|
43
|
+
const powershell = resolveExe(
|
|
23
44
|
"powershell.exe",
|
|
45
|
+
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
|
46
|
+
);
|
|
47
|
+
spawn(
|
|
48
|
+
powershell,
|
|
24
49
|
[
|
|
25
50
|
"-NoProfile",
|
|
26
51
|
"-NonInteractive",
|
|
@@ -30,9 +55,11 @@ export function openBrowser(url: string): void {
|
|
|
30
55
|
{ detached: true, shell: false, windowsHide: true },
|
|
31
56
|
).unref();
|
|
32
57
|
} else if (process.platform === "darwin") {
|
|
33
|
-
|
|
58
|
+
const open = resolveExe("open", "/usr/bin/open");
|
|
59
|
+
spawn(open, [url], { detached: true }).unref();
|
|
34
60
|
} else {
|
|
35
|
-
|
|
61
|
+
const xdgOpen = resolveExe("xdg-open", "/usr/bin/xdg-open");
|
|
62
|
+
spawn(xdgOpen, [url], { detached: true }).unref();
|
|
36
63
|
}
|
|
37
64
|
} catch (err) {
|
|
38
65
|
// Best-effort — browser opening is non-critical
|
package/lib/util.ts
CHANGED
|
@@ -1,262 +1,351 @@
|
|
|
1
|
-
import { createLogger } from "./logger.ts";
|
|
2
|
-
import type { ProviderModelConfig } from "./types.ts";
|
|
3
|
-
|
|
4
|
-
const _logger = createLogger("util");
|
|
5
|
-
|
|
6
|
-
// =============================================================================
|
|
7
|
-
// Shared Utilities
|
|
8
|
-
// =============================================================================
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Log a warning message for provider operations
|
|
12
|
-
*/
|
|
13
|
-
export function logWarning(
|
|
14
|
-
provider: string,
|
|
15
|
-
message: string,
|
|
16
|
-
error?: unknown,
|
|
17
|
-
): void {
|
|
18
|
-
_logger.warn(
|
|
19
|
-
`[${provider}] ${message}`,
|
|
20
|
-
error ? { error: String(error) } : undefined,
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Fetch with timeout using AbortController
|
|
26
|
-
*/
|
|
27
|
-
export async function fetchWithTimeout(
|
|
28
|
-
url: string,
|
|
29
|
-
options: RequestInit,
|
|
30
|
-
timeoutMs = 30000,
|
|
31
|
-
): Promise<Response> {
|
|
32
|
-
const controller = new AbortController();
|
|
33
|
-
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
const response = await fetch(url, {
|
|
37
|
-
...options,
|
|
38
|
-
signal: controller.signal,
|
|
39
|
-
});
|
|
40
|
-
return response;
|
|
41
|
-
} finally {
|
|
42
|
-
clearTimeout(timeoutId);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Fetch with retry logic and timeout
|
|
48
|
-
*/
|
|
49
|
-
export async function fetchWithRetry(
|
|
50
|
-
url: string,
|
|
51
|
-
options: RequestInit,
|
|
52
|
-
retries = 3,
|
|
53
|
-
delayMs = 1000,
|
|
54
|
-
timeoutMs = 30000,
|
|
55
|
-
): Promise<Response> {
|
|
56
|
-
let lastError: unknown;
|
|
57
|
-
|
|
58
|
-
for (let i = 0; i < retries; i++) {
|
|
59
|
-
try {
|
|
60
|
-
const response = await fetchWithTimeout(url, options, timeoutMs);
|
|
61
|
-
if (response.ok) return response;
|
|
62
|
-
|
|
63
|
-
// If it's a rate limit, throw immediately
|
|
64
|
-
if (response.status === 429) {
|
|
65
|
-
throw new Error(`Rate limited (429)`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// For server errors, retry
|
|
69
|
-
if (response.status >= 500) {
|
|
70
|
-
lastError = new Error(`Server error ${response.status}`);
|
|
71
|
-
if (i < retries - 1) {
|
|
72
|
-
await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
// Last retry exhausted - throw the error
|
|
76
|
-
throw lastError;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return response; // Return non-ok but non-retryable responses
|
|
80
|
-
} catch (error) {
|
|
81
|
-
lastError = error;
|
|
82
|
-
if (i < retries - 1) {
|
|
83
|
-
await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
throw lastError;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// =============================================================================
|
|
92
|
-
// Shared API Response Parsing
|
|
93
|
-
// =============================================================================
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Parse and validate model list API response
|
|
97
|
-
* Shared between Kilo, OpenRouter, and other providers
|
|
98
|
-
*/
|
|
99
|
-
export async function parseModelResponse<T>(
|
|
100
|
-
response: Response,
|
|
101
|
-
providerName: string,
|
|
102
|
-
): Promise<{ data: T[] }> {
|
|
103
|
-
if (!response.ok) {
|
|
104
|
-
throw new Error(
|
|
105
|
-
`Failed to fetch ${providerName} models: ${response.status} ${response.statusText}`,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const json = (await response.json()) as { data?: T[] };
|
|
110
|
-
|
|
111
|
-
if (!json.data || !Array.isArray(json.data)) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`Invalid ${providerName} models response: missing data array`,
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return { data: json.data };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// =============================================================================
|
|
121
|
-
// Model Filtering Utilities
|
|
122
|
-
// =============================================================================
|
|
123
|
-
|
|
124
|
-
// Models known to be small (no "Xb" in their ID) that should be filtered.
|
|
125
|
-
// Updated as new small free models appear on OpenRouter/Kilo.
|
|
126
|
-
const KNOWN_SMALL_MODELS: ReadonlySet<string> = new Set([
|
|
127
|
-
// Microsoft Phi models (1.5B–14B)
|
|
128
|
-
"microsoft/phi-3-mini-128k-instruct",
|
|
129
|
-
"microsoft/phi-3-mini-4k-instruct",
|
|
130
|
-
"microsoft/phi-3-small-128k-instruct",
|
|
131
|
-
"microsoft/phi-3-small-8k-instruct",
|
|
132
|
-
"microsoft/phi-3-medium-128k-instruct",
|
|
133
|
-
"microsoft/phi-3-medium-4k-instruct",
|
|
134
|
-
"microsoft/phi-3.5-mini-instruct",
|
|
135
|
-
"microsoft/phi-4-mini-instruct",
|
|
136
|
-
"microsoft/phi-4-mini-reasoning",
|
|
137
|
-
"microsoft/phi-4-reasoning-plus",
|
|
138
|
-
// OpenChat (7B)
|
|
139
|
-
"openchat/openchat-3.5-0106",
|
|
140
|
-
"openchat/openchat-3.5-1210",
|
|
141
|
-
// Mistral 7B variants
|
|
142
|
-
"mistralai/mistral-7b-instruct-v0.1",
|
|
143
|
-
"mistralai/mistral-7b-instruct-v0.2",
|
|
144
|
-
"mistralai/mistral-7b-instruct-v0.3",
|
|
145
|
-
// Gemma small variants
|
|
146
|
-
"google/gemma-2b-it",
|
|
147
|
-
"google/gemma-1.1-2b-it",
|
|
148
|
-
// DeepSeek small variants
|
|
149
|
-
"deepseek/deepseek-r1-distill-qwen-1.5b",
|
|
150
|
-
"deepseek/deepseek-r1-distill-llama-8b",
|
|
151
|
-
"deepseek/deepseek-r1-distill-qwen-7b",
|
|
152
|
-
"deepseek/deepseek-r1-distill-qwen-14b",
|
|
153
|
-
// Stripe Hyena (2.7B)
|
|
154
|
-
"togethercomputer/stripedhy-2.7b",
|
|
155
|
-
// TinyLlama
|
|
156
|
-
"tinyllama/tinyllama-1.1b-chat-v1.0",
|
|
157
|
-
]);
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Check if model is usable based on size constraints and naming.
|
|
161
|
-
* Extracts model size from ID (e.g., "llama-3-70b" -> 70) and compares to minSizeB.
|
|
162
|
-
* Falls back to a blocklist for models that don't encode size in the name.
|
|
163
|
-
*/
|
|
164
|
-
export function isUsableModel(modelId: string, minSizeB?: number): boolean {
|
|
165
|
-
// Filter out models that are likely test or debug models
|
|
166
|
-
if (modelId.includes("test") || modelId.includes("debug")) {
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Filter by minimum size if specified
|
|
171
|
-
if (minSizeB !== undefined) {
|
|
172
|
-
// Known-small blocklist (models without "Xb" in the name)
|
|
173
|
-
// Strip :free suffix used by OpenRouter/Kilo
|
|
174
|
-
const baseId = modelId.replace(/:free$/, "");
|
|
175
|
-
if (KNOWN_SMALL_MODELS.has(baseId)) return false;
|
|
176
|
-
|
|
177
|
-
// Check Mixture-of-Experts models first (e.g., "8x22b" = 176b total)
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
1
|
+
import { createLogger } from "./logger.ts";
|
|
2
|
+
import type { ProviderModelConfig } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const _logger = createLogger("util");
|
|
5
|
+
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// Shared Utilities
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Log a warning message for provider operations
|
|
12
|
+
*/
|
|
13
|
+
export function logWarning(
|
|
14
|
+
provider: string,
|
|
15
|
+
message: string,
|
|
16
|
+
error?: unknown,
|
|
17
|
+
): void {
|
|
18
|
+
_logger.warn(
|
|
19
|
+
`[${provider}] ${message}`,
|
|
20
|
+
error ? { error: String(error) } : undefined,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch with timeout using AbortController
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchWithTimeout(
|
|
28
|
+
url: string,
|
|
29
|
+
options: RequestInit,
|
|
30
|
+
timeoutMs = 30000,
|
|
31
|
+
): Promise<Response> {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(url, {
|
|
37
|
+
...options,
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
});
|
|
40
|
+
return response;
|
|
41
|
+
} finally {
|
|
42
|
+
clearTimeout(timeoutId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fetch with retry logic and timeout
|
|
48
|
+
*/
|
|
49
|
+
export async function fetchWithRetry(
|
|
50
|
+
url: string,
|
|
51
|
+
options: RequestInit,
|
|
52
|
+
retries = 3,
|
|
53
|
+
delayMs = 1000,
|
|
54
|
+
timeoutMs = 30000,
|
|
55
|
+
): Promise<Response> {
|
|
56
|
+
let lastError: unknown;
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < retries; i++) {
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetchWithTimeout(url, options, timeoutMs);
|
|
61
|
+
if (response.ok) return response;
|
|
62
|
+
|
|
63
|
+
// If it's a rate limit, throw immediately
|
|
64
|
+
if (response.status === 429) {
|
|
65
|
+
throw new Error(`Rate limited (429)`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// For server errors, retry
|
|
69
|
+
if (response.status >= 500) {
|
|
70
|
+
lastError = new Error(`Server error ${response.status}`);
|
|
71
|
+
if (i < retries - 1) {
|
|
72
|
+
await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// Last retry exhausted - throw the error
|
|
76
|
+
throw lastError;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return response; // Return non-ok but non-retryable responses
|
|
80
|
+
} catch (error) {
|
|
81
|
+
lastError = error;
|
|
82
|
+
if (i < retries - 1) {
|
|
83
|
+
await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw lastError;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// =============================================================================
|
|
92
|
+
// Shared API Response Parsing
|
|
93
|
+
// =============================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse and validate model list API response
|
|
97
|
+
* Shared between Kilo, OpenRouter, and other providers
|
|
98
|
+
*/
|
|
99
|
+
export async function parseModelResponse<T>(
|
|
100
|
+
response: Response,
|
|
101
|
+
providerName: string,
|
|
102
|
+
): Promise<{ data: T[] }> {
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Failed to fetch ${providerName} models: ${response.status} ${response.statusText}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const json = (await response.json()) as { data?: T[] };
|
|
110
|
+
|
|
111
|
+
if (!json.data || !Array.isArray(json.data)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Invalid ${providerName} models response: missing data array`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { data: json.data };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// Model Filtering Utilities
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
// Models known to be small (no "Xb" in their ID) that should be filtered.
|
|
125
|
+
// Updated as new small free models appear on OpenRouter/Kilo.
|
|
126
|
+
const KNOWN_SMALL_MODELS: ReadonlySet<string> = new Set([
|
|
127
|
+
// Microsoft Phi models (1.5B–14B)
|
|
128
|
+
"microsoft/phi-3-mini-128k-instruct",
|
|
129
|
+
"microsoft/phi-3-mini-4k-instruct",
|
|
130
|
+
"microsoft/phi-3-small-128k-instruct",
|
|
131
|
+
"microsoft/phi-3-small-8k-instruct",
|
|
132
|
+
"microsoft/phi-3-medium-128k-instruct",
|
|
133
|
+
"microsoft/phi-3-medium-4k-instruct",
|
|
134
|
+
"microsoft/phi-3.5-mini-instruct",
|
|
135
|
+
"microsoft/phi-4-mini-instruct",
|
|
136
|
+
"microsoft/phi-4-mini-reasoning",
|
|
137
|
+
"microsoft/phi-4-reasoning-plus",
|
|
138
|
+
// OpenChat (7B)
|
|
139
|
+
"openchat/openchat-3.5-0106",
|
|
140
|
+
"openchat/openchat-3.5-1210",
|
|
141
|
+
// Mistral 7B variants
|
|
142
|
+
"mistralai/mistral-7b-instruct-v0.1",
|
|
143
|
+
"mistralai/mistral-7b-instruct-v0.2",
|
|
144
|
+
"mistralai/mistral-7b-instruct-v0.3",
|
|
145
|
+
// Gemma small variants
|
|
146
|
+
"google/gemma-2b-it",
|
|
147
|
+
"google/gemma-1.1-2b-it",
|
|
148
|
+
// DeepSeek small variants
|
|
149
|
+
"deepseek/deepseek-r1-distill-qwen-1.5b",
|
|
150
|
+
"deepseek/deepseek-r1-distill-llama-8b",
|
|
151
|
+
"deepseek/deepseek-r1-distill-qwen-7b",
|
|
152
|
+
"deepseek/deepseek-r1-distill-qwen-14b",
|
|
153
|
+
// Stripe Hyena (2.7B)
|
|
154
|
+
"togethercomputer/stripedhy-2.7b",
|
|
155
|
+
// TinyLlama
|
|
156
|
+
"tinyllama/tinyllama-1.1b-chat-v1.0",
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if model is usable based on size constraints and naming.
|
|
161
|
+
* Extracts model size from ID (e.g., "llama-3-70b" -> 70) and compares to minSizeB.
|
|
162
|
+
* Falls back to a blocklist for models that don't encode size in the name.
|
|
163
|
+
*/
|
|
164
|
+
export function isUsableModel(modelId: string, minSizeB?: number): boolean {
|
|
165
|
+
// Filter out models that are likely test or debug models
|
|
166
|
+
if (modelId.includes("test") || modelId.includes("debug")) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Filter by minimum size if specified
|
|
171
|
+
if (minSizeB !== undefined) {
|
|
172
|
+
// Known-small blocklist (models without "Xb" in the name)
|
|
173
|
+
// Strip :free suffix used by OpenRouter/Kilo
|
|
174
|
+
const baseId = modelId.replace(/:free$/, "");
|
|
175
|
+
if (KNOWN_SMALL_MODELS.has(baseId)) return false;
|
|
176
|
+
|
|
177
|
+
// Check Mixture-of-Experts models first (e.g., "8x22b" = 176b total)
|
|
178
|
+
const parsed = parseModelSize(modelId);
|
|
179
|
+
if (parsed?.type === "moe") {
|
|
180
|
+
if (parsed.experts * parsed.sizePerExpert < minSizeB) return false;
|
|
181
|
+
return true; // MoE model passed size check
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Standard model size (e.g., "70b", "8b")
|
|
185
|
+
if (parsed?.type === "standard" && parsed.size < minSizeB) return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// =============================================================================
|
|
192
|
+
// Model Size Parsing (no regex — avoids SonarCloud S5852 flags)
|
|
193
|
+
// =============================================================================
|
|
194
|
+
|
|
195
|
+
interface MoeSize {
|
|
196
|
+
type: "moe";
|
|
197
|
+
experts: number;
|
|
198
|
+
sizePerExpert: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface StandardSize {
|
|
202
|
+
type: "standard";
|
|
203
|
+
size: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Extract model size from a model ID without using regex.
|
|
208
|
+
* Handles both MoE ("8x22b") and standard ("70b", "8b") formats.
|
|
209
|
+
*/
|
|
210
|
+
function parseModelSize(modelId: string): MoeSize | StandardSize | null {
|
|
211
|
+
const lower = modelId.toLowerCase();
|
|
212
|
+
|
|
213
|
+
// MoE: digits "x" digits "b" (e.g., "8x22b", "a35b" is NOT MoE)
|
|
214
|
+
// Walk through each 'x' to find one preceded by a digit and followed by digits then 'b'
|
|
215
|
+
let searchPos = 0;
|
|
216
|
+
while (true) {
|
|
217
|
+
const xIdx = lower.indexOf("x", searchPos);
|
|
218
|
+
if (xIdx <= 0) break;
|
|
219
|
+
const beforeChar = lower[xIdx - 1];
|
|
220
|
+
if (beforeChar >= "0" && beforeChar <= "9") {
|
|
221
|
+
const bIdx = lower.indexOf("b", xIdx + 1);
|
|
222
|
+
if (bIdx > xIdx + 1) {
|
|
223
|
+
// Walk backwards from x to find start of expert-count number
|
|
224
|
+
let countStart = xIdx - 1;
|
|
225
|
+
while (
|
|
226
|
+
countStart > 0 &&
|
|
227
|
+
lower[countStart - 1] >= "0" &&
|
|
228
|
+
lower[countStart - 1] <= "9"
|
|
229
|
+
) {
|
|
230
|
+
countStart--;
|
|
231
|
+
}
|
|
232
|
+
const experts = Number.parseInt(lower.slice(countStart, xIdx), 10);
|
|
233
|
+
const size = Number.parseFloat(lower.slice(xIdx + 1, bIdx));
|
|
234
|
+
if (
|
|
235
|
+
!Number.isNaN(experts) &&
|
|
236
|
+
!Number.isNaN(size) &&
|
|
237
|
+
experts > 0 &&
|
|
238
|
+
size > 0
|
|
239
|
+
) {
|
|
240
|
+
const afterB = lower.slice(bIdx + 1);
|
|
241
|
+
if (
|
|
242
|
+
afterB.length === 0 ||
|
|
243
|
+
((afterB[0] < "0" || afterB[0] > "9") && afterB[0] !== ".")
|
|
244
|
+
) {
|
|
245
|
+
return { type: "moe", experts, sizePerExpert: size };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
searchPos = xIdx + 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Standard: "digits" "b" not followed by digit/dot (e.g., "70b", "8b")
|
|
254
|
+
for (let i = 0; i < lower.length; i++) {
|
|
255
|
+
if (lower[i] === "b") {
|
|
256
|
+
const afterB = lower.slice(i + 1);
|
|
257
|
+
if (
|
|
258
|
+
afterB.length > 0 &&
|
|
259
|
+
((afterB[0] >= "0" && afterB[0] <= "9") || afterB[0] === ".")
|
|
260
|
+
) {
|
|
261
|
+
continue; // b followed by digit or dot — not our match
|
|
262
|
+
}
|
|
263
|
+
// Walk backwards to find start of number
|
|
264
|
+
let start = i;
|
|
265
|
+
while (
|
|
266
|
+
start > 0 &&
|
|
267
|
+
((lower[start - 1] >= "0" && lower[start - 1] <= "9") ||
|
|
268
|
+
lower[start - 1] === ".")
|
|
269
|
+
) {
|
|
270
|
+
start--;
|
|
271
|
+
}
|
|
272
|
+
if (start < i) {
|
|
273
|
+
const numStr = lower.slice(start, i);
|
|
274
|
+
const size = Number.parseFloat(numStr);
|
|
275
|
+
if (!Number.isNaN(size) && size > 0) {
|
|
276
|
+
return { type: "standard", size };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// =============================================================================
|
|
287
|
+
// Model Name Cleaning
|
|
288
|
+
// =============================================================================
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Strip provider prefix from model names.
|
|
292
|
+
* OpenRouter/Kilo return names like "Provider : Model Name" or "Provider / Model Name".
|
|
293
|
+
* We only want the model name part.
|
|
294
|
+
*/
|
|
295
|
+
export function cleanModelName(name: string): string {
|
|
296
|
+
// Handle patterns like "Provider : Model Name" or "Provider / Model Name"
|
|
297
|
+
const colonIdx = name.indexOf(":");
|
|
298
|
+
const slashIdx = name.indexOf("/");
|
|
299
|
+
const idx =
|
|
300
|
+
colonIdx === -1
|
|
301
|
+
? slashIdx
|
|
302
|
+
: slashIdx === -1
|
|
303
|
+
? colonIdx
|
|
304
|
+
: Math.min(colonIdx, slashIdx);
|
|
305
|
+
if (idx > 0) {
|
|
306
|
+
return name.slice(idx + 1).trim();
|
|
307
|
+
}
|
|
308
|
+
return name.trim();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// =============================================================================
|
|
312
|
+
// Model Mapping
|
|
313
|
+
// =============================================================================
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Map OpenRouter/Kilo API model to ProviderModelConfig
|
|
317
|
+
* Shared between OpenRouter and Kilo providers
|
|
318
|
+
*/
|
|
319
|
+
export function mapOpenRouterModel(m: {
|
|
320
|
+
id: string;
|
|
321
|
+
name: string;
|
|
322
|
+
context_length?: number;
|
|
323
|
+
max_completion_tokens?: number | null;
|
|
324
|
+
top_provider?: { max_completion_tokens?: number | null };
|
|
325
|
+
pricing?: { prompt?: string | null; completion?: string | null };
|
|
326
|
+
architecture?: {
|
|
327
|
+
input_modalities?: string[] | null;
|
|
328
|
+
output_modalities?: string[] | null;
|
|
329
|
+
};
|
|
330
|
+
}): ProviderModelConfig {
|
|
331
|
+
const promptPrice = parseFloat(m.pricing?.prompt ?? "0");
|
|
332
|
+
const completionPrice = parseFloat(m.pricing?.completion ?? "0");
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
id: m.id,
|
|
336
|
+
name: cleanModelName(m.name),
|
|
337
|
+
reasoning: false, // OpenRouter doesn't expose reasoning flag directly
|
|
338
|
+
input: m.architecture?.input_modalities?.includes("image")
|
|
339
|
+
? (["text", "image"] as const)
|
|
340
|
+
: (["text"] as const),
|
|
341
|
+
cost: {
|
|
342
|
+
input: promptPrice,
|
|
343
|
+
output: completionPrice,
|
|
344
|
+
cacheRead: 0,
|
|
345
|
+
cacheWrite: 0,
|
|
346
|
+
},
|
|
347
|
+
contextWindow: m.context_length ?? 4096,
|
|
348
|
+
maxTokens:
|
|
349
|
+
m.max_completion_tokens ?? m.top_provider?.max_completion_tokens ?? 4096,
|
|
350
|
+
};
|
|
351
|
+
}
|