pi-free 2.2.2 → 2.2.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.
@@ -1,237 +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
- // Either the API failed (already logged inside fetchBaiModels) or
188
- // the API returned zero text chat models. We can't tell the user
189
- // which, but we can give a hint to check the log / their key.
190
- _logger.warn(
191
- "[bai] No text chat models available verify BAI_API_KEY is valid and see ~/.pi/free.log for details",
192
- );
193
- return;
194
- }
195
-
196
- // Use isFreeModel with allModels for proper detection
197
- // B.AI doesn't expose pricing, so Route B (name-based) applies:
198
- // FREE if name contains "free" OR _isFree is true (known-free hardcoded).
199
- const freeModels = allModels.filter((m) =>
200
- isFreeModel({ ...m, provider: PROVIDER_BAI }, allModels),
201
- );
202
- const stored = { free: freeModels, all: allModels };
203
-
204
- _logger.info(
205
- `[bai] Registered ${allModels.length} models (${freeModels.length} free)`,
206
- );
207
-
208
- const reRegister = createReRegister(pi, {
209
- providerId: PROVIDER_BAI,
210
- baseUrl: BASE_URL_BAI,
211
- apiKey,
212
- });
213
-
214
- registerWithGlobalToggle(PROVIDER_BAI, stored, reRegister, true);
215
-
216
- setupProvider(
217
- pi,
218
- {
219
- providerId: PROVIDER_BAI,
220
- initialShowPaid: getBaiShowPaid(),
221
- tosUrl: "https://b.ai/",
222
- reRegister: (models, _stored) => {
223
- if (_stored) {
224
- stored.free = _stored.free;
225
- stored.all = _stored.all;
226
- }
227
- reRegister(models);
228
- },
229
- },
230
- stored,
231
- );
232
-
233
- const showPaid = getBaiShowPaid();
234
- const initialModels =
235
- showPaid && stored.all.length > 0 ? stored.all : freeModels;
236
- reRegister(initialModels);
237
- }
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
+ // detected by name suffix. Catches `:free`-tagged models the gateway
51
+ // advertises as promotional.
52
+ // =============================================================================
53
+
54
+ function isBaiKnownFree(modelId: string): boolean {
55
+ return modelId.toLowerCase().endsWith(":free");
56
+ }
57
+
58
+ // =============================================================================
59
+ // Types
60
+ // =============================================================================
61
+
62
+ interface BaiModel {
63
+ id: string;
64
+ object?: string;
65
+ created?: number;
66
+ owned_by?: string;
67
+ supported_endpoint_types?: string[];
68
+ }
69
+
70
+ // =============================================================================
71
+ // Helpers
72
+ // =============================================================================
73
+
74
+ /** Text-capable chat endpoints (excludes image/video/audio-only types) */
75
+ const CHAT_ENDPOINT_TYPES = new Set([
76
+ "openai",
77
+ "openai-response",
78
+ "anthropic",
79
+ "anthropic-compatible",
80
+ "gemini",
81
+ ]);
82
+
83
+ function isTextChatModel(model: BaiModel): boolean {
84
+ const endpoints = model.supported_endpoint_types ?? [];
85
+ if (endpoints.length === 0) {
86
+ // No endpoint info — assume text chat (matches TokenRouter fallback)
87
+ return true;
88
+ }
89
+ return endpoints.some((t) => CHAT_ENDPOINT_TYPES.has(t));
90
+ }
91
+
92
+ function mapBaiModel(model: BaiModel): ProviderModelConfig & {
93
+ _pricingKnown?: boolean;
94
+ _freeKnown?: boolean;
95
+ _isFree?: boolean;
96
+ } {
97
+ const name = cleanModelName(model.id);
98
+ const reasoning = isLikelyReasoningModel({ id: model.id, name });
99
+ const isKnownFree = isBaiKnownFree(model.id);
100
+
101
+ return {
102
+ id: model.id,
103
+ name,
104
+ reasoning,
105
+ input: ["text"],
106
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
107
+ contextWindow: 128_000,
108
+ maxTokens: 16_384,
109
+ compat: getProxyModelCompat({ id: model.id, name }),
110
+ // Known-free models bypass name-based detection entirely
111
+ _freeKnown: isKnownFree,
112
+ _isFree: isKnownFree,
113
+ // Non-free models signal no pricing data (name-based detection only)
114
+ _pricingKnown: false,
115
+ } as ProviderModelConfig & {
116
+ _pricingKnown?: boolean;
117
+ _freeKnown?: boolean;
118
+ _isFree?: boolean;
119
+ };
120
+ }
121
+
122
+ // =============================================================================
123
+ // Fetch Models
124
+ // =============================================================================
125
+
126
+ async function fetchBaiModels(apiKey: string): Promise<ProviderModelConfig[]> {
127
+ _logger.info("[bai] Fetching models from B.AI API...");
128
+
129
+ try {
130
+ const response = await fetchWithRetry(
131
+ `${BASE_URL_BAI}/models`,
132
+ {
133
+ headers: {
134
+ Authorization: `Bearer ${apiKey}`,
135
+ Accept: "application/json",
136
+ "Content-Type": "application/json",
137
+ },
138
+ },
139
+ 3,
140
+ 1000,
141
+ DEFAULT_FETCH_TIMEOUT_MS,
142
+ );
143
+
144
+ if (!response.ok) {
145
+ throw new Error(`B.AI API error: ${response.status}`);
146
+ }
147
+
148
+ const json = (await response.json()) as { data?: BaiModel[] };
149
+ const models = (json.data ?? []).filter(isTextChatModel);
150
+
151
+ _logger.info(`[bai] Fetched ${models.length} text chat models`);
152
+ const enriched = await safeEnrichModelsWithModelsDev(
153
+ models.map(mapBaiModel),
154
+ { providerId: PROVIDER_BAI },
155
+ );
156
+ return applyHidden(enriched, PROVIDER_BAI);
157
+ } catch (error) {
158
+ _logger.error("[bai] Failed to fetch models", {
159
+ error: error instanceof Error ? error.message : String(error),
160
+ });
161
+ return [];
162
+ }
163
+ }
164
+
165
+ // =============================================================================
166
+ // Extension Entry Point
167
+ // =============================================================================
168
+
169
+ export default async function baiProvider(pi: ExtensionAPI) {
170
+ const apiKey = getBaiApiKey();
171
+
172
+ if (!apiKey) {
173
+ _logger.info(
174
+ "[bai] Skipping BAI_API_KEY not set. Sign up at https://b.ai/",
175
+ );
176
+ return;
177
+ }
178
+
179
+ const allModels = await fetchBaiModels(apiKey);
180
+
181
+ if (allModels.length === 0) {
182
+ // Either the API failed (already logged inside fetchBaiModels) or
183
+ // the API returned zero text chat models. We can't tell the user
184
+ // which, but we can give a hint to check the log / their key.
185
+ _logger.warn(
186
+ "[bai] No text chat models available — verify BAI_API_KEY is valid and see ~/.pi/free.log for details",
187
+ );
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
+ }
@@ -170,7 +170,9 @@ const CORE_CLINE_TOOL_NAMES = [
170
170
 
171
171
  function stringArg(args: Record<string, unknown>, key: string): string {
172
172
  const value = args[key];
173
- return typeof value === "string" ? value : value == null ? "" : String(value);
173
+ if (typeof value === "string") return value;
174
+ if (value == null) return "";
175
+ return String(value);
174
176
  }
175
177
 
176
178
  function booleanArg(args: Record<string, unknown>, key: string): boolean {
@@ -178,7 +180,8 @@ function booleanArg(args: Record<string, unknown>, key: string): boolean {
178
180
  }
179
181
 
180
182
  function shellQuote(value: string): string {
181
- return `'${value.replaceAll("'", `'"'"'`)}'`;
183
+ const escaped = value.replaceAll("'", `'"'"'`);
184
+ return `'${escaped}'`;
182
185
  }
183
186
 
184
187
  function buildListFilesCommand(args: Record<string, unknown>): string {
@@ -639,23 +642,25 @@ function buildToolInstructions(tools: Tool[] | undefined): string {
639
642
  if (bridges.length === 0) return "";
640
643
 
641
644
  const sections = bridges.map((bridge) => {
642
- const params =
643
- bridge.remoteName === "replace_in_file"
644
- ? [
645
- " <path>path/to/file</path>",
646
- " <diff>",
647
- "------- SEARCH",
648
- "exact text to replace",
649
- "=======",
650
- "new text",
651
- "+++++++ REPLACE",
652
- " </diff>",
653
- ].join("\n")
654
- : bridge.parameters.length
655
- ? bridge.parameters
656
- .map((name) => ` <${name}>value</${name}>`)
657
- .join("\n")
658
- : " <arguments>{}</arguments>";
645
+ let params: string;
646
+ if (bridge.remoteName === "replace_in_file") {
647
+ params = [
648
+ " <path>path/to/file</path>",
649
+ " <diff>",
650
+ "------- SEARCH",
651
+ "exact text to replace",
652
+ "=======",
653
+ "new text",
654
+ "+++++++ REPLACE",
655
+ " </diff>",
656
+ ].join("\n");
657
+ } else if (bridge.parameters.length) {
658
+ params = bridge.parameters
659
+ .map((name) => ` <${name}>value</${name}>`)
660
+ .join("\n");
661
+ } else {
662
+ params = " <arguments>{}</arguments>";
663
+ }
659
664
  return [
660
665
  `Tool: ${bridge.remoteName}`,
661
666
  `Description: ${bridge.description ?? bridge.runtimeName}`,
@@ -1461,12 +1466,13 @@ export function streamClineXml(
1461
1466
  pushToolCall(assistant, toolCall, stream);
1462
1467
  }
1463
1468
 
1464
- assistant.stopReason =
1465
- toolCalls.length > 0
1466
- ? "toolUse"
1467
- : finishReason === "length"
1468
- ? "length"
1469
- : "stop";
1469
+ if (toolCalls.length > 0) {
1470
+ assistant.stopReason = "toolUse";
1471
+ } else if (finishReason === "length") {
1472
+ assistant.stopReason = "length";
1473
+ } else {
1474
+ assistant.stopReason = "stop";
1475
+ }
1470
1476
  stream.push({
1471
1477
  type: "done",
1472
1478
  reason: assistant.stopReason as "stop" | "length" | "toolUse",
@@ -19,7 +19,7 @@ import type {
19
19
  ExtensionAPI,
20
20
  ProviderModelConfig,
21
21
  } from "@earendil-works/pi-coding-agent";
22
- import { getClineShowPaid } from "../../config.ts";
22
+ import { getClineApiKey, getClineShowPaid } from "../../config.ts";
23
23
  import { BASE_URL_CLINE, PROVIDER_CLINE } from "../../constants.ts";
24
24
  import {
25
25
  DEFAULT_PROVIDER_CACHE_TTL_MS,
@@ -84,6 +84,9 @@ function toApiKey(credentials: OAuthCredentials): string {
84
84
  // =============================================================================
85
85
 
86
86
  export default async function clineProvider(pi: ExtensionAPI) {
87
+ const clineApiKey = getClineApiKey();
88
+ const useApiKeyAuth = !!clineApiKey;
89
+
87
90
  let allModels: ProviderModelConfig[];
88
91
  const cachedModels = loadProviderCache(PROVIDER_CLINE);
89
92
  if (cachedModels && cachedModels.length > 0) {
@@ -114,16 +117,21 @@ export default async function clineProvider(pi: ExtensionAPI) {
114
117
  baseUrl: BASE_URL_CLINE,
115
118
  api: "cline-xml-tools" as const,
116
119
  authHeader: false,
120
+ apiKey: clineApiKey,
117
121
  headers: buildClineHeaders(),
118
122
  streamSimple: (model, context, options) =>
119
123
  streamClineXml(model as any, context, options, buildClineHeaders()),
120
124
  models: enhanceWithCI(m),
121
- oauth: {
122
- name: "Cline",
123
- login: loginCline,
124
- refreshToken: refreshClineToken,
125
- getApiKey: toApiKey,
126
- },
125
+ ...(useApiKeyAuth
126
+ ? {}
127
+ : {
128
+ oauth: {
129
+ name: "Cline",
130
+ login: loginCline,
131
+ refreshToken: refreshClineToken,
132
+ getApiKey: toApiKey,
133
+ },
134
+ }),
127
135
  });
128
136
  };
129
137
 
@@ -138,7 +146,8 @@ export default async function clineProvider(pi: ExtensionAPI) {
138
146
  toggleState.applyCurrent(reRegister);
139
147
  };
140
148
 
141
- registerWithGlobalToggle(PROVIDER_CLINE, stored, (m) => reRegister(m), false);
149
+ // Register with global toggle system (hasKey=true if API key auth configured)
150
+ registerWithGlobalToggle(PROVIDER_CLINE, stored, (m) => reRegister(m), useApiKeyAuth);
142
151
  toggleState.applyCurrent(reRegister);
143
152
 
144
153
  pi.registerCommand("toggle-cline", {