pi-free 2.0.13 → 2.0.15

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 CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.15] - 2026-06-02
11
+
12
+ ### Fixed
13
+
14
+ - **Qwen 3.7 reasoning compat** — `qwen/qwen3.7-max` on Cline/OpenRouter uses DeepSeek-style `reasoning_content` format. Added `DEEPSEEK_PROXY_COMPAT` so Pi preserves and replays reasoning tokens correctly, preventing plan-mode hangs ([#213](https://github.com/apmantza/pi-free/pull/213)).
15
+
16
+ - **Kimi K2.6 reasoning compat** — Kimi models on NVIDIA/OpenRouter need `requiresReasoningContentOnAssistantMessages: true` to correctly replay reasoning tokens in assistant messages. Without it, the model gets stuck when trying to call tools or produce output after thinking. Refs [earendil-works/pi#5309](https://github.com/earendil-works/pi/issues/5309) ([#213](https://github.com/apmantza/pi-free/pull/213)).
17
+
18
+ - **MiniMax reasoning compat** — MiniMax M3 and other MiniMax models now have full DeepSeek-style compat (`thinkingFormat: "deepseek"`, `requiresReasoningContentOnAssistantMessages: true`). Previously, models marked `reasoning: true` without `thinkingFormat` caused Pi to enter plan mode but couldn't parse the reasoning tokens, resulting in hangs ([#212](https://github.com/apmantza/pi-free/pull/212), [#213](https://github.com/apmantza/pi-free/pull/213)).
19
+
20
+ ### Added
21
+
22
+ - **`/probe-routeway` command** — Tests each Routeway model with a minimal chat request and auto-hides models that return 5xx or 404 errors. Runs lazily on first `session_start` with 24h probe cache TTL. Follows the same pattern as `/probe-nvidia` ([#213](https://github.com/apmantza/pi-free/pull/213)).
23
+
24
+ ## [2.0.14] - 2026-06-02
25
+
26
+ ### Added
27
+
28
+ - **Routeway provider** — OpenAI-compatible gateway (`api.routeway.ai/v1`) with 219 models, 16 free (`:free` suffix). Set `ROUTEWAY_API_KEY` or add `routeway_api_key` to `~/.pi/free.json`. Toggle with `/toggle-routeway` ([#209](https://github.com/apmantza/pi-free/pull/209)).
29
+
30
+ ### Fixed
31
+
32
+ - **Cline free model merging** — Free-to-try models (e.g. `qwen3.7-plus`) from Cline's recommended list now appear in the free model picker even when absent from the main catalog ([#209](https://github.com/apmantza/pi-free/pull/209)).
33
+
34
+ - **`_pricingKnown` / `_freeKnown` authoritatve flag** — Providers can now signal whether pricing data is authoritative via `_pricingKnown`. When `false`, `isFreeModel` falls back to name-based detection. Kilo's `isFree` API flag now flows through as `_freeKnown` ([#209](https://github.com/apmantza/pi-free/pull/209)).
35
+
36
+ - **MiniMax reasoning compat** — MiniMax M3 and other MiniMax models now have `supportsReasoningEffort: true` compat settings. Previously, models marked `reasoning: true` without compat caused Pi to enter plan mode without knowing the thinking format, resulting in hangs.
37
+
10
38
  ## [2.0.13] - 2026-05-21
11
39
 
12
40
  ### Added
package/README.md CHANGED
@@ -64,6 +64,7 @@ Free models are shown by default — look for the provider prefixes:
64
64
  - `codestral/` — Codestral via Mistral (free Experiment plan: 2 req/min, 1B tokens/month)
65
65
  - `deepinfra/` — DeepInfra inference cloud ($5 one-time trial credit, no credit card)
66
66
  - `novita/` — Novita AI (100+ open-source models, OpenAI-compatible, 3 free models)
67
+ - `routeway/` — Routeway AI gateway (OpenAI-compatible, `:free` models)
67
68
 
68
69
  > **Note:** Paid providers may occasionally offer free models or promotional credits. The `isFreeModel` helper automatically detects free models based on provider pricing data or model names containing "free". For providers that don't expose pricing (like CrofAI), only models with "free" in their names are marked as free.
69
70
 
@@ -102,6 +103,7 @@ Want to see paid models too? Run the toggle command for your provider:
102
103
  /toggle-sambanova # Toggle SambaNova (🔄 freemium)
103
104
  /toggle-llm7 # Toggle LLM7 (✅ free gateway)
104
105
  /toggle-novita # Toggle Novita AI (💳 paid — 3 free models)
106
+ /toggle-routeway # Toggle Routeway AI (💳 paid — has :free models)
105
107
  /toggle-fastrouter # Toggle FastRouter (🔧 dynamic — always discovered)
106
108
  ```
107
109
 
@@ -132,7 +134,8 @@ Add your API keys to this file:
132
134
  "sambanova_api_key": "...",
133
135
  "llm7_api_key": "...",
134
136
  "zenmux_api_key": "...",
135
- "crofai_api_key": "..."
137
+ "crofai_api_key": "...",
138
+ "routeway_api_key": "sk-..."
136
139
  }
137
140
  ```
138
141
 
@@ -464,10 +467,11 @@ Each provider has toggle commands to switch between free and all models:
464
467
 
465
468
  Test models for 404/403 errors and auto-hide broken ones:
466
469
 
467
- | Command | What it does |
468
- | --------------- | ----------------------------------------------------------- |
469
- | `/probe-nvidia` | Test all NVIDIA models, auto-hide 404s in `~/.pi/free.json` |
470
- | `/probe-ollama` | Test all Ollama models, auto-hide 403s in `~/.pi/free.json` |
470
+ | Command | What it does |
471
+ | ----------------- | ----------------------------------------------------------- |
472
+ | `/probe-nvidia` | Test all NVIDIA models, auto-hide 404s in `~/.pi/free.json` |
473
+ | `/probe-ollama` | Test all Ollama models, auto-hide 403s in `~/.pi/free.json` |
474
+ | `/probe-routeway` | Test all Routeway models, auto-hide 5xx/404s |
471
475
 
472
476
  **How it works:**
473
477
 
package/config.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  PROVIDER_MODAL,
18
18
  PROVIDER_NVIDIA,
19
19
  PROVIDER_QWEN,
20
+ PROVIDER_ROUTEWAY,
20
21
  } from "./constants.ts";
21
22
  import { createLogger } from "./lib/logger.ts";
22
23
 
@@ -33,6 +34,7 @@ interface PiFreeConfig {
33
34
  sambanova_api_key?: string;
34
35
  together_api_key?: string;
35
36
  novita_api_key?: string;
37
+ routeway_api_key?: string;
36
38
  fastrouter_api_key?: string;
37
39
  kilo_free_only?: boolean;
38
40
  hidden_models?: string[];
@@ -48,6 +50,7 @@ interface PiFreeConfig {
48
50
  sambanova_show_paid?: boolean;
49
51
  together_show_paid?: boolean;
50
52
  novita_show_paid?: boolean;
53
+ routeway_show_paid?: boolean;
51
54
  fastrouter_show_paid?: boolean;
52
55
  openrouter_show_paid?: boolean;
53
56
  opencode_show_paid?: boolean;
@@ -64,6 +67,7 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
64
67
  sambanova_api_key: "",
65
68
  together_api_key: "",
66
69
  novita_api_key: "",
70
+ routeway_api_key: "",
67
71
  fastrouter_api_key: "",
68
72
 
69
73
  kilo_free_only: false,
@@ -80,6 +84,7 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
80
84
  sambanova_show_paid: false,
81
85
  together_show_paid: false,
82
86
  novita_show_paid: false,
87
+ routeway_show_paid: false,
83
88
  fastrouter_show_paid: false,
84
89
  openrouter_show_paid: false,
85
90
  opencode_show_paid: false,
@@ -222,6 +227,10 @@ export function getNovitaShowPaid(): boolean {
222
227
  return resolveBool("NOVITA_SHOW_PAID", loadConfigFile().novita_show_paid);
223
228
  }
224
229
 
230
+ export function getRoutewayShowPaid(): boolean {
231
+ return resolveBool("ROUTEWAY_SHOW_PAID", loadConfigFile().routeway_show_paid);
232
+ }
233
+
225
234
  export function getFastrouterShowPaid(): boolean {
226
235
  return resolveBool(
227
236
  "FASTROUTER_SHOW_PAID",
@@ -266,6 +275,8 @@ export function getProviderShowPaid(providerId: string): boolean {
266
275
  return getTogetherShowPaid();
267
276
  case "novita":
268
277
  return getNovitaShowPaid();
278
+ case "routeway":
279
+ return getRoutewayShowPaid();
269
280
  case "fastrouter":
270
281
  return getFastrouterShowPaid();
271
282
  case "ollama-cloud":
@@ -331,6 +342,10 @@ export function getNovitaApiKey(): string | undefined {
331
342
  return resolve("NOVITA_API_KEY", loadConfigFile().novita_api_key);
332
343
  }
333
344
 
345
+ export function getRoutewayApiKey(): string | undefined {
346
+ return resolve("ROUTEWAY_API_KEY", loadConfigFile().routeway_api_key);
347
+ }
348
+
334
349
  export function getFastrouterApiKey(): string | undefined {
335
350
  return resolve("FASTROUTER_API_KEY", loadConfigFile().fastrouter_api_key);
336
351
  }
package/constants.ts CHANGED
@@ -23,6 +23,7 @@ export const PROVIDER_DEEPINFRA = "deepinfra";
23
23
  export const PROVIDER_SAMBANOVA = "sambanova";
24
24
  export const PROVIDER_TOGETHER = "together";
25
25
  export const PROVIDER_NOVITA = "novita";
26
+ export const PROVIDER_ROUTEWAY = "routeway";
26
27
 
27
28
  export const ALL_UNIQUE_PROVIDERS = [
28
29
  PROVIDER_KILO,
@@ -40,6 +41,7 @@ export const ALL_UNIQUE_PROVIDERS = [
40
41
  PROVIDER_SAMBANOVA,
41
42
  PROVIDER_TOGETHER,
42
43
  PROVIDER_NOVITA,
44
+ PROVIDER_ROUTEWAY,
43
45
  ] as const;
44
46
 
45
47
  // =============================================================================
@@ -62,6 +64,7 @@ export const BASE_URL_DEEPINFRA = "https://api.deepinfra.com/v1/openai";
62
64
  export const BASE_URL_SAMBANOVA = "https://api.sambanova.ai/v1";
63
65
  export const BASE_URL_TOGETHER = "https://api.together.xyz/v1";
64
66
  export const BASE_URL_NOVITA = "https://api.novita.ai/openai/v1";
67
+ export const BASE_URL_ROUTEWAY = "https://api.routeway.ai/v1";
65
68
 
66
69
  /** Cline fetches free models from OpenRouter */
67
70
  export const BASE_URL_OPENROUTER = "https://openrouter.ai/api/v1";
package/index.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * - DeepInfra: AI inference cloud ($5 trial credit)
15
15
  * - SambaNova: Fast inference on RDU hardware (free tier, no credit card)
16
16
  * - Together: Fast inference on 200+ open-source models ($1 trial credit)
17
+ * - Routeway: OpenAI-compatible gateway with free `:free` models
17
18
  * - LLM7: AI gateway (free default/fast selectors)
18
19
  */
19
20
 
@@ -24,6 +25,13 @@ import {
24
25
  processQuotaResponse,
25
26
  formatQuotaStatus,
26
27
  } from "./lib/quota-monitor.ts";
28
+ import {
29
+ startModelCall,
30
+ recordModelCall,
31
+ getAllTelemetry,
32
+ getTelemetryPath,
33
+ clearTelemetry,
34
+ } from "./lib/telemetry.ts";
27
35
  import {
28
36
  applyGlobalFilter,
29
37
  getGlobalFreeOnly,
@@ -41,6 +49,7 @@ import deepinfra from "./providers/deepinfra/deepinfra.ts";
41
49
  import sambanova from "./providers/sambanova/sambanova.ts";
42
50
  import together from "./providers/together/together.ts";
43
51
  import novita from "./providers/novita/novita.ts";
52
+ import routeway from "./providers/routeway/routeway.ts";
44
53
  import nvidia from "./providers/nvidia/nvidia.ts";
45
54
  import ollama from "./providers/ollama/ollama.ts";
46
55
  import zenmux from "./providers/zenmux/zenmux.ts";
@@ -145,6 +154,64 @@ function setupGlobalCommands(pi: ExtensionAPI) {
145
154
  ctx.ui.notify(lines.join("\n"), "info");
146
155
  },
147
156
  });
157
+
158
+ // /telemetry — Show model telemetry data
159
+ pi.registerCommand("free-telemetry", {
160
+ description:
161
+ "Show real-world performance data for free models (tokens/s, latency, success rate)",
162
+ handler: async (_args, ctx) => {
163
+ const allTelemetry = getAllTelemetry();
164
+ const entries = Object.entries(allTelemetry);
165
+
166
+ if (entries.length === 0) {
167
+ ctx.ui.notify(
168
+ "No telemetry data yet. Use some free models first!",
169
+ "info",
170
+ );
171
+ return;
172
+ }
173
+
174
+ // Sort by total calls descending
175
+ entries.sort((a, b) => b[1].totalCalls - a[1].totalCalls);
176
+
177
+ const lines = ["📊 Model Telemetry:", ""];
178
+ lines.push(
179
+ `${`Model`.padEnd(40)} ${`Calls`.padEnd(6)} ${`OK%`.padEnd(6)} ${`Lat`.padEnd(7)} ${`tok/s`.padEnd(7)} ${`Cost`}`,
180
+ );
181
+ lines.push(`─`.repeat(75));
182
+
183
+ for (const [key, t] of entries.slice(0, 20)) {
184
+ const name = key.length > 38 ? key.slice(0, 35) + "..." : key;
185
+ const calls = String(t.totalCalls).padStart(5);
186
+ const ok = `${t.successRate}%`.padStart(5);
187
+ const lat =
188
+ t.avgLatencyMs > 0
189
+ ? `${t.avgLatencyMs}ms`.padStart(6)
190
+ : "—".padStart(6);
191
+ const tps =
192
+ t.avgTokensPerSecond > 0
193
+ ? `${t.avgTokensPerSecond}`.padStart(6)
194
+ : "—".padStart(6);
195
+ const cost =
196
+ t.totalCost > 0
197
+ ? `$${t.totalCost.toFixed(4)}`.padStart(8)
198
+ : "free".padStart(8);
199
+ lines.push(`${name.padEnd(40)} ${calls} ${ok} ${lat} ${tps} ${cost}`);
200
+ }
201
+
202
+ lines.push("", `File: ${getTelemetryPath()}`);
203
+ ctx.ui.notify(lines.join("\n"), "info");
204
+ },
205
+ });
206
+
207
+ // /clear-free-telemetry — Clear all telemetry data
208
+ pi.registerCommand("clear-free-telemetry", {
209
+ description: "Clear all model telemetry data",
210
+ handler: async (_args, ctx) => {
211
+ clearTelemetry();
212
+ ctx.ui.notify("Telemetry data cleared", "info");
213
+ },
214
+ });
148
215
  }
149
216
 
150
217
  // =============================================================================
@@ -183,6 +250,70 @@ function setupQuotaMonitoring(pi: ExtensionAPI) {
183
250
  });
184
251
  }
185
252
 
253
+ // =============================================================================
254
+ // Model Telemetry
255
+ // =============================================================================
256
+
257
+ function setupTelemetry(pi: ExtensionAPI) {
258
+ // Only track telemetry for FREE models (uses same isFreeModel logic as model filtering)
259
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
+ (pi as any).on("before_agent_start", (_event: any, ctx: any) => {
261
+ if (!ctx.model) return;
262
+ if (!isFreeModel(ctx.model as any)) return;
263
+ const provider = ctx.model?.provider;
264
+ const model = ctx.model?.id;
265
+ if (provider && model) {
266
+ startModelCall(provider, model);
267
+ }
268
+ });
269
+
270
+ // Record telemetry when a turn completes
271
+ pi.on("turn_end", (event, ctx) => {
272
+ if (!ctx.model) return;
273
+ if (!isFreeModel(ctx.model as any)) return;
274
+
275
+ const msg = (
276
+ event as {
277
+ message?: {
278
+ role?: string;
279
+ model?: string;
280
+ usage?: {
281
+ input?: number;
282
+ output?: number;
283
+ totalTokens?: number;
284
+ cost?: { total?: number };
285
+ };
286
+ stopReason?: string;
287
+ errorMessage?: string;
288
+ };
289
+ }
290
+ ).message;
291
+
292
+ if (msg?.role !== "assistant") return;
293
+
294
+ const provider = ctx.model?.provider;
295
+ const model = msg.model || ctx.model?.id;
296
+ if (!provider || !model) return;
297
+
298
+ const usage = msg.usage;
299
+ const inputTokens = usage?.input ?? 0;
300
+ const outputTokens = usage?.output ?? 0;
301
+ const totalTokens = usage?.totalTokens ?? inputTokens + outputTokens;
302
+ const cost = usage?.cost?.total ?? 0;
303
+ const isError = msg.stopReason === "error" || !!msg.errorMessage;
304
+
305
+ recordModelCall(
306
+ provider,
307
+ model,
308
+ { input: inputTokens, output: outputTokens, totalTokens },
309
+ cost,
310
+ !isError,
311
+ msg.stopReason,
312
+ msg.errorMessage,
313
+ );
314
+ });
315
+ }
316
+
186
317
  // =============================================================================
187
318
  // Main Entry Point
188
319
  // =============================================================================
@@ -197,6 +328,9 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
197
328
  // Setup quota monitoring (passive, no extra API calls)
198
329
  setupQuotaMonitoring(pi);
199
330
 
331
+ // Setup model telemetry (tracks real-world performance)
332
+ setupTelemetry(pi);
333
+
200
334
  // Load all unique providers
201
335
  // Each provider will register itself with the global toggle system
202
336
  await Promise.allSettled([
@@ -212,6 +346,7 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
212
346
  sambanova(pi),
213
347
  together(pi),
214
348
  novita(pi),
349
+ routeway(pi),
215
350
  ]);
216
351
 
217
352
  // Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face,
@@ -278,9 +278,9 @@ function setupStatusBar(
278
278
 
279
279
  function getApiKeyEnvForProvider(providerId: string): string {
280
280
  const envMap: Record<string, string> = {
281
- opencode: "OPENCODE_API_KEY",
282
- "opencode-go": "OPENCODE_API_KEY",
283
- openrouter: "OPENROUTER_API_KEY",
281
+ opencode: "$OPENCODE_API_KEY",
282
+ "opencode-go": "$OPENCODE_API_KEY",
283
+ openrouter: "$OPENROUTER_API_KEY",
284
284
  };
285
- return envMap[providerId] || `${providerId.toUpperCase()}_API_KEY`;
285
+ return envMap[providerId] || `$${providerId.toUpperCase()}_API_KEY`;
286
286
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Provider model probe cache.
3
+ *
4
+ * Stores the last successful accessibility probe per provider/model so
5
+ * background cleanup can avoid spending quota on the same checks every session.
6
+ */
7
+
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { createJSONStore } from "./json-persistence.ts";
11
+ import { createLogger } from "./logger.ts";
12
+
13
+ const _logger = createLogger("probe-cache");
14
+
15
+ export const DEFAULT_PROBE_TTL_MS = 24 * 60 * 60 * 1000;
16
+
17
+ export type ProbeStatus = "ok" | "broken";
18
+
19
+ export interface ModelProbeResult {
20
+ modelId: string;
21
+ status: ProbeStatus;
22
+ }
23
+
24
+ interface ModelProbeEntry {
25
+ lastProbedAt: string;
26
+ status: ProbeStatus;
27
+ }
28
+
29
+ interface ProviderProbeCache {
30
+ provider: string;
31
+ models: Record<string, ModelProbeEntry>;
32
+ }
33
+
34
+ interface ProbeCacheData {
35
+ providers: Record<string, ProviderProbeCache>;
36
+ }
37
+
38
+ const CACHE_FILE = join(homedir(), ".pi", "probe-cache.json");
39
+ const _cache = createJSONStore<ProbeCacheData>(CACHE_FILE, { providers: {} });
40
+
41
+ export function getModelsDueForProbe(
42
+ providerId: string,
43
+ modelIds: string[],
44
+ ttlMs = DEFAULT_PROBE_TTL_MS,
45
+ ): string[] {
46
+ const provider = _cache.load().providers[providerId];
47
+ const now = Date.now();
48
+
49
+ return modelIds.filter((modelId) => {
50
+ const entry = provider?.models[modelId];
51
+ if (!entry) return true;
52
+
53
+ // Broken models are normally hidden immediately. If a user later unhides one,
54
+ // re-check it instead of letting a stale broken cache suppress cleanup.
55
+ if (entry.status === "broken") return true;
56
+
57
+ const lastProbedAt = Date.parse(entry.lastProbedAt);
58
+ if (!Number.isFinite(lastProbedAt)) return true;
59
+
60
+ return now - lastProbedAt >= ttlMs;
61
+ });
62
+ }
63
+
64
+ export function recordModelProbeResults(
65
+ providerId: string,
66
+ results: ModelProbeResult[],
67
+ ): void {
68
+ if (results.length === 0) return;
69
+
70
+ const data = _cache.load();
71
+ const provider = (data.providers[providerId] ??= {
72
+ provider: providerId,
73
+ models: {},
74
+ });
75
+ const lastProbedAt = new Date().toISOString();
76
+
77
+ for (const result of results) {
78
+ provider.models[result.modelId] = {
79
+ lastProbedAt,
80
+ status: result.status,
81
+ };
82
+ }
83
+
84
+ _cache.save(data);
85
+ _logger.debug(`Recorded ${results.length} probe results for ${providerId}`);
86
+ }
@@ -23,6 +23,10 @@ export function isLikelyReasoningModel(model: ProviderModelIdentity): boolean {
23
23
  const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
24
24
  return (
25
25
  isDeepSeekModel(model) ||
26
+ haystack.includes("minimax") ||
27
+ haystack.includes("kimi") ||
28
+ haystack.includes("qwen3.7") ||
29
+ haystack.includes("qwen3-7") ||
26
30
  haystack.includes("thinking") ||
27
31
  haystack.includes("reasoning") ||
28
32
  haystack.includes("reasoner") ||
@@ -42,5 +46,34 @@ export function getProxyModelCompat(
42
46
  return DEEPSEEK_PROXY_COMPAT;
43
47
  }
44
48
 
49
+ // MiniMax on OpenRouter/Cline uses reasoning_content (DeepSeek format)
50
+ if (model.id.toLowerCase().includes("minimax")) {
51
+ return {
52
+ supportsStore: false,
53
+ supportsDeveloperRole: false,
54
+ supportsReasoningEffort: true,
55
+ requiresReasoningContentOnAssistantMessages: true,
56
+ thinkingFormat: "deepseek",
57
+ };
58
+ }
59
+
60
+ // Qwen 3.7+ on OpenRouter/Cline uses reasoning_content (DeepSeek format)
61
+ if (
62
+ model.id.toLowerCase().includes("qwen3.7") ||
63
+ model.id.toLowerCase().includes("qwen3-7")
64
+ ) {
65
+ return DEEPSEEK_PROXY_COMPAT;
66
+ }
67
+
68
+ // Kimi K2.6 needs reasoning_content on assistant messages (OpenRouter issue #5309)
69
+ if (model.id.toLowerCase().includes("kimi")) {
70
+ return {
71
+ supportsStore: false,
72
+ supportsDeveloperRole: false,
73
+ supportsReasoningEffort: true,
74
+ requiresReasoningContentOnAssistantMessages: true,
75
+ };
76
+ }
77
+
45
78
  return undefined;
46
79
  }
package/lib/registry.ts CHANGED
@@ -82,7 +82,12 @@ function detectPricingExposed(allModels: ProviderModelConfig[]): boolean {
82
82
  * @returns true if the model is definitively free per the provider's API
83
83
  */
84
84
  export function isFreeModel(
85
- model: ProviderModelConfig & { provider?: string; _pricingKnown?: boolean },
85
+ model: ProviderModelConfig & {
86
+ provider?: string;
87
+ _pricingKnown?: boolean;
88
+ _freeKnown?: boolean;
89
+ _isFree?: boolean;
90
+ },
86
91
  allModels?: ProviderModelConfig[],
87
92
  ): boolean {
88
93
  return isFreeModelInternal(model, allModels);
@@ -90,9 +95,21 @@ export function isFreeModel(
90
95
 
91
96
  // Internal implementation to work around TypeScript filter callback issues
92
97
  function isFreeModelInternal(
93
- model: ProviderModelConfig & { provider?: string; _pricingKnown?: boolean },
98
+ model: ProviderModelConfig & {
99
+ provider?: string;
100
+ _pricingKnown?: boolean;
101
+ _freeKnown?: boolean;
102
+ _isFree?: boolean;
103
+ },
94
104
  allModels: ProviderModelConfig[] | undefined,
95
105
  ): boolean {
106
+ // Some gateways expose an authoritative free/paid flag. Prefer it over
107
+ // pricing because a few non-chat or preview models can report zero token
108
+ // prices while still not being offered as free chat models.
109
+ if (model._freeKnown === true) {
110
+ return model._isFree === true;
111
+ }
112
+
96
113
  // Determine if pricing is exposed
97
114
  let pricingExposed: boolean;
98
115
 
@@ -213,7 +230,12 @@ export function applyGlobalFilter(
213
230
 
214
231
  for (const [providerId, entry] of providerRegistry) {
215
232
  try {
216
- applyFilterToProvider(providerId, entry, freeOnly, options.force === true);
233
+ applyFilterToProvider(
234
+ providerId,
235
+ entry,
236
+ freeOnly,
237
+ options.force === true,
238
+ );
217
239
  } catch (err) {
218
240
  _logger.error(
219
241
  `[pi-free] Failed to apply filter to ${providerId}`,