pi-free 2.0.1 → 2.0.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/config.ts CHANGED
@@ -24,54 +24,45 @@ const _logger = createLogger("config");
24
24
 
25
25
  interface PiFreeConfig {
26
26
  nvidia_api_key?: string;
27
- cloudflare_api_token?: string;
28
- cloudflare_account_id?: string;
29
27
  ollama_api_key?: string;
30
- modal_api_key?: string;
31
- opencode_api_key?: string;
28
+ zenmux_api_key?: string;
29
+ crofai_api_key?: string;
32
30
  mistral_api_key?: string;
33
31
  groq_api_key?: string;
34
32
  cerebras_api_key?: string;
35
33
  xai_api_key?: string;
36
34
  hf_token?: string;
37
- openrouter_api_key?: string;
38
35
  kilo_free_only?: boolean;
39
36
  hidden_models?: string[];
40
37
  free_only?: boolean;
41
38
  kilo_show_paid?: boolean;
42
- nvidia_show_paid?: boolean;
43
- cloudflare_show_paid?: boolean;
44
39
  ollama_show_paid?: boolean;
45
40
  cline_show_paid?: boolean;
46
- qwen_show_paid?: boolean;
47
- modal_show_paid?: boolean;
41
+ zenmux_show_paid?: boolean;
42
+ crofai_show_paid?: boolean;
48
43
  openrouter_show_paid?: boolean;
49
44
  opencode_show_paid?: boolean;
50
45
  }
51
46
 
52
47
  const CONFIG_TEMPLATE: PiFreeConfig = {
53
48
  nvidia_api_key: "",
54
- cloudflare_api_token: "",
55
- cloudflare_account_id: "",
56
49
  ollama_api_key: "",
57
- modal_api_key: "",
58
- opencode_api_key: "",
50
+ zenmux_api_key: "",
51
+ crofai_api_key: "",
59
52
  mistral_api_key: "",
60
53
  groq_api_key: "",
61
54
  cerebras_api_key: "",
62
55
  xai_api_key: "",
63
56
  hf_token: "",
64
- openrouter_api_key: "",
57
+
65
58
  kilo_free_only: false,
66
59
  hidden_models: [],
67
60
  free_only: true,
68
61
  kilo_show_paid: false,
69
- nvidia_show_paid: false,
70
- cloudflare_show_paid: false,
71
62
  ollama_show_paid: false,
72
63
  cline_show_paid: false,
73
- qwen_show_paid: false,
74
- modal_show_paid: false,
64
+ zenmux_show_paid: false,
65
+ crofai_show_paid: false,
75
66
  openrouter_show_paid: false,
76
67
  opencode_show_paid: false,
77
68
  };
@@ -144,34 +135,22 @@ export function getKiloShowPaid(): boolean {
144
135
  return resolveBool("KILO_SHOW_PAID", loadConfigFile().kilo_show_paid);
145
136
  }
146
137
 
147
- export function getNvidiaShowPaid(): boolean {
148
- return resolveBool("NVIDIA_SHOW_PAID", loadConfigFile().nvidia_show_paid);
149
- }
150
-
151
138
  export function getClineShowPaid(): boolean {
152
139
  return resolveBool("CLINE_SHOW_PAID", loadConfigFile().cline_show_paid);
153
140
  }
154
141
 
