pi-free 2.0.1 → 2.0.4
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 +179 -3
- package/README.md +495 -393
- package/config.ts +46 -54
- package/constants.ts +6 -0
- package/index.ts +39 -12
- package/lib/built-in-toggle.ts +63 -43
- package/lib/model-enhancer.ts +20 -20
- package/lib/open-browser.ts +1 -1
- package/lib/provider-compat.ts +46 -0
- package/lib/registry.ts +193 -144
- package/lib/toggle-state.ts +86 -0
- package/lib/types.ts +101 -108
- package/package.json +8 -8
- package/provider-failover/benchmark-lookup.ts +637 -247
- package/provider-helper.ts +279 -260
- package/providers/cline/cline-auth.ts +473 -473
- package/providers/cline/cline-models.ts +129 -128
- package/providers/cline/cline.ts +311 -298
- package/providers/crofai/crofai.ts +170 -0
- package/providers/dynamic-built-in/index.ts +259 -308
- package/providers/kilo/kilo-auth.ts +155 -155
- package/providers/kilo/kilo-models.ts +2 -1
- package/providers/kilo/kilo.ts +263 -235
- package/providers/nvidia/nvidia.ts +476 -152
- package/providers/ollama/ollama.ts +130 -7
- package/providers/opencode-session.ts +3 -4
- package/providers/qwen/qwen-models.ts +101 -101
- package/providers/zenmux/zenmux.ts +176 -0
- package/scripts/check-extensions.mjs +64 -55
- package/provider-factory.ts +0 -207
- package/providers/cloudflare/cloudflare.ts +0 -368
- package/providers/modal/modal.ts +0 -44
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
applyHidden,
|
|
29
29
|
getOllamaApiKey,
|
|
30
30
|
getOllamaShowPaid,
|
|
31
|
+
loadConfigFile,
|
|
32
|
+
saveConfig,
|
|
31
33
|
} from "../../config.ts";
|
|
32
34
|
import {
|
|
33
35
|
BASE_URL_OLLAMA,
|
|
@@ -36,11 +38,21 @@ import {
|
|
|
36
38
|
} from "../../constants.ts";
|
|
37
39
|
import { createLogger } from "../../lib/logger.ts";
|
|
38
40
|
import { registerWithGlobalToggle } from "../../lib/registry.ts";
|
|
39
|
-
import { fetchWithRetry } from "../../lib/util.ts";
|
|
41
|
+
import { fetchWithRetry, fetchWithTimeout } from "../../lib/util.ts";
|
|
40
42
|
import { createReRegister, enhanceWithCI } from "../../provider-helper.ts";
|
|
41
43
|
|
|
42
44
|
const _logger = createLogger("ollama-cloud");
|
|
43
45
|
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Known 403 models (listed but return "access denied" on /v1/chat/completions)
|
|
48
|
+
// These are models that appear in /v1/models but aren't provisioned for chat.
|
|
49
|
+
// Add new IDs here as they surface via /probe-ollama command.
|
|
50
|
+
// =============================================================================
|
|
51
|
+
const OLLAMA_KNOWN_403_MODELS: ReadonlySet<string> = new Set([
|
|
52
|
+
// Example entries - populate via probe-ollama.mjs
|
|
53
|
+
// "model-id-that-403s",
|
|
54
|
+
]);
|
|
55
|
+
|
|
44
56
|
// =============================================================================
|
|
45
57
|
// Fetch + map
|
|
46
58
|
// =============================================================================
|
|
@@ -79,12 +91,20 @@ async function fetchOllamaModels(
|
|
|
79
91
|
);
|
|
80
92
|
|
|
81
93
|
// Filter to chat/text generation models only
|
|
82
|
-
const chatModels = models
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
const chatModels = models
|
|
95
|
+
.filter((m) => {
|
|
96
|
+
// Skip embedding-only models (typically have "embed" in name)
|
|
97
|
+
const name = m.id.toLowerCase();
|
|
98
|
+
if (name.includes("embed")) return false;
|
|
99
|
+
return true;
|
|
100
|
+
})
|
|
101
|
+
// Filter out known 403 models (listed but not provisioned for chat)
|
|
102
|
+
.filter((m) => {
|
|
103
|
+
if (OLLAMA_KNOWN_403_MODELS.has(m.id)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
});
|
|
88
108
|
|
|
89
109
|
const result = applyHidden(
|
|
90
110
|
chatModels.map(
|
|
@@ -110,6 +130,7 @@ async function fetchOllamaModels(
|
|
|
110
130
|
maxTokens: 4096, // Default, varies by model
|
|
111
131
|
}),
|
|
112
132
|
),
|
|
133
|
+
PROVIDER_OLLAMA,
|
|
113
134
|
);
|
|
114
135
|
|
|
115
136
|
return result;
|
|
@@ -169,4 +190,106 @@ export default async function (pi: ExtensionAPI) {
|
|
|
169
190
|
_logger.info(
|
|
170
191
|
`[ollama-cloud] Registered ${initialModels.length} models (usage-based free tier)`,
|
|
171
192
|
);
|
|
193
|
+
|
|
194
|
+
// ── Probe command: test all registered models for 403s ─────────────
|
|
195
|
+
pi.registerCommand("probe-ollama", {
|
|
196
|
+
description: "Test all Ollama Cloud models for 403 'access denied' errors",
|
|
197
|
+
handler: async (_args, ctx) => {
|
|
198
|
+
if (!apiKey) {
|
|
199
|
+
ctx.ui.notify("OLLAMA_API_KEY not set", "error");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const modelsToTest = allModels;
|
|
204
|
+
ctx.ui.notify(`Probing ${modelsToTest.length} Ollama models…`, "info");
|
|
205
|
+
|
|
206
|
+
const notFound: string[] = [];
|
|
207
|
+
const batchSize = 5;
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < modelsToTest.length; i += batchSize) {
|
|
210
|
+
const batch = modelsToTest.slice(i, i + batchSize);
|
|
211
|
+
const results = await Promise.all(
|
|
212
|
+
batch.map(async (m) => {
|
|
213
|
+
const ok = await probeOllamaModel(apiKey, m.id);
|
|
214
|
+
return { id: m.id, ok };
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
for (const r of results) {
|
|
218
|
+
if (!r.ok) notFound.push(r.id);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (notFound.length === 0) {
|
|
223
|
+
ctx.ui.notify("All Ollama models are accessible ✅", "info");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Auto-hide 403 models in config (provider-scoped)
|
|
228
|
+
const config = loadConfigFile();
|
|
229
|
+
const existingHidden = new Set(config.hidden_models ?? []);
|
|
230
|
+
for (const id of notFound) existingHidden.add(`${PROVIDER_OLLAMA}/${id}`);
|
|
231
|
+
saveConfig({ hidden_models: Array.from(existingHidden) });
|
|
232
|
+
|
|
233
|
+
// Re-register so hidden models disappear immediately
|
|
234
|
+
const filtered = await fetchOllamaModels(apiKey);
|
|
235
|
+
stored.free = filtered;
|
|
236
|
+
stored.all = filtered;
|
|
237
|
+
reRegister(filtered);
|
|
238
|
+
|
|
239
|
+
ctx.ui.notify(
|
|
240
|
+
`Found ${notFound.length} broken models (auto-hidden):\n${notFound.join("\n")}`,
|
|
241
|
+
"warning",
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ── Status bar for provider selection ─────────────────────────
|
|
247
|
+
|
|
248
|
+
pi.on("model_select", (_event, ctx) => {
|
|
249
|
+
if (_event.model?.provider !== PROVIDER_OLLAMA) {
|
|
250
|
+
ctx.ui.setStatus(`${PROVIDER_OLLAMA}-status`, undefined);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const count = allModels.length;
|
|
255
|
+
ctx.ui.setStatus(
|
|
256
|
+
`${PROVIDER_OLLAMA}-status`,
|
|
257
|
+
`ollama: ${count} models (usage-based)`,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Probe a single Ollama model with a minimal chat request.
|
|
264
|
+
* Returns true if the model is accessible (not 403), false if it 403s.
|
|
265
|
+
*/
|
|
266
|
+
async function probeOllamaModel(
|
|
267
|
+
apiKey: string,
|
|
268
|
+
modelId: string,
|
|
269
|
+
): Promise<boolean> {
|
|
270
|
+
try {
|
|
271
|
+
const response = await fetchWithTimeout(
|
|
272
|
+
`${BASE_URL_OLLAMA}/chat/completions`,
|
|
273
|
+
{
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: {
|
|
276
|
+
Authorization: `Bearer ${apiKey}`,
|
|
277
|
+
"Content-Type": "application/json",
|
|
278
|
+
"User-Agent": "pi-free-providers",
|
|
279
|
+
},
|
|
280
|
+
body: JSON.stringify({
|
|
281
|
+
model: modelId,
|
|
282
|
+
messages: [{ role: "user", content: "hi" }],
|
|
283
|
+
max_tokens: 1,
|
|
284
|
+
}),
|
|
285
|
+
},
|
|
286
|
+
10000, // 10 second timeout
|
|
287
|
+
);
|
|
288
|
+
// 403 = access denied (model not provisioned)
|
|
289
|
+
// 200/400/401/etc = at least accessible
|
|
290
|
+
return response.status !== 403;
|
|
291
|
+
} catch {
|
|
292
|
+
// Network errors / timeouts are not "access denied"
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
172
295
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Shared OpenCode session/request tracking.
|
|
3
5
|
*
|
|
@@ -9,10 +11,7 @@ export function createOpenCodeSessionTracker() {
|
|
|
9
11
|
let requestCount = 0;
|
|
10
12
|
|
|
11
13
|
function generateId(): string {
|
|
12
|
-
return (
|
|
13
|
-
Math.random().toString(36).substring(2, 15) +
|
|
14
|
-
Math.random().toString(36).substring(2, 15)
|
|
15
|
-
);
|
|
14
|
+
return randomUUID().replace(/-/g, "");
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
function getSessionId(): string {
|
|
@@ -1,101 +1,101 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Qwen OAuth model definitions.
|
|
3
|
-
*
|
|
4
|
-
* @deprecated The 1,000 req/day free tier is no longer available. Auth is broken.
|
|
5
|
-
* This provider remains for backward compatibility but should not be used.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import { createLogger } from "../../lib/logger.ts";
|
|
10
|
-
|
|
11
|
-
const _logger = createLogger("qwen-models");
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* portal.qwen.ai compatibility settings.
|
|
15
|
-
*
|
|
16
|
-
* portal.qwen.ai's OpenAI-compatible API does not support several parameters
|
|
17
|
-
* that the pi framework sends by default.
|
|
18
|
-
*/
|
|
19
|
-
export const PORTAL_COMPAT: NonNullable<ProviderModelConfig["compat"]> = {
|
|
20
|
-
supportsStore: false,
|
|
21
|
-
supportsDeveloperRole: false,
|
|
22
|
-
supportsReasoningEffort: false,
|
|
23
|
-
supportsUsageInStreaming: false,
|
|
24
|
-
supportsStrictMode: false,
|
|
25
|
-
maxTokensField: "max_tokens",
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Fallback model used before OAuth completes or if model discovery fails.
|
|
30
|
-
* The real model ID is resolved dynamically via fetchQwenLiveModels() after auth.
|
|
31
|
-
*/
|
|
32
|
-
export const QWEN_FREE_MODELS: ProviderModelConfig[] = [
|
|
33
|
-
{
|
|
34
|
-
id: "coder-model",
|
|
35
|
-
name: "Qwen Coder — DEPRECATED (free tier discontinued)",
|
|
36
|
-
reasoning: false,
|
|
37
|
-
input: ["text"],
|
|
38
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
39
|
-
contextWindow: 131_072,
|
|
40
|
-
maxTokens: 16_384,
|
|
41
|
-
compat: PORTAL_COMPAT,
|
|
42
|
-
},
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Fetch Qwen models. Returns static model list for backward compatibility.
|
|
47
|
-
* @deprecated Qwen free tier is discontinued.
|
|
48
|
-
*/
|
|
49
|
-
export async function fetchQwenModels(): Promise<ProviderModelConfig[]> {
|
|
50
|
-
_logger.info("Qwen provider is deprecated, returning placeholder models");
|
|
51
|
-
return QWEN_FREE_MODELS;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Fetch live model list from the Qwen API using the OAuth access token.
|
|
56
|
-
* Returns updated models with real IDs from the server, or the original
|
|
57
|
-
* models unchanged if the request fails.
|
|
58
|
-
*/
|
|
59
|
-
export async function fetchQwenLiveModels(
|
|
60
|
-
baseUrl: string,
|
|
61
|
-
accessToken: string,
|
|
62
|
-
templateModels: ProviderModelConfig[],
|
|
63
|
-
): Promise<ProviderModelConfig[]> {
|
|
64
|
-
try {
|
|
65
|
-
const response = await fetch(`${baseUrl}/models`, {
|
|
66
|
-
headers: {
|
|
67
|
-
Authorization: `Bearer ${accessToken}`,
|
|
68
|
-
Accept: "application/json",
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
if (!response.ok) {
|
|
73
|
-
_logger.info("Qwen /v1/models fetch failed, keeping current model IDs", {
|
|
74
|
-
status: response.status,
|
|
75
|
-
});
|
|
76
|
-
return templateModels;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
interface ModelEntry {
|
|
80
|
-
id: string;
|
|
81
|
-
}
|
|
82
|
-
const data = (await response.json()) as { data?: ModelEntry[] };
|
|
83
|
-
const ids: string[] = (data.data ?? [])
|
|
84
|
-
.map((m: ModelEntry) => m.id)
|
|
85
|
-
.filter(Boolean);
|
|
86
|
-
|
|
87
|
-
_logger.info("Qwen live models discovered", { ids });
|
|
88
|
-
|
|
89
|
-
if (ids.length === 0) return templateModels;
|
|
90
|
-
|
|
91
|
-
// Prefer a coder model if available, otherwise use the first model
|
|
92
|
-
const preferred = ids.find((id) => /coder/i.test(id)) ?? ids[0];
|
|
93
|
-
|
|
94
|
-
return templateModels.map((m) => ({ ...m, id: preferred }));
|
|
95
|
-
} catch (err) {
|
|
96
|
-
_logger.info("Qwen live model fetch error, keeping current model IDs", {
|
|
97
|
-
error: String(err),
|
|
98
|
-
});
|
|
99
|
-
return templateModels;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Qwen OAuth model definitions.
|
|
3
|
+
*
|
|
4
|
+
* @deprecated The 1,000 req/day free tier is no longer available. Auth is broken.
|
|
5
|
+
* This provider remains for backward compatibility but should not be used.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { createLogger } from "../../lib/logger.ts";
|
|
10
|
+
|
|
11
|
+
const _logger = createLogger("qwen-models");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* portal.qwen.ai compatibility settings.
|
|
15
|
+
*
|
|
16
|
+
* portal.qwen.ai's OpenAI-compatible API does not support several parameters
|
|
17
|
+
* that the pi framework sends by default.
|
|
18
|
+
*/
|
|
19
|
+
export const PORTAL_COMPAT: NonNullable<ProviderModelConfig["compat"]> = {
|
|
20
|
+
supportsStore: false,
|
|
21
|
+
supportsDeveloperRole: false,
|
|
22
|
+
supportsReasoningEffort: false,
|
|
23
|
+
supportsUsageInStreaming: false,
|
|
24
|
+
supportsStrictMode: false,
|
|
25
|
+
maxTokensField: "max_tokens",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fallback model used before OAuth completes or if model discovery fails.
|
|
30
|
+
* The real model ID is resolved dynamically via fetchQwenLiveModels() after auth.
|
|
31
|
+
*/
|
|
32
|
+
export const QWEN_FREE_MODELS: ProviderModelConfig[] = [
|
|
33
|
+
{
|
|
34
|
+
id: "coder-model",
|
|
35
|
+
name: "Qwen Coder — DEPRECATED (free tier discontinued)",
|
|
36
|
+
reasoning: false,
|
|
37
|
+
input: ["text"],
|
|
38
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
39
|
+
contextWindow: 131_072,
|
|
40
|
+
maxTokens: 16_384,
|
|
41
|
+
compat: PORTAL_COMPAT,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetch Qwen models. Returns static model list for backward compatibility.
|
|
47
|
+
* @deprecated Qwen free tier is discontinued.
|
|
48
|
+
*/
|
|
49
|
+
export async function fetchQwenModels(): Promise<ProviderModelConfig[]> {
|
|
50
|
+
_logger.info("Qwen provider is deprecated, returning placeholder models");
|
|
51
|
+
return QWEN_FREE_MODELS;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fetch live model list from the Qwen API using the OAuth access token.
|
|
56
|
+
* Returns updated models with real IDs from the server, or the original
|
|
57
|
+
* models unchanged if the request fails.
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchQwenLiveModels(
|
|
60
|
+
baseUrl: string,
|
|
61
|
+
accessToken: string,
|
|
62
|
+
templateModels: ProviderModelConfig[],
|
|
63
|
+
): Promise<ProviderModelConfig[]> {
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(`${baseUrl}/models`, {
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${accessToken}`,
|
|
68
|
+
Accept: "application/json",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
_logger.info("Qwen /v1/models fetch failed, keeping current model IDs", {
|
|
74
|
+
status: response.status,
|
|
75
|
+
});
|
|
76
|
+
return templateModels;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ModelEntry {
|
|
80
|
+
id: string;
|
|
81
|
+
}
|
|
82
|
+
const data = (await response.json()) as { data?: ModelEntry[] };
|
|
83
|
+
const ids: string[] = (data.data ?? [])
|
|
84
|
+
.map((m: ModelEntry) => m.id)
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
|
|
87
|
+
_logger.info("Qwen live models discovered", { ids });
|
|
88
|
+
|
|
89
|
+
if (ids.length === 0) return templateModels;
|
|
90
|
+
|
|
91
|
+
// Prefer a coder model if available, otherwise use the first model
|
|
92
|
+
const preferred = ids.find((id) => /coder/i.test(id)) ?? ids[0];
|
|
93
|
+
|
|
94
|
+
return templateModels.map((m) => ({ ...m, id: preferred }));
|
|
95
|
+
} catch (err) {
|
|
96
|
+
_logger.info("Qwen live model fetch error, keeping current model IDs", {
|
|
97
|
+
error: String(err),
|
|
98
|
+
});
|
|
99
|
+
return templateModels;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZenMux Provider Extension
|
|
3
|
+
*
|
|
4
|
+
* Provides access to ZenMux AI gateway - unified API for 200+ models from
|
|
5
|
+
* OpenAI, Anthropic, Google, and other providers.
|
|
6
|
+
*
|
|
7
|
+
* Setup:
|
|
8
|
+
* 1. Get API key from https://zenmux.ai
|
|
9
|
+
* 2. Set ZENMUX_API_KEY env var or add to ~/.pi/free.json
|
|
10
|
+
*
|
|
11
|
+
* Responds to global free-only filter.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* pi install git:github.com/apmantza/pi-free
|
|
15
|
+
* # Set ZENMUX_API_KEY env var
|
|
16
|
+
* # Models appear in /model selector
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
ExtensionAPI,
|
|
21
|
+
ProviderModelConfig,
|
|
22
|
+
} from "@mariozechner/pi-coding-agent";
|
|
23
|
+
import { getZenmuxApiKey, getZenmuxShowPaid } from "../../config.ts";
|
|
24
|
+
import {
|
|
25
|
+
BASE_URL_ZENMUX,
|
|
26
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
27
|
+
PROVIDER_ZENMUX,
|
|
28
|
+
} from "../../constants.ts";
|
|
29
|
+
import { createLogger } from "../../lib/logger.ts";
|
|
30
|
+
import {
|
|
31
|
+
getProxyModelCompat,
|
|
32
|
+
isLikelyReasoningModel,
|
|
33
|
+
} from "../../lib/provider-compat.ts";
|
|
34
|
+
import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
|
|
35
|
+
import { fetchWithRetry } from "../../lib/util.ts";
|
|
36
|
+
import { createReRegister, setupProvider } from "../../provider-helper.ts";
|
|
37
|
+
|
|
38
|
+
const _logger = createLogger("zenmux");
|
|
39
|
+
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// Fetch ZenMux models
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
interface ZenMuxModel {
|
|
45
|
+
id: string;
|
|
46
|
+
name?: string;
|
|
47
|
+
context_length?: number;
|
|
48
|
+
pricing?: {
|
|
49
|
+
prompt?: number;
|
|
50
|
+
completion?: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isZenmuxReasoningModel(model: Pick<ZenMuxModel, "id" | "name">) {
|
|
55
|
+
const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
|
|
56
|
+
return isLikelyReasoningModel(model) || haystack.includes("claude");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function fetchZenmuxModels(
|
|
60
|
+
apiKey: string,
|
|
61
|
+
): Promise<ProviderModelConfig[]> {
|
|
62
|
+
_logger.info("[zenmux] Fetching models from ZenMux API...");
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetchWithRetry(
|
|
66
|
+
`${BASE_URL_ZENMUX}/models`,
|
|
67
|
+
{
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${apiKey}`,
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
3,
|
|
74
|
+
1000,
|
|
75
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`ZenMux API error: ${response.status}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const data = (await response.json()) as { data?: ZenMuxModel[] };
|
|
83
|
+
const models = data.data ?? [];
|
|
84
|
+
|
|
85
|
+
_logger.info(`[zenmux] Fetched ${models.length} models`);
|
|
86
|
+
|
|
87
|
+
return models.map(
|
|
88
|
+
(m): ProviderModelConfig => ({
|
|
89
|
+
id: m.id,
|
|
90
|
+
name: m.name || m.id,
|
|
91
|
+
reasoning: isZenmuxReasoningModel(m),
|
|
92
|
+
input: ["text"],
|
|
93
|
+
cost: {
|
|
94
|
+
input: m.pricing?.prompt || 0,
|
|
95
|
+
output: m.pricing?.completion || 0,
|
|
96
|
+
cacheRead: 0,
|
|
97
|
+
cacheWrite: 0,
|
|
98
|
+
},
|
|
99
|
+
contextWindow: m.context_length || 128000,
|
|
100
|
+
maxTokens: m.context_length ? Math.floor(m.context_length / 2) : 4096,
|
|
101
|
+
compat: getProxyModelCompat(m),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
_logger.error("[zenmux] Failed to fetch models:", {
|
|
106
|
+
error: error instanceof Error ? error.message : String(error),
|
|
107
|
+
});
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// Extension Entry Point
|
|
114
|
+
// =============================================================================
|
|
115
|
+
|
|
116
|
+
export default async function zenmuxProvider(pi: ExtensionAPI) {
|
|
117
|
+
const apiKey = getZenmuxApiKey();
|
|
118
|
+
|
|
119
|
+
if (!apiKey) {
|
|
120
|
+
_logger.info(
|
|
121
|
+
"[zenmux] Skipping - ZENMUX_API_KEY not set (env var or ~/.pi/free.json)",
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fetch models
|
|
127
|
+
const allModels = await fetchZenmuxModels(apiKey);
|
|
128
|
+
|
|
129
|
+
if (allModels.length === 0) {
|
|
130
|
+
_logger.warn("[zenmux] No models available");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Use isFreeModel with allModels for proper detection
|
|
135
|
+
// ZenMux exposes pricing, so Route A (OR logic) will be used:
|
|
136
|
+
// FREE if cost=0 OR "free" in name
|
|
137
|
+
const freeModels = allModels.filter((m) =>
|
|
138
|
+
isFreeModel({ ...m, provider: PROVIDER_ZENMUX }, allModels),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const stored = { free: freeModels, all: allModels };
|
|
142
|
+
|
|
143
|
+
_logger.info(
|
|
144
|
+
`[zenmux] Registered ${allModels.length} models (${freeModels.length} free)`,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Create re-register function
|
|
148
|
+
const reRegister = createReRegister(pi, {
|
|
149
|
+
providerId: PROVIDER_ZENMUX,
|
|
150
|
+
baseUrl: BASE_URL_ZENMUX,
|
|
151
|
+
apiKey,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Register with global toggle
|
|
155
|
+
registerWithGlobalToggle(PROVIDER_ZENMUX, stored, reRegister, true);
|
|
156
|
+
|
|
157
|
+
// Setup provider with toggle command
|
|
158
|
+
setupProvider(
|
|
159
|
+
pi,
|
|
160
|
+
{
|
|
161
|
+
providerId: PROVIDER_ZENMUX,
|
|
162
|
+
initialShowPaid: getZenmuxShowPaid(),
|
|
163
|
+
reRegister: (models, _stored) => {
|
|
164
|
+
if (_stored) {
|
|
165
|
+
stored.free = _stored.free;
|
|
166
|
+
stored.all = _stored.all;
|
|
167
|
+
}
|
|
168
|
+
reRegister(models);
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
stored,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Initial registration
|
|
175
|
+
reRegister(freeModels);
|
|
176
|
+
}
|