pi-free 2.0.4 → 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.
package/banner.svg ADDED
@@ -0,0 +1,132 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0%" stop-color="#0f0b1a"/>
5
+ <stop offset="50%" stop-color="#1a1130"/>
6
+ <stop offset="100%" stop-color="#0d0a1a"/>
7
+ </linearGradient>
8
+ <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
9
+ <stop offset="0%" stop-color="#7c3aed"/>
10
+ <stop offset="50%" stop-color="#a78bfa"/>
11
+ <stop offset="100%" stop-color="#7c3aed"/>
12
+ </linearGradient>
13
+ <linearGradient id="accent2" x1="0" y1="0" x2="1" y2="0">
14
+ <stop offset="0%" stop-color="#06b6d4"/>
15
+ <stop offset="50%" stop-color="#22d3ee"/>
16
+ <stop offset="100%" stop-color="#06b6d4"/>
17
+ </linearGradient>
18
+ <linearGradient id="card1" x1="0" y1="0" x2="0" y2="1">
19
+ <stop offset="0%" stop-color="#7c3aed" stop-opacity="0.15"/>
20
+ <stop offset="100%" stop-color="#7c3aed" stop-opacity="0.05"/>
21
+ </linearGradient>
22
+ <linearGradient id="card2" x1="0" y1="0" x2="0" y2="1">
23
+ <stop offset="0%" stop-color="#06b6d4" stop-opacity="0.15"/>
24
+ <stop offset="100%" stop-color="#06b6d4" stop-opacity="0.05"/>
25
+ </linearGradient>
26
+ <linearGradient id="card3" x1="0" y1="0" x2="0" y2="1">
27
+ <stop offset="0%" stop-color="#10b981" stop-opacity="0.15"/>
28
+ <stop offset="100%" stop-color="#10b981" stop-opacity="0.05"/>
29
+ </linearGradient>
30
+ <linearGradient id="card4" x1="0" y1="0" x2="0" y2="1">
31
+ <stop offset="0%" stop-color="#f59e0b" stop-opacity="0.15"/>
32
+ <stop offset="100%" stop-color="#f59e0b" stop-opacity="0.05"/>
33
+ </linearGradient>
34
+ <filter id="glow">
35
+ <feGaussianBlur stdDeviation="3" result="blur"/>
36
+ <feMerge>
37
+ <feMergeNode in="blur"/>
38
+ <feMergeNode in="SourceGraphic"/>
39
+ </feMerge>
40
+ </filter>
41
+ <filter id="shadow">
42
+ <feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000" flood-opacity="0.3"/>
43
+ </filter>
44
+ <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
45
+ <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#7c3aed" stroke-opacity="0.04" stroke-width="1"/>
46
+ </pattern>
47
+ </defs>
48
+
49
+ <!-- Background -->
50
+ <rect width="1280" height="320" fill="url(#bg)"/>
51
+ <rect width="1280" height="320" fill="url(#grid)"/>
52
+
53
+ <!-- Decorative circles -->
54
+ <circle cx="1200" cy="60" r="180" fill="#7c3aed" opacity="0.06"/>
55
+ <circle cx="80" cy="280" r="140" fill="#06b6d4" opacity="0.05"/>
56
+ <circle cx="640" cy="320" r="200" fill="#a78bfa" opacity="0.04"/>
57
+
58
+ <!-- Decorative lines -->
59
+ <line x1="0" y1="280" x2="1280" y2="280" stroke="url(#accent)" stroke-opacity="0.15" stroke-width="1"/>
60
+ <line x1="0" y1="282" x2="1280" y2="282" stroke="url(#accent2)" stroke-opacity="0.08" stroke-width="0.5"/>
61
+
62
+ <!-- Pi icon (stylized) -->
63
+ <g transform="translate(80, 80)">
64
+ <rect x="0" y="0" width="56" height="56" rx="14" fill="url(#accent)" opacity="0.15"/>
65
+ <rect x="2" y="2" width="52" height="52" rx="12" fill="none" stroke="url(#accent)" stroke-width="1.5" opacity="0.3"/>
66
+ <text x="28" y="40" font-family="monospace" font-size="36" font-weight="bold" fill="url(#accent)" text-anchor="middle" filter="url(#glow)">π</text>
67
+ </g>
68
+
69
+ <!-- Title -->
70
+ <text x="160" y="115" font-family="system-ui, -apple-system, sans-serif" font-size="44" font-weight="800" fill="#f0ecfc" letter-spacing="-0.02em">
71
+ pi-free
72
+ </text>
73
+ <text x="160" y="148" font-family="system-ui, -apple-system, sans-serif" font-size="18" fill="#a78bfa" letter-spacing="0.01em" opacity="0.9">
74
+ Free &amp; Paid AI Model Providers for Pi
75
+ </text>
76
+
77
+ <!-- Subtitle -->
78
+ <text x="160" y="178" font-family="system-ui, -apple-system, sans-serif" font-size="13" fill="#7c8a9a" letter-spacing="0.02em">
79
+ </text>
80
+
81
+ <!-- Provider badges -->
82
+ <g transform="translate(160, 200)">
83
+ <!-- Free -->
84
+ <rect x="0" y="0" width="90" height="28" rx="6" fill="#10b981" opacity="0.15" stroke="#10b981" stroke-width="0.5" stroke-opacity="0.3"/>
85
+ <text x="45" y="18" font-family="system-ui, sans-serif" font-size="11" fill="#6ee7b7" text-anchor="middle" font-weight="600">✅ Free</text>
86
+ </g>
87
+
88
+ <g transform="translate(260, 200)">
89
+ <rect x="0" y="0" width="100" height="28" rx="6" fill="#f59e0b" opacity="0.15" stroke="#f59e0b" stroke-width="0.5" stroke-opacity="0.3"/>
90
+ <text x="50" y="18" font-family="system-ui, sans-serif" font-size="11" fill="#fcd34d" text-anchor="middle" font-weight="600">🔄 Freemium</text>
91
+ </g>
92
+
93
+ <g transform="translate(370, 200)">
94
+ <rect x="0" y="0" width="90" height="28" rx="6" fill="#7c3aed" opacity="0.15" stroke="#7c3aed" stroke-width="0.5" stroke-opacity="0.3"/>
95
+ <text x="45" y="18" font-family="system-ui, sans-serif" font-size="11" fill="#a78bfa" text-anchor="middle" font-weight="600">🔧 Dynamic</text>
96
+ </g>
97
+
98
+ <g transform="translate(470, 200)">
99
+ <rect x="0" y="0" width="90" height="28" rx="6" fill="#ef4444" opacity="0.15" stroke="#ef4444" stroke-width="0.5" stroke-opacity="0.3"/>
100
+ <text x="45" y="18" font-family="system-ui, sans-serif" font-size="11" fill="#fca5a5" text-anchor="middle" font-weight="600">💳 Paid</text>
101
+ </g>
102
+
103
+ <!-- Provider cards (right side) -->
104
+ <g transform="translate(700, 55)">
105
+ <!-- Card 1: Custom Providers -->
106
+ <rect x="0" y="0" width="250" height="170" rx="12" fill="url(#card1)" stroke="#7c3aed" stroke-width="0.5" stroke-opacity="0.2" filter="url(#shadow)"/>
107
+ <rect x="0" y="0" width="250" height="170" rx="12" fill="none" stroke="url(#accent)" stroke-width="0.5" opacity="0.1"/>
108
+ <text x="20" y="28" font-family="system-ui, sans-serif" font-size="13" font-weight="700" fill="#c4b5fd">Custom Providers</text>
109
+ <text x="20" y="55" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">Kilo · Cline</text>
110
+ <text x="20" y="78" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">NVIDIA</text>
111
+ <text x="20" y="101" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">Ollama Cloud · ZenMux</text>
112
+ <text x="20" y="124" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">CrofAI</text>
113
+ </g>
114
+
115
+ <g transform="translate(970, 55)">
116
+ <!-- Card 2: Features -->
117
+ <rect x="0" y="0" width="250" height="170" rx="12" fill="url(#card2)" stroke="#06b6d4" stroke-width="0.5" stroke-opacity="0.2" filter="url(#shadow)"/>
118
+ <rect x="0" y="0" width="250" height="170" rx="12" fill="none" stroke="url(#accent2)" stroke-width="0.5" opacity="0.1"/>
119
+ <text x="20" y="28" font-family="system-ui, sans-serif" font-size="13" font-weight="700" fill="#67e8f9">Features</text>
120
+ <text x="20" y="52" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ Free model auto-detection</text>
121
+ <text x="20" y="72" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ Per-provider toggles</text>
122
+ <text x="20" y="92" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ OAuth flows (Kilo, Cline)</text>
123
+ <text x="20" y="112" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ Coding Index (CI) scores</text>
124
+ <text x="20" y="132" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ Model health probes</text>
125
+ <text x="20" y="152" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ 404/403 auto-hide on probes</text>
126
+ </g>
127
+
128
+ <!-- Bottom tagline -->
129
+ <text x="640" y="305" font-family="system-ui, -apple-system, sans-serif" font-size="12" fill="#5a6a7a" text-anchor="middle" letter-spacing="0.04em">
130
+ npx pi install git:github.com/apmantza/pi-free
131
+ </text>
132
+ </svg>
package/index.ts CHANGED
@@ -158,7 +158,7 @@ export default async function (pi: ExtensionAPI) {
158
158
  // Apply initial global filter if free-only mode is enabled
159
159
  if (globalFreeOnly) {
160
160
  _logger.info("[pi-free] Applying initial free-only filter");
161
- await applyGlobalFilter(pi, true);
161
+ applyGlobalFilter(pi, true);
162
162
  }
163
163
 
164
164
  const registry = getProviderRegistry();
@@ -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
  }
