pi-free 2.2.2 → 2.2.4
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 +18 -39
- package/README.md +41 -532
- package/banner.svg +23 -20
- package/config.ts +774 -702
- package/constants.ts +11 -1
- package/index.ts +432 -419
- package/lib/model-detection.ts +296 -296
- package/lib/model-metadata.ts +10 -3
- package/lib/telemetry.ts +36 -44
- package/package.json +3 -2
- package/provider-failover/benchmark-lookup.ts +30 -15
- package/provider-helper.ts +27 -8
- package/providers/bai/bai.ts +232 -237
- package/providers/cline/cline-xml-bridge.ts +31 -25
- package/providers/cline/cline.ts +17 -8
- package/providers/kilo/kilo.ts +11 -6
- package/providers/model-fetcher.ts +1 -1
- package/providers/opencode-session.ts +2 -2
- package/providers/openmodel/openmodel.ts +525 -0
- package/providers/qoder/auth.ts +548 -0
- package/providers/qoder/cosy.ts +236 -0
- package/providers/qoder/encoding.ts +48 -0
- package/providers/qoder/models.ts +321 -0
- package/providers/qoder/qoder.ts +154 -0
- package/providers/qoder/stream.ts +677 -0
- package/providers/qoder/thinking-parser.ts +251 -0
- package/providers/qoder/transform.ts +189 -0
- package/providers/tokenrouter/tokenrouter.ts +3 -6
package/lib/model-detection.ts
CHANGED
|
@@ -1,296 +1,296 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Model detection utilities for pi-free-providers.
|
|
3
|
-
* Extracts and adapts model family detection from pi-models.
|
|
4
|
-
* Used for failover when providers hit rate limits.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
8
|
-
import type { ProviderModelConfig } from "./types.ts";
|
|
9
|
-
|
|
10
|
-
export interface ModelInfo {
|
|
11
|
-
id: string;
|
|
12
|
-
name?: string;
|
|
13
|
-
provider: string;
|
|
14
|
-
isFree: boolean;
|
|
15
|
-
inputCost: number;
|
|
16
|
-
outputCost: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface ModelFamily {
|
|
20
|
-
id: string; // Normalized family ID (e.g., "claude-sonnet")
|
|
21
|
-
displayName: string; // Human readable (e.g., "Claude Sonnet")
|
|
22
|
-
models: ModelInfo[]; // All models in this family
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Convert Pi's Model type to ModelInfo for internal use
|
|
27
|
-
*/
|
|
28
|
-
export function toModelInfo(model: Model<any>): ModelInfo {
|
|
29
|
-
return {
|
|
30
|
-
id: model.id,
|
|
31
|
-
name: model.name,
|
|
32
|
-
provider: model.provider,
|
|
33
|
-
isFree: !model.cost || (model.cost.input === 0 && model.cost.output === 0),
|
|
34
|
-
inputCost: model.cost?.input ?? 0,
|
|
35
|
-
outputCost: model.cost?.output ?? 0,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Convert ProviderModelConfig to ModelInfo for internal use
|
|
41
|
-
*/
|
|
42
|
-
export function toProviderModelInfo(model: ProviderModelConfig): ModelInfo {
|
|
43
|
-
return {
|
|
44
|
-
id: model.id,
|
|
45
|
-
name: model.name,
|
|
46
|
-
provider: "", // Will be set by caller
|
|
47
|
-
isFree: !model.cost || (model.cost.input === 0 && model.cost.output === 0),
|
|
48
|
-
inputCost: model.cost?.input ?? 0,
|
|
49
|
-
outputCost: model.cost?.output ?? 0,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// =============================================================================
|
|
54
|
-
// Shared helpers for model family detection
|
|
55
|
-
// =============================================================================
|
|
56
|
-
|
|
57
|
-
const VERSION_RE = /^v?\d+(\.\d+)?$/;
|
|
58
|
-
const ROUTER_RE = /\b(?:router|auto)\b/;
|
|
59
|
-
const SKIP_PARTS = new Set([
|
|
60
|
-
"latest",
|
|
61
|
-
"preview",
|
|
62
|
-
"rc",
|
|
63
|
-
"beta",
|
|
64
|
-
"alpha",
|
|
65
|
-
"dev",
|
|
66
|
-
"free",
|
|
67
|
-
]);
|
|
68
|
-
|
|
69
|
-
interface BrandMapping {
|
|
70
|
-
keywords: string[];
|
|
71
|
-
familyId: string;
|
|
72
|
-
familyName: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const BRAND_MAPPINGS: BrandMapping[] = [
|
|
76
|
-
{ keywords: ["claude"], familyId: "claude", familyName: "Claude" },
|
|
77
|
-
{ keywords: ["deepseek"], familyId: "deepseek", familyName: "DeepSeek" },
|
|
78
|
-
{ keywords: ["gemini"], familyId: "gemini", familyName: "Gemini" },
|
|
79
|
-
{ keywords: ["gpt"], familyId: "gpt", familyName: "GPT" },
|
|
80
|
-
{ keywords: ["llama"], familyId: "llama", familyName: "Llama" },
|
|
81
|
-
{ keywords: ["minimax"], familyId: "minimax", familyName: "MiniMax" },
|
|
82
|
-
{ keywords: ["qwen"], familyId: "qwen", familyName: "Qwen" },
|
|
83
|
-
{ keywords: ["nemotron"], familyId: "nemotron", familyName: "Nemotron" },
|
|
84
|
-
{ keywords: ["kimi", "moonshot"], familyId: "kimi", familyName: "Kimi" },
|
|
85
|
-
{ keywords: ["glm", "chatglm"], familyId: "glm", familyName: "GLM" },
|
|
86
|
-
{ keywords: ["mistral"], familyId: "mistral", familyName: "Mistral" },
|
|
87
|
-
{ keywords: ["arcee", "trinity"], familyId: "arcee", familyName: "Arcee" },
|
|
88
|
-
{ keywords: ["o1", "o3"], familyId: "openai-o", familyName: "OpenAI o" },
|
|
89
|
-
];
|
|
90
|
-
|
|
91
|
-
const PROVIDER_MAPPINGS: Record<
|
|
92
|
-
string,
|
|
93
|
-
{ familyId: string; familyName: string }
|
|
94
|
-
> = {
|
|
95
|
-
minimax: { familyId: "minimax", familyName: "MiniMax" },
|
|
96
|
-
minimaxai: { familyId: "minimax", familyName: "MiniMax" },
|
|
97
|
-
deepseek: { familyId: "deepseek", familyName: "DeepSeek" },
|
|
98
|
-
nvidia: { familyId: "nemotron", familyName: "Nemotron" },
|
|
99
|
-
moonshot: { familyId: "kimi", familyName: "Kimi" },
|
|
100
|
-
zhipu: { familyId: "glm", familyName: "GLM" },
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
function capitalize(s: string): string {
|
|
104
|
-
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function findBrandInText(
|
|
108
|
-
text: string,
|
|
109
|
-
): { familyId: string; familyName: string } | null {
|
|
110
|
-
for (const mapping of BRAND_MAPPINGS) {
|
|
111
|
-
for (const keyword of mapping.keywords) {
|
|
112
|
-
if (text.includes(keyword)) {
|
|
113
|
-
return { familyId: mapping.familyId, familyName: mapping.familyName };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function findBrandInParts(
|
|
121
|
-
parts: string[],
|
|
122
|
-
): { familyId: string; familyName: string } | null {
|
|
123
|
-
for (const part of parts) {
|
|
124
|
-
const result = findBrandInText(part);
|
|
125
|
-
if (result) return result;
|
|
126
|
-
}
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Detect the model family from a model's ID or name.
|
|
132
|
-
* Returns the family ID and display name.
|
|
133
|
-
*/
|
|
134
|
-
export function detectModelFamily(
|
|
135
|
-
model: ModelInfo,
|
|
136
|
-
): { familyId: string; familyName: string } | null {
|
|
137
|
-
const id = model.id.toLowerCase();
|
|
138
|
-
const name = (model.name || "").toLowerCase();
|
|
139
|
-
const fullText = `${id} ${name}`;
|
|
140
|
-
|
|
141
|
-
// Router models (gateways to free models) - group into "other"
|
|
142
|
-
if (ROUTER_RE.test(fullText) || id === "kilo-auto/free") {
|
|
143
|
-
return { familyId: "other", familyName: "Other" };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Known brand keywords in full text
|
|
147
|
-
const brandFromText = findBrandInText(fullText);
|
|
148
|
-
if (brandFromText) return brandFromText;
|
|
149
|
-
|
|
150
|
-
// Provider-specific fallbacks for models without brand in ID/name
|
|
151
|
-
const providerResult = PROVIDER_MAPPINGS[model.provider];
|
|
152
|
-
if (providerResult) return providerResult;
|
|
153
|
-
|
|
154
|
-
// Fallback: try to identify brand from model ID structure
|
|
155
|
-
const parts = id.split(/[-_:.@]/);
|
|
156
|
-
const firstPart = parts[0];
|
|
157
|
-
|
|
158
|
-
const brandFromParts = findBrandInParts(parts);
|
|
159
|
-
if (brandFromParts) return brandFromParts;
|
|
160
|
-
|
|
161
|
-
// Use first part as brand if it looks brand-like
|
|
162
|
-
if (firstPart && !VERSION_RE.test(firstPart)) {
|
|
163
|
-
return { familyId: firstPart, familyName: capitalize(firstPart) };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// First non-version, non-skip part
|
|
167
|
-
const nonVersion = parts.find(
|
|
168
|
-
(p) => p && !VERSION_RE.test(p) && !SKIP_PARTS.has(p),
|
|
169
|
-
);
|
|
170
|
-
if (nonVersion) {
|
|
171
|
-
return { familyId: nonVersion, familyName: capitalize(nonVersion) };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
familyId: firstPart || id,
|
|
176
|
-
familyName: capitalize(firstPart || id),
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Normalize a model name for comparison by removing provider-specific suffixes
|
|
182
|
-
* and common qualifiers. This helps detect when the same model is offered by
|
|
183
|
-
* multiple providers with slightly different naming.
|
|
184
|
-
*
|
|
185
|
-
* Uses string operations instead of regex backtracking to avoid ReDoS warnings.
|
|
186
|
-
*/
|
|
187
|
-
export function normalizeModelName(name: string): string {
|
|
188
|
-
const suffixes = ["(free)", "(cline)", "-free", "free"];
|
|
189
|
-
let normalized = name.toLowerCase().trimEnd();
|
|
190
|
-
|
|
191
|
-
// Remove common literal suffixes — simple string ops, no regex backtracking
|
|
192
|
-
for (const suffix of suffixes) {
|
|
193
|
-
while (normalized.endsWith(suffix)) {
|
|
194
|
-
normalized = normalized.slice(0, -suffix.length).trimEnd();
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// CI score suffix — regex with disjoint char classes (linear)
|
|
199
|
-
// Anchored with $, matches at most once → .replace() is correct (S4144 N/A)
|
|
200
|
-
normalized = normalized.replace(/\(ci:\s*[\d.]+\)$/, "").trimEnd();
|
|
201
|
-
normalized = normalized.replace(/\[ci:\s*[\d.]+\]$/, "").trimEnd();
|
|
202
|
-
|
|
203
|
-
// Remove any trailing parenthetical — non-regex loop
|
|
204
|
-
while (normalized.endsWith(")")) {
|
|
205
|
-
const idx = normalized.lastIndexOf("(", normalized.length - 1);
|
|
206
|
-
if (idx === -1) break;
|
|
207
|
-
normalized = normalized.slice(0, idx).trimEnd();
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return normalized.trim();
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Try to merge a model into another family if its normalized name
|
|
215
|
-
* matches a model in a different family.
|
|
216
|
-
*/
|
|
217
|
-
function tryMergeFamily(
|
|
218
|
-
byFamily: Map<string, ModelInfo[]>,
|
|
219
|
-
nameToFamilyId: Map<string, string>,
|
|
220
|
-
familyId: string,
|
|
221
|
-
model: ModelInfo,
|
|
222
|
-
): boolean {
|
|
223
|
-
const normalizedName = normalizeModelName(model.name || model.id);
|
|
224
|
-
if (!normalizedName) return false;
|
|
225
|
-
|
|
226
|
-
const existingFamilyForName = nameToFamilyId.get(normalizedName);
|
|
227
|
-
if (!existingFamilyForName || existingFamilyForName === familyId) {
|
|
228
|
-
nameToFamilyId.set(normalizedName, familyId);
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Same model name found in different family - merge them
|
|
233
|
-
const targetFamily = byFamily.get(existingFamilyForName);
|
|
234
|
-
const sourceFamily = byFamily.get(familyId);
|
|
235
|
-
if (!targetFamily || !sourceFamily) return false;
|
|
236
|
-
|
|
237
|
-
targetFamily.push(...sourceFamily);
|
|
238
|
-
byFamily.delete(familyId);
|
|
239
|
-
return true;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Build a sorted list of ModelFamily from a by-family grouping map.
|
|
244
|
-
*/
|
|
245
|
-
function buildFamiliesList(byFamily: Map<string, ModelInfo[]>): ModelFamily[] {
|
|
246
|
-
const families: ModelFamily[] = [];
|
|
247
|
-
for (const [id, familyModels] of byFamily) {
|
|
248
|
-
const firstModel = familyModels[0]!;
|
|
249
|
-
const familyInfo = detectModelFamily(firstModel)!;
|
|
250
|
-
|
|
251
|
-
families.push({
|
|
252
|
-
id,
|
|
253
|
-
displayName: familyInfo.familyName,
|
|
254
|
-
models: familyModels.sort(
|
|
255
|
-
(a, b) =>
|
|
256
|
-
a.provider.localeCompare(b.provider) || b.id.localeCompare(a.id),
|
|
257
|
-
),
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return families.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Get all model families from a list of models.
|
|
266
|
-
* Groups models by family and merges same-name models across providers.
|
|
267
|
-
*/
|
|
268
|
-
export function getModelFamilies(models: ModelInfo[]): ModelFamily[] {
|
|
269
|
-
const byFamily = new Map<string, ModelInfo[]>();
|
|
270
|
-
const nameToFamilyId = new Map<string, string>();
|
|
271
|
-
|
|
272
|
-
// First pass: group models by detected family
|
|
273
|
-
for (const model of models) {
|
|
274
|
-
const family = detectModelFamily(model);
|
|
275
|
-
if (!family) continue;
|
|
276
|
-
|
|
277
|
-
const existing = byFamily.get(family.familyId) ?? [];
|
|
278
|
-
existing.push(model);
|
|
279
|
-
byFamily.set(family.familyId, existing);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Second pass: merge families whose models have the same normalized name
|
|
283
|
-
const familyIds = [...byFamily.keys()];
|
|
284
|
-
for (const familyId of familyIds) {
|
|
285
|
-
const familyModels = byFamily.get(familyId);
|
|
286
|
-
if (!familyModels) continue;
|
|
287
|
-
|
|
288
|
-
for (const model of familyModels) {
|
|
289
|
-
if (tryMergeFamily(byFamily, nameToFamilyId, familyId, model)) {
|
|
290
|
-
break;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return buildFamiliesList(byFamily);
|
|
296
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Model detection utilities for pi-free-providers.
|
|
3
|
+
* Extracts and adapts model family detection from pi-models.
|
|
4
|
+
* Used for failover when providers hit rate limits.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { ProviderModelConfig } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export interface ModelInfo {
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
provider: string;
|
|
14
|
+
isFree: boolean;
|
|
15
|
+
inputCost: number;
|
|
16
|
+
outputCost: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ModelFamily {
|
|
20
|
+
id: string; // Normalized family ID (e.g., "claude-sonnet")
|
|
21
|
+
displayName: string; // Human readable (e.g., "Claude Sonnet")
|
|
22
|
+
models: ModelInfo[]; // All models in this family
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert Pi's Model type to ModelInfo for internal use
|
|
27
|
+
*/
|
|
28
|
+
export function toModelInfo(model: Model<any>): ModelInfo {
|
|
29
|
+
return {
|
|
30
|
+
id: model.id,
|
|
31
|
+
name: model.name,
|
|
32
|
+
provider: model.provider,
|
|
33
|
+
isFree: !model.cost || (model.cost.input === 0 && model.cost.output === 0),
|
|
34
|
+
inputCost: model.cost?.input ?? 0,
|
|
35
|
+
outputCost: model.cost?.output ?? 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert ProviderModelConfig to ModelInfo for internal use
|
|
41
|
+
*/
|
|
42
|
+
export function toProviderModelInfo(model: ProviderModelConfig): ModelInfo {
|
|
43
|
+
return {
|
|
44
|
+
id: model.id,
|
|
45
|
+
name: model.name,
|
|
46
|
+
provider: "", // Will be set by caller
|
|
47
|
+
isFree: !model.cost || (model.cost.input === 0 && model.cost.output === 0),
|
|
48
|
+
inputCost: model.cost?.input ?? 0,
|
|
49
|
+
outputCost: model.cost?.output ?? 0,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Shared helpers for model family detection
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
const VERSION_RE = /^v?\d+(\.\d+)?$/;
|
|
58
|
+
const ROUTER_RE = /\b(?:router|auto)\b/;
|
|
59
|
+
const SKIP_PARTS = new Set([
|
|
60
|
+
"latest",
|
|
61
|
+
"preview",
|
|
62
|
+
"rc",
|
|
63
|
+
"beta",
|
|
64
|
+
"alpha",
|
|
65
|
+
"dev",
|
|
66
|
+
"free",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
interface BrandMapping {
|
|
70
|
+
keywords: string[];
|
|
71
|
+
familyId: string;
|
|
72
|
+
familyName: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const BRAND_MAPPINGS: BrandMapping[] = [
|
|
76
|
+
{ keywords: ["claude"], familyId: "claude", familyName: "Claude" },
|
|
77
|
+
{ keywords: ["deepseek"], familyId: "deepseek", familyName: "DeepSeek" },
|
|
78
|
+
{ keywords: ["gemini"], familyId: "gemini", familyName: "Gemini" },
|
|
79
|
+
{ keywords: ["gpt"], familyId: "gpt", familyName: "GPT" },
|
|
80
|
+
{ keywords: ["llama"], familyId: "llama", familyName: "Llama" },
|
|
81
|
+
{ keywords: ["minimax"], familyId: "minimax", familyName: "MiniMax" },
|
|
82
|
+
{ keywords: ["qwen"], familyId: "qwen", familyName: "Qwen" },
|
|
83
|
+
{ keywords: ["nemotron"], familyId: "nemotron", familyName: "Nemotron" },
|
|
84
|
+
{ keywords: ["kimi", "moonshot"], familyId: "kimi", familyName: "Kimi" },
|
|
85
|
+
{ keywords: ["glm", "chatglm"], familyId: "glm", familyName: "GLM" },
|
|
86
|
+
{ keywords: ["mistral"], familyId: "mistral", familyName: "Mistral" },
|
|
87
|
+
{ keywords: ["arcee", "trinity"], familyId: "arcee", familyName: "Arcee" },
|
|
88
|
+
{ keywords: ["o1", "o3"], familyId: "openai-o", familyName: "OpenAI o" },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const PROVIDER_MAPPINGS: Record<
|
|
92
|
+
string,
|
|
93
|
+
{ familyId: string; familyName: string }
|
|
94
|
+
> = {
|
|
95
|
+
minimax: { familyId: "minimax", familyName: "MiniMax" },
|
|
96
|
+
minimaxai: { familyId: "minimax", familyName: "MiniMax" },
|
|
97
|
+
deepseek: { familyId: "deepseek", familyName: "DeepSeek" },
|
|
98
|
+
nvidia: { familyId: "nemotron", familyName: "Nemotron" },
|
|
99
|
+
moonshot: { familyId: "kimi", familyName: "Kimi" },
|
|
100
|
+
zhipu: { familyId: "glm", familyName: "GLM" },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function capitalize(s: string): string {
|
|
104
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findBrandInText(
|
|
108
|
+
text: string,
|
|
109
|
+
): { familyId: string; familyName: string } | null {
|
|
110
|
+
for (const mapping of BRAND_MAPPINGS) {
|
|
111
|
+
for (const keyword of mapping.keywords) {
|
|
112
|
+
if (text.includes(keyword)) {
|
|
113
|
+
return { familyId: mapping.familyId, familyName: mapping.familyName };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findBrandInParts(
|
|
121
|
+
parts: string[],
|
|
122
|
+
): { familyId: string; familyName: string } | null {
|
|
123
|
+
for (const part of parts) {
|
|
124
|
+
const result = findBrandInText(part);
|
|
125
|
+
if (result) return result;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect the model family from a model's ID or name.
|
|
132
|
+
* Returns the family ID and display name.
|
|
133
|
+
*/
|
|
134
|
+
export function detectModelFamily(
|
|
135
|
+
model: ModelInfo,
|
|
136
|
+
): { familyId: string; familyName: string } | null {
|
|
137
|
+
const id = model.id.toLowerCase();
|
|
138
|
+
const name = (model.name || "").toLowerCase();
|
|
139
|
+
const fullText = `${id} ${name}`;
|
|
140
|
+
|
|
141
|
+
// Router models (gateways to free models) - group into "other"
|
|
142
|
+
if (ROUTER_RE.test(fullText) || id === "kilo-auto/free") {
|
|
143
|
+
return { familyId: "other", familyName: "Other" };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Known brand keywords in full text
|
|
147
|
+
const brandFromText = findBrandInText(fullText);
|
|
148
|
+
if (brandFromText) return brandFromText;
|
|
149
|
+
|
|
150
|
+
// Provider-specific fallbacks for models without brand in ID/name
|
|
151
|
+
const providerResult = PROVIDER_MAPPINGS[model.provider];
|
|
152
|
+
if (providerResult) return providerResult;
|
|
153
|
+
|
|
154
|
+
// Fallback: try to identify brand from model ID structure
|
|
155
|
+
const parts = id.split(/[-_:.@]/);
|
|
156
|
+
const firstPart = parts[0];
|
|
157
|
+
|
|
158
|
+
const brandFromParts = findBrandInParts(parts);
|
|
159
|
+
if (brandFromParts) return brandFromParts;
|
|
160
|
+
|
|
161
|
+
// Use first part as brand if it looks brand-like
|
|
162
|
+
if (firstPart && !VERSION_RE.test(firstPart)) {
|
|
163
|
+
return { familyId: firstPart, familyName: capitalize(firstPart) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// First non-version, non-skip part
|
|
167
|
+
const nonVersion = parts.find(
|
|
168
|
+
(p) => p && !VERSION_RE.test(p) && !SKIP_PARTS.has(p),
|
|
169
|
+
);
|
|
170
|
+
if (nonVersion) {
|
|
171
|
+
return { familyId: nonVersion, familyName: capitalize(nonVersion) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
familyId: firstPart || id,
|
|
176
|
+
familyName: capitalize(firstPart || id),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Normalize a model name for comparison by removing provider-specific suffixes
|
|
182
|
+
* and common qualifiers. This helps detect when the same model is offered by
|
|
183
|
+
* multiple providers with slightly different naming.
|
|
184
|
+
*
|
|
185
|
+
* Uses string operations instead of regex backtracking to avoid ReDoS warnings.
|
|
186
|
+
*/
|
|
187
|
+
export function normalizeModelName(name: string): string {
|
|
188
|
+
const suffixes = ["(free)", "(cline)", "-free", "free"];
|
|
189
|
+
let normalized = name.toLowerCase().trimEnd();
|
|
190
|
+
|
|
191
|
+
// Remove common literal suffixes — simple string ops, no regex backtracking
|
|
192
|
+
for (const suffix of suffixes) {
|
|
193
|
+
while (normalized.endsWith(suffix)) {
|
|
194
|
+
normalized = normalized.slice(0, -suffix.length).trimEnd();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// CI score suffix — regex with disjoint char classes (linear)
|
|
199
|
+
// Anchored with $, matches at most once → .replace() is correct (S4144 N/A)
|
|
200
|
+
normalized = normalized.replace(/\(ci:\s*[\d.]+\)$/, "").trimEnd();
|
|
201
|
+
normalized = normalized.replace(/\[ci:\s*[\d.]+\]$/, "").trimEnd();
|
|
202
|
+
|
|
203
|
+
// Remove any trailing parenthetical — non-regex loop
|
|
204
|
+
while (normalized.endsWith(")")) {
|
|
205
|
+
const idx = normalized.lastIndexOf("(", normalized.length - 1);
|
|
206
|
+
if (idx === -1) break;
|
|
207
|
+
normalized = normalized.slice(0, idx).trimEnd();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return normalized.trim();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Try to merge a model into another family if its normalized name
|
|
215
|
+
* matches a model in a different family.
|
|
216
|
+
*/
|
|
217
|
+
function tryMergeFamily(
|
|
218
|
+
byFamily: Map<string, ModelInfo[]>,
|
|
219
|
+
nameToFamilyId: Map<string, string>,
|
|
220
|
+
familyId: string,
|
|
221
|
+
model: ModelInfo,
|
|
222
|
+
): boolean {
|
|
223
|
+
const normalizedName = normalizeModelName(model.name || model.id);
|
|
224
|
+
if (!normalizedName) return false;
|
|
225
|
+
|
|
226
|
+
const existingFamilyForName = nameToFamilyId.get(normalizedName);
|
|
227
|
+
if (!existingFamilyForName || existingFamilyForName === familyId) {
|
|
228
|
+
nameToFamilyId.set(normalizedName, familyId);
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Same model name found in different family - merge them
|
|
233
|
+
const targetFamily = byFamily.get(existingFamilyForName);
|
|
234
|
+
const sourceFamily = byFamily.get(familyId);
|
|
235
|
+
if (!targetFamily || !sourceFamily) return false;
|
|
236
|
+
|
|
237
|
+
targetFamily.push(...sourceFamily);
|
|
238
|
+
byFamily.delete(familyId);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Build a sorted list of ModelFamily from a by-family grouping map.
|
|
244
|
+
*/
|
|
245
|
+
function buildFamiliesList(byFamily: Map<string, ModelInfo[]>): ModelFamily[] {
|
|
246
|
+
const families: ModelFamily[] = [];
|
|
247
|
+
for (const [id, familyModels] of byFamily) {
|
|
248
|
+
const firstModel = familyModels[0]!;
|
|
249
|
+
const familyInfo = detectModelFamily(firstModel)!;
|
|
250
|
+
|
|
251
|
+
families.push({
|
|
252
|
+
id,
|
|
253
|
+
displayName: familyInfo.familyName,
|
|
254
|
+
models: familyModels.sort(
|
|
255
|
+
(a, b) =>
|
|
256
|
+
a.provider.localeCompare(b.provider) || b.id.localeCompare(a.id),
|
|
257
|
+
),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return families.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get all model families from a list of models.
|
|
266
|
+
* Groups models by family and merges same-name models across providers.
|
|
267
|
+
*/
|
|
268
|
+
export function getModelFamilies(models: ModelInfo[]): ModelFamily[] {
|
|
269
|
+
const byFamily = new Map<string, ModelInfo[]>();
|
|
270
|
+
const nameToFamilyId = new Map<string, string>();
|
|
271
|
+
|
|
272
|
+
// First pass: group models by detected family
|
|
273
|
+
for (const model of models) {
|
|
274
|
+
const family = detectModelFamily(model);
|
|
275
|
+
if (!family) continue;
|
|
276
|
+
|
|
277
|
+
const existing = byFamily.get(family.familyId) ?? [];
|
|
278
|
+
existing.push(model);
|
|
279
|
+
byFamily.set(family.familyId, existing);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Second pass: merge families whose models have the same normalized name
|
|
283
|
+
const familyIds = [...byFamily.keys()];
|
|
284
|
+
for (const familyId of familyIds) {
|
|
285
|
+
const familyModels = byFamily.get(familyId);
|
|
286
|
+
if (!familyModels) continue;
|
|
287
|
+
|
|
288
|
+
for (const model of familyModels) {
|
|
289
|
+
if (tryMergeFamily(byFamily, nameToFamilyId, familyId, model)) {
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return buildFamiliesList(byFamily);
|
|
296
|
+
}
|
package/lib/model-metadata.ts
CHANGED
|
@@ -45,7 +45,9 @@ function errorMessage(error: unknown): string {
|
|
|
45
45
|
return error instanceof Error ? error.message : String(error);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
async function fetchModelsDevCatalog(): Promise<
|
|
48
|
+
async function fetchModelsDevCatalog(): Promise<
|
|
49
|
+
Record<string, ModelsDevProvider>
|
|
50
|
+
> {
|
|
49
51
|
let lastError: unknown;
|
|
50
52
|
|
|
51
53
|
for (let attempt = 1; attempt <= MODELS_DEV_RETRIES; attempt++) {
|
|
@@ -291,7 +293,9 @@ function enrichModel<T extends ProviderModelConfig>(
|
|
|
291
293
|
? (["text", "image"] as const)
|
|
292
294
|
: model.input;
|
|
293
295
|
const reasoning =
|
|
294
|
-
ctx.enrichReasoning && modelMeta.reasoning === true
|
|
296
|
+
ctx.enrichReasoning && modelMeta.reasoning === true
|
|
297
|
+
? true
|
|
298
|
+
: model.reasoning;
|
|
295
299
|
const thinkingLevelMap =
|
|
296
300
|
ctx.enrichReasoning && model.thinkingLevelMap === undefined
|
|
297
301
|
? thinkingMapFromReasoningOptions(modelMeta.reasoning_options)
|
|
@@ -301,7 +305,10 @@ function enrichModel<T extends ProviderModelConfig>(
|
|
|
301
305
|
? (costFromModelsDev(modelMeta.cost) ?? model.cost)
|
|
302
306
|
: model.cost;
|
|
303
307
|
const compat = ctx.enrichCompat
|
|
304
|
-
? mergeCompat(
|
|
308
|
+
? mergeCompat(
|
|
309
|
+
model.compat,
|
|
310
|
+
getProxyModelCompat(identityFromMeta(model, modelMeta)),
|
|
311
|
+
)
|
|
305
312
|
: model.compat;
|
|
306
313
|
|
|
307
314
|
const modelsDevMetadata: ModelMatchHints = {
|