pi-free 2.2.4 → 2.2.6

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/banner.svg +1 -1
  4. package/config.ts +58 -29
  5. package/constants.ts +1 -4
  6. package/index.ts +30 -24
  7. package/lib/built-in-toggle.ts +185 -18
  8. package/lib/provider-cache.ts +22 -0
  9. package/package.json +74 -74
  10. package/provider-failover/benchmark-lookup.ts +4 -1
  11. package/provider-failover/benchmarks-chunk-0.ts +570 -570
  12. package/provider-failover/benchmarks-chunk-1.ts +676 -676
  13. package/provider-failover/benchmarks-chunk-2.ts +673 -673
  14. package/provider-failover/benchmarks-chunk-3.ts +680 -680
  15. package/provider-failover/benchmarks-chunk-4.ts +683 -683
  16. package/provider-failover/benchmarks-chunk-5.ts +816 -474
  17. package/provider-helper.ts +64 -0
  18. package/providers/bai/bai.ts +8 -2
  19. package/providers/crofai/crofai.ts +8 -2
  20. package/providers/deepinfra/deepinfra.ts +8 -2
  21. package/providers/dynamic-built-in/index.ts +36 -26
  22. package/providers/kilo/kilo.ts +41 -22
  23. package/providers/novita/novita.ts +8 -2
  24. package/providers/openmodel/openmodel.ts +8 -2
  25. package/providers/qoder/models.ts +63 -175
  26. package/providers/qoder/qoder.ts +49 -84
  27. package/providers/qoder/stream.ts +182 -274
  28. package/providers/qoder/transform.ts +5 -2
  29. package/providers/routeway/routeway.ts +8 -2
  30. package/providers/sambanova/sambanova.ts +12 -6
  31. package/providers/together/together.ts +8 -2
  32. package/providers/tokenrouter/tokenrouter.ts +8 -2
  33. package/providers/zenmux/zenmux.ts +8 -2
  34. package/providers/codestral/codestral.ts +0 -128
  35. package/providers/qoder/encoding.ts +0 -48
@@ -1,19 +1,28 @@
1
1
  /**
2
2
  * Qoder model definitions and cache management.
3
3
  *
4
- * Qoder provides a static set of models (all at zero cost) with the option
5
- * to dynamically discover more from the `/algo/api/v2/model/list` endpoint.
6
- * The dynamic list is cached at `~/.pi/agent/qoder-models-cache.json` with
7
- * a 1-hour TTL and falls back to the static models on cache miss or API error.
4
+ * Qoder operates on a credits-based pricing model:
5
+ * - Community Edition (free): basic models with daily message limits
6
+ * - Pro / Pro+ / Ultra (paid): premium models via monthly credits
8
7
  *
9
- * ALL Qoder models are free no pricing data needed.
8
+ * The dynamic model list API is currently unavailable (legacy api3 endpoint
9
+ * is decommissioned). We keep a static curated list and classify models as
10
+ * basic (free tier) or premium (paid credits) by model ID.
11
+ *
12
+ * Dynamic model discovery is disabled until Qoder publishes a model-list
13
+ * endpoint on api2-v2. Stale legacy cache entries are ignored and static
14
+ * models in `staticModels` remain the source of truth.
10
15
  */
11
16
 
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { existsSync, readFileSync } from "node:fs";
13
18
  import { homedir } from "node:os";
14
- import { dirname, join } from "node:path";
19
+ import { join } from "node:path";
15
20
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
16
- import { buildAuthHeaders } from "./cosy.ts";
21
+ import { createLogger } from "../../lib/logger.ts";
22
+
23
+ const _logger = createLogger("qoder");
24
+
25
+ export type QoderModelConfig = ProviderModelConfig;
17
26
 
18
27
  // ─── Cache ───────────────────────────────────────────────────────────────────
19
28
 
@@ -26,14 +35,29 @@ const ZERO_COST = Object.freeze({
26
35
  cacheWrite: 0,
27
36
  });
28
37
 
38
+ // ─── Basic (free-tier) model IDs ─────────────────────────────────────────────
39
+ // These are the Qoder-branded router models available on Community Edition.
40
+ // Named models (DeepSeek, Qwen, GLM, Kimi, MiniMax) are premium and cost credits.
41
+ // This set is the single source of truth for basic-model classification.
42
+ const BASIC_MODEL_IDS = new Set([
43
+ "auto",
44
+ "ultimate",
45
+ "performance",
46
+ "efficient",
47
+ "lite",
48
+ ]);
49
+
29
50
  // ─── Static model list ───────────────────────────────────────────────────────
