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 +132 -0
- package/index.ts +1 -1
- package/lib/model-detection.ts +176 -139
- package/lib/registry.ts +28 -21
- package/lib/util.ts +10 -4
- package/package.json +2 -1
- package/provider-failover/benchmark-lookup.ts +189 -138
- package/providers/cline/cline.ts +27 -10
- package/providers/dynamic-built-in/index.ts +3 -1
- package/providers/nvidia/nvidia.ts +48 -50
- package/providers/qwen/qwen.ts +47 -49
- package/scripts/check-extensions.mjs +8 -1
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 & 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
|
-
|
|
161
|
+
applyGlobalFilter(pi, true);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
const registry = getProviderRegistry();
|
package/lib/model-detection.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
|
111
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
142
|
-
if (
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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)
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
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": {
|