pi-free 2.0.2 → 2.0.5

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.
@@ -60,6 +60,83 @@ export function toProviderModelInfo(model: ProviderModelConfig): ModelInfo {
60
60
  };
61
61
  }
62
62
 
63
+ // =============================================================================
64
+ // Shared helpers for model family detection
65
+ // =============================================================================
66
+
67
+ const VERSION_RE = /^v?\d+(\.\d+)?$/;
68
+ const ROUTER_RE = /\b(?:router|auto)\b/;
69
+ const SKIP_PARTS = new Set([
70
+ "latest",
71
+ "preview",
72
+ "rc",
73
+ "beta",
74
+ "alpha",
75
+ "dev",
76
+ "free",
77
+ ]);
78
+
79
+ interface BrandMapping {
80
+ keywords: string[];
81
+ familyId: string;
82
+ familyName: string;
83
+ }
84
+
85
+ const BRAND_MAPPINGS: BrandMapping[] = [
86
+ { keywords: ["claude"], familyId: "claude", familyName: "Claude" },
87
+ { keywords: ["deepseek"], familyId: "deepseek", familyName: "DeepSeek" },
88
+ { keywords: ["gemini"], familyId: "gemini", familyName: "Gemini" },
89
+ { keywords: ["gpt"], familyId: "gpt", familyName: "GPT" },
90
+ { keywords: ["llama"], familyId: "llama", familyName: "Llama" },
91
+ { keywords: ["minimax"], familyId: "minimax", familyName: "MiniMax" },
92
+ { keywords: ["qwen"], familyId: "qwen", familyName: "Qwen" },
93
+ { keywords: ["nemotron"], familyId: "nemotron", familyName: "Nemotron" },
94
+ { keywords: ["kimi", "moonshot"], familyId: "kimi", familyName: "Kimi" },
95
+ { keywords: ["glm", "chatglm"], familyId: "glm", familyName: "GLM" },
96
+ { keywords: ["mistral"], familyId: "mistral", familyName: "Mistral" },
97
+ { keywords: ["arcee", "trinity"], familyId: "arcee", familyName: "Arcee" },
98
+ { keywords: ["o1", "o3"], familyId: "openai-o", familyName: "OpenAI o" },
99
+ ];
100
+
101
+ const PROVIDER_MAPPINGS: Record<
102
+ string,
103
+ { familyId: string; familyName: string }
104
+ > = {
105
+ minimax: { familyId: "minimax", familyName: "MiniMax" },
106
+ minimaxai: { familyId: "minimax", familyName: "MiniMax" },
107
+ deepseek: { familyId: "deepseek", familyName: "DeepSeek" },
108
+ nvidia: { familyId: "nemotron", familyName: "Nemotron" },
109
+ moonshot: { familyId: "kimi", familyName: "Kimi" },
110
+ zhipu: { familyId: "glm", familyName: "GLM" },
111
+ };
112
+
113
+ function capitalize(s: string): string {
114
+ return s.charAt(0).toUpperCase() + s.slice(1);
115
+ }
116
+
117
+ function findBrandInText(
118
+ text: string,
119
+ ): { familyId: string; familyName: string } | null {
120
+ for (const mapping of BRAND_MAPPINGS) {
121
+ for (const keyword of mapping.keywords) {
122
+ if (text.includes(keyword)) {
123
+ return { familyId: mapping.familyId, familyName: mapping.familyName };
124
+ }
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+
130
+ function findBrandInParts(
131
+ parts: string[],
132
+ ): { familyId: string; familyName: string } | null {
133
+ for (const part of parts) {
134
+ const result = findBrandInText(part);
135
+ if (result) return result;
136
+ }
137
+ return null;
138
+ }
139
+
63
140
  /**
64
141
  * Detect the model family from a model's ID or name.
65
142
  * Returns the family ID and display name.
@@ -72,116 +149,41 @@ export function detectModelFamily(
72
149
  const fullText = `${id} ${name}`;
73
150
 
74
151
  // Router models (gateways to free models) - group into "other"
75
- if (/\brouter\b/.test(fullText) || /\bauto\b/.test(fullText) || id === "kilo-auto/free") {
152
+ if (ROUTER_RE.test(fullText) || id === "kilo-auto/free") {
76
153
  return { familyId: "other", familyName: "Other" };
77
154
  }
78
155
 
79
- // Known brand keywords - order matters: more specific/longer matches first
80
- const brandMappings: {
81
- keywords: string[];
82
- familyId: string;
83
- familyName: string;
84
- }[] = [
85
- { keywords: ["claude"], familyId: "claude", familyName: "Claude" },
86
- { keywords: ["deepseek"], familyId: "deepseek", familyName: "DeepSeek" },
87
- { keywords: ["gemini"], familyId: "gemini", familyName: "Gemini" },
88
- { keywords: ["gpt"], familyId: "gpt", familyName: "GPT" },
89
- { keywords: ["llama"], familyId: "llama", familyName: "Llama" },
90
- { keywords: ["minimax"], familyId: "minimax", familyName: "MiniMax" },
91
- { keywords: ["qwen"], familyId: "qwen", familyName: "Qwen" },
92
- { keywords: ["nemotron"], familyId: "nemotron", familyName: "Nemotron" },
93
- { keywords: ["kimi", "moonshot"], familyId: "kimi", familyName: "Kimi" },
94
- { keywords: ["glm", "chatglm"], familyId: "glm", familyName: "GLM" },
95
- { keywords: ["mistral"], familyId: "mistral", familyName: "Mistral" },
96
- { keywords: ["arcee", "trinity"], familyId: "arcee", familyName: "Arcee" },
97
- { keywords: ["o1", "o3"], familyId: "openai-o", familyName: "OpenAI o" },
98
- ];
99
-
100
- // Check for known brands in ID or name
101
- for (const mapping of brandMappings) {
102
- for (const keyword of mapping.keywords) {
103
- if (fullText.includes(keyword)) {
104
- return { familyId: mapping.familyId, familyName: mapping.familyName };
105
- }
106
- }
107
- }
156
+ // Known brand keywords in full text
157
+ const brandFromText = findBrandInText(fullText);
158
+ if (brandFromText) return brandFromText;
108
159
 
109
160
  // Provider-specific fallbacks for models without brand in ID/name
110
- const providerMappings: Record<string, { familyId: string; familyName: string }> = {
111
- minimax: { familyId: "minimax", familyName: "MiniMax" },
112
- minimaxai: { familyId: "minimax", familyName: "MiniMax" },
113
- deepseek: { familyId: "deepseek", familyName: "DeepSeek" },
114
- nvidia: { familyId: "nemotron", familyName: "Nemotron" },
115
- moonshot: { familyId: "kimi", familyName: "Kimi" },
116
- zhipu: { familyId: "glm", familyName: "GLM" },
117
- };
118
-
119
- if (providerMappings[model.provider]) {
120
- return providerMappings[model.provider];
121
- }
122
-
123
- // Helper to find brand in ID parts
124
- function findBrandInParts(parts: string[]): { familyId: string; familyName: string } | null {
125
- for (const part of parts) {
126
- for (const mapping of brandMappings) {
127
- for (const keyword of mapping.keywords) {
128
- if (part.includes(keyword)) {
129
- return { familyId: mapping.familyId, familyName: mapping.familyName };
130
- }
131
- }
132
- }
133
- }
134
- return null;
135
- }
161
+ const providerResult = PROVIDER_MAPPINGS[model.provider];
162
+ if (providerResult) return providerResult;
136
163
 
137
- // Smart fallback: try to identify brand from model ID structure
164
+ // Fallback: try to identify brand from model ID structure
138
165
  const parts = id.split(/[-_:.@]/);
139
166
  const firstPart = parts[0];
140
167
 
141
- // If ID starts with a version number, check remaining parts for brand
142
- if (firstPart && /^v?\d+(\.\d+)?$/.test(firstPart)) {
143
- const brandFromParts = findBrandInParts(parts.slice(1));
144
- if (brandFromParts) {
145
- return brandFromParts;
146
- }
147
- }
148
-
149
- // If ID has multiple parts, check ALL parts for brand keywords
150
- if (parts.length > 1) {
151
- const brandFromParts = findBrandInParts(parts);
152
- if (brandFromParts) {
153
- return brandFromParts;
154
- }
168
+ const brandFromParts = findBrandInParts(parts);
169
+ if (brandFromParts) return brandFromParts;
155
170
 
156
- // Use first part as brand if it looks brand-like
157
- if (firstPart && !/^v?\d+(\.\d+)?$/.test(firstPart)) {
158
- return {
159
- familyId: firstPart,
160
- familyName: firstPart.charAt(0).toUpperCase() + firstPart.slice(1),
161
- };
162
- }
171
+ // Use first part as brand if it looks brand-like
172
+ if (firstPart && !VERSION_RE.test(firstPart)) {
173
+ return { familyId: firstPart, familyName: capitalize(firstPart) };
163
174
  }
164
175
 
165
- // Last resort: use first non-version part
166
- if (firstPart && /^v?\d+(\.\d+)?$/.test(firstPart) && parts.length > 1) {
167
- for (let i = 1; i < parts.length; i++) {
168
- const part = parts[i];
169
- if (
170
- part &&
171
- !/^v?\d+(\.\d+)?$/.test(part) &&
172
- !["latest", "preview", "rc", "beta", "alpha", "dev", "free"].includes(part)
173
- ) {
174
- return {
175
- familyId: part,
176
- familyName: part.charAt(0).toUpperCase() + part.slice(1),
177
- };
178
- }
179
- }
176
+ // First non-version, non-skip part
177
+ const nonVersion = parts.find(
178
+ (p) => p && !VERSION_RE.test(p) && !SKIP_PARTS.has(p),
179
+ );
180
+ if (nonVersion) {
181
+ return { familyId: nonVersion, familyName: capitalize(nonVersion) };
180
182
  }
181
183
 
182
184
  return {
183
185
  familyId: firstPart || id,
184
- familyName: (firstPart || id).charAt(0).toUpperCase() + (firstPart || id).slice(1),
186
+ familyName: capitalize(firstPart || id),
185
187
  };
186
188
  }
187
189
 
@@ -189,21 +191,83 @@ export function detectModelFamily(
189
191
  * Normalize a model name for comparison by removing provider-specific suffixes
190
192
  * and common qualifiers. This helps detect when the same model is offered by
191
193
  * multiple providers with slightly different naming.
194
+ *
195
+ * Uses string operations instead of regex backtracking to avoid ReDoS warnings.
192
196
  */
193
197
  export function normalizeModelName(name: string): string {
194
- return (
195
- name
196
- .toLowerCase()
197
- // Remove common suffixes added by providers
198
- .replace(/\s*\(free\)\s*$/i, "")
199
- .replace(/\s*\(cline\)\s*$/i, "")
200
- .replace(/\s*\(ci:\s*[\d.]+\)\s*$/i, "")
201
- .replace(/\s*\[ci:\s*[\d.]+\]\s*$/i, "")
202
- .replace(/\s*\([^)]*\)\s*$/g, "") // Remove any trailing parenthetical
203
- .replace(/\s*-\s*free\s*$/i, "") // e.g., "minimax-m2.5-free"
204
- .replace(/\s*free\s*$/i, "") // trailing "free"
205
- .trim()
206
- );
198
+ const suffixes = ["(free)", "(cline)", "-free", "free"];
199
+ let normalized = name.toLowerCase().trimEnd();
200
+
201
+ // Remove common literal suffixes simple string ops, no regex backtracking
202
+ for (const suffix of suffixes) {
203
+ while (normalized.endsWith(suffix)) {
204
+ normalized = normalized.slice(0, -suffix.length).trimEnd();
205
+ }
206
+ }
207
+
208
+ // CI score suffix — regex with disjoint char classes (linear)
209
+ normalized = normalized.replace(/\(ci:\s*[\d.]+\)$/, "").trimEnd();
210
+ normalized = normalized.replace(/\[ci:\s*[\d.]+\]$/, "").trimEnd();
211
+
212
+ // Remove any trailing parenthetical — non-regex loop
213
+ while (normalized.endsWith(")")) {
214
+ const idx = normalized.lastIndexOf("(", normalized.length - 1);
215
+ if (idx === -1) break;
216
+ normalized = normalized.slice(0, idx).trimEnd();
217
+ }
218
+
219
+ return normalized.trim();
220
+ }
221
+
222
+ /**
223
+ * Try to merge a model into another family if its normalized name
224
+ * matches a model in a different family.
225
+ */
226
+ function tryMergeFamily(
227
+ byFamily: Map<string, ModelInfo[]>,
228
+ nameToFamilyId: Map<string, string>,
229
+ familyId: string,
230
+ model: ModelInfo,
231
+ ): boolean {
232
+ const normalizedName = normalizeModelName(model.name || model.id);
233
+ if (!normalizedName) return false;
234
+
235
+ const existingFamilyForName = nameToFamilyId.get(normalizedName);
236
+ if (!existingFamilyForName || existingFamilyForName === familyId) {
237
+ nameToFamilyId.set(normalizedName, familyId);
238
+ return false;
239
+ }
240
+
241
+ // Same model name found in different family - merge them
242
+ const targetFamily = byFamily.get(existingFamilyForName);
243
+ const sourceFamily = byFamily.get(familyId);
244
+ if (!targetFamily || !sourceFamily) return false;
245
+
246
+ targetFamily.push(...sourceFamily);
247
+ byFamily.delete(familyId);
248
+ return true;
249
+ }
250
+
251
+ /**
252
+ * Build a sorted list of ModelFamily from a by-family grouping map.
253
+ */
254
+ function buildFamiliesList(byFamily: Map<string, ModelInfo[]>): ModelFamily[] {
255
+ const families: ModelFamily[] = [];
256
+ for (const [id, familyModels] of byFamily) {
257
+ const firstModel = familyModels[0]!;
258
+ const familyInfo = detectModelFamily(firstModel)!;
259
+
260
+ families.push({
261
+ id,
262
+ displayName: familyInfo.familyName,
263
+ models: familyModels.sort(
264
+ (a, b) =>
265
+ a.provider.localeCompare(b.provider) || b.id.localeCompare(a.id),
266
+ ),
267
+ });
268
+ }
269
+
270
+ return families.sort((a, b) => a.displayName.localeCompare(b.displayName));
207
271
  }
208
272
 
209
273
  /**
@@ -214,6 +278,7 @@ export function getModelFamilies(models: ModelInfo[]): ModelFamily[] {
214
278
  const byFamily = new Map<string, ModelInfo[]>();
215
279
  const nameToFamilyId = new Map<string, string>();
216
280
 
281
+ // First pass: group models by detected family
217
282
  for (const model of models) {
218
283
  const family = detectModelFamily(model);
219
284
  if (!family) continue;
@@ -223,46 +288,18 @@ export function getModelFamilies(models: ModelInfo[]): ModelFamily[] {
223
288
  byFamily.set(family.familyId, existing);
224
289
  }
225
290
 
226
- // Second pass: merge families with models that have the same normalized name
291
+ // Second pass: merge families whose models have the same normalized name
227
292
  const familyIds = [...byFamily.keys()];
228
293
  for (const familyId of familyIds) {
229
294
  const familyModels = byFamily.get(familyId);
230
295
  if (!familyModels) continue;
231
296
 
232
297
  for (const model of familyModels) {
233
- const normalizedName = normalizeModelName(model.name || model.id);
234
- if (!normalizedName) continue;
235
-
236
- const existingFamilyForName = nameToFamilyId.get(normalizedName);
237
- if (existingFamilyForName && existingFamilyForName !== familyId) {
238
- // Same model name found in different family - merge them
239
- const targetFamily = byFamily.get(existingFamilyForName);
240
- const sourceFamily = byFamily.get(familyId);
241
- if (targetFamily && sourceFamily) {
242
- targetFamily.push(...sourceFamily);
243
- byFamily.delete(familyId);
244
- break;
245
- }
246
- } else {
247
- nameToFamilyId.set(normalizedName, familyId);
298
+ if (tryMergeFamily(byFamily, nameToFamilyId, familyId, model)) {
299
+ break;
248
300
  }
249
301
  }
250
302
  }
251
303
 
252
- const families: ModelFamily[] = [];
253
- for (const [id, familyModels] of byFamily) {
254
- const firstModel = familyModels[0]!;
255
- const familyInfo = detectModelFamily(firstModel)!;
256
-
257
- families.push({
258
- id,
259
- displayName: familyInfo.familyName,
260
- models: familyModels.sort(
261
- (a, b) =>
262
- a.provider.localeCompare(b.provider) || b.id.localeCompare(a.id),
263
- ),
264
- });
265
- }
266
-
267
- return families.sort((a, b) => a.displayName.localeCompare(b.displayName));
304
+ return buildFamiliesList(byFamily);
268
305
  }
@@ -1,20 +1,20 @@
1
- /**
2
- * Model name enhancement helper
3
- * Adds Coding Index scores to model names for display in /model
4
- */
5
-
6
- import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
7
- import { enhanceModelNameWithCodingIndex } from "../provider-failover/benchmark-lookup.ts";
8
-
9
- /**
10
- * Enhance model names with Coding Index scores
11
- * Use this before registering providers to show CI in /model list
12
- */
13
- export function enhanceModelsWithCodingIndex(
14
- models: ProviderModelConfig[],
15
- ): ProviderModelConfig[] {
16
- return models.map((m) => ({
17
- ...m,
18
- name: enhanceModelNameWithCodingIndex(m.name, m.id),
19
- }));
20
- }
1
+ /**
2
+ * Model name enhancement helper
3
+ * Adds Coding Index scores to model names for display in /model
4
+ */
5
+
6
+ import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
7
+ import { enhanceModelNameWithCodingIndex } from "../provider-failover/benchmark-lookup.ts";
8
+
9
+ /**
10
+ * Enhance model names with Coding Index scores
11
+ * Use this before registering providers to show CI in /model list
12
+ */
13
+ export function enhanceModelsWithCodingIndex(
14
+ models: ProviderModelConfig[],
15
+ ): ProviderModelConfig[] {
16
+ return models.map((m) => ({
17
+ ...m,
18
+ name: enhanceModelNameWithCodingIndex(m.name, m.id),
19
+ }));
20
+ }
@@ -25,7 +25,7 @@ export function openBrowser(url: string): void {
25
25
  "-NoProfile",
26
26
  "-NonInteractive",
27
27
  "-Command",
28
- `Start-Process "${url.replace(/"/g, '\\"')}"`,
28
+ `Start-Process "${url.replace(/[\\"]/g, "\\$&")}"`,
29
29
  ],
30
30
  { detached: true, shell: false, windowsHide: true },
31
31
  ).unref();
@@ -0,0 +1,46 @@
1
+ import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
2
+
3
+ export interface ProviderModelIdentity {
4
+ id: string;
5
+ name?: string;
6
+ }
7
+
8
+ export const DEEPSEEK_PROXY_COMPAT: NonNullable<ProviderModelConfig["compat"]> =
9
+ {
10
+ supportsStore: false,
11
+ supportsDeveloperRole: false,
12
+ supportsReasoningEffort: true,
13
+ requiresReasoningContentOnAssistantMessages: true,
14
+ thinkingFormat: "deepseek",
15
+ };
16
+
17
+ export function isDeepSeekModel(model: ProviderModelIdentity): boolean {
18
+ const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
19
+ return haystack.includes("deepseek");
20
+ }
21
+
22
+ export function isLikelyReasoningModel(model: ProviderModelIdentity): boolean {
23
+ const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
24
+ return (
25
+ isDeepSeekModel(model) ||
26
+ haystack.includes("thinking") ||
27
+ haystack.includes("reasoning") ||
28
+ haystack.includes("reasoner") ||
29
+ haystack.includes("r1") ||
30
+ haystack.includes("qwq")
31
+ );
32
+ }
33
+
34
+ /**
35
+ * For gateway/proxy providers that mask the upstream DeepSeek base URL,
36
+ * add explicit compat so pi-ai preserves and replays reasoning_content.
37
+ */
38
+ export function getProxyModelCompat(
39
+ model: ProviderModelIdentity,
40
+ ): ProviderModelConfig["compat"] | undefined {
41
+ if (isDeepSeekModel(model)) {
42
+ return DEEPSEEK_PROXY_COMPAT;
43
+ }
44
+
45
+ return undefined;
46
+ }