30
51
 
31
52
  /**
32
53
  * Static model definitions for Qoder.
33
- * All models are free (zero cost) no paid tier exists.
34
- * These serve as the fallback when the dynamic API is unreachable.
54
+ * Basic models (free tier) are identified by membership in BASIC_MODEL_IDS.
55
+ * Premium models consume credits and require a paid plan.
56
+ *
57
+ * Model IDs are validated against the live api2-v2 endpoint; invalid IDs
58
+ * (dfmodel, gm51model, qmodel_latest) are excluded here.
35
59
  */
36
- export const staticModels: ProviderModelConfig[] = [
60
+ export const staticModels: QoderModelConfig[] = [
37
61
  {
38
62
  id: "auto",
39
63
  name: "Qoder Auto",
@@ -88,15 +112,6 @@ export const staticModels: ProviderModelConfig[] = [
88
112
  contextWindow: 1_000_000,
89
113
  maxTokens: 32_768,
90
114
  },
91
- {
92
- id: "qmodel_latest",
93
- name: "Qwen3.7 Max (Qoder)",
94
- reasoning: false,
95
- input: ["text", "image"] as ("text" | "image")[],
96
- cost: ZERO_COST,
97
- contextWindow: 1_000_000,
98
- maxTokens: 32_768,
99
- },
100
115
  {
101
116
  id: "dmodel",
102
117
  name: "DeepSeek V4 Pro (Qoder)",
@@ -106,24 +121,6 @@ export const staticModels: ProviderModelConfig[] = [
106
121
  contextWindow: 1_000_000,
107
122
  maxTokens: 32_768,
108
123
  },
109
- {
110
- id: "dfmodel",
111
- name: "DeepSeek V4 Flash (Qoder)",
112
- reasoning: true,
113
- input: ["text", "image"] as ("text" | "image")[],
114
- cost: ZERO_COST,
115
- contextWindow: 1_000_000,
116
- maxTokens: 32_768,
117
- },
118
- {
119
- id: "gm51model",
120
- name: "GLM 5.1 (Qoder)",
121
- reasoning: true,
122
- input: ["text", "image"] as ("text" | "image")[],
123
- cost: ZERO_COST,
124
- contextWindow: 180_000,
125
- maxTokens: 32_768,
126
- },
127
124
  {
128
125
  id: "kmodel",
129
126
  name: "Kimi K2.6 (Qoder)",
@@ -144,74 +141,30 @@ export const staticModels: ProviderModelConfig[] = [
144
141
  },
145
142
  ];
146
143
 
147
- // ─── Dynamic model API ───────────────────────────────────────────────────────
144
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
148
145
 
149
- interface QoderModelEntry {
150
- key?: string;
151
- enable?: boolean;
152
- display_name?: string;
153
- max_input_tokens?: number;
154
- max_output_tokens?: number;
155
- context_config?: Record<string, { token_count?: number }>;
156
- is_vl?: boolean;
157
- is_reasoning?: boolean;
158
- thinking_config?: { enabled?: { efforts?: unknown } };
159
- source?: string;
160
- [key: string]: unknown;
146
+ /** Check if a model is a basic (free-tier) model. */
147
+ export function isBasicModel(model: ProviderModelConfig): boolean {
148
+ return BASIC_MODEL_IDS.has(model.id);
161
149
  }
162
150
 
163
151
  // ─── Cache management ────────────────────────────────────────────────────────
164
152
 
165
- function modelEntryToConfig(
166
- entry: QoderModelEntry,
167
- ): ProviderModelConfig | null {
168
- const key = entry.key;
169
- if (!key || !entry.enable) return null;
170
-
171
- const display = entry.display_name || key;
172
- const ctxLen = resolveContextLength(entry);
173
- const isVL = Boolean(entry.is_vl);
174
- const isReasoning = Boolean(entry.is_reasoning) || Boolean(entry.thinking_config);
175
- const input: ("text" | "image")[] = isVL ? ["text", "image"] : ["text"];
176
-
177
- return {
178
- id: key,
179
- name: display,
180
- reasoning: isReasoning,
181
- input,
182
- cost: ZERO_COST,
183
- contextWindow: ctxLen,
184
- maxTokens: entry.max_output_tokens || 32_768,
185
- };
186
- }
187
-
188
- function resolveContextLength(entry: QoderModelEntry): number {
189
- let ctxLen = entry.max_input_tokens || 180_000;
190
- if (entry.context_config && typeof entry.context_config === "object") {
191
- for (const val of Object.values(entry.context_config)) {
192
- if (
193
- val &&
194
- typeof val === "object" &&
195
- typeof (val as Record<string, unknown>).token_count === "number"
196
- ) {
197
- const tc = (val as Record<string, number>).token_count;
198
- if (tc > ctxLen) ctxLen = tc;
199
- }
200
- }
201
- }
202
- return ctxLen;
203
- }
204
-
205
153
  /** Get models from cache, falling back to static models. */
206
- export function getCachedModels(): ProviderModelConfig[] {
207
- if (existsSync(CACHE_PATH)) {
154
+ export function getCachedModels(): QoderModelConfig[] {
155
+ if (existsSync(CACHE_PATH) && !isCacheStale()) {
208
156
  try {
209
157
  const data = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
210
158
  if (data && Array.isArray(data.models)) {
211
- return data.models as ProviderModelConfig[];
159
+ return data.models as QoderModelConfig[];
212
160
  }
213
- } catch {
214
- // Fall through to static
161
+ } catch (err) {
162
+ _logger.warn(
163
+ "Failed to read Qoder model cache; falling back to static models",
164
+ {
165
+ error: err instanceof Error ? err.message : String(err),
166
+ },
167
+ );
215
168
  }
216
169
  }
217
170
  return staticModels;
@@ -224,81 +177,11 @@ export function isCacheStale(): boolean {
224
177
  const data = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
225
178
  if (!data || typeof data.updatedAt !== "number") return true;
226
179
  return Date.now() - data.updatedAt > 3_600_000; // 1 hour
227
- } catch {
228
- return true;
229
- }
230
- }
231
-
232
- /**
233
- * Fetch available models from Qoder's dynamic model list API and cache them.
234
- * Falls back silently if the API is unreachable.
235
- */
236
- export async function updateQoderModelsCache(
237
- authToken: string,
238
- userID: string,
239
- name: string,
240
- email: string,
241
- ): Promise<void> {
242
- const modelListURL = "https://api3.qoder.sh/algo/api/v2/model/list";
243
- try {
244
- const headers = buildAuthHeaders(null, modelListURL, {
245
- userID,
246
- authToken,
247
- name,
248
- email,
180
+ } catch (err) {
181
+ _logger.warn("Failed to check Qoder cache staleness; treating as stale", {
182
+ error: err instanceof Error ? err.message : String(err),
249
183
  });
250
-
251
- const response = await fetch(modelListURL, {
252
- method: "GET",
253
- headers: {
254
- Accept: "application/json",
255
- ...headers,
256
- },
257
- });
258
-
259
- if (!response.ok) return;
260
-
261
- const resData = (await response.json()) as {
262
- chat?: QoderModelEntry[];
263
- };
264
- const chatModels = resData.chat || [];
265
- if (chatModels.length === 0) return;
266
-
267
- const newModels: ProviderModelConfig[] = [];
268
- const configs: Record<string, QoderModelEntry> = {};
269
-
270
- for (const entry of chatModels) {
271
- const model = modelEntryToConfig(entry);
272
- if (!model) continue;
273
- configs[model.id] = entry;
274
- newModels.push(model);
275
- }
276
-
277
- if (newModels.length === 0) return;
278
-
279
- // Ensure the auto router model is present
280
- if (!newModels.some((m) => m.id === "auto")) {
281
- newModels.unshift({
282
- id: "auto",
283
- name: "Qoder Auto",
284
- reasoning: true,
285
- input: ["text", "image"] as ("text" | "image")[],
286
- cost: ZERO_COST,
287
- contextWindow: 180_000,
288
- maxTokens: 32_768,
289
- });
290
- }
291
-
292
- const cacheData = {
293
- updatedAt: Date.now(),
294
- models: newModels,
295
- configs,
296
- };
297
-
298
- mkdirSync(dirname(CACHE_PATH), { recursive: true });
299
- writeFileSync(CACHE_PATH, JSON.stringify(cacheData, null, 2), "utf-8");
300
- } catch {
301
- // Best-effort
184
+ return true;
302
185
  }
303
186
  }
304
187
 
@@ -306,15 +189,20 @@ export async function updateQoderModelsCache(
306
189
  * Get the cached model config for a specific model key.
307
190
  * Used to determine per-model settings (reasoning, max tokens, etc.) at stream time.
308
191
  */
309
- export function getCachedModelConfig(modelKey: string): QoderModelEntry | null {
310
- if (existsSync(CACHE_PATH)) {
192
+ export function getCachedModelConfig(
193
+ modelKey: string,
194
+ ): Record<string, unknown> | null {
195
+ if (existsSync(CACHE_PATH) && !isCacheStale()) {
311
196
  try {
312
197
  const data = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
313
198
  if (data?.configs?.[modelKey]) {
314
- return data.configs[modelKey] as QoderModelEntry;
199
+ return data.configs[modelKey] as Record<string, unknown>;
315
200
  }
316
- } catch {
317
- // Fall through
201
+ } catch (err) {
202
+ _logger.warn("Failed to read Qoder model config cache", {
203
+ modelKey,
204
+ error: err instanceof Error ? err.message : String(err),
205
+ });
318
206
  }
319
207
  }
320
208
  return null;
@@ -3,15 +3,17 @@
3
3
  *
4
4
  * Registers the Qoder provider with Pi, providing free access to top-tier
5
5
  * LLM models (DeepSeek V4 Pro/Flash, Qwen3.7 Plus/Max, GLM 5.1, Kimi K2.6,
6
- * MiniMax M3) through Qoder's proprietary API.
6
+ * MiniMax M3) through Qoder's OpenAI-compatible API.
7
7
  *
8
8
  * Qoder uses a custom authentication protocol (PAT exchange + COSY signing)
9
- * and a non-standard streaming API. All models are completely free — no
10
- * paid tier exists.
9
+ * and a credits-based pricing model:
10
+ * - Community Edition (free): basic models with daily message limits
11
+ * - Pro / Pro+ / Ultra (paid): premium models via monthly credits
11
12
  *
12
13
  * Usage:
13
14
  * Install pi-free, then run /login qoder to authenticate
14
15
  * (PAT paste or browser OAuth)
16
+ * /toggle-qoder switches between basic (free-tier) and all models
15
17
  *
16
18
  * Environment variables:
17
19
  * QODER_PERSONAL_ACCESS_TOKEN — PAT for headless auth (optional)
@@ -24,15 +26,17 @@ import type {
24
26
  ProviderModelConfig,
25
27
  } from "@earendil-works/pi-coding-agent";
26
28
  import { BASE_URL_QODER, PROVIDER_QODER } from "../../constants.ts";
29
+ import { getProviderShowPaid } from "../../config.ts";
27
30
  import {
28
31
  getCachedModels,
29
- isCacheStale,
30
- updateQoderModelsCache,
32
+ isBasicModel,
33
+ type QoderModelConfig,
31
34
  } from "./models.ts";
32
35
  import { getCachedCredentials, loginQoder, refreshQoderToken } from "./auth.ts";
33
36
  import { streamQoder } from "./stream.ts";
34
37
  import { enhanceWithCI } from "../../provider-helper.ts";
35
- import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
38
+ import { registerWithGlobalToggle } from "../../lib/registry.ts";
39
+ import { createToggleState } from "../../lib/toggle-state.ts";
36
40
 
37
41
  // =============================================================================
38
42
  // Extension Entry Point
@@ -42,33 +46,21 @@ export default async function qoderProvider(pi: ExtensionAPI) {
42
46
  const logger = (await import("../../lib/logger.ts")).createLogger("qoder");
43
47
 
44
48
  // Initial model fetch
45
- let allModels: ProviderModelConfig[] = getCachedModels();
46
- let freeModels: ProviderModelConfig[] = allModels.filter((m) =>
47
- isFreeModel({ ...m, provider: PROVIDER_QODER }, allModels),
48
- );
49
- const stored = { free: freeModels, all: allModels };
49
+ const allModels: QoderModelConfig[] = getCachedModels();
50
+ const basicModels: QoderModelConfig[] = allModels.filter(isBasicModel);
51
+ const stored = { free: basicModels, all: allModels };
52
+
53
+ const toggleState = createToggleState({
54
+ providerId: PROVIDER_QODER,
55
+ initialShowPaid: getProviderShowPaid(PROVIDER_QODER),
56
+ initialModels: stored,
57
+ });
50
58
 
51
59
  // ── OAuth config (defined before reRegister so it's always available) ──
52
60
  const oauthConfig = {
53
61
  name: "Qoder (Browser OAuth / PAT)",
54
62
  login: async (callbacks: any): Promise<OAuthCredentials> => {
55
- const cred = await loginQoder(callbacks);
56
-
57
- // After login, refresh models from API
58
- try {
59
- const accessToken = cred.access as string;
60
- const creds = getCachedCredentials();
61
- await refreshModels(
62
- accessToken,
63
- creds?.userID || "qoder-user",
64
- creds?.name || "Qoder User",
65
- creds?.email || "user@qoder.com",
66
- );
67
- } catch {
68
- // Best-effort
69
- }
70
-
71
- return cred;
63
+ return loginQoder(callbacks);
72
64
  },
73
65
  refreshToken: refreshQoderToken,
74
66
  getApiKey: (cred: OAuthCredentials) => cred.access,
@@ -86,67 +78,40 @@ export default async function qoderProvider(pi: ExtensionAPI) {
86
78
  });
87
79
  };
88
80
 
89
- // ── Helper: refresh models from API and re-register ──
90
- const refreshModels = async (
91
- accessToken: string,
92
- userID: string,
93
- name: string,
94
- email: string,
95
- ) => {
96
- await updateQoderModelsCache(accessToken, userID, name, email);
97
- const fresh = getCachedModels();
98
- if (fresh.length > 0) {
99
- allModels = fresh;
100
- freeModels = fresh.filter((m) =>
101
- isFreeModel({ ...m, provider: PROVIDER_QODER }, fresh),
102
- );
103
- stored.all = allModels;
104
- stored.free = freeModels;
105
- reRegister(allModels);
106
- logger.info(`[qoder] Models refreshed: ${allModels.length}`);
107
- }
108
- };
109
-
110
81
  // Register with global toggle system so it participates in /toggle-free
111
82
  registerWithGlobalToggle(PROVIDER_QODER, stored, (m) => reRegister(m), false);
112
83
 
113
- // If user is already authenticated and cache is stale, refresh at startup
114
- // (mirrors kilo/cline pattern: check cache freshness before hitting network)
115
- try {
116
- const cachedCreds = getCachedCredentials();
117
- if (cachedCreds?.access && isCacheStale()) {
118
- await refreshModels(
119
- cachedCreds.access as string,
120
- cachedCreds.userID || "qoder-user",
121
- cachedCreds.name || "Qoder User",
122
- cachedCreds.email || "user@qoder.com",
123
- );
124
- }
125
- } catch {
126
- // Best-effort: fall back to cached / static models
127
- }
128
-
129
- // Initial registration
130
- reRegister(allModels);
131
-
132
- // Refresh models cache on session_start if stale (>1h old)
133
- pi.on("session_start", async (_event, ctx) => {
134
- try {
135
- const accessToken =
136
- await ctx.modelRegistry.getApiKeyForProvider(PROVIDER_QODER);
137
- if (!accessToken || !isCacheStale()) return;
138
- const creds = getCachedCredentials();
139
- await refreshModels(
140
- accessToken,
141
- creds?.userID || "qoder-user",
142
- creds?.name || "Qoder User",
143
- creds?.email || "user@qoder.com",
144
- );
145
- } catch {
146
- // Best-effort: fall back to existing cache / static models
147
- }
84
+ // Per-provider toggle: /toggle-qoder (basic free-tier all models)
85
+ pi.registerCommand("toggle-qoder", {
86
+ description: "Toggle between basic (free-tier) and all Qoder models",
87
+ handler: async (_args, ctx) => {
88
+ try {
89
+ const applied = toggleState.toggle(reRegister);
90
+ const basicCount = stored.free.length;
91
+ const premiumCount = stored.all.length - basicCount;
92
+ if (applied.mode === "all") {
93
+ ctx.ui.notify(
94
+ `qoder: showing all ${stored.all.length} models (${basicCount} basic, ${premiumCount} premium)`,
95
+ "info",
96
+ );
97
+ } else {
98
+ ctx.ui.notify(
99
+ `qoder: showing ${stored.free.length} basic (free-tier) models`,
100
+ "info",
101
+ );
102
+ }
103
+ } catch (err) {
104
+ logger.error("[qoder] toggle failed", {
105
+ error: err instanceof Error ? err.message : String(err),
106
+ });
107
+ ctx.ui.notify("qoder: toggle failed", "error");
108
+ }
109
+ },
148
110
  });
149
111
 
112
+ // Initial registration respects the configured show-paid mode.
113
+ toggleState.applyCurrent(reRegister);
114
+
150
115
  logger.info(`[qoder] Provider registered with ${allModels.length} models`);
151
116
  }
152
117