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.
@@ -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.6",
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 Ollama",
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
  }
@@ -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
+ }