pi-free 2.1.0 → 2.2.0
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 +48 -3
- package/README.md +32 -4
- package/banner.svg +1 -1
- package/config.ts +644 -629
- package/constants.ts +4 -0
- package/index.ts +380 -378
- package/lib/built-in-toggle.ts +0 -40
- package/lib/probe-cache.ts +8 -0
- package/lib/provider-probe.ts +15 -0
- package/package.json +5 -5
- package/provider-helper.ts +1 -25
- package/providers/bai/bai.ts +232 -0
- package/providers/cline/cline-xml-bridge.ts +631 -105
- package/providers/cline/cline.ts +0 -23
- package/providers/codestral/codestral.ts +0 -11
- package/providers/dynamic-built-in/index.ts +12 -20
- package/providers/kilo/kilo.ts +2 -19
- package/providers/ollama/ollama.ts +12 -12
- package/providers/routeway/routeway.ts +10 -0
- package/providers/tokenrouter/tokenrouter.ts +634 -378
package/lib/built-in-toggle.ts
CHANGED
|
@@ -95,7 +95,6 @@ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
|
|
|
95
95
|
for (const config of activeConfigs) {
|
|
96
96
|
registerToggleCommand(pi, config);
|
|
97
97
|
}
|
|
98
|
-
setupStatusBar(pi, activeConfigs);
|
|
99
98
|
commandsRegistered = true;
|
|
100
99
|
}
|
|
101
100
|
|
|
@@ -250,45 +249,6 @@ function modelToProviderConfig(
|
|
|
250
249
|
return base;
|
|
251
250
|
}
|
|
252
251
|
|
|
253
|
-
// =============================================================================
|
|
254
|
-
// Status bar for provider selection
|
|
255
|
-
// =============================================================================
|
|
256
|
-
|
|
257
|
-
function setupStatusBar(
|
|
258
|
-
pi: ExtensionAPI,
|
|
259
|
-
configs: BuiltInToggleConfig[],
|
|
260
|
-
): void {
|
|
261
|
-
pi.on("model_select", (_event, ctx) => {
|
|
262
|
-
const selected = _event.model?.provider;
|
|
263
|
-
|
|
264
|
-
// Clear status for all fallback-captured built-in providers
|
|
265
|
-
for (const config of configs) {
|
|
266
|
-
if (selected !== config.id) {
|
|
267
|
-
ctx.ui.setStatus(`${config.id}-status`, undefined);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (!selected) return;
|
|
272
|
-
|
|
273
|
-
const state = providerStates.get(selected);
|
|
274
|
-
if (!state) return;
|
|
275
|
-
|
|
276
|
-
const free = state.stored.free.length;
|
|
277
|
-
const total = state.stored.all.length;
|
|
278
|
-
const paid = total - free;
|
|
279
|
-
const mode = state.toggleState.getCurrentMode();
|
|
280
|
-
let status: string;
|
|
281
|
-
if (paid === 0) {
|
|
282
|
-
status = `${selected}: ${free} free models`;
|
|
283
|
-
} else if (mode === "all") {
|
|
284
|
-
status = `${selected}: ${total} models (free + paid)`;
|
|
285
|
-
} else {
|
|
286
|
-
status = `${selected}: ${free} free \u00b7 ${paid} paid`;
|
|
287
|
-
}
|
|
288
|
-
ctx.ui.setStatus(`${selected}-status`, status);
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
252
|
function getApiKeyEnvForProvider(providerId: string): string {
|
|
293
253
|
const envMap: Record<string, string> = {
|
|
294
254
|
opencode: "$OPENCODE_API_KEY",
|
package/lib/probe-cache.ts
CHANGED
|
@@ -60,6 +60,14 @@ export function getModelsDueForProbe(
|
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export function areAllModelsFresh(
|
|
64
|
+
providerId: string,
|
|
65
|
+
modelIds: string[],
|
|
66
|
+
ttlMs = DEFAULT_PROBE_TTL_MS,
|
|
67
|
+
): boolean {
|
|
68
|
+
return getModelsDueForProbe(providerId, modelIds, ttlMs).length === 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
export async function recordModelProbeResults(
|
|
64
72
|
providerId: string,
|
|
65
73
|
results: ModelProbeResult[],
|
package/lib/provider-probe.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
|
24
24
|
import { updateConfig } from "../config.ts";
|
|
25
25
|
import { createLogger } from "./logger.ts";
|
|
26
26
|
import {
|
|
27
|
+
areAllModelsFresh,
|
|
27
28
|
getModelsDueForProbe,
|
|
28
29
|
recordModelProbeResults,
|
|
29
30
|
type ModelProbeResult,
|
|
@@ -175,6 +176,20 @@ export function createProviderProbe(
|
|
|
175
176
|
return () => {
|
|
176
177
|
if (done) return;
|
|
177
178
|
done = true;
|
|
179
|
+
|
|
180
|
+
// Skip scheduling entirely if every model was probed recently.
|
|
181
|
+
// Without this check the handler fires on every session_start and
|
|
182
|
+
// only then discovers the cache is fresh inside run().
|
|
183
|
+
if (
|
|
184
|
+
areAllModelsFresh(
|
|
185
|
+
providerId,
|
|
186
|
+
models.map((m) => m.id),
|
|
187
|
+
)
|
|
188
|
+
) {
|
|
189
|
+
_logger.info(`[probe] ${providerId}: auto-probe cache is fresh`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
178
193
|
_logger.info(`[probe] Starting lazy auto-probe for ${providerId}...`);
|
|
179
194
|
run(apiKey, models, { useCache: true }).catch((err) => {
|
|
180
195
|
_logger.warn(`[probe] ${providerId}: auto-probe failed`, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-free",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI model providers for Pi with free model filtering and dynamic model fetching",
|
|
6
6
|
"keywords": [
|
|
@@ -55,14 +55,14 @@
|
|
|
55
55
|
"smoke:cline": "tsx scripts/smoke-cline-xml-bridge.ts"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@earendil-works/pi-ai": "
|
|
59
|
-
"@earendil-works/pi-coding-agent": "
|
|
60
|
-
"@earendil-works/pi-tui": "
|
|
58
|
+
"@earendil-works/pi-ai": "^0.79.8",
|
|
59
|
+
"@earendil-works/pi-coding-agent": "^0.79.8",
|
|
60
|
+
"@earendil-works/pi-tui": "^0.79.8"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@vitest/ui": "^4.1.5",
|
|
64
64
|
"tsx": "^4.0.0",
|
|
65
|
-
"typescript": "^6.0.
|
|
65
|
+
"typescript": "^6.0.3",
|
|
66
66
|
"vitest": "^4.1.5"
|
|
67
67
|
},
|
|
68
68
|
"pi": {
|
package/provider-helper.ts
CHANGED
|
@@ -222,31 +222,7 @@ export function setupProvider(
|
|
|
222
222
|
});
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
// ──
|
|
226
|
-
|
|
227
|
-
pi.on("model_select", (_event, ctx) => {
|
|
228
|
-
if (_event.model?.provider !== providerId) {
|
|
229
|
-
ctx.ui.setStatus(`${providerId}-status`, undefined);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Build status line for this provider
|
|
234
|
-
const free = stored.free.length;
|
|
235
|
-
const total = stored.all.length || free;
|
|
236
|
-
const paid = total - free;
|
|
237
|
-
let status: string;
|
|
238
|
-
|
|
239
|
-
if (paid === 0) {
|
|
240
|
-
status = `${providerId}: ${free} free models`;
|
|
241
|
-
} else if (currentShowPaid) {
|
|
242
|
-
status = `${providerId}: ${total} models (free + paid)`;
|
|
243
|
-
} else {
|
|
244
|
-
status = `${providerId}: ${free} free · ${paid} paid`;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (config.hasKey) status += " 🔑";
|
|
248
|
-
ctx.ui.setStatus(`${providerId}-status`, status);
|
|
249
|
-
});
|
|
225
|
+
// ── Model count status bar removed (keeps footer clean) ───────
|
|
250
226
|
|
|
251
227
|
// ── Error handling / usage tracking are temporarily deprecated ─────────
|
|
252
228
|
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B.AI Provider Extension
|
|
3
|
+
*
|
|
4
|
+
* B.AI (https://b.ai) is an OpenAI-compatible LLM gateway providing access
|
|
5
|
+
* to many models (OpenAI, Anthropic, Google, DeepSeek, Qwen, GLM, Kimi).
|
|
6
|
+
*
|
|
7
|
+
* API: https://api.b.ai/v1
|
|
8
|
+
* Models: /v1/models
|
|
9
|
+
* Chat: /v1/chat/completions
|
|
10
|
+
*
|
|
11
|
+
* Pricing is not exposed via the /v1/models endpoint, so all models
|
|
12
|
+
* default to cost=0. The `isFreeModel` Route B detection (name contains
|
|
13
|
+
* "free") is therefore used. As a result, with `free_only: true` no b.ai
|
|
14
|
+
* models will be visible until you run `/toggle-bai` to enable paid models.
|
|
15
|
+
*
|
|
16
|
+
* A small set of known-promotional models are hardcoded as known-free so
|
|
17
|
+
* they remain visible even when free-only mode is on (mirrors the
|
|
18
|
+
* TokenRouter approach for `MiniMax-M3`).
|
|
19
|
+
*
|
|
20
|
+
* Setup:
|
|
21
|
+
* BAI_API_KEY=sk-...
|
|
22
|
+
* # or add bai_api_key to ~/.pi/free.json
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
ExtensionAPI,
|
|
27
|
+
ProviderModelConfig,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { getBaiApiKey, getBaiShowPaid, applyHidden } from "../../config.ts";
|
|
30
|
+
import {
|
|
31
|
+
BASE_URL_BAI,
|
|
32
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
33
|
+
PROVIDER_BAI,
|
|
34
|
+
} from "../../constants.ts";
|
|
35
|
+
import { createLogger } from "../../lib/logger.ts";
|
|
36
|
+
import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
|
|
37
|
+
import {
|
|
38
|
+
getProxyModelCompat,
|
|
39
|
+
isLikelyReasoningModel,
|
|
40
|
+
} from "../../lib/provider-compat.ts";
|
|
41
|
+
import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
|
|
42
|
+
import { cleanModelName, fetchWithRetry } from "../../lib/util.ts";
|
|
43
|
+
import { createReRegister, setupProvider } from "../../provider-helper.ts";
|
|
44
|
+
|
|
45
|
+
const _logger = createLogger("bai");
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// Known Free Models
|
|
49
|
+
// B.AI doesn't expose pricing via /v1/models, so known-free models are
|
|
50
|
+
// hardcoded. The site currently advertises `MiniMax-M3` as a limited-time
|
|
51
|
+
// free promotional model; we hardcode that alias and any future `:free`
|
|
52
|
+
// suffixed IDs (catches dynamic promotional additions).
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
const BAI_KNOWN_FREE_MODELS = new Set(["minimax-m3", "MiniMax-M3"]);
|
|
56
|
+
|
|
57
|
+
function isBaiKnownFree(modelId: string): boolean {
|
|
58
|
+
if (BAI_KNOWN_FREE_MODELS.has(modelId)) return true;
|
|
59
|
+
// Catch any future `:free` suffixed model the gateway advertises
|
|
60
|
+
return modelId.toLowerCase().endsWith(":free");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// Types
|
|
65
|
+
// =============================================================================
|
|
66
|
+
|
|
67
|
+
interface BaiModel {
|
|
68
|
+
id: string;
|
|
69
|
+
object?: string;
|
|
70
|
+
created?: number;
|
|
71
|
+
owned_by?: string;
|
|
72
|
+
supported_endpoint_types?: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// Helpers
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
/** Text-capable chat endpoints (excludes image/video/audio-only types) */
|
|
80
|
+
const CHAT_ENDPOINT_TYPES = new Set([
|
|
81
|
+
"openai",
|
|
82
|
+
"openai-response",
|
|
83
|
+
"anthropic",
|
|
84
|
+
"anthropic-compatible",
|
|
85
|
+
"gemini",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
function isTextChatModel(model: BaiModel): boolean {
|
|
89
|
+
const endpoints = model.supported_endpoint_types ?? [];
|
|
90
|
+
if (endpoints.length === 0) {
|
|
91
|
+
// No endpoint info — assume text chat (matches TokenRouter fallback)
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return endpoints.some((t) => CHAT_ENDPOINT_TYPES.has(t));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mapBaiModel(model: BaiModel): ProviderModelConfig & {
|
|
98
|
+
_pricingKnown?: boolean;
|
|
99
|
+
_freeKnown?: boolean;
|
|
100
|
+
_isFree?: boolean;
|
|
101
|
+
} {
|
|
102
|
+
const name = cleanModelName(model.id);
|
|
103
|
+
const reasoning = isLikelyReasoningModel({ id: model.id, name });
|
|
104
|
+
const isKnownFree = isBaiKnownFree(model.id);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
id: model.id,
|
|
108
|
+
name,
|
|
109
|
+
reasoning,
|
|
110
|
+
input: ["text"],
|
|
111
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
112
|
+
contextWindow: 128_000,
|
|
113
|
+
maxTokens: 16_384,
|
|
114
|
+
compat: getProxyModelCompat({ id: model.id, name }),
|
|
115
|
+
// Known-free models bypass name-based detection entirely
|
|
116
|
+
_freeKnown: isKnownFree,
|
|
117
|
+
_isFree: isKnownFree,
|
|
118
|
+
// Non-free models signal no pricing data (name-based detection only)
|
|
119
|
+
_pricingKnown: false,
|
|
120
|
+
} as ProviderModelConfig & {
|
|
121
|
+
_pricingKnown?: boolean;
|
|
122
|
+
_freeKnown?: boolean;
|
|
123
|
+
_isFree?: boolean;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// Fetch Models
|
|
129
|
+
// =============================================================================
|
|
130
|
+
|
|
131
|
+
async function fetchBaiModels(apiKey: string): Promise<ProviderModelConfig[]> {
|
|
132
|
+
_logger.info("[bai] Fetching models from B.AI API...");
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetchWithRetry(
|
|
136
|
+
`${BASE_URL_BAI}/models`,
|
|
137
|
+
{
|
|
138
|
+
headers: {
|
|
139
|
+
Authorization: `Bearer ${apiKey}`,
|
|
140
|
+
Accept: "application/json",
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
3,
|
|
145
|
+
1000,
|
|
146
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new Error(`B.AI API error: ${response.status}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const json = (await response.json()) as { data?: BaiModel[] };
|
|
154
|
+
const models = (json.data ?? []).filter(isTextChatModel);
|
|
155
|
+
|
|
156
|
+
_logger.info(`[bai] Fetched ${models.length} text chat models`);
|
|
157
|
+
const enriched = await safeEnrichModelsWithModelsDev(
|
|
158
|
+
models.map(mapBaiModel),
|
|
159
|
+
{ providerId: PROVIDER_BAI },
|
|
160
|
+
);
|
|
161
|
+
return applyHidden(enriched, PROVIDER_BAI);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
_logger.error("[bai] Failed to fetch models", {
|
|
164
|
+
error: error instanceof Error ? error.message : String(error),
|
|
165
|
+
});
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// =============================================================================
|
|
171
|
+
// Extension Entry Point
|
|
172
|
+
// =============================================================================
|
|
173
|
+
|
|
174
|
+
export default async function baiProvider(pi: ExtensionAPI) {
|
|
175
|
+
const apiKey = getBaiApiKey();
|
|
176
|
+
|
|
177
|
+
if (!apiKey) {
|
|
178
|
+
_logger.info(
|
|
179
|
+
"[bai] Skipping — BAI_API_KEY not set. Sign up at https://b.ai/",
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const allModels = await fetchBaiModels(apiKey);
|
|
185
|
+
|
|
186
|
+
if (allModels.length === 0) {
|
|
187
|
+
_logger.warn("[bai] No text chat models available");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Use isFreeModel with allModels for proper detection
|
|
192
|
+
// B.AI doesn't expose pricing, so Route B (name-based) applies:
|
|
193
|
+
// FREE if name contains "free" OR _isFree is true (known-free hardcoded).
|
|
194
|
+
const freeModels = allModels.filter((m) =>
|
|
195
|
+
isFreeModel({ ...m, provider: PROVIDER_BAI }, allModels),
|
|
196
|
+
);
|
|
197
|
+
const stored = { free: freeModels, all: allModels };
|
|
198
|
+
|
|
199
|
+
_logger.info(
|
|
200
|
+
`[bai] Registered ${allModels.length} models (${freeModels.length} free)`,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const reRegister = createReRegister(pi, {
|
|
204
|
+
providerId: PROVIDER_BAI,
|
|
205
|
+
baseUrl: BASE_URL_BAI,
|
|
206
|
+
apiKey,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
registerWithGlobalToggle(PROVIDER_BAI, stored, reRegister, true);
|
|
210
|
+
|
|
211
|
+
setupProvider(
|
|
212
|
+
pi,
|
|
213
|
+
{
|
|
214
|
+
providerId: PROVIDER_BAI,
|
|
215
|
+
initialShowPaid: getBaiShowPaid(),
|
|
216
|
+
tosUrl: "https://b.ai/",
|
|
217
|
+
reRegister: (models, _stored) => {
|
|
218
|
+
if (_stored) {
|
|
219
|
+
stored.free = _stored.free;
|
|
220
|
+
stored.all = _stored.all;
|
|
221
|
+
}
|
|
222
|
+
reRegister(models);
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
stored,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const showPaid = getBaiShowPaid();
|
|
229
|
+
const initialModels =
|
|
230
|
+
showPaid && stored.all.length > 0 ? stored.all : freeModels;
|
|
231
|
+
reRegister(initialModels);
|
|
232
|
+
}
|