pi-free 2.2.3 → 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.
package/lib/telemetry.ts CHANGED
@@ -105,67 +105,59 @@ const _store = createJSONStore<TelemetryStore>(TELEMETRY_FILE, {
105
105
  // =============================================================================
106
106
 
107
107
  function deriveModelTelemetry(
108
- _modelKey: string,
109
108
  entries: TelemetryEntry[],
110
109
  ): ModelTelemetry {
111
110
  const recent = entries.slice(-MAX_RECENT_CALLS);
111
+
112
+ let successCalls = 0;
113
+ let totalTokensFromSuccessful = 0;
114
+ let totalLatencyFromSuccessful = 0;
115
+ let totalTokens = 0;
116
+ let totalPromptTokens = 0;
117
+ let totalCompletionTokens = 0;
118
+ let totalLatencyMs = 0;
119
+ let totalCost = 0;
120
+
121
+ for (const e of entries) {
122
+ totalTokens += e.totalTokens;
123
+ totalPromptTokens += e.promptTokens;
124
+ totalCompletionTokens += e.completionTokens;
125
+ totalLatencyMs += e.latencyMs;
126
+ totalCost += e.cost;
127
+ if (e.success) {
128
+ successCalls++;
129
+ totalTokensFromSuccessful += e.totalTokens;
130
+ totalLatencyFromSuccessful += e.latencyMs;
131
+ }
132
+ }
133
+
112
134
  const totalCalls = entries.length;
113
- const successCalls = entries.filter((e) => e.success).length;
114
- const errorCalls = totalCalls - successCalls;
115
-
116
- const stats = entries.reduce(
117
- (acc, e) => {
118
- acc.totalTokens += e.totalTokens;
119
- acc.totalPromptTokens += e.promptTokens;
120
- acc.totalCompletionTokens += e.completionTokens;
121
- acc.totalLatencyMs += e.latencyMs;
122
- acc.totalCost += e.cost;
123
- return acc;
124
- },
125
- {
126
- totalTokens: 0,
127
- totalPromptTokens: 0,
128
- totalCompletionTokens: 0,
129
- totalLatencyMs: 0,
130
- totalCost: 0,
131
- },
132
- );
133
-
134
- const totalSuccessEntries = entries.filter((e) => e.success);
135
- const totalTokensFromSuccessful = totalSuccessEntries.reduce(
136
- (s, e) => s + e.totalTokens,
137
- 0,
138
- );
139
- const totalLatencyFromSuccessful = totalSuccessEntries.reduce(
140
- (s, e) => s + e.latencyMs,
141
- 0,
142
- );
143
135
 
144
136
  return {
145
137
  totalCalls,
146
138
  successCalls,
147
- errorCalls,
148
- totalTokens: stats.totalTokens,
149
- totalPromptTokens: stats.totalPromptTokens,
150
- totalCompletionTokens: stats.totalCompletionTokens,
151
- totalLatencyMs: stats.totalLatencyMs,
152
- totalCost: stats.totalCost,
139
+ errorCalls: totalCalls - successCalls,
140
+ totalTokens,
141
+ totalPromptTokens,
142
+ totalCompletionTokens,
143
+ totalLatencyMs,
144
+ totalCost,
153
145
  avgLatencyMs:
154
- totalSuccessEntries.length > 0
155
- ? Math.round(totalLatencyFromSuccessful / totalSuccessEntries.length)
146
+ successCalls > 0
147
+ ? Math.round(totalLatencyFromSuccessful / successCalls)
156
148
  : 0,
157
149
  avgTokensPerSecond:
158
150
  totalLatencyFromSuccessful > 0
159
- ? parseFloat(
151
+ ? Number.parseFloat(
160
152
  (
161
153
  totalTokensFromSuccessful /
162
154
  (totalLatencyFromSuccessful / 1000)
163
155
  ).toFixed(1),
164
- )
156
+ )
165
157
  : 0,
166
158
  successRate:
167
159
  totalCalls > 0
168
- ? parseFloat(((successCalls / totalCalls) * 100).toFixed(1))
160
+ ? Number.parseFloat(((successCalls / totalCalls) * 100).toFixed(1))
169
161
  : 0,
170
162
  recentCalls: recent,
171
163
  };
@@ -186,7 +178,7 @@ async function addEntry(entry: TelemetryEntry): Promise<void> {
186
178
  ...store,
187
179
  models: {
188
180
  ...store.models,
189
- [modelKey]: deriveModelTelemetry(modelKey, pruned),
181
+ [modelKey]: deriveModelTelemetry(pruned),
190
182
  },
191
183
  lastUpdated: Date.now(),
192
184
  };
@@ -310,7 +302,7 @@ export async function recordModelCall(
310
302
  const totalTokens = usage.totalTokens || usage.input + usage.output;
311
303
  const tokensPerSecond =
312
304
  latencyMs > 0
313
- ? parseFloat((totalTokens / (latencyMs / 1000)).toFixed(1))
305
+ ? Number.parseFloat((totalTokens / (latencyMs / 1000)).toFixed(1))
314
306
  : 0;
315
307
 
316
308
  const entry: TelemetryEntry = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-free",
3
- "version": "2.2.3",
3
+ "version": "2.2.4",
4
4
  "type": "module",
5
5
  "description": "AI model providers for Pi with free model filtering and dynamic model fetching",
6
6
  "keywords": [
@@ -52,7 +52,8 @@
52
52
  "test": "vitest",
53
53
  "test:ui": "vitest --ui",
54
54
  "test:run": "vitest run",
55
- "smoke:cline": "tsx scripts/smoke-cline-xml-bridge.ts"
55
+ "smoke:cline": "tsx scripts/smoke-cline-xml-bridge.ts",
56
+ "smoke:openmodel": "tsx scripts/smoke-openmodel-wire-format.ts"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "@earendil-works/pi-ai": "^0.79.8",
@@ -551,23 +551,35 @@ function tryDirectSubstringMatch(
551
551
  modelId: string,
552
552
  modelName: string,
553
553
  ): HardcodedBenchmark | null {
554
+ // Collect ALL substring matches, then return the LONGEST key. This
555
+ // prevents short general keys (e.g. "mistral-medium-3") from shadowing
556
+ // longer specific keys (e.g. "mistral-medium-3.5") when a provider
557
+ // uses a different separator convention in the model ID.
558
+ let bestKey: string | null = null;
559
+ let bestData: HardcodedBenchmark | null = null;
554
560
  for (const [key, data] of Object.entries(HARDCODED_BENCHMARKS) as [
555
561
  string,
556
562
  HardcodedBenchmark,
557
563
  ][]) {
558
564
  if (search.includes(key.toLowerCase())) {
559
- logDebug({
560
- provider,
561
- modelId,
562
- modelName,
563
- action: "match",
564
- strategy: "direct-substring",
565
- matchKey: key,
566
- codingIndex: data.codingIndex,
567
- });
568
- return data;
565
+ if (bestKey === null || key.length > bestKey.length) {
566
+ bestKey = key;
567
+ bestData = data;
568
+ }
569
569
  }
570
570
  }
571
+ if (bestKey !== null && bestData !== null) {
572
+ logDebug({
573
+ provider,
574
+ modelId,
575
+ modelName,
576
+ action: "match",
577
+ strategy: "direct-substring",
578
+ matchKey: bestKey,
579
+ codingIndex: bestData.codingIndex,
580
+ });
581
+ return bestData;
582
+ }
571
583
  return null;
572
584
  }
573
585
 
@@ -685,14 +697,17 @@ export function findHardcodedBenchmark(
685
697
 
686
698
  logDebug({ provider, modelId, modelName, action: "attempt" });
687
699
 
688
- // 1. Direct substring match
689
- const direct = tryDirectSubstringMatch(search, provider, modelId, modelName);
690
- if (direct) return direct;
691
-
692
- // 2. Variant alias matching
700
+ // 1. Variant alias matching (human-curated, runs first so deliberate
701
+ // aliases for separator/suffix mismatches can override generic substring
702
+ // matches).
693
703
  const variant = tryVariantAliasMatch(search, provider, modelId, modelName);
694
704
  if (variant) return variant;
695
705
 
706
+ // 2. Direct substring match (longest-key wins, so "minimax-m2.5" beats
707
+ // "minimax-m2" when both could match).
708
+ const direct = tryDirectSubstringMatch(search, provider, modelId, modelName);
709
+ if (direct) return direct;
710
+
696
711
  // 3. Provider-specific normalization
697
712
  const { result: normalizedResult, normalized } = tryProviderNormalizedMatch(
698
713
  modelId,
@@ -13,14 +13,11 @@ import type {
13
13
  } from "@earendil-works/pi-coding-agent";
14
14
  import { saveConfig } from "./config.ts";
15
15
  import { createLogger } from "./lib/logger.ts";
16
+ import type { ModelsDevEnrichedMetadata } from "./lib/types.ts";
16
17
  import { enhanceModelNameWithCodingIndex } from "./provider-failover/benchmark-lookup.ts";
17
18
 
18
19
  const _logger = createLogger("provider-helper");
19
20
 
20
- type ModelsDevEnrichedMetadata = {
21
- modelsDev?: Parameters<typeof enhanceModelNameWithCodingIndex>[3];
22
- };
23
-
24
21
  // =============================================================================
25
22
  // Types
26
23
  // =============================================================================
@@ -66,6 +63,14 @@ export interface OpenAICompatibleConfig {
66
63
  baseUrl: string;
67
64
  /** Environment variable name for the API key */
68
65
  apiKey: string;
66
+ /**
67
+ * Wire API to use. Defaults to `"openai-completions"` for backward
68
+ * compatibility with the 17 existing providers that pass through
69
+ * this helper without setting it. Set to `"anthropic-messages"`
70
+ * for Anthropic-protocol gateways (e.g. OpenModel). The pi-ai
71
+ * runtime dispatches to the right client based on this value.
72
+ */
73
+ api?: "openai-completions" | "anthropic-messages";
69
74
  /** Additional headers to include */
70
75
  headers?: Record<string, string>;
71
76
  /** OAuth configuration (optional) */
@@ -105,12 +110,19 @@ export function registerOpenAICompatible(
105
110
  config: OpenAICompatibleConfig,
106
111
  models: ProviderModelConfig[],
107
112
  ): void {
108
- const { providerId, baseUrl, apiKey, headers, oauth } = config;
113
+ const {
114
+ providerId,
115
+ baseUrl,
116
+ apiKey,
117
+ api = "openai-completions",
118
+ headers,
119
+ oauth,
120
+ } = config;
109
121
 
110
122
  pi.registerProvider(providerId, {
111
123
  baseUrl,
112
124
  apiKey,
113
- api: "openai-completions" as const,
125
+ api,
114
126
  headers: {
115
127
  "User-Agent": "pi-free-providers",
116
128
  ...headers,
@@ -143,13 +155,20 @@ export function createCtxReRegister(
143
155
  },
144
156
  config: OpenAICompatibleConfig,
145
157
  ): (models: ProviderModelConfig[]) => void {
146
- const { providerId, baseUrl, apiKey, headers, oauth } = config;
158
+ const {
159
+ providerId,
160
+ baseUrl,
161
+ apiKey,
162
+ api = "openai-completions",
163
+ headers,
164
+ oauth,
165
+ } = config;
147
166
 
148
167
  return (models: ProviderModelConfig[]) => {
149
168
  ctx.modelRegistry.registerProvider(providerId, {
150
169
  baseUrl,
151
170
  apiKey,
152
- api: "openai-completions" as const,
171
+ api,
153
172
  headers: {
154
173
  "User-Agent": "pi-free-providers",
155
174
  ...headers,
@@ -47,16 +47,11 @@ const _logger = createLogger("bai");
47
47
  // =============================================================================
48
48
  // Known Free Models
49
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).
50
+ // detected by name suffix. Catches `:free`-tagged models the gateway
51
+ // advertises as promotional.
53
52
  // =============================================================================
54
53
 
55
- const BAI_KNOWN_FREE_MODELS = new Set(["minimax-m3", "MiniMax-M3"]);
56
-
57
54
  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
55
  return modelId.toLowerCase().endsWith(":free");
61
56
  }
62
57
 
@@ -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", {
@@ -18,6 +18,7 @@ import type {
18
18
  ProviderModelConfig,
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import {
21
+ getKiloApiKey,
21
22
  getKiloFreeOnly,
22
23
  getKiloShowPaid,
23
24
  PROVIDER_KILO,
@@ -152,7 +153,7 @@ function parseXmlToolCalls(
152
153
  const KILO_PROVIDER_CONFIG = {
153
154
  providerId: PROVIDER_KILO,
154
155
  baseUrl: KILO_GATEWAY_BASE,
155
- apiKey: "$KILO_API_KEY",
156
+ apiKey: getKiloApiKey() || "$KILO_API_KEY",
156
157
  headers: {
157
158
  "X-KILOCODE-EDITORNAME": "Pi",
158
159
  },
@@ -172,14 +173,17 @@ function applyKiloCompat<T extends { compat?: ProviderModelConfig["compat"] }>(
172
173
  }
173
174
 
174
175
  export default async function kiloProvider(pi: ExtensionAPI) {
176
+ // Resolve API key (env var or ~/.pi/free.json)
177
+ const kiloApiKey = getKiloApiKey();
178
+
175
179
  // Try to fetch ALL models at startup (like Cline/OpenRouter)
176
- // If no API key, this will return free models only
180
+ // With API key: returns all models; without: returns free-only
177
181
  let allModels: ProviderModelConfig[] = [];
178
182
  let freeModels: ProviderModelConfig[] = [];
179
183
 
180
184
  try {
181
185
  // Fetch all models (returns free-only if no auth, all if auth available)
182
- allModels = await fetchKiloModels({ freeOnly: false });
186
+ allModels = await fetchKiloModels({ token: kiloApiKey, freeOnly: false });
183
187
  // Derive free list using isFreeModel with allModels for detection
184
188
  freeModels = allModels.filter((m) =>
185
189
  isFreeModel({ ...m, provider: PROVIDER_KILO }, allModels),
@@ -211,11 +215,12 @@ export default async function kiloProvider(pi: ExtensionAPI) {
211
215
  baseReRegister(applyKiloCompat(models));
212
216
 
213
217
  // Register with global toggle system
218
+ const hasKiloKey = !!kiloApiKey;
214
219
  registerWithGlobalToggle(
215
220
  PROVIDER_KILO,
216
221
  stored,
217
222
  reRegister,
218
- !!process.env.KILO_API_KEY,
223
+ hasKiloKey,
219
224
  );
220
225
 
221
226
  // OAuth config for Kilo
@@ -283,14 +288,14 @@ export default async function kiloProvider(pi: ExtensionAPI) {
283
288
  const modelsWithCompat = applyKiloCompat(currentModels);
284
289
  pi.registerProvider(PROVIDER_KILO, {
285
290
  baseUrl: KILO_GATEWAY_BASE,
286
- apiKey: "$KILO_API_KEY",
291
+ apiKey: kiloApiKey || "$KILO_API_KEY",
287
292
  api: "openai-completions" as const,
288
293
  headers: {
289
294
  "X-KILOCODE-EDITORNAME": "Pi",
290
295
  "User-Agent": "pi-free-providers",
291
296
  },
292
297
  models: enhanceWithCI(modelsWithCompat),
293
- oauth: oauthConfig,
298
+ ...(!!kiloApiKey ? {} : { oauth: oauthConfig }),
294
299
  });
295
300
 
296
301
  // Registration complete - models registered silently (use LOG_LEVEL=info to see details)
@@ -138,7 +138,7 @@ export async function fetchOpenRouterModelsWithFree(
138
138
 
139
139
  const free = all.filter((m) => {
140
140
  const cost = m.cost;
141
- return cost && cost.input === 0 && cost.output === 0;
141
+ return cost != null && cost.input === 0 && cost.output === 0;
142
142
  });
143
143
 
144
144
  return { free, all };
@@ -226,8 +226,8 @@ function resolvePiAiSubpathFromPackage(specifier: string): string | undefined {
226
226
  }
227
227
 
228
228
  class DeferredAssistantMessageEventStream {
229
- private queue: AssistantMessageEvent[] = [];
230
- private waiting: Array<
229
+ private readonly queue: AssistantMessageEvent[] = [];
230
+ private readonly waiting: Array<
231
231
  (result: IteratorResult<AssistantMessageEvent>) => void
232
232
  > = [];
233
233
  private done = false;