pi-free 2.1.1 → 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.
@@ -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[],
@@ -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.1.1",
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.2",
65
+ "typescript": "^6.0.3",
66
66
  "vitest": "^4.1.5"
67
67
  },
68
68
  "pi": {
@@ -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
+ }
@@ -315,10 +315,25 @@ function replaceInFileBridge(tool?: Tool): ToolBridge {
315
315
  description:
316
316
  tool?.description ?? "Edit a file using Cline SEARCH/REPLACE blocks",
317
317
  parameters: ["path", "diff"],
318
- toRuntimeArgs: (args) => ({
319
- path: stringArg(args, "path"),
320
- edits: parseSearchReplaceBlocks(stringArg(args, "diff")),
321
- }),
318
+ toRuntimeArgs: (args) => {
319
+ // Pi native <edit> form sends <edits>[{oldText,newText},...]</edits>
320
+ // as JSON. Cline <replace_in_file> form uses SEARCH/REPLACE <diff>.
321
+ if (Array.isArray(args.edits)) {
322
+ return {
323
+ path: stringArg(args, "path"),
324
+ edits: args.edits
325
+ .map((edit) => ({
326
+ oldText: stringArg(edit as Record<string, unknown>, "oldText"),
327
+ newText: stringArg(edit as Record<string, unknown>, "newText"),
328
+ }))
329
+ .filter((edit) => edit.oldText || edit.newText),
330
+ };
331
+ }
332
+ return {
333
+ path: stringArg(args, "path"),
334
+ edits: parseSearchReplaceBlocks(stringArg(args, "diff")),
335
+ };
336
+ },
322
337
  fromRuntimeArgs: (args) => {
323
338
  const edits = Array.isArray(args.edits)
324
339
  ? args.edits
@@ -949,10 +964,20 @@ function parseXmlToolCalls(
949
964
  rawText: string,
950
965
  tools: Tool[] | undefined,
951
966
  ): ParsedToolCalls {
967
+ const bridges = getParseToolBridges(tools);
952
968
  const bridgeByRemoteName = new Map(
953
- getParseToolBridges(tools).map((bridge) => [bridge.remoteName, bridge]),
969
+ bridges.map((bridge) => [bridge.remoteName, bridge]),
970
+ );
971
+ // Some Cline/MiMo variants use the Pi runtime tool name (e.g. <edit>,
972
+ // <write>) instead of the Cline XML name (<replace_in_file>, <write_to_file>).
973
+ // Register runtime names as aliases so both forms are recognised.
974
+ const bridgeByName = new Map(
975
+ bridges.flatMap((bridge) => [
976
+ [bridge.remoteName, bridge],
977
+ [bridge.runtimeName, bridge],
978
+ ]),
954
979
  );
955
- const toolNames = new Set(bridgeByRemoteName.keys());
980
+ const toolNames = new Set(bridgeByName.keys());
956
981
 
957
982
  // Extract <function=name> Pi SDK tool calls directly (no Cline XML intermediate)
958
983
  const fnResult = extractFunctionTagToolCalls(rawText, bridgeByRemoteName);
@@ -972,7 +997,9 @@ function parseXmlToolCalls(
972
997
  while (cursor < sourceText.length) {
973
998
  const next = findNextToolStart(sourceText, toolNames, cursor);
974
999
  if (!next) break;
975
- const closeTag = `</${next.name}>`;
1000
+ const bridge = bridgeByName.get(next.name);
1001
+ const remoteName = bridge?.remoteName ?? next.name;
1002
+ const closeTag = `</${remoteName}>`;
976
1003
  const closeStart = sourceText.indexOf(
977
1004
  closeTag,
978
1005
  next.index + next.openTag.length,
@@ -980,11 +1007,10 @@ function parseXmlToolCalls(
980
1007
  pushTextFragment(textParts, sourceText.slice(cursor, next.index));
981
1008
  const blockEnd = closeStart === -1 ? sourceText.length : closeStart;
982
1009
  const block = sourceText.slice(next.index + next.openTag.length, blockEnd);
983
- const bridge = bridgeByRemoteName.get(next.name);
984
1010
  const remoteArgs = parseToolArguments(block);
985
1011
  const writeRuntimeName = getWriteRuntimeToolName(tools);
986
1012
  const heredocWrite =
987
- next.name === "execute_command" && writeRuntimeName
1013
+ remoteName === "execute_command" && writeRuntimeName
988
1014
  ? parseCatHeredocWriteCommand(stringArg(remoteArgs, "command"))
989
1015
  : undefined;
990
1016
  if (heredocWrite && writeRuntimeName) {
@@ -1003,7 +1029,10 @@ function parseXmlToolCalls(
1003
1029
  }
1004
1030
 
1005
1031
  pushTextFragment(textParts, sourceText.slice(cursor));
1006
- return { text: textParts.join("\n\n").trim(), toolCalls: [...fnResult.toolCalls, ...toolCalls] };
1032
+ return {
1033
+ text: textParts.join("\n\n").trim(),
1034
+ toolCalls: [...fnResult.toolCalls, ...toolCalls],
1035
+ };
1007
1036
  }
1008
1037
 
1009
1038
  function parseReasoningHiddenToolCalls(
@@ -1393,7 +1422,10 @@ export function streamClineXml(
1393
1422
  thinking,
1394
1423
  currentContext.tools,
1395
1424
  );
1396
- const parsed = parseXmlToolCalls(extractedThinking.text, currentContext.tools);
1425
+ const parsed = parseXmlToolCalls(
1426
+ extractedThinking.text,
1427
+ currentContext.tools,
1428
+ );
1397
1429
  output = prepareClineXmlOutput(
1398
1430
  parsed.text,
1399
1431
  extractedThinking.thinking,
@@ -1404,10 +1436,7 @@ export function streamClineXml(
1404
1436
  // Reasoning-only response: MiMo stopped without producing visible
1405
1437
  // text or tool calls. Auto-retry once with a "continue" nudge
1406
1438
  // instead of showing a dead-end error to the user.
1407
- if (
1408
- output.visibleText === INTERNAL_ONLY_RESPONSE &&
1409
- attempt === 0
1410
- ) {
1439
+ if (output.visibleText === INTERNAL_ONLY_RESPONSE && attempt === 0) {
1411
1440
  currentContext = {
1412
1441
  ...context,
1413
1442
  messages: [
@@ -45,6 +45,7 @@ import { createLogger } from "../../lib/logger.ts";
45
45
  import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
46
46
  import { getProxyModelCompat } from "../../lib/provider-compat.ts";
47
47
  import {
48
+ areAllModelsFresh,
48
49
  getModelsDueForProbe,
49
50
  recordModelProbeResults,
50
51
  } from "../../lib/probe-cache.ts";
@@ -605,6 +606,17 @@ async function registerProvider(
605
606
  wrapSessionStartHandler(`${config.providerId}-auto-probe`, async () => {
606
607
  if (_autoProbeDone) return;
607
608
  _autoProbeDone = true;
609
+ if (
610
+ areAllModelsFresh(
611
+ config.providerId,
612
+ stored.free.map((m) => m.id),
613
+ )
614
+ ) {
615
+ _logger.info(
616
+ `[probe] ${config.providerId}: auto-probe cache is fresh`,
617
+ );
618
+ return;
619
+ }
608
620
  _logger.info(
609
621
  `Starting lazy auto-probe of ${config.providerId} free models...`,
610
622
  );
@@ -43,6 +43,7 @@ import {
43
43
  } from "../../lib/provider-cache.ts";
44
44
  import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
45
45
  import {
46
+ areAllModelsFresh,
46
47
  getModelsDueForProbe,
47
48
  recordModelProbeResults,
48
49
  } from "../../lib/probe-cache.ts";
@@ -592,6 +593,18 @@ export default async function ollamaProvider(pi: ExtensionAPI) {
592
593
  });
593
594
 
594
595
  const runProbeInBackground = (models: ProviderModelConfig[]) => {
596
+ // Skip scheduling entirely if every model was probed recently.
597
+ // Without this check the probe runs on every session_start and
598
+ // only then discovers the cache is fresh inside runOllamaProbe.
599
+ if (
600
+ areAllModelsFresh(
601
+ PROVIDER_OLLAMA,
602
+ models.map((m) => m.id),
603
+ )
604
+ ) {
605
+ _logger.info("Auto-probe: Ollama probe cache is fresh");
606
+ return;
607
+ }
595
608
  runOllamaProbe(apiKey, models, applyModelList, { useCache: true }).catch(
596
609
  (error) => {
597
610
  _logger.warn("Auto-probe failed", {
@@ -37,6 +37,7 @@ import {
37
37
  isLikelyReasoningModel,
38
38
  } from "../../lib/provider-compat.ts";
39
39
  import {
40
+ areAllModelsFresh,
40
41
  getModelsDueForProbe,
41
42
  recordModelProbeResults,
42
43
  } from "../../lib/probe-cache.ts";
@@ -350,6 +351,15 @@ export default async function routewayProvider(pi: ExtensionAPI) {
350
351
  wrapSessionStartHandler("routeway", async () => {
351
352
  if (_autoProbeDone || !apiKey) return;
352
353
  _autoProbeDone = true;
354
+ if (
355
+ areAllModelsFresh(
356
+ PROVIDER_ROUTEWAY,
357
+ allModels.map((m) => m.id),
358
+ )
359
+ ) {
360
+ _logger.info("Auto-probe: Routeway probe cache is fresh");
361
+ return;
362
+ }
353
363
  _logger.info("Starting lazy auto-probe of Routeway models...");
354
364
  runRoutewayProbe(apiKey, allModels, stored, reRegister, {
355
365
  useCache: true,