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.
- package/CHANGELOG.md +24 -0
- package/README.md +1 -1
- package/banner.svg +1 -1
- package/config.ts +58 -29
- package/constants.ts +1 -4
- package/index.ts +30 -24
- package/lib/built-in-toggle.ts +185 -18
- package/lib/provider-cache.ts +22 -0
- package/package.json +74 -74
- package/provider-failover/benchmark-lookup.ts +4 -1
- package/provider-failover/benchmarks-chunk-0.ts +570 -570
- package/provider-failover/benchmarks-chunk-1.ts +676 -676
- package/provider-failover/benchmarks-chunk-2.ts +673 -673
- package/provider-failover/benchmarks-chunk-3.ts +680 -680
- package/provider-failover/benchmarks-chunk-4.ts +683 -683
- package/provider-failover/benchmarks-chunk-5.ts +816 -474
- package/provider-helper.ts +64 -0
- package/providers/bai/bai.ts +8 -2
- package/providers/crofai/crofai.ts +8 -2
- package/providers/deepinfra/deepinfra.ts +8 -2
- package/providers/dynamic-built-in/index.ts +36 -26
- package/providers/kilo/kilo.ts +41 -22
- package/providers/novita/novita.ts +8 -2
- package/providers/openmodel/openmodel.ts +8 -2
- package/providers/qoder/models.ts +63 -175
- package/providers/qoder/qoder.ts +49 -84
- package/providers/qoder/stream.ts +182 -274
- package/providers/qoder/transform.ts +5 -2
- package/providers/routeway/routeway.ts +8 -2
- package/providers/sambanova/sambanova.ts +12 -6
- package/providers/together/together.ts +8 -2
- package/providers/tokenrouter/tokenrouter.ts +8 -2
- package/providers/zenmux/zenmux.ts +8 -2
- package/providers/codestral/codestral.ts +0 -128
- package/providers/qoder/encoding.ts +0 -48
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.2.6] - 2026-07-09
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Faster startup (~30x)** — warm-cache load dropped from ~2.1s to ~70ms: providers now serve from a 1-hour disk cache and fetch live only on cold/stale cache. Extends the cache-first pattern (already used by Cline) to kilo, fastrouter, and all OpenAI-compatible fetchers (tokenrouter, zenmux, crofai, deepinfra, sambanova, together, novita, routeway, bai, openmodel); the dynamic built-in phase runs concurrently with the static providers.
|
|
15
|
+
- **Coding-Index debug logging is now opt-in** — `~/.pi/modelmatch.log` previously received one synchronous `appendFileSync` per model per match attempt at startup. Now off by default; set `PI_FREE_BENCHMARK_DEBUG=1` to re-enable.
|
|
16
|
+
- **Config reads are memoized** — `~/.pi/free.json` is parsed once and reused while its mtime is unchanged.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **Cache-poisoning guard** — a transient partial API response (a 200 returning a near-empty list) can no longer overwrite a healthy cached model list; fetches returning < 50% of the cached count keep the existing cache.
|
|
21
|
+
|
|
22
|
+
### Removed
|
|
23
|
+
|
|
24
|
+
- **Codestral provider** — removed the Codestral provider (`codestral.mistral.ai`). Codestral's free tier is no longer available; use `MISTRAL_API_KEY` with pi's built-in dynamic Mistral provider instead.
|
|
25
|
+
|
|
26
|
+
## [2.2.5] - 2026-06-28
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Qoder api2-v2 migration** — Move Qoder to the OpenAI-compatible `https://api2-v2.qoder.sh/model/v1/chat/completions` endpoint, replacing the legacy proxy-style request path. Streaming and non-streaming responses are normalized through the shared stream wrapper, stale legacy model cache entries are ignored, and Qoder now uses the standard basic/all toggle state instead of bespoke toggle logic. The provider avoids logging request bodies or credentials, clamps `max_tokens` to a valid positive value, propagates SSE error payloads, and guards against unbounded SSE buffers. Live validation confirmed the `lite` model works with saved Qoder auth; other account-gated models returned provider quota errors rather than protocol failures ([#285](https://github.com/apmantza/pi-free/pull/285)).
|
|
31
|
+
|
|
32
|
+
- **OpenCode built-in toggle before registry load** — `/toggle-opencode` and `/toggle-opencode-go` now fall back to direct `/models` discovery with saved OpenCode credentials when Pi's built-in model registry has not populated yet, instead of warning `models not loaded yet. Start a session first.` OpenCode-discovered models preserve the dynamic stream wrapper and are run through `isFreeModel` with the full model set so only `*-free` models are marked free; non-free models no longer appear as free in the picker just because OpenCode's model-list endpoint omits pricing ([#286](https://github.com/apmantza/pi-free/pull/286), [#287](https://github.com/apmantza/pi-free/pull/287)).
|
|
33
|
+
|
|
10
34
|
## [2.2.4] - 2026-06-27
|
|
11
35
|
|
|
12
36
|
### Added
|
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ First run creates `~/.pi/free.json` automatically. Add keys there or use environ
|
|
|
74
74
|
| Category | Providers |
|
|
75
75
|
|---|---|
|
|
76
76
|
| ✅ **Free** | Kilo, Cline, OpenRouter, OpenCode, LLM7, OpenModel, TokenRouter (1 free) |
|
|
77
|
-
| 🔄 **Freemium** | Ollama Cloud, SambaNova,
|
|
77
|
+
| 🔄 **Freemium** | Ollama Cloud, SambaNova, AgentRouter |
|
|
78
78
|
| 💳 **Paid** | ZenMux, CrofAI, DeepInfra, Together, Novita, Routeway, b.ai |
|
|
79
79
|
| 🔧 **Dynamic** | Mistral, Groq, Cerebras, xAI, Hugging Face, FastRouter |
|
|
80
80
|
|
package/banner.svg
CHANGED
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
<text x="20" y="46" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Kilo · Cline · OpenCode</text>
|
|
96
96
|
<text x="20" y="66" font-family="system-ui, sans-serif" font-size="11" fill="#a78bfa">OpenModel · AgentRouter</text>
|
|
97
97
|
<text x="20" y="86" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Ollama Cloud · SambaNova</text>
|
|
98
|
-
<text x="20" y="106" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">
|
|
98
|
+
<text x="20" y="106" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">LLM7 · Novita</text>
|
|
99
99
|
<text x="20" y="126" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">ZenMux · CrofAI · DeepInfra</text>
|
|
100
100
|
<text x="20" y="146" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Together · TokenRouter · FastRouter</text>
|
|
101
101
|
<text x="20" y="166" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">+ dynamic (Mistral, Groq, …)</text>
|
package/config.ts
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
* (e.g. after toggle-{provider}) are visible immediately.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
chmodSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
statSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
13
19
|
import { join } from "node:path";
|
|
14
20
|
import {
|
|
15
21
|
PROVIDER_BAI,
|
|
@@ -20,11 +26,11 @@ import {
|
|
|
20
26
|
PROVIDER_OPENCODE,
|
|
21
27
|
PROVIDER_OPENMODEL,
|
|
22
28
|
PROVIDER_OPENROUTER,
|
|
29
|
+
PROVIDER_QODER,
|
|
23
30
|
PROVIDER_ROUTEWAY,
|
|
24
31
|
PROVIDER_TOKENROUTER,
|
|
25
32
|
PROVIDER_ZENMUX,
|
|
26
33
|
PROVIDER_CROFAI,
|
|
27
|
-
PROVIDER_CODESTRAL,
|
|
28
34
|
PROVIDER_LLM7,
|
|
29
35
|
PROVIDER_DEEPINFRA,
|
|
30
36
|
PROVIDER_SAMBANOVA,
|
|
@@ -39,6 +45,7 @@ export {
|
|
|
39
45
|
PROVIDER_MODAL,
|
|
40
46
|
PROVIDER_OPENCODE,
|
|
41
47
|
PROVIDER_OPENROUTER,
|
|
48
|
+
PROVIDER_QODER,
|
|
42
49
|
PROVIDER_QWEN,
|
|
43
50
|
PROVIDER_ROUTEWAY,
|
|
44
51
|
PROVIDER_TOKENROUTER,
|
|
@@ -63,7 +70,6 @@ interface PiFreeConfig {
|
|
|
63
70
|
ollama_api_key?: string;
|
|
64
71
|
zenmux_api_key?: string;
|
|
65
72
|
crofai_api_key?: string;
|
|
66
|
-
codestral_api_key?: string;
|
|
67
73
|
llm7_api_key?: string;
|
|
68
74
|
deepinfra_api_key?: string;
|
|
69
75
|
sambanova_api_key?: string;
|
|
@@ -84,7 +90,6 @@ interface PiFreeConfig {
|
|
|
84
90
|
cline_show_paid?: boolean;
|
|
85
91
|
zenmux_show_paid?: boolean;
|
|
86
92
|
crofai_show_paid?: boolean;
|
|
87
|
-
codestral_show_paid?: boolean;
|
|
88
93
|
llm7_show_paid?: boolean;
|
|
89
94
|
deepinfra_show_paid?: boolean;
|
|
90
95
|
sambanova_show_paid?: boolean;
|
|
@@ -97,6 +102,7 @@ interface PiFreeConfig {
|
|
|
97
102
|
openmodel_show_paid?: boolean;
|
|
98
103
|
openrouter_show_paid?: boolean;
|
|
99
104
|
opencode_show_paid?: boolean;
|
|
105
|
+
qoder_show_paid?: boolean;
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
const CONFIG_TEMPLATE: PiFreeConfig = {
|
|
@@ -104,7 +110,6 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
|
|
|
104
110
|
ollama_api_key: "",
|
|
105
111
|
zenmux_api_key: "",
|
|
106
112
|
crofai_api_key: "",
|
|
107
|
-
codestral_api_key: "",
|
|
108
113
|
llm7_api_key: "",
|
|
109
114
|
deepinfra_api_key: "",
|
|
110
115
|
sambanova_api_key: "",
|
|
@@ -126,7 +131,6 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
|
|
|
126
131
|
cline_show_paid: false,
|
|
127
132
|
zenmux_show_paid: false,
|
|
128
133
|
crofai_show_paid: false,
|
|
129
|
-
codestral_show_paid: false,
|
|
130
134
|
llm7_show_paid: false,
|
|
131
135
|
deepinfra_show_paid: false,
|
|
132
136
|
sambanova_show_paid: false,
|
|
@@ -139,10 +143,19 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
|
|
|
139
143
|
openmodel_show_paid: false,
|
|
140
144
|
openrouter_show_paid: false,
|
|
141
145
|
opencode_show_paid: false,
|
|
146
|
+
qoder_show_paid: false,
|
|
142
147
|
};
|
|
143
148
|
|
|
144
149
|
const CONFIG_PATH = join(PI_DATA_DIR, "free.json");
|
|
145
150
|
|
|
151
|
+
// Memoize the parsed config: re-read+parse only when the file's mtime changes.
|
|
152
|
+
// loadConfigFile() is called on every config getter (dozens of times at
|
|
153
|
+
// startup); this avoids repeated readFileSync + JSON.parse of the same file.
|
|
154
|
+
// Every write path uses writeFileSync (which bumps mtime), so the mtime check
|
|
155
|
+
// auto-invalidates after saveConfig()/updateConfig()/ensureConfigFile().
|
|
156
|
+
let cachedConfig: PiFreeConfig | null = null;
|
|
157
|
+
let cachedConfigMtime = -1;
|
|
158
|
+
|
|
146
159
|
function ensureConfigFile(): void {
|
|
147
160
|
try {
|
|
148
161
|
ensureDir(PI_DATA_DIR);
|
|
@@ -209,17 +222,44 @@ function restrictConfigFilePermissions(): void {
|
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
export function loadConfigFile(): PiFreeConfig {
|
|
225
|
+
// Return the memoized parse when the file hasn't changed on disk.
|
|
226
|
+
// Use a single stat result for both the hit check and the cached mtime to
|
|
227
|
+
// reduce the TOCTOU window between the check and the read.
|
|
228
|
+
let mtime: number | undefined;
|
|
229
|
+
try {
|
|
230
|
+
mtime = statSync(CONFIG_PATH).mtimeMs;
|
|
231
|
+
if (cachedConfig !== null && mtime === cachedConfigMtime) {
|
|
232
|
+
// Return a frozen copy so callers cannot mutate the shared cache.
|
|
233
|
+
return Object.freeze(cachedConfig) as PiFreeConfig;
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// stat failed (e.g. file removed) — fall through to read+parse below
|
|
237
|
+
cachedConfig = null;
|
|
238
|
+
}
|
|
239
|
+
|
|
212
240
|
try {
|
|
213
|
-
|
|
241
|
+
const parsed = JSON.parse(
|
|
214
242
|
readFileSync(CONFIG_PATH, "utf8"),
|
|
215
243
|
safeJsonReviver,
|
|
216
244
|
) as PiFreeConfig;
|
|
245
|
+
cachedConfig = parsed;
|
|
246
|
+
cachedConfigMtime = mtime ?? -1;
|
|
247
|
+
return Object.freeze(parsed) as PiFreeConfig;
|
|
217
248
|
} catch (err) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
249
|
+
cachedConfig = null;
|
|
250
|
+
cachedConfigMtime = -1;
|
|
251
|
+
if (err instanceof SyntaxError) {
|
|
252
|
+
_logger.error("Config file is corrupt (invalid JSON) — returning empty config", {
|
|
253
|
+
path: CONFIG_PATH,
|
|
254
|
+
error: err.message,
|
|
255
|
+
});
|
|
256
|
+
} else {
|
|
257
|
+
_logger.error("Could not read config file — returning empty config", {
|
|
258
|
+
path: CONFIG_PATH,
|
|
259
|
+
error: err instanceof Error ? err.message : String(err),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return Object.freeze({}) as PiFreeConfig;
|
|
223
263
|
}
|
|
224
264
|
}
|
|
225
265
|
|
|
@@ -230,7 +270,11 @@ export function loadConfigFile(): PiFreeConfig {
|
|
|
230
270
|
function readRawConfigFile(): string | undefined {
|
|
231
271
|
try {
|
|
232
272
|
return readFileSync(CONFIG_PATH, "utf8");
|
|
233
|
-
} catch {
|
|
273
|
+
} catch (err) {
|
|
274
|
+
_logger.warn("Could not read config file", {
|
|
275
|
+
path: CONFIG_PATH,
|
|
276
|
+
error: err instanceof Error ? err.message : String(err),
|
|
277
|
+
});
|
|
234
278
|
return undefined;
|
|
235
279
|
}
|
|
236
280
|
}
|
|
@@ -271,11 +315,6 @@ const PROVIDER_META: readonly ProviderMeta[] = [
|
|
|
271
315
|
{ id: PROVIDER_CLINE, prefix: "CLINE", showPaidKey: "cline_show_paid" },
|
|
272
316
|
{ id: PROVIDER_ZENMUX, prefix: "ZENMUX", showPaidKey: "zenmux_show_paid" },
|
|
273
317
|
{ id: PROVIDER_CROFAI, prefix: "CROFAI", showPaidKey: "crofai_show_paid" },
|
|
274
|
-
{
|
|
275
|
-
id: PROVIDER_CODESTRAL,
|
|
276
|
-
prefix: "CODESTRAL",
|
|
277
|
-
showPaidKey: "codestral_show_paid",
|
|
278
|
-
},
|
|
279
318
|
{ id: PROVIDER_LLM7, prefix: "LLM7", showPaidKey: "llm7_show_paid" },
|
|
280
319
|
{
|
|
281
320
|
id: PROVIDER_DEEPINFRA,
|
|
@@ -325,6 +364,7 @@ const PROVIDER_META: readonly ProviderMeta[] = [
|
|
|
325
364
|
prefix: "OPENCODE",
|
|
326
365
|
showPaidKey: "opencode_show_paid",
|
|
327
366
|
},
|
|
367
|
+
{ id: PROVIDER_QODER, prefix: "QODER", showPaidKey: "qoder_show_paid" },
|
|
328
368
|
];
|
|
329
369
|
|
|
330
370
|
const PROVIDER_META_BY_ID = new Map(PROVIDER_META.map((m) => [m.id, m]));
|
|
@@ -364,13 +404,6 @@ export function getCrofaiShowPaid(): boolean {
|
|
|
364
404
|
return resolveBool("CROFAI_SHOW_PAID", loadConfigFile().crofai_show_paid);
|
|
365
405
|
}
|
|
366
406
|
|
|
367
|
-
export function getCodestralShowPaid(): boolean {
|
|
368
|
-
return resolveBool(
|
|
369
|
-
"CODESTRAL_SHOW_PAID",
|
|
370
|
-
loadConfigFile().codestral_show_paid,
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
407
|
export function getLlm7ShowPaid(): boolean {
|
|
375
408
|
return resolveBool("LLM7_SHOW_PAID", loadConfigFile().llm7_show_paid);
|
|
376
409
|
}
|
|
@@ -473,10 +506,6 @@ export function getCrofaiApiKey(): string | undefined {
|
|
|
473
506
|
return resolve("CROFAI_API_KEY", loadConfigFile().crofai_api_key);
|
|
474
507
|
}
|
|
475
508
|
|
|
476
|
-
export function getCodestralApiKey(): string | undefined {
|
|
477
|
-
return resolve("CODESTRAL_API_KEY", loadConfigFile().codestral_api_key);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
509
|
export function getLlm7ApiKey(): string | undefined {
|
|
481
510
|
return resolve("LLM7_API_KEY", loadConfigFile().llm7_api_key);
|
|
482
511
|
}
|
package/constants.ts
CHANGED
|
@@ -16,7 +16,6 @@ export const PROVIDER_QWEN = "qwen";
|
|
|
16
16
|
export const PROVIDER_MODAL = "modal";
|
|
17
17
|
export const PROVIDER_ZENMUX = "zenmux";
|
|
18
18
|
export const PROVIDER_CROFAI = "crofai";
|
|
19
|
-
export const PROVIDER_CODESTRAL = "codestral";
|
|
20
19
|
export const PROVIDER_LLM7 = "llm7";
|
|
21
20
|
export const PROVIDER_DEEPINFRA = "deepinfra";
|
|
22
21
|
export const PROVIDER_SAMBANOVA = "sambanova";
|
|
@@ -42,7 +41,6 @@ export const ALL_UNIQUE_PROVIDERS = [
|
|
|
42
41
|
PROVIDER_OLLAMA,
|
|
43
42
|
PROVIDER_ZENMUX,
|
|
44
43
|
PROVIDER_CROFAI,
|
|
45
|
-
PROVIDER_CODESTRAL,
|
|
46
44
|
PROVIDER_LLM7,
|
|
47
45
|
PROVIDER_DEEPINFRA,
|
|
48
46
|
PROVIDER_SAMBANOVA,
|
|
@@ -68,7 +66,6 @@ export const BASE_URL_QWEN =
|
|
|
68
66
|
"https://dashscope.aliyuncs.com/compatible-mode/v1";
|
|
69
67
|
export const BASE_URL_ZENMUX = "https://zenmux.ai/api/v1";
|
|
70
68
|
export const BASE_URL_CROFAI = "https://crof.ai/v1";
|
|
71
|
-
export const BASE_URL_CODESTRAL = "https://codestral.mistral.ai/v1";
|
|
72
69
|
export const BASE_URL_LLM7 = "https://api.llm7.io/v1";
|
|
73
70
|
export const BASE_URL_DEEPINFRA = "https://api.deepinfra.com/v1/openai";
|
|
74
71
|
export const BASE_URL_SAMBANOVA = "https://api.sambanova.ai/v1";
|
|
@@ -83,7 +80,7 @@ export const BASE_URL_BAI = "https://api.b.ai/v1";
|
|
|
83
80
|
* NOT include `/v1`. See {@link PROVIDER_OPENMODEL}.
|
|
84
81
|
*/
|
|
85
82
|
export const BASE_URL_OPENMODEL = "https://api.openmodel.ai";
|
|
86
|
-
export const BASE_URL_QODER = "https://
|
|
83
|
+
export const BASE_URL_QODER = "https://api2-v2.qoder.sh";
|
|
87
84
|
|
|
88
85
|
/** Cline fetches free models from OpenRouter */
|
|
89
86
|
export const BASE_URL_OPENROUTER = "https://openrouter.ai/api/v1";
|
package/index.ts
CHANGED
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
} from "./lib/registry.ts";
|
|
32
32
|
// Import unique provider extensions (only providers NOT built into pi)
|
|
33
33
|
import cline from "./providers/cline/cline.ts";
|
|
34
|
-
import codestral from "./providers/codestral/codestral.ts";
|
|
35
34
|
import crofai from "./providers/crofai/crofai.ts";
|
|
36
35
|
import kilo from "./providers/kilo/kilo.ts";
|
|
37
36
|
import llm7 from "./providers/llm7/llm7.ts";
|
|
@@ -61,7 +60,6 @@ const UNIQUE_PROVIDERS: ReadonlyArray<(pi: ExtensionAPI) => Promise<void>> = [
|
|
|
61
60
|
cline,
|
|
62
61
|
zenmux,
|
|
63
62
|
crofai,
|
|
64
|
-
codestral,
|
|
65
63
|
llm7,
|
|
66
64
|
deepinfra,
|
|
67
65
|
sambanova,
|
|
@@ -386,28 +384,36 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
|
|
|
386
384
|
// Setup model telemetry (tracks real-world performance)
|
|
387
385
|
setupTelemetry(pi);
|
|
388
386
|
|
|
389
|
-
// Load all unique providers
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
387
|
+
// Load all unique providers + dynamic built-in providers CONCURRENTLY.
|
|
388
|
+
// Running the dynamic phase (e.g. FastRouter) in parallel with the static
|
|
389
|
+
// providers avoids serializing two independent network windows at startup.
|
|
390
|
+
// Each provider registers itself with the global toggle system.
|
|
391
|
+
const dynamicSetup = (async () => {
|
|
392
|
+
try {
|
|
393
|
+
const { setupDynamicBuiltInProviders } = await import(
|
|
394
|
+
"./providers/dynamic-built-in/index.ts"
|
|
395
|
+
);
|
|
396
|
+
await setupDynamicBuiltInProviders(pi);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
// Dynamic providers are a best-effort enhancement — if the import
|
|
399
|
+
// or init fails (e.g. upstream API change), continue with the
|
|
400
|
+
// already-registered static providers rather than failing the whole
|
|
401
|
+
// extension load. Log full error (message + stack) to the structured
|
|
402
|
+
// log so the user can investigate, but never block startup.
|
|
403
|
+
_logger.error(
|
|
404
|
+
"[pi-free] Dynamic built-in providers failed to load",
|
|
405
|
+
{
|
|
406
|
+
error: err instanceof Error ? err.message : String(err),
|
|
407
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
})();
|
|
412
|
+
|
|
413
|
+
await Promise.allSettled([
|
|
414
|
+
...UNIQUE_PROVIDERS.map((setup) => setup(pi)),
|
|
415
|
+
dynamicSetup,
|
|
416
|
+
]);
|
|
411
417
|
|
|
412
418
|
// Setup toggles for pi's built-in providers (e.g., OpenCode)
|
|
413
419
|
setupBuiltInProviderToggles(pi);
|
package/lib/built-in-toggle.ts
CHANGED
|
@@ -16,7 +16,12 @@ import type {
|
|
|
16
16
|
ExtensionAPI,
|
|
17
17
|
ProviderModelConfig,
|
|
18
18
|
} from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
getOpencodeApiKey,
|
|
21
|
+
getOpencodeShowPaid,
|
|
22
|
+
getOpenrouterApiKey,
|
|
23
|
+
getOpenrouterShowPaid,
|
|
24
|
+
} from "../config.ts";
|
|
20
25
|
import { createLogger } from "./logger.ts";
|
|
21
26
|
import {
|
|
22
27
|
getProviderRegistry,
|
|
@@ -25,6 +30,7 @@ import {
|
|
|
25
30
|
} from "./registry.ts";
|
|
26
31
|
import { wrapSessionStartHandler } from "./session-start-metrics.ts";
|
|
27
32
|
import { createToggleState } from "./toggle-state.ts";
|
|
33
|
+
import { fetchWithTimeout } from "./util.ts";
|
|
28
34
|
import {
|
|
29
35
|
OPENCODE_DYNAMIC_API,
|
|
30
36
|
createOpenCodeSessionTracker,
|
|
@@ -53,12 +59,33 @@ function getOpenCodeSession() {
|
|
|
53
59
|
interface BuiltInToggleConfig {
|
|
54
60
|
id: string;
|
|
55
61
|
getShowPaid: () => boolean;
|
|
62
|
+
baseUrl: string;
|
|
63
|
+
api: Api;
|
|
64
|
+
getApiKey: () => string | undefined;
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
const BUILT_IN_TOGGLE_PROVIDERS: BuiltInToggleConfig[] = [
|
|
59
|
-
{
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
{
|
|
69
|
+
id: "opencode",
|
|
70
|
+
getShowPaid: getOpencodeShowPaid,
|
|
71
|
+
baseUrl: "https://opencode.ai/zen/v1",
|
|
72
|
+
api: OPENCODE_DYNAMIC_API,
|
|
73
|
+
getApiKey: getOpencodeApiKey,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "opencode-go",
|
|
77
|
+
getShowPaid: getOpencodeShowPaid,
|
|
78
|
+
baseUrl: "https://opencode.ai/zen/go/v1",
|
|
79
|
+
api: OPENCODE_DYNAMIC_API,
|
|
80
|
+
getApiKey: getOpencodeApiKey,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "openrouter",
|
|
84
|
+
getShowPaid: getOpenrouterShowPaid,
|
|
85
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
86
|
+
api: "openai-completions",
|
|
87
|
+
getApiKey: getOpenrouterApiKey,
|
|
88
|
+
},
|
|
62
89
|
];
|
|
63
90
|
|
|
64
91
|
// =============================================================================
|
|
@@ -74,6 +101,24 @@ interface BuiltInProviderState {
|
|
|
74
101
|
const providerStates = new Map<string, BuiltInProviderState>();
|
|
75
102
|
let commandsRegistered = false;
|
|
76
103
|
|
|
104
|
+
const ZERO_COST = Object.freeze({
|
|
105
|
+
input: 0,
|
|
106
|
+
output: 0,
|
|
107
|
+
cacheRead: 0,
|
|
108
|
+
cacheWrite: 0,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// OpenCode's /models endpoint does not expose real pricing. Use a tiny
|
|
112
|
+
// non-zero sentinel for known non-free models so the model picker does not
|
|
113
|
+
// label every OpenCode model as free. Authoritative free detection still uses
|
|
114
|
+
// _freeKnown/_isFree, not the sentinel amount.
|
|
115
|
+
const UNKNOWN_PAID_COST = Object.freeze({
|
|
116
|
+
input: 0.000001,
|
|
117
|
+
output: 0.000001,
|
|
118
|
+
cacheRead: 0,
|
|
119
|
+
cacheWrite: 0,
|
|
120
|
+
});
|
|
121
|
+
|
|
77
122
|
// =============================================================================
|
|
78
123
|
// Setup
|
|
79
124
|
// =============================================================================
|
|
@@ -138,18 +183,87 @@ function tryCaptureProvider(
|
|
|
138
183
|
const allModels = providerModels.map((m: Model<Api>) =>
|
|
139
184
|
modelToProviderConfig(m, config.id),
|
|
140
185
|
);
|
|
186
|
+
|
|
187
|
+
return createProviderState(pi, config, {
|
|
188
|
+
allModels,
|
|
189
|
+
baseUrl: providerModels[0].baseUrl,
|
|
190
|
+
api: providerModels[0].api,
|
|
191
|
+
apiKey: getApiKeyEnvForProvider(config.id),
|
|
192
|
+
source: "captured",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function tryDiscoverProvider(
|
|
197
|
+
pi: ExtensionAPI,
|
|
198
|
+
config: BuiltInToggleConfig,
|
|
199
|
+
): Promise<BuiltInProviderState | undefined> {
|
|
200
|
+
const apiKey = config.getApiKey();
|
|
201
|
+
if (!apiKey) return undefined;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const response = await fetchWithTimeout(
|
|
205
|
+
`${config.baseUrl}/models`,
|
|
206
|
+
{
|
|
207
|
+
headers: {
|
|
208
|
+
Accept: "application/json",
|
|
209
|
+
Authorization: `Bearer ${apiKey}`,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
30_000,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const body = (await response.json()) as
|
|
220
|
+
| Array<Record<string, unknown>>
|
|
221
|
+
| { data?: Array<Record<string, unknown>> };
|
|
222
|
+
const rawModels = Array.isArray(body) ? body : (body.data ?? []);
|
|
223
|
+
const mappedModels = rawModels
|
|
224
|
+
.map((m) => rawModelToProviderConfig(m, config))
|
|
225
|
+
.filter((m): m is ProviderModelConfig & { _pricingKnown?: boolean } =>
|
|
226
|
+
m !== undefined,
|
|
227
|
+
);
|
|
228
|
+
const allModels = applyAuthoritativeFreeFlags(mappedModels, config.id);
|
|
229
|
+
|
|
230
|
+
if (allModels.length === 0) return undefined;
|
|
231
|
+
|
|
232
|
+
return createProviderState(pi, config, {
|
|
233
|
+
allModels,
|
|
234
|
+
baseUrl: config.baseUrl,
|
|
235
|
+
api: config.api,
|
|
236
|
+
apiKey,
|
|
237
|
+
source: "discovered",
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
_logger.warn(`[built-in-toggle] ${config.id}: on-demand discovery failed`, {
|
|
241
|
+
error: err instanceof Error ? err.message : String(err),
|
|
242
|
+
});
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function createProviderState(
|
|
248
|
+
pi: ExtensionAPI,
|
|
249
|
+
config: BuiltInToggleConfig,
|
|
250
|
+
options: {
|
|
251
|
+
allModels: ProviderModelConfig[];
|
|
252
|
+
baseUrl: string;
|
|
253
|
+
api: Api;
|
|
254
|
+
apiKey: string;
|
|
255
|
+
source: "captured" | "discovered";
|
|
256
|
+
},
|
|
257
|
+
): BuiltInProviderState {
|
|
258
|
+
const { allModels, baseUrl, api, apiKey, source } = options;
|
|
141
259
|
const freeModels = allModels.filter((m: ProviderModelConfig) =>
|
|
142
260
|
isFreeModel({ ...m, provider: config.id }, allModels),
|
|
143
261
|
);
|
|
144
262
|
|
|
145
|
-
const baseUrl = providerModels[0].baseUrl;
|
|
146
|
-
const api = providerModels[0].api;
|
|
147
|
-
const apiKeyEnv = getApiKeyEnvForProvider(config.id);
|
|
148
|
-
|
|
149
263
|
const reRegister = (models: ProviderModelConfig[]) => {
|
|
150
264
|
pi.registerProvider(config.id, {
|
|
151
265
|
baseUrl,
|
|
152
|
-
apiKey
|
|
266
|
+
apiKey,
|
|
153
267
|
api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : api,
|
|
154
268
|
...(isOpenCodeProvider(config.id)
|
|
155
269
|
? { streamSimple: createOpenCodeStreamSimple(getOpenCodeSession()) }
|
|
@@ -171,7 +285,7 @@ function tryCaptureProvider(
|
|
|
171
285
|
registerWithGlobalToggle(config.id, stored, reRegister, true);
|
|
172
286
|
|
|
173
287
|
_logger.info(
|
|
174
|
-
`[built-in-toggle] ${config.id}:
|
|
288
|
+
`[built-in-toggle] ${config.id}: ${source} ${allModels.length} models (${freeModels.length} free)`,
|
|
175
289
|
);
|
|
176
290
|
|
|
177
291
|
return state;
|
|
@@ -191,15 +305,21 @@ function registerToggleCommand(
|
|
|
191
305
|
handler: async (_args, ctx) => {
|
|
192
306
|
let state = providerStates.get(config.id);
|
|
193
307
|
if (!state) {
|
|
194
|
-
// Models may have loaded after session_start — try on-demand capture
|
|
308
|
+
// Models may have loaded after session_start — try on-demand capture.
|
|
195
309
|
state = tryCaptureProvider(pi, config, ctx);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
310
|
+
}
|
|
311
|
+
if (!state) {
|
|
312
|
+
// If Pi has not exposed built-in models yet, fetch the provider's
|
|
313
|
+
// /models endpoint directly so /toggle-opencode works before the
|
|
314
|
+
// first chat session has populated the model registry.
|
|
315
|
+
state = await tryDiscoverProvider(pi, config);
|
|
316
|
+
}
|
|
317
|
+
if (!state) {
|
|
318
|
+
ctx.ui.notify(
|
|
319
|
+
`${config.id}: models not loaded yet and on-demand discovery failed. Check your API key, then try again.`,
|
|
320
|
+
"warning",
|
|
321
|
+
);
|
|
322
|
+
return;
|
|
203
323
|
}
|
|
204
324
|
|
|
205
325
|
const applied = state.toggleState.toggle(state.reRegister);
|
|
@@ -249,6 +369,53 @@ function modelToProviderConfig(
|
|
|
249
369
|
return base;
|
|
250
370
|
}
|
|
251
371
|
|
|
372
|
+
function rawModelToProviderConfig(
|
|
373
|
+
m: Record<string, unknown>,
|
|
374
|
+
config: BuiltInToggleConfig,
|
|
375
|
+
): (ProviderModelConfig & { _pricingKnown?: boolean }) | undefined {
|
|
376
|
+
const id = String(m.id ?? "").trim();
|
|
377
|
+
if (!id) return undefined;
|
|
378
|
+
const inputModalities = Array.isArray(m.input_modalities)
|
|
379
|
+
? m.input_modalities
|
|
380
|
+
: undefined;
|
|
381
|
+
const supportsImage = inputModalities?.includes("image") === true;
|
|
382
|
+
return {
|
|
383
|
+
id,
|
|
384
|
+
name: String(m.name ?? m.model ?? id),
|
|
385
|
+
api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : config.api,
|
|
386
|
+
reasoning: Boolean(m.reasoning ?? false),
|
|
387
|
+
input: supportsImage ? ["text", "image"] : ["text"],
|
|
388
|
+
cost: ZERO_COST,
|
|
389
|
+
contextWindow:
|
|
390
|
+
((m.context_length ??
|
|
391
|
+
m.max_context_length ??
|
|
392
|
+
m.context_window) as number) ?? 128_000,
|
|
393
|
+
maxTokens: ((m.max_tokens ?? m.max_completion_tokens) as number) ?? 16_384,
|
|
394
|
+
_pricingKnown: false,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function applyAuthoritativeFreeFlags(
|
|
399
|
+
models: Array<ProviderModelConfig & { _pricingKnown?: boolean }>,
|
|
400
|
+
providerId: string,
|
|
401
|
+
): Array<
|
|
402
|
+
ProviderModelConfig & {
|
|
403
|
+
_pricingKnown?: boolean;
|
|
404
|
+
_freeKnown?: boolean;
|
|
405
|
+
_isFree?: boolean;
|
|
406
|
+
}
|
|
407
|
+
> {
|
|
408
|
+
return models.map((model) => {
|
|
409
|
+
const isFree = isFreeModel({ ...model, provider: providerId }, models);
|
|
410
|
+
return {
|
|
411
|
+
...model,
|
|
412
|
+
cost: isFree ? ZERO_COST : UNKNOWN_PAID_COST,
|
|
413
|
+
_freeKnown: true,
|
|
414
|
+
_isFree: isFree,
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
252
419
|
function getApiKeyEnvForProvider(providerId: string): string {
|
|
253
420
|
const envMap: Record<string, string> = {
|
|
254
421
|
opencode: "$OPENCODE_API_KEY",
|
package/lib/provider-cache.ts
CHANGED
|
@@ -128,6 +128,28 @@ export async function saveProviderCache(
|
|
|
128
128
|
_logger.debug(`Saved ${models.length} models to cache for ${providerId}`);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Persist models to the cache, but refuse to overwrite a healthy existing
|
|
133
|
+
* entry with a drastically smaller list (a transient partial/error response).
|
|
134
|
+
* Also never caches an empty list. Returns true when the cache was written.
|
|
135
|
+
*/
|
|
136
|
+
export async function saveProviderCacheGuarded(
|
|
137
|
+
providerId: string,
|
|
138
|
+
models: ProviderModelConfig[],
|
|
139
|
+
): Promise<boolean> {
|
|
140
|
+
if (!models || models.length === 0) return false;
|
|
141
|
+
const existing = getProviderCacheEntry(providerId);
|
|
142
|
+
const existingCount = existing?.models.length ?? 0;
|
|
143
|
+
if (existingCount > 0 && models.length < existingCount * 0.5) {
|
|
144
|
+
_logger.debug(
|
|
145
|
+
`Not caching ${providerId}: ${models.length} models vs ${existingCount} cached (likely transient shrink)`,
|
|
146
|
+
);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
await saveProviderCache(providerId, models);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
131
153
|
/**
|
|
132
154
|
* Clear cached models for a provider.
|
|
133
155
|
*/
|