155
- /** @deprecated Qwen provider is deprecated. */
156
- export function getQwenShowPaid(): boolean {
157
- return resolveBool("QWEN_SHOW_PAID", loadConfigFile().qwen_show_paid);
142
+ export function getZenmuxShowPaid(): boolean {
143
+ return resolveBool("ZENMUX_SHOW_PAID", loadConfigFile().zenmux_show_paid);
158
144
  }
159
145
 
160
- export function getModalShowPaid(): boolean {
161
- return resolveBool("MODAL_SHOW_PAID", loadConfigFile().modal_show_paid);
146
+ export function getCrofaiShowPaid(): boolean {
147
+ return resolveBool("CROFAI_SHOW_PAID", loadConfigFile().crofai_show_paid);
162
148
  }
163
149
 
164
150
  export function getOllamaShowPaid(): boolean {
165
151
  return resolveBool("OLLAMA_SHOW_PAID", loadConfigFile().ollama_show_paid);
166
152
  }
167
153
 
168
- export function getCloudflareShowPaid(): boolean {
169
- return resolveBool(
170
- "CLOUDFLARE_SHOW_PAID",
171
- loadConfigFile().cloudflare_show_paid,
172
- );
173
- }
174
-
175
154
  export function getOpenrouterShowPaid(): boolean {
176
155
  return resolveBool(
177
156
  "OPENROUTER_SHOW_PAID",
@@ -203,27 +182,16 @@ export function getNvidiaApiKey(): string | undefined {
203
182
  return resolve("NVIDIA_API_KEY", loadConfigFile().nvidia_api_key);
204
183
  }
205
184
 
206
- export function getModalApiKey(): string | undefined {
207
- return resolve("MODAL_API_KEY", loadConfigFile().modal_api_key);
208
- }
209
-
210
- export function getOllamaApiKey(): string | undefined {
211
- return resolve("OLLAMA_API_KEY", loadConfigFile().ollama_api_key);
185
+ export function getZenmuxApiKey(): string | undefined {
186
+ return resolve("ZENMUX_API_KEY", loadConfigFile().zenmux_api_key);
212
187
  }
213
188
 
214
- export function getCloudflareApiToken(): string | undefined {
215
- return resolve("CLOUDFLARE_API_TOKEN", loadConfigFile().cloudflare_api_token);
189
+ export function getCrofaiApiKey(): string | undefined {
190
+ return resolve("CROFAI_API_KEY", loadConfigFile().crofai_api_key);
216
191
  }
217
192
 
218
- export function getCloudflareAccountId(): string | undefined {
219
- return resolve(
220
- "CLOUDFLARE_ACCOUNT_ID",
221
- loadConfigFile().cloudflare_account_id,
222
- );
223
- }
224
-
225
- export function getOpencodeApiKey(): string | undefined {
226
- return resolve("OPENCODE_API_KEY", loadConfigFile().opencode_api_key);
193
+ export function getOllamaApiKey(): string | undefined {
194
+ return resolve("OLLAMA_API_KEY", loadConfigFile().ollama_api_key);
227
195
  }
228
196
 
229
197
  export function getMistralApiKey(): string | undefined {
@@ -246,18 +214,42 @@ export function getHfToken(): string | undefined {
246
214
  return resolve("HF_TOKEN", loadConfigFile().hf_token);
247
215
  }
248
216
 
217
+ /**
218
+ * OpenRouter key — pi's built-in provider reads from ~/.pi/agent/auth.json.
219
+ * pi-free only checks the env var to avoid stale keys from free.json.
220
+ */
249
221
  export function getOpenrouterApiKey(): string | undefined {
250
- return resolve("OPENROUTER_API_KEY", loadConfigFile().openrouter_api_key);
222
+ return process.env.OPENROUTER_API_KEY;
251
223
  }
252
224
 
253
225
  // =============================================================================
254
226
  // Hidden models (re-reads config on every call)
255
227
  // =============================================================================
256
228
 
257
- export function applyHidden<T extends { id: string }>(models: T[]): T[] {
229
+ /**
230
+ * Apply hidden models filter with provider scoping.
231
+ * Hidden models can be specified as:
232
+ * - "model-id" (global, applies to all providers - deprecated)
233
+ * - "provider/model-id" (provider-specific, preferred)
234
+ */
235
+ export function applyHidden<T extends { id: string }>(
236
+ models: T[],
237
+ providerId?: string,
238
+ ): T[] {
258
239
  const hidden = new Set(loadConfigFile().hidden_models ?? []);
259
240
  if (hidden.size === 0) return models;
260
- return models.filter((m) => !hidden.has(m.id));
241
+
242
+ return models.filter((m) => {
243
+ // Check provider-scoped ID (preferred format: "provider/model-id")
244
+ if (providerId && hidden.has(`${providerId}/${m.id}`)) {
245
+ return false;
246
+ }
247
+ // Check global ID (legacy format, still supported for backward compat)
248
+ if (hidden.has(m.id)) {
249
+ return false;
250
+ }
251
+ return true;
252
+ });
261
253
  }
262
254
 
263
255
  // =============================================================================
package/constants.ts CHANGED
@@ -15,6 +15,8 @@ export const PROVIDER_OLLAMA = "ollama-cloud";
15
15
  /** @deprecated Qwen provider is deprecated. The 1,000 req/day free tier is no longer available. */
16
16
  export const PROVIDER_QWEN = "qwen";
17
17
  export const PROVIDER_MODAL = "modal";
18
+ export const PROVIDER_ZENMUX = "zenmux";
19
+ export const PROVIDER_CROFAI = "crofai";
18
20
 
19
21
  export const ALL_UNIQUE_PROVIDERS = [
20
22
  PROVIDER_KILO,
@@ -24,6 +26,8 @@ export const ALL_UNIQUE_PROVIDERS = [
24
26
  PROVIDER_QWEN,
25
27
  PROVIDER_MODAL,
26
28
  PROVIDER_OLLAMA,
29
+ PROVIDER_ZENMUX,
30
+ PROVIDER_CROFAI,
27
31
  ] as const;
28
32
 
29
33
  // =============================================================================
@@ -38,6 +42,8 @@ export const BASE_URL_CLINE = "https://api.cline.bot/api/v1";
38
42
  export const BASE_URL_MODAL = "https://api.us-west-2.modal.direct/v1";
39
43
  export const BASE_URL_QWEN =
40
44
  "https://dashscope.aliyuncs.com/compatible-mode/v1";
45
+ export const BASE_URL_ZENMUX = "https://zenmux.ai/api/v1";
46
+ export const BASE_URL_CROFAI = "https://crof.ai/v1";
41
47
 
42
48
  /** Cline fetches free models from OpenRouter */
43
49
  export const BASE_URL_OPENROUTER = "https://openrouter.ai/api/v1";
package/index.ts CHANGED
@@ -9,8 +9,7 @@
9
9
  * - Cline: Cline bot integration
10
10
  * - NVIDIA: NVIDIA NIM hosting (free tier available)
11
11
  * - Ollama Cloud: Ollama's cloud-hosted models with usage-based free tier
12
- * - Qwen: OAuth-based Qwen access (deprecated)
13
- * - Modal: Modal Labs hosting
12
+ * - ZenMux: Unified AI API gateway with 200+ models
14
13
  */
15
14
 
16
15
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -25,12 +24,11 @@ import {
25
24
  } from "./lib/registry.ts";
26
25
  // Import unique provider extensions (only providers NOT built into pi)
27
26
  import cline from "./providers/cline/cline.ts";
28
- import cloudflare from "./providers/cloudflare/cloudflare.ts";
27
+ import crofai from "./providers/crofai/crofai.ts";
29
28
  import kilo from "./providers/kilo/kilo.ts";
30
- import modal from "./providers/modal/modal.ts";
31
29
  import nvidia from "./providers/nvidia/nvidia.ts";
32
30
  import ollama from "./providers/ollama/ollama.ts";
33
- import qwen from "./providers/qwen/qwen.ts";
31
+ import zenmux from "./providers/zenmux/zenmux.ts";
34
32
 
35
33
  const _logger = createLogger("pi-free");
36
34
 
@@ -39,6 +37,39 @@ const _logger = createLogger("pi-free");
39
37
  // =============================================================================
40
38
 
41
39
  function setupGlobalCommands(pi: ExtensionAPI) {
40
+ // /toggle-free - Global free-only mode toggle
41
+ pi.registerCommand("toggle-free", {
42
+ description: "Toggle global free-only mode for all providers",
43
+ handler: async (_args, ctx) => {
44
+ const current = getGlobalFreeOnly();
45
+ const next = !current;
46
+ applyGlobalFilter(pi, next);
47
+
48
+ const registry = getProviderRegistry();
49
+ const providerCount = registry.size;
50
+
51
+ if (next) {
52
+ const totalFree = [...registry.values()].reduce(
53
+ (sum, e) => sum + e.stored.free.length,
54
+ 0,
55
+ );
56
+ ctx.ui.notify(
57
+ `Free-only mode: ON (${totalFree} free models across ${providerCount} providers)`,
58
+ "info",
59
+ );
60
+ } else {
61
+ const totalAll = [...registry.values()].reduce(
62
+ (sum, e) => sum + (e.stored.all.length || e.stored.free.length),
63
+ 0,
64
+ );
65
+ ctx.ui.notify(
66
+ `Free-only mode: OFF (all ${totalAll} models visible across ${providerCount} providers)`,
67
+ "info",
68
+ );
69
+ }
70
+ },
71
+ });
72
+
42
73
  // /free-providers - Show free model counts by provider
43
74
  pi.registerCommand("free-providers", {
44
75
  description: "Show free/paid model counts for all pi-free providers",
@@ -106,19 +137,15 @@ export default async function (pi: ExtensionAPI) {
106
137
  // Load all unique providers
107
138
  // Each provider will register itself with the global toggle system
108
139
  await Promise.allSettled([
109
- cloudflare(pi),
110
- modal(pi),
111
140
  nvidia(pi),
112
141
  kilo(pi),
113
142
  ollama(pi),
114
- // Qwen is deprecated
115
- qwen(pi).catch((err) => {
116
- _logger.warn("[pi-free] Qwen provider failed to load (deprecated)", err);
117
- }),
118
143
  cline(pi),
144
+ zenmux(pi),
145
+ crofai(pi),
119
146
  ]);
120
147
 
121
- // Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face, OpenRouter)
148
+ // Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face)
122
149
  // These only activate if the user has configured API keys (OpenRouter works without key too)
123
150
  const { setupDynamicBuiltInProviders } = await import(
124
151
  "./providers/dynamic-built-in/index.ts"
@@ -16,17 +16,10 @@ import type {
16
16
  ExtensionAPI,
17
17
  ProviderModelConfig,
18
18
  } from "@mariozechner/pi-coding-agent";
19
- import {
20
- getOpencodeShowPaid,
21
- getOpenrouterShowPaid,
22
- saveConfig,
23
- } from "../config.ts";
19
+ import { getOpencodeShowPaid, getOpenrouterShowPaid } from "../config.ts";
24
20
  import { createLogger } from "./logger.ts";
25
- import {
26
- getGlobalFreeOnly,
27
- isFreeModel,
28
- registerWithGlobalToggle,
29
- } from "./registry.ts";
21
+ import { isFreeModel, registerWithGlobalToggle } from "./registry.ts";
22
+ import { createToggleState } from "./toggle-state.ts";
30
23
 
31
24
  const _logger = createLogger("built-in-toggle");
32
25
 
@@ -49,10 +42,9 @@ const BUILT_IN_TOGGLE_PROVIDERS: BuiltInToggleConfig[] = [
49
42
  // =============================================================================
50
43
 
51
44
  interface BuiltInProviderState {
52
- free: ProviderModelConfig[];
53
- all: ProviderModelConfig[];
45
+ stored: { free: ProviderModelConfig[]; all: ProviderModelConfig[] };
54
46
  reRegister: (models: ProviderModelConfig[]) => void;
55
- showPaid: boolean;
47
+ toggleState: ReturnType<typeof createToggleState<ProviderModelConfig>>;
56
48
  }
57
49
 
58
50
  const providerStates = new Map<string, BuiltInProviderState>();
@@ -68,6 +60,7 @@ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
68
60
  for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
69
61
  registerToggleCommand(pi, config);
70
62
  }
63
+ setupStatusBar(pi);
71
64
  commandsRegistered = true;
72
65
  }
73
66
 
@@ -87,7 +80,9 @@ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
87
80
  if (providerModels.length === 0) continue;
88
81
 
89
82
  const allModels = providerModels.map(modelToProviderConfig);
90
- const freeModels = allModels.filter(isFreeModel);
83
+ const freeModels = allModels.filter((m) =>
84
+ isFreeModel({ ...m, provider: config.id }, allModels),
85
+ );
91
86
 
92
87
  const baseUrl = providerModels[0].baseUrl;
93
88
  const api = providerModels[0].api;
@@ -102,35 +97,29 @@ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
102
97
  });
103
98
  };
104
99
 
100
+ const stored = { free: freeModels, all: allModels };
101
+ const toggleState = createToggleState<ProviderModelConfig>({
102
+ providerId: config.id,
103
+ initialShowPaid: config.getShowPaid(),
104
+ initialModels: stored,
105
+ });
106
+
105
107
  providerStates.set(config.id, {
106
- free: freeModels,
107
- all: allModels,
108
+ stored,
108
109
  reRegister,
109
- showPaid: config.getShowPaid(),
110
+ toggleState,
110
111
  });
111
112
 
112
- // Register with global free-only filter
113
- registerWithGlobalToggle(
114
- config.id,
115
- { free: freeModels, all: allModels },
116
- reRegister,
117
- true,
118
- );
113
+ registerWithGlobalToggle(config.id, stored, reRegister, true);
119
114
 
120
115
  _logger.info(
121
116
  `[built-in-toggle] ${config.id}: captured ${allModels.length} models (${freeModels.length} free)`,
122
117
  );
123
118
 
124
- // Respect global free-only setting at capture time
125
- if (!getGlobalFreeOnly() && !config.getShowPaid()) {
126
- // Default: show free only (same as other pi-free providers)
127
- if (freeModels.length > 0) {
128
- reRegister(freeModels);
129
- _logger.info(
130
- `[built-in-toggle] ${config.id}: applied free-only filter`,
131
- );
132
- }
133
- }
119
+ const applied = toggleState.applyCurrent(reRegister);
120
+ _logger.info(
121
+ `[built-in-toggle] ${config.id}: applied ${applied.mode} mode with ${applied.models.length} models`,
122
+ );
134
123
  }
135
124
  });
136
125
  }
@@ -156,21 +145,16 @@ function registerToggleCommand(
156
145
  return;
157
146
  }
158
147
 
159
- state.showPaid = !state.showPaid;
160
-
161
- // Persist preference
162
- saveConfig({ [`${config.id}_show_paid`]: state.showPaid });
148
+ const applied = state.toggleState.toggle(state.reRegister);
163
149
 
164
- if (state.showPaid) {
165
- state.reRegister(state.all);
150
+ if (applied.mode === "all") {
166
151
  ctx.ui.notify(
167
- `${config.id}: showing all ${state.all.length} models`,
152
+ `${config.id}: showing all ${state.stored.all.length} models`,
168
153
  "info",
169
154
  );
170
155
  } else {
171
- state.reRegister(state.free);
172
156
  ctx.ui.notify(
173
- `${config.id}: showing ${state.free.length} free models`,
157
+ `${config.id}: showing ${state.stored.free.length} free models`,
174
158
  "info",
175
159
  );
176
160
  }
@@ -197,6 +181,42 @@ function modelToProviderConfig(m: Model<Api>): ProviderModelConfig {
197
181
  };
198
182
  }
199
183
 
184
+ // =============================================================================
185
+ // Status bar for provider selection
186
+ // =============================================================================
187
+
188
+ function setupStatusBar(pi: ExtensionAPI): void {
189
+ pi.on("model_select", (_event, ctx) => {
190
+ const selected = _event.model?.provider;
191
+
192
+ // Clear status for all built-in toggle providers
193
+ for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
194
+ if (selected !== config.id) {
195
+ ctx.ui.setStatus(`${config.id}-status`, undefined);
196
+ }
197
+ }
198
+
199
+ if (!selected) return;
200
+
201
+ const state = providerStates.get(selected);
202
+ if (!state) return;
203
+
204
+ const free = state.stored.free.length;
205
+ const total = state.stored.all.length;
206
+ const paid = total - free;
207
+ const mode = state.toggleState.getCurrentMode();
208
+ let status: string;
209
+ if (paid === 0) {
210
+ status = `${selected}: ${free} free models`;
211
+ } else if (mode === "all") {
212
+ status = `${selected}: ${total} models (free + paid)`;
213
+ } else {
214
+ status = `${selected}: ${free} free \u00b7 ${paid} paid`;
215
+ }
216
+ ctx.ui.setStatus(`${selected}-status`, status);
217
+ });
218
+ }
219
+
200
220
  function getApiKeyEnvForProvider(providerId: string): string {
201
221
  const envMap: Record<string, string> = {
202
222
  opencode: "OPENCODE_API_KEY",
@@ -1,20 +1,20 @@
1
- /**
2
- * Model name enhancement helper
3
- * Adds Coding Index scores to model names for display in /model
4
- */
5
-
6
- import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
7
- import { enhanceModelNameWithCodingIndex } from "../provider-failover/benchmark-lookup.ts";
8
-
9
- /**
10
- * Enhance model names with Coding Index scores
11
- * Use this before registering providers to show CI in /model list
12
- */
13
- export function enhanceModelsWithCodingIndex(
14
- models: ProviderModelConfig[],
15
- ): ProviderModelConfig[] {
16
- return models.map((m) => ({
17
- ...m,
18
- name: enhanceModelNameWithCodingIndex(m.name, m.id),
19
- }));
20
- }
1
+ /**
2
+ * Model name enhancement helper
3
+ * Adds Coding Index scores to model names for display in /model
4
+ */
5
+
6
+ import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
7
+ import { enhanceModelNameWithCodingIndex } from "../provider-failover/benchmark-lookup.ts";
8
+
9
+ /**
10
+ * Enhance model names with Coding Index scores
11
+ * Use this before registering providers to show CI in /model list
12
+ */
13
+ export function enhanceModelsWithCodingIndex(
14
+ models: ProviderModelConfig[],
15
+ ): ProviderModelConfig[] {
16
+ return models.map((m) => ({
17
+ ...m,
18
+ name: enhanceModelNameWithCodingIndex(m.name, m.id),
19
+ }));
20
+ }
@@ -25,7 +25,7 @@ export function openBrowser(url: string): void {
25
25
  "-NoProfile",
26
26
  "-NonInteractive",
27
27
  "-Command",
28
- `Start-Process "${url.replace(/"/g, '\\"')}"`,
28
+ `Start-Process "${url.replace(/[\\"]/g, "\\$&")}"`,
29
29
  ],
30
30
  { detached: true, shell: false, windowsHide: true },
31
31
  ).unref();
@@ -0,0 +1,46 @@
1
+ import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
2
+
3
+ export interface ProviderModelIdentity {
4
+ id: string;
5
+ name?: string;
6
+ }
7
+
8
+ export const DEEPSEEK_PROXY_COMPAT: NonNullable<ProviderModelConfig["compat"]> =
9
+ {
10
+ supportsStore: false,
11
+ supportsDeveloperRole: false,
12
+ supportsReasoningEffort: true,
13
+ requiresReasoningContentOnAssistantMessages: true,
14
+ thinkingFormat: "deepseek",
15
+ };
16
+
17
+ export function isDeepSeekModel(model: ProviderModelIdentity): boolean {
18
+ const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
19
+ return haystack.includes("deepseek");
20
+ }
21
+
22
+ export function isLikelyReasoningModel(model: ProviderModelIdentity): boolean {
23
+ const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
24
+ return (
25
+ isDeepSeekModel(model) ||
26
+ haystack.includes("thinking") ||
27
+ haystack.includes("reasoning") ||
28
+ haystack.includes("reasoner") ||
29
+ haystack.includes("r1") ||
30
+ haystack.includes("qwq")
31
+ );
32
+ }
33
+
34
+ /**
35
+ * For gateway/proxy providers that mask the upstream DeepSeek base URL,
36
+ * add explicit compat so pi-ai preserves and replays reasoning_content.
37
+ */
38
+ export function getProxyModelCompat(
39
+ model: ProviderModelIdentity,
40
+ ): ProviderModelConfig["compat"] | undefined {
41
+ if (isDeepSeekModel(model)) {
42
+ return DEEPSEEK_PROXY_COMPAT;
43
+ }
44
+
45
+ return undefined;
46
+ }