package/lib/registry.ts CHANGED
@@ -156,33 +156,40 @@ export function getProviderRegistry(): ReadonlyMap<string, ProviderEntry> {
156
156
  // Global filter application
157
157
  // =============================================================================
158
158
 
159
+ function applyFilterToProvider(
160
+ providerId: string,
161
+ entry: ProviderEntry,
162
+ freeOnly: boolean,
163
+ ): void {
164
+ if (freeOnly) {
165
+ if (entry.stored.free.length > 0) {
166
+ entry.reRegister(entry.stored.free);
167
+ _logger.info(
168
+ `[pi-free] ${providerId}: filtered to ${entry.stored.free.length} free models`,
169
+ );
170
+ } else {
171
+ _logger.warn(`[pi-free] ${providerId}: no free models available`);
172
+ }
173
+ } else {
174
+ // Show all models (paid + free)
175
+ const allModels =
176
+ entry.stored.all.length > 0 ? entry.stored.all : entry.stored.free;
177
+ if (allModels.length > 0) {
178
+ entry.reRegister(allModels);
179
+ _logger.info(
180
+ `[pi-free] ${providerId}: showing all ${allModels.length} models`,
181
+ );
182
+ }
183
+ }
184
+ }
185
+
159
186
  export function applyGlobalFilter(_pi: ExtensionAPI, freeOnly: boolean): void {
160
187
  globalFreeOnly = freeOnly;
161
188
  saveConfig({ free_only: freeOnly });
162
189
 
163
190
  for (const [providerId, entry] of providerRegistry) {
164
191
  try {
165
- if (freeOnly) {
166
- // Show only free models
167
- if (entry.stored.free.length > 0) {
168
- entry.reRegister(entry.stored.free);
169
- _logger.info(
170
- `[pi-free] ${providerId}: filtered to ${entry.stored.free.length} free models`,
171
- );
172
- } else {
173
- _logger.warn(`[pi-free] ${providerId}: no free models available`);
174
- }
175
- } else {
176
- // Show all models (paid + free)
177
- const allModels =
178
- entry.stored.all.length > 0 ? entry.stored.all : entry.stored.free;
179
- if (allModels.length > 0) {
180
- entry.reRegister(allModels);
181
- _logger.info(
182
- `[pi-free] ${providerId}: showing all ${allModels.length} models`,
183
- );
184
- }
185
- }
192
+ applyFilterToProvider(providerId, entry, freeOnly);
186
193
  } catch (err) {
187
194
  _logger.error(
188
195
  `[pi-free] Failed to apply filter to ${providerId}`,
package/lib/util.ts CHANGED
@@ -205,10 +205,16 @@ export function isUsableModel(modelId: string, minSizeB?: number): boolean {
205
205
  */
206
206
  export function cleanModelName(name: string): string {
207
207
  // Handle patterns like "Provider : Model Name" or "Provider / Model Name"
208
- // Match colon or slash separator with optional surrounding whitespace
209
- const separatorMatch = name.match(/^[^:]+\s*[:/]\s*(.+)$/);
210
- if (separatorMatch) {
211
- return separatorMatch[1].trim();
208
+ const colonIdx = name.indexOf(":");
209
+ const slashIdx = name.indexOf("/");
210
+ const idx =
211
+ colonIdx === -1
212
+ ? slashIdx
213
+ : slashIdx === -1
214
+ ? colonIdx
215
+ : Math.min(colonIdx, slashIdx);
216
+ if (idx > 0) {
217
+ return name.slice(idx + 1).trim();
212
218
  }
213
219
  return name.trim();
214
220
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-free",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "type": "module",
5
5
  "description": "AI model providers for Pi with free model filtering. Shows only $0 cost models by default. Supports Kilo (free OAuth), Cline (free), NVIDIA (freemium), ZenMux, CrofAI, Ollama Cloud, and more.",
6
6
  "keywords": [
@@ -40,6 +40,7 @@
40
40
  "README.md",
41
41
  "LICENSE",
42
42
  "CHANGELOG.md",
43
+ "banner.svg",
43
44
  "scripts/check-extensions.mjs"
44
45
  ],
45
46
  "scripts": {