pi-free 1.0.6 → 1.0.8
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 +17 -0
- package/README.md +81 -3
- package/config.ts +25 -0
- package/constants.ts +14 -1
- package/lib/logger.ts +50 -5
- package/lib/model-detection.ts +268 -0
- package/package.json +16 -7
- package/provider-factory.ts +2 -0
- package/provider-failover/auto-switch.ts +350 -0
- package/provider-failover/hardcoded-benchmarks.ts +2810 -2670
- package/provider-failover/index.ts +53 -9
- package/provider-helper.ts +9 -86
- package/providers/cline.ts +2 -2
- package/providers/go.ts +216 -0
- package/providers/kilo.ts +11 -9
- package/providers/modal.ts +43 -0
- package/providers/model-fetcher.ts +55 -2
- package/providers/opencode-session.ts +34 -0
- package/providers/qwen-auth.ts +432 -0
- package/providers/qwen-models.ts +95 -0
- package/providers/qwen.ts +127 -0
- package/providers/zen.ts +7 -52
- package/scripts/check-extensions.mjs +87 -0
- package/widget/data.ts +113 -0
- package/widget/format.ts +26 -0
- package/widget/render.ts +117 -0
|
@@ -0,0 +1,268 @@
|
|
|
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 "@mariozechner/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
|
+
* Check if a model is free (zero input and output cost)
|
|
27
|
+
*/
|
|
28
|
+
export function isModelFree(model: {
|
|
29
|
+
cost?: { input: number; output: number };
|
|
30
|
+
}): boolean {
|
|
31
|
+
if (!model.cost) return true;
|
|
32
|
+
return model.cost.input === 0 && model.cost.output === 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Convert Pi's Model type to ModelInfo for internal use
|
|
37
|
+
*/
|
|
38
|
+
export function toModelInfo(model: Model<any>): ModelInfo {
|
|
39
|
+
return {
|
|
40
|
+
id: model.id,
|
|
41
|
+
name: model.name,
|
|
42
|
+
provider: model.provider,
|
|
43
|
+
isFree: isModelFree(model),
|
|
44
|
+
inputCost: model.cost?.input ?? 0,
|
|
45
|
+
outputCost: model.cost?.output ?? 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert ProviderModelConfig to ModelInfo for internal use
|
|
51
|
+
*/
|
|
52
|
+
export function toProviderModelInfo(model: ProviderModelConfig): ModelInfo {
|
|
53
|
+
return {
|
|
54
|
+
id: model.id,
|
|
55
|
+
name: model.name,
|
|
56
|
+
provider: "", // Will be set by caller
|
|
57
|
+
isFree: isModelFree(model),
|
|
58
|
+
inputCost: model.cost?.input ?? 0,
|
|
59
|
+
outputCost: model.cost?.output ?? 0,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect the model family from a model's ID or name.
|
|
65
|
+
* Returns the family ID and display name.
|
|
66
|
+
*/
|
|
67
|
+
export function detectModelFamily(
|
|
68
|
+
model: ModelInfo,
|
|
69
|
+
): { familyId: string; familyName: string } | null {
|
|
70
|
+
const id = model.id.toLowerCase();
|
|
71
|
+
const name = (model.name || "").toLowerCase();
|
|
72
|
+
const fullText = `${id} ${name}`;
|
|
73
|
+
|
|
74
|
+
// Router models (gateways to free models) - group into "other"
|
|
75
|
+
if (/\brouter\b/.test(fullText) || /\bauto\b/.test(fullText) || id === "kilo-auto/free") {
|
|
76
|
+
return { familyId: "other", familyName: "Other" };
|
|
77
|
+
}
|
|
78
|
+
|
|
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
|
+
}
|
|
108
|
+
|
|
109
|
+
// 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
|
+
}
|
|
136
|
+
|
|
137
|
+
// Smart fallback: try to identify brand from model ID structure
|
|
138
|
+
const parts = id.split(/[-_:.@]/);
|
|
139
|
+
const firstPart = parts[0];
|
|
140
|
+
|
|
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
|
+
}
|
|
155
|
+
|
|
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
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
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
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
familyId: firstPart || id,
|
|
184
|
+
familyName: (firstPart || id).charAt(0).toUpperCase() + (firstPart || id).slice(1),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Normalize a model name for comparison by removing provider-specific suffixes
|
|
190
|
+
* and common qualifiers. This helps detect when the same model is offered by
|
|
191
|
+
* multiple providers with slightly different naming.
|
|
192
|
+
*/
|
|
193
|
+
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
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get all model families from a list of models.
|
|
211
|
+
* Groups models by family and merges same-name models across providers.
|
|
212
|
+
*/
|
|
213
|
+
export function getModelFamilies(models: ModelInfo[]): ModelFamily[] {
|
|
214
|
+
const byFamily = new Map<string, ModelInfo[]>();
|
|
215
|
+
const nameToFamilyId = new Map<string, string>();
|
|
216
|
+
|
|
217
|
+
for (const model of models) {
|
|
218
|
+
const family = detectModelFamily(model);
|
|
219
|
+
if (!family) continue;
|
|
220
|
+
|
|
221
|
+
const existing = byFamily.get(family.familyId) ?? [];
|
|
222
|
+
existing.push(model);
|
|
223
|
+
byFamily.set(family.familyId, existing);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Second pass: merge families with models that have the same normalized name
|
|
227
|
+
const familyIds = [...byFamily.keys()];
|
|
228
|
+
for (const familyId of familyIds) {
|
|
229
|
+
const familyModels = byFamily.get(familyId);
|
|
230
|
+
if (!familyModels) continue;
|
|
231
|
+
|
|
232
|
+
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);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
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));
|
|
268
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-free",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "AIO Free AI models for Pi - Access free models from Kilo, Zen, OpenRouter, NVIDIA, Cline, Mistral, and
|
|
5
|
+
"description": "AIO Free AI models for Pi - Access free models from Kilo, Zen, OpenRouter, NVIDIA, Cline, Mistral, Ollama, and Qwen",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
8
8
|
"pi-extension",
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"cline",
|
|
15
15
|
"ollama",
|
|
16
16
|
"mistral",
|
|
17
|
-
"fireworks"
|
|
17
|
+
"fireworks",
|
|
18
|
+
"qwen",
|
|
19
|
+
"qwen-oauth"
|
|
18
20
|
],
|
|
19
21
|
"license": "MIT",
|
|
20
22
|
"author": "Apostolos Mantzaris",
|
|
@@ -34,15 +36,18 @@
|
|
|
34
36
|
"lib/**/*.ts",
|
|
35
37
|
"usage/**/*.ts",
|
|
36
38
|
"provider-failover/**/*.ts",
|
|
39
|
+
"widget/**/*.ts",
|
|
37
40
|
"config.ts",
|
|
38
41
|
"constants.ts",
|
|
39
42
|
"provider-factory.ts",
|
|
40
43
|
"provider-helper.ts",
|
|
41
44
|
"README.md",
|
|
42
45
|
"LICENSE",
|
|
43
|
-
"CHANGELOG.md"
|
|
46
|
+
"CHANGELOG.md",
|
|
47
|
+
"scripts/check-extensions.mjs"
|
|
44
48
|
],
|
|
45
49
|
"scripts": {
|
|
50
|
+
"check": "node scripts/check-extensions.mjs",
|
|
46
51
|
"test": "vitest",
|
|
47
52
|
"test:ui": "vitest --ui",
|
|
48
53
|
"test:run": "vitest run"
|
|
@@ -54,20 +59,24 @@
|
|
|
54
59
|
"@sinclair/typebox": "*"
|
|
55
60
|
},
|
|
56
61
|
"devDependencies": {
|
|
57
|
-
"vitest": "^1.0.0",
|
|
58
62
|
"@vitest/ui": "^1.0.0",
|
|
59
|
-
"tsx": "^4.0.0"
|
|
63
|
+
"tsx": "^4.0.0",
|
|
64
|
+
"typescript": "^6.0.2",
|
|
65
|
+
"vitest": "^1.0.0"
|
|
60
66
|
},
|
|
61
67
|
"pi": {
|
|
62
68
|
"extensions": [
|
|
63
69
|
"./providers/kilo.ts",
|
|
64
70
|
"./providers/zen.ts",
|
|
71
|
+
"./providers/go.ts",
|
|
65
72
|
"./providers/openrouter.ts",
|
|
66
73
|
"./providers/nvidia.ts",
|
|
67
74
|
"./providers/cline.ts",
|
|
68
75
|
"./providers/fireworks.ts",
|
|
69
76
|
"./providers/mistral.ts",
|
|
70
|
-
"./providers/ollama.ts"
|
|
77
|
+
"./providers/ollama.ts",
|
|
78
|
+
"./providers/qwen.ts",
|
|
79
|
+
"./providers/modal.ts"
|
|
71
80
|
]
|
|
72
81
|
}
|
|
73
82
|
}
|
package/provider-factory.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
OLLAMA_SHOW_PAID,
|
|
34
34
|
OPENCODE_API_KEY,
|
|
35
35
|
ZEN_SHOW_PAID,
|
|
36
|
+
MODAL_API_KEY,
|
|
36
37
|
} from "./config.ts";
|
|
37
38
|
import { createLogger } from "./lib/logger.ts";
|
|
38
39
|
import { logWarning } from "./lib/util.ts";
|
|
@@ -86,6 +87,7 @@ const API_KEY_GETTERS: Record<string, () => string | undefined> = {
|
|
|
86
87
|
ollama_api_key: () => OLLAMA_API_KEY,
|
|
87
88
|
mistral_api_key: () => MISTRAL_API_KEY,
|
|
88
89
|
opencode_api_key: () => OPENCODE_API_KEY,
|
|
90
|
+
modal_api_key: () => MODAL_API_KEY,
|
|
89
91
|
};
|
|
90
92
|
|
|
91
93
|
const SHOW_PAID_GETTERS: Record<string, () => boolean> = {
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-switch failover for pi-free-providers.
|
|
3
|
+
*
|
|
4
|
+
* When a provider hits a 429 or capacity error, this module finds
|
|
5
|
+
* an equivalent or similar model from another provider and switches to it.
|
|
6
|
+
*
|
|
7
|
+
* Strategy:
|
|
8
|
+
* 1. Extract the base model name/family from the failed model
|
|
9
|
+
* 2. Search all available models for the same model (different provider)
|
|
10
|
+
* 3. If not found, find a similar model in the same family
|
|
11
|
+
* 4. If not found, find any free model with similar capability
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
16
|
+
import { createLogger } from "../lib/logger.ts";
|
|
17
|
+
import {
|
|
18
|
+
detectModelFamily,
|
|
19
|
+
normalizeModelName,
|
|
20
|
+
toModelInfo,
|
|
21
|
+
type ModelInfo,
|
|
22
|
+
} from "../lib/model-detection.ts";
|
|
23
|
+
import { getHardcodedScore } from "./hardcoded-benchmarks.ts";
|
|
24
|
+
|
|
25
|
+
const _logger = createLogger("auto-switch");
|
|
26
|
+
|
|
27
|
+
export interface AutoSwitchConfig {
|
|
28
|
+
/** Whether to enable auto-switching (can be disabled by user) */
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
/** Maximum CI score degradation allowed (e.g., 10 = can drop up to 10 points) */
|
|
31
|
+
maxCIScoreDrop: number;
|
|
32
|
+
/** Provider priority for fallback (preferred first) */
|
|
33
|
+
providerPriority: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_CONFIG: AutoSwitchConfig = {
|
|
37
|
+
enabled: true,
|
|
38
|
+
maxCIScoreDrop: 15,
|
|
39
|
+
providerPriority: ["zen", "go", "kilo", "openrouter", "nvidia", "fireworks", "mistral", "ollama", "cline"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface AutoSwitchResult {
|
|
43
|
+
success: boolean;
|
|
44
|
+
switched: boolean;
|
|
45
|
+
message: string;
|
|
46
|
+
fallbackModel?: ModelInfo;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CandidateModel {
|
|
50
|
+
model: Model<any>;
|
|
51
|
+
modelInfo: ModelInfo;
|
|
52
|
+
ciScore: number;
|
|
53
|
+
normalizedName: string;
|
|
54
|
+
family: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find a fallback model when the current provider fails.
|
|
59
|
+
*
|
|
60
|
+
* Priority order:
|
|
61
|
+
* 1. Same model name from different provider (best match)
|
|
62
|
+
* 2. Same model family, prefer free models
|
|
63
|
+
* 3. Any free model with similar CI score
|
|
64
|
+
*/
|
|
65
|
+
export async function findFallbackModel(
|
|
66
|
+
failedModel: Model<any>,
|
|
67
|
+
availableModels: Model<any>[],
|
|
68
|
+
config: Partial<AutoSwitchConfig> = {},
|
|
69
|
+
): Promise<CandidateModel | null> {
|
|
70
|
+
const fullConfig = { ...DEFAULT_CONFIG, ...config };
|
|
71
|
+
|
|
72
|
+
// Convert to ModelInfo for internal processing
|
|
73
|
+
const failedModelInfo = toModelInfo(failedModel);
|
|
74
|
+
const failedFamily = detectModelFamily(failedModelInfo);
|
|
75
|
+
const failedNormalizedName = normalizeModelName(failedModelInfo.name || failedModelInfo.id);
|
|
76
|
+
const failedCIScore = getHardcodedScore(failedModel.name || "", failedModel.id) ?? 20;
|
|
77
|
+
|
|
78
|
+
_logger.info("Finding fallback model", {
|
|
79
|
+
failedModel: failedModel.id,
|
|
80
|
+
failedProvider: failedModel.provider,
|
|
81
|
+
failedFamily: failedFamily?.familyId,
|
|
82
|
+
failedNormalizedName: failedNormalizedName,
|
|
83
|
+
failedCIScore,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Build candidate list
|
|
87
|
+
const candidates: CandidateModel[] = [];
|
|
88
|
+
|
|
89
|
+
for (const candidate of availableModels) {
|
|
90
|
+
// Skip the same provider
|
|
91
|
+
if (candidate.provider === failedModel.provider) continue;
|
|
92
|
+
|
|
93
|
+
// Skip if no auth configured for this provider
|
|
94
|
+
// (We'll assume available models have auth, but check anyway)
|
|
95
|
+
if (!candidate.baseUrl) continue;
|
|
96
|
+
|
|
97
|
+
const modelInfo = toModelInfo(candidate);
|
|
98
|
+
const family = detectModelFamily(modelInfo);
|
|
99
|
+
const normalizedName = normalizeModelName(modelInfo.name || modelInfo.id);
|
|
100
|
+
const ciScore = getHardcodedScore(candidate.name || "", candidate.id) ?? 20;
|
|
101
|
+
|
|
102
|
+
candidates.push({
|
|
103
|
+
model: candidate,
|
|
104
|
+
modelInfo,
|
|
105
|
+
ciScore,
|
|
106
|
+
normalizedName,
|
|
107
|
+
family: family?.familyId ?? "other",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (candidates.length === 0) {
|
|
112
|
+
_logger.info("No candidate models found");
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Priority 1: Same model name (different provider)
|
|
117
|
+
// e.g., "minimax-m2.5" on zen → "minimax-m2.5" on openrouter
|
|
118
|
+
const sameName = candidates.find(
|
|
119
|
+
(c) => c.normalizedName === failedNormalizedName && c.model.provider !== failedModel.provider,
|
|
120
|
+
);
|
|
121
|
+
if (sameName) {
|
|
122
|
+
_logger.info("Found exact model match", {
|
|
123
|
+
provider: sameName.model.provider,
|
|
124
|
+
model: sameName.model.id,
|
|
125
|
+
});
|
|
126
|
+
return sameName;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Priority 2: Same model family, prefer free models
|
|
130
|
+
const sameFamily = candidates
|
|
131
|
+
.filter((c) => c.family === failedFamily?.familyId && c.model.provider !== failedModel.provider)
|
|
132
|
+
.sort((a, b) => {
|
|
133
|
+
// Prefer free models
|
|
134
|
+
if (a.modelInfo.isFree !== b.modelInfo.isFree) {
|
|
135
|
+
return a.modelInfo.isFree ? -1 : 1;
|
|
136
|
+
}
|
|
137
|
+
// Then by CI score
|
|
138
|
+
return b.ciScore - a.ciScore;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (sameFamily.length > 0) {
|
|
142
|
+
const best = sameFamily[0]!;
|
|
143
|
+
_logger.info("Found same family model", {
|
|
144
|
+
provider: best.model.provider,
|
|
145
|
+
model: best.model.id,
|
|
146
|
+
family: best.family,
|
|
147
|
+
isFree: best.modelInfo.isFree,
|
|
148
|
+
ciScore: best.ciScore,
|
|
149
|
+
});
|
|
150
|
+
return best;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Priority 3: Any free model with similar CI score
|
|
154
|
+
// Check CI score degradation limit
|
|
155
|
+
const freeCandidates = candidates
|
|
156
|
+
.filter((c) => {
|
|
157
|
+
// Must be free
|
|
158
|
+
if (!c.modelInfo.isFree) return false;
|
|
159
|
+
// Must not drop CI score too much
|
|
160
|
+
const ciDrop = failedCIScore - c.ciScore;
|
|
161
|
+
return ciDrop <= fullConfig.maxCIScoreDrop;
|
|
162
|
+
})
|
|
163
|
+
.sort((a, b) => {
|
|
164
|
+
// Sort by CI score (closest to failed model first)
|
|
165
|
+
return b.ciScore - a.ciScore;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (freeCandidates.length > 0) {
|
|
169
|
+
const best = freeCandidates[0]!;
|
|
170
|
+
_logger.info("Found free fallback model", {
|
|
171
|
+
provider: best.model.provider,
|
|
172
|
+
model: best.model.id,
|
|
173
|
+
ciScore: best.ciScore,
|
|
174
|
+
ciDrop: failedCIScore - best.ciScore,
|
|
175
|
+
});
|
|
176
|
+
return best;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Priority 4: Any free model (no CI limit)
|
|
180
|
+
const anyFree = candidates
|
|
181
|
+
.filter((c) => c.modelInfo.isFree)
|
|
182
|
+
.sort((a, b) => b.ciScore - a.ciScore);
|
|
183
|
+
|
|
184
|
+
if (anyFree.length > 0) {
|
|
185
|
+
const best = anyFree[0]!;
|
|
186
|
+
_logger.info("Found any free fallback", {
|
|
187
|
+
provider: best.model.provider,
|
|
188
|
+
model: best.model.id,
|
|
189
|
+
ciScore: best.ciScore,
|
|
190
|
+
});
|
|
191
|
+
return best;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Priority 5: Any model with similar CI score
|
|
195
|
+
const similarCI = candidates
|
|
196
|
+
.filter((c) => {
|
|
197
|
+
const ciDrop = failedCIScore - c.ciScore;
|
|
198
|
+
return ciDrop <= fullConfig.maxCIScoreDrop;
|
|
199
|
+
})
|
|
200
|
+
.sort((a, b) => {
|
|
201
|
+
// Prefer providers in priority order
|
|
202
|
+
const aPriority = fullConfig.providerPriority.indexOf(a.model.provider);
|
|
203
|
+
const bPriority = fullConfig.providerPriority.indexOf(b.model.provider);
|
|
204
|
+
if (aPriority !== bPriority && aPriority >= 0 && bPriority >= 0) {
|
|
205
|
+
return aPriority - bPriority;
|
|
206
|
+
}
|
|
207
|
+
// Then by CI score
|
|
208
|
+
return b.ciScore - a.ciScore;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (similarCI.length > 0) {
|
|
212
|
+
const best = similarCI[0]!;
|
|
213
|
+
_logger.info("Found similar CI fallback", {
|
|
214
|
+
provider: best.model.provider,
|
|
215
|
+
model: best.model.id,
|
|
216
|
+
ciScore: best.ciScore,
|
|
217
|
+
});
|
|
218
|
+
return best;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Last resort: any model, prefer by provider priority
|
|
222
|
+
const anyModel = candidates.sort((a, b) => {
|
|
223
|
+
const aPriority = fullConfig.providerPriority.indexOf(a.model.provider);
|
|
224
|
+
const bPriority = fullConfig.providerPriority.indexOf(b.model.provider);
|
|
225
|
+
if (aPriority !== bPriority && aPriority >= 0 && bPriority >= 0) {
|
|
226
|
+
return aPriority - bPriority;
|
|
227
|
+
}
|
|
228
|
+
return b.ciScore - a.ciScore;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (anyModel.length > 0) {
|
|
232
|
+
const best = anyModel[0]!;
|
|
233
|
+
_logger.info("Found any fallback", {
|
|
234
|
+
provider: best.model.provider,
|
|
235
|
+
model: best.model.id,
|
|
236
|
+
ciScore: best.ciScore,
|
|
237
|
+
});
|
|
238
|
+
return best;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Perform automatic failover when a provider hits an error.
|
|
246
|
+
* Returns the result of the switch attempt.
|
|
247
|
+
*/
|
|
248
|
+
export async function autoFailover(
|
|
249
|
+
_errorMessage: string,
|
|
250
|
+
failedModel: Model<any>,
|
|
251
|
+
pi: ExtensionAPI,
|
|
252
|
+
ctx: ExtensionContext,
|
|
253
|
+
config: Partial<AutoSwitchConfig> = {},
|
|
254
|
+
): Promise<AutoSwitchResult> {
|
|
255
|
+
const fullConfig = { ...DEFAULT_CONFIG, ...config };
|
|
256
|
+
if (!fullConfig.enabled) {
|
|
257
|
+
return {
|
|
258
|
+
success: false,
|
|
259
|
+
switched: false,
|
|
260
|
+
message: "Auto-switch disabled",
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Get all available models
|
|
265
|
+
const availableModels = ctx.modelRegistry.getAvailable();
|
|
266
|
+
|
|
267
|
+
if (availableModels.length === 0) {
|
|
268
|
+
return {
|
|
269
|
+
success: false,
|
|
270
|
+
switched: false,
|
|
271
|
+
message: "No alternative models available",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Find fallback model
|
|
276
|
+
const fallback = await findFallbackModel(
|
|
277
|
+
failedModel,
|
|
278
|
+
availableModels,
|
|
279
|
+
fullConfig,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
if (!fallback) {
|
|
283
|
+
return {
|
|
284
|
+
success: false,
|
|
285
|
+
switched: false,
|
|
286
|
+
message: `No fallback model found for ${failedModel.provider}/${failedModel.id}`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Attempt to switch
|
|
291
|
+
const success = await pi.setModel(fallback.model);
|
|
292
|
+
|
|
293
|
+
if (success) {
|
|
294
|
+
const freeStatus = fallback.modelInfo.isFree ? " (free)" : "";
|
|
295
|
+
return {
|
|
296
|
+
success: true,
|
|
297
|
+
switched: true,
|
|
298
|
+
message: `Switched from ${failedModel.provider} to ${fallback.model.provider}/${fallback.model.id}${freeStatus}`,
|
|
299
|
+
fallbackModel: fallback.modelInfo,
|
|
300
|
+
};
|
|
301
|
+
} else {
|
|
302
|
+
return {
|
|
303
|
+
success: false,
|
|
304
|
+
switched: false,
|
|
305
|
+
message: `Failed to switch to ${fallback.model.provider}/${fallback.model.id} (no API key?)`,
|
|
306
|
+
fallbackModel: fallback.modelInfo,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Check if a model is available from multiple providers
|
|
313
|
+
*/
|
|
314
|
+
export function getModelAvailability(
|
|
315
|
+
modelId: string,
|
|
316
|
+
availableModels: Model<any>[],
|
|
317
|
+
): string[] {
|
|
318
|
+
const normalizedName = normalizeModelName(modelId);
|
|
319
|
+
|
|
320
|
+
return availableModels
|
|
321
|
+
.filter((m) => {
|
|
322
|
+
const mNormalized = normalizeModelName(m.name || m.id);
|
|
323
|
+
return mNormalized === normalizedName;
|
|
324
|
+
})
|
|
325
|
+
.map((m) => m.provider);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get a summary of available models grouped by family
|
|
330
|
+
*/
|
|
331
|
+
export function getModelAvailabilitySummary(
|
|
332
|
+
availableModels: Model<any>[],
|
|
333
|
+
): Map<string, string[]> {
|
|
334
|
+
const families = new Map<string, string[]>();
|
|
335
|
+
|
|
336
|
+
for (const model of availableModels) {
|
|
337
|
+
const modelInfo = toModelInfo(model);
|
|
338
|
+
const family = detectModelFamily(modelInfo);
|
|
339
|
+
|
|
340
|
+
if (!family) continue;
|
|
341
|
+
|
|
342
|
+
const existing = families.get(family.familyId) ?? [];
|
|
343
|
+
if (!existing.includes(model.provider)) {
|
|
344
|
+
existing.push(model.provider);
|
|
345
|
+
}
|
|
346
|
+
families.set(family.familyId, existing);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return families;
|
|
350
|
+
}
|