pi-free 2.2.6 → 2.2.7

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,17 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.7] - 2026-07-10
11
+
12
+ ### Added
13
+
14
+ - **AnyAPI provider** — add AnyAPI's OpenAI-compatible gateway with dynamic model discovery, free-model filtering, a 100K-token daily free plan, and `/toggle-anyapi` for switching to the full catalog.
15
+
16
+ ### Changed
17
+
18
+ - **Pi 0.80 compatibility** — update the Pi SDK peer requirements to `>=0.80.0` and refresh the development toolchain dependencies.
19
+
20
+ ### Fixed
21
+
22
+ - **AnyAPI model limits** — enrich context and output limits from models.dev when AnyAPI omits them, avoiding incorrect 4K defaults and migrating existing caches once.
23
+ - **TokenRouter Pi AI compatibility** — use the supported lazy OpenAI completions API path for Pi 0.80.
24
+
10
25
  ## [2.2.6] - 2026-07-09
11
26
 
12
27
  ### Changed
13
28
 
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.
29
+ - **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. Thanks @lmilojevicc
30
+ - **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. Thanks @lmilojevicc
31
+ - **Config reads are memoized** — `~/.pi/free.json` is parsed once and reused while its mtime is unchanged. Thanks @lmilojevicc
17
32
 
18
33
  ### Fixed
19
34
 
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.
35
+ - **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. Thanks @lmilojevicc
21
36
 
22
37
  ### Removed
23
38
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ Free and paid AI model providers for [Pi](https://pi.dev). Access **free and pai
14
14
 
15
15
  When you install pi-free, it:
16
16
 
17
- 1. **Registers free-tier providers** — Kilo, Cline, LLM7, OpenModel, TokenRouter, and more
17
+ 1. **Registers free-tier providers** — Kilo, Cline, LLM7, OpenModel, TokenRouter, AnyAPI, and more
18
18
  2. **Captures Pi's built-in providers** with free/paid toggles — OpenCode, OpenRouter
19
19
  3. **Fetches models dynamically** — ZenMux, CrofAI, Mistral, Groq, Cerebras, xAI, Hugging Face when API keys are configured
20
20
  4. **Filters to show only free models by default** — paid models hidden until explicitly toggled on
@@ -62,6 +62,7 @@ First run creates `~/.pi/free.json` automatically. Add keys there or use environ
62
62
  ```json
63
63
  {
64
64
  "ollama_api_key": "...",
65
+ "anyapi_api_key": "...",
65
66
  "sambanova_api_key": "...",
66
67
  "deepinfra_api_key": "..."
67
68
  }
@@ -74,7 +75,7 @@ First run creates `~/.pi/free.json` automatically. Add keys there or use environ
74
75
  | Category | Providers |
75
76
  |---|---|
76
77
  | ✅ **Free** | Kilo, Cline, OpenRouter, OpenCode, LLM7, OpenModel, TokenRouter (1 free) |
77
- | 🔄 **Freemium** | Ollama Cloud, SambaNova, AgentRouter |
78
+ | 🔄 **Freemium** | AnyAPI, Ollama Cloud, SambaNova |
78
79
  | 💳 **Paid** | ZenMux, CrofAI, DeepInfra, Together, Novita, Routeway, b.ai |
79
80
  | 🔧 **Dynamic** | Mistral, Groq, Cerebras, xAI, Hugging Face, FastRouter |
80
81
 
package/config.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import {
21
+ PROVIDER_ANYAPI,
21
22
  PROVIDER_BAI,
22
23
  PROVIDER_CLINE,
23
24
  PROVIDER_FASTROUTER,
@@ -38,6 +39,7 @@ import {
38
39
  PROVIDER_NOVITA,
39
40
  } from "./constants.ts";
40
41
  export {
42
+ PROVIDER_ANYAPI,
41
43
  PROVIDER_BAI,
42
44
  PROVIDER_CLINE,
43
45
  PROVIDER_FASTROUTER,
@@ -78,6 +80,7 @@ interface PiFreeConfig {
78
80
  routeway_api_key?: string;
79
81
  fastrouter_api_key?: string;
80
82
  tokenrouter_api_key?: string;
83
+ anyapi_api_key?: string;
81
84
  bai_api_key?: string;
82
85
  openmodel_api_key?: string;
83
86
  kilo_api_key?: string;
@@ -98,6 +101,7 @@ interface PiFreeConfig {
98
101
  routeway_show_paid?: boolean;
99
102
  fastrouter_show_paid?: boolean;
100
103
  tokenrouter_show_paid?: boolean;
104
+ anyapi_show_paid?: boolean;
101
105
  bai_show_paid?: boolean;
102
106
  openmodel_show_paid?: boolean;
103
107
  openrouter_show_paid?: boolean;
@@ -118,6 +122,7 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
118
122
  routeway_api_key: "",
119
123
  fastrouter_api_key: "",
120
124
  tokenrouter_api_key: "",
125
+ anyapi_api_key: "",
121
126
  bai_api_key: "",
122
127
  openmodel_api_key: "",
123
128
  kilo_api_key: "",
@@ -139,6 +144,7 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
139
144
  routeway_show_paid: false,
140
145
  fastrouter_show_paid: false,
141
146
  tokenrouter_show_paid: false,
147
+ anyapi_show_paid: false,
142
148
  bai_show_paid: false,
143
149
  openmodel_show_paid: false,
144
150
  openrouter_show_paid: false,
@@ -249,10 +255,13 @@ export function loadConfigFile(): PiFreeConfig {
249
255
  cachedConfig = null;
250
256
  cachedConfigMtime = -1;
251
257
  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
- });
258
+ _logger.error(
259
+ "Config file is corrupt (invalid JSON) — returning empty config",
260
+ {
261
+ path: CONFIG_PATH,
262
+ error: err.message,
263
+ },
264
+ );
256
265
  } else {
257
266
  _logger.error("Could not read config file — returning empty config", {
258
267
  path: CONFIG_PATH,
@@ -342,6 +351,7 @@ const PROVIDER_META: readonly ProviderMeta[] = [
342
351
  prefix: "TOKENROUTER",
343
352
  showPaidKey: "tokenrouter_show_paid",
344
353
  },
354
+ { id: PROVIDER_ANYAPI, prefix: "ANYAPI", showPaidKey: "anyapi_show_paid" },
345
355
  { id: PROVIDER_BAI, prefix: "BAI", showPaidKey: "bai_show_paid" },
346
356
  {
347
357
  id: PROVIDER_OPENMODEL,
@@ -441,6 +451,10 @@ export function getTokenrouterShowPaid(): boolean {
441
451
  );
442
452
  }
443
453
 
454
+ export function getAnyapiShowPaid(): boolean {
455
+ return resolveBool("ANYAPI_SHOW_PAID", loadConfigFile().anyapi_show_paid);
456
+ }
457
+
444
458
  export function getBaiShowPaid(): boolean {
445
459
  return resolveBool("BAI_SHOW_PAID", loadConfigFile().bai_show_paid);
446
460
  }
@@ -538,6 +552,10 @@ export function getTokenrouterApiKey(): string | undefined {
538
552
  return resolve("TOKENROUTER_API_KEY", loadConfigFile().tokenrouter_api_key);
539
553
  }
540
554
 
555
+ export function getAnyapiApiKey(): string | undefined {
556
+ return resolve("ANYAPI_API_KEY", loadConfigFile().anyapi_api_key);
557
+ }
558
+
541
559
  export function getBaiApiKey(): string | undefined {
542
560
  return resolve("BAI_API_KEY", loadConfigFile().bai_api_key);
543
561
  }
package/constants.ts CHANGED
@@ -23,6 +23,7 @@ export const PROVIDER_TOGETHER = "together";
23
23
  export const PROVIDER_NOVITA = "novita";
24
24
  export const PROVIDER_ROUTEWAY = "routeway";
25
25
  export const PROVIDER_TOKENROUTER = "tokenrouter";
26
+ export const PROVIDER_ANYAPI = "anyapi";
26
27
  export const PROVIDER_BAI = "bai";
27
28
  export const PROVIDER_OPENMODEL = "openmodel";
28
29
  export const PROVIDER_QODER = "qoder";
@@ -48,6 +49,7 @@ export const ALL_UNIQUE_PROVIDERS = [
48
49
  PROVIDER_NOVITA,
49
50
  PROVIDER_ROUTEWAY,
50
51
  PROVIDER_TOKENROUTER,
52
+ PROVIDER_ANYAPI,
51
53
  PROVIDER_BAI,
52
54
  PROVIDER_OPENMODEL,
53
55
  PROVIDER_QODER,
@@ -73,6 +75,7 @@ export const BASE_URL_TOGETHER = "https://api.together.xyz/v1";
73
75
  export const BASE_URL_NOVITA = "https://api.novita.ai/openai/v1";
74
76
  export const BASE_URL_ROUTEWAY = "https://api.routeway.ai/v1";
75
77
  export const BASE_URL_TOKENROUTER = "https://api.tokenrouter.com/v1";
78
+ export const BASE_URL_ANYAPI = "https://api.anyapi.ai/v1";
76
79
  export const BASE_URL_BAI = "https://api.b.ai/v1";
77
80
  /**
78
81
  * OpenModel is registered with `api: "anthropic-messages"`. The pi-ai
package/index.ts CHANGED
@@ -43,6 +43,7 @@ import tokenRouter from "./providers/tokenrouter/tokenrouter.ts";
43
43
  import ollama from "./providers/ollama/ollama.ts";
44
44
  import zenmux from "./providers/zenmux/zenmux.ts";
45
45
  import bai from "./providers/bai/bai.ts";
46
+ import anyapi from "./providers/anyapi/anyapi.ts";
46
47
  import openmodel from "./providers/openmodel/openmodel.ts";
47
48
  import qoder from "./providers/qoder/qoder.ts";
48
49
 
@@ -67,6 +68,7 @@ const UNIQUE_PROVIDERS: ReadonlyArray<(pi: ExtensionAPI) => Promise<void>> = [
67
68
  novita,
68
69
  routeway,
69
70
  tokenRouter,
71
+ anyapi,
70
72
  bai,
71
73
  openmodel,
72
74
  qoder,
@@ -400,13 +402,10 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
400
402
  // already-registered static providers rather than failing the whole
401
403
  // extension load. Log full error (message + stack) to the structured
402
404
  // 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
- );
405
+ _logger.error("[pi-free] Dynamic built-in providers failed to load", {
406
+ error: err instanceof Error ? err.message : String(err),
407
+ stack: err instanceof Error ? err.stack : undefined,
408
+ });
410
409
  }
411
410
  })();
412
411
 
package/package.json CHANGED
@@ -1,74 +1,74 @@
1
- {
2
- "name": "pi-free",
3
- "version": "2.2.6",
4
- "type": "module",
5
- "description": "AI model providers for Pi with free model filtering and dynamic model fetching",
6
- "keywords": [
7
- "pi-package",
8
- "pi-extension",
9
- "free-models",
10
- "paid-models",
11
- "model-filter",
12
- "nvidia-nim",
13
- "kilo",
14
- "cline",
15
- "zenmux",
16
- "crofai",
17
- "ollama-cloud"
18
- ],
19
- "license": "MIT",
20
- "author": "Apostolos Mantzaris",
21
- "homepage": "https://github.com/apmantza/pi-free#readme",
22
- "bugs": {
23
- "url": "https://github.com/apmantza/pi-free/issues"
24
- },
25
- "repository": {
26
- "type": "git",
27
- "url": "git+https://github.com/apmantza/pi-free.git"
28
- },
29
- "engines": {
30
- "node": ">=20.0.0"
31
- },
32
- "files": [
33
- "index.ts",
34
- "providers/**/*.ts",
35
- "lib/**/*.ts",
36
- "provider-failover/**/*.ts",
37
- "config.ts",
38
- "constants.ts",
39
- "provider-helper.ts",
40
- "README.md",
41
- "LICENSE",
42
- "CHANGELOG.md",
43
- "banner.svg",
44
- "scripts/check-extensions.mjs"
45
- ],
46
- "scripts": {
47
- "audit:prod": "npm audit --omit=dev --audit-level=high",
48
- "check": "node scripts/check-extensions.mjs",
49
- "check:lockfile": "node scripts/check-lockfile-sync.mjs",
50
- "check:tarball": "node scripts/check-tarball.mjs",
51
- "lint": "tsc --noEmit",
52
- "test": "vitest",
53
- "test:ui": "vitest --ui",
54
- "test:run": "vitest run",
55
- "smoke:cline": "tsx scripts/smoke-cline-xml-bridge.ts",
56
- "smoke:openmodel": "tsx scripts/smoke-openmodel-wire-format.ts"
57
- },
58
- "peerDependencies": {
59
- "@earendil-works/pi-ai": "^0.79.8",
60
- "@earendil-works/pi-coding-agent": "^0.79.8",
61
- "@earendil-works/pi-tui": "^0.79.8"
62
- },
63
- "devDependencies": {
64
- "@vitest/ui": "^4.1.5",
65
- "tsx": "^4.0.0",
66
- "typescript": "^6.0.3",
67
- "vitest": "^4.1.5"
68
- },
69
- "pi": {
70
- "extensions": [
71
- "./index.ts"
72
- ]
73
- }
74
- }
1
+ {
2
+ "name": "pi-free",
3
+ "version": "2.2.7",
4
+ "type": "module",
5
+ "description": "AI model providers for Pi with free model filtering and dynamic model fetching",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "free-models",
10
+ "paid-models",
11
+ "model-filter",
12
+ "nvidia-nim",
13
+ "kilo",
14
+ "cline",
15
+ "zenmux",
16
+ "crofai",
17
+ "ollama-cloud"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "Apostolos Mantzaris",
21
+ "homepage": "https://github.com/apmantza/pi-free#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/apmantza/pi-free/issues"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/apmantza/pi-free.git"
28
+ },
29
+ "engines": {
30
+ "node": ">=20.0.0"
31
+ },
32
+ "files": [
33
+ "index.ts",
34
+ "providers/**/*.ts",
35
+ "lib/**/*.ts",
36
+ "provider-failover/**/*.ts",
37
+ "config.ts",
38
+ "constants.ts",
39
+ "provider-helper.ts",
40
+ "README.md",
41
+ "LICENSE",
42
+ "CHANGELOG.md",
43
+ "banner.svg",
44
+ "scripts/check-extensions.mjs"
45
+ ],
46
+ "scripts": {
47
+ "audit:prod": "npm audit --omit=dev --audit-level=high",
48
+ "check": "node scripts/check-extensions.mjs",
49
+ "check:lockfile": "node scripts/check-lockfile-sync.mjs",
50
+ "check:tarball": "node scripts/check-tarball.mjs",
51
+ "lint": "tsc --noEmit",
52
+ "test": "vitest",
53
+ "test:ui": "vitest --ui",
54
+ "test:run": "vitest run",
55
+ "smoke:cline": "tsx scripts/smoke-cline-xml-bridge.ts",
56
+ "smoke:openmodel": "tsx scripts/smoke-openmodel-wire-format.ts"
57
+ },
58
+ "peerDependencies": {
59
+ "@earendil-works/pi-ai": ">=0.80.0",
60
+ "@earendil-works/pi-coding-agent": ">=0.80.0",
61
+ "@earendil-works/pi-tui": ">=0.80.0"
62
+ },
63
+ "devDependencies": {
64
+ "@vitest/ui": "^4.1.10",
65
+ "tsx": "^4.23.0",
66
+ "typescript": "^6.0.3",
67
+ "vitest": "^4.1.10"
68
+ },
69
+ "pi": {
70
+ "extensions": [
71
+ "./index.ts"
72
+ ]
73
+ }
74
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * AnyAPI provider extension.
3
+ *
4
+ * AnyAPI is an OpenAI-compatible gateway with a free plan and a catalog of
5
+ * explicitly free models. It exposes the catalog at /v1/models and routes
6
+ * chat requests through /v1/chat/completions.
7
+ *
8
+ * Setup:
9
+ * ANYAPI_API_KEY=...
10
+ * # or add anyapi_api_key to ~/.pi/free.json
11
+ */
12
+
13
+ import type {
14
+ ExtensionAPI,
15
+ ProviderModelConfig,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import {
18
+ applyHidden,
19
+ getAnyapiApiKey,
20
+ getAnyapiShowPaid,
21
+ } from "../../config.ts";
22
+ import {
23
+ BASE_URL_ANYAPI,
24
+ DEFAULT_FETCH_TIMEOUT_MS,
25
+ PROVIDER_ANYAPI,
26
+ } from "../../constants.ts";
27
+ import { createLogger } from "../../lib/logger.ts";
28
+ import { loadProviderCache } from "../../lib/provider-cache.ts";
29
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
30
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
31
+ import { fetchWithRetry, mapOpenRouterModel } from "../../lib/util.ts";
32
+ import {
33
+ createReRegister,
34
+ loadCachedOrFetchModels,
35
+ setupProvider,
36
+ } from "../../provider-helper.ts";
37
+
38
+ const _logger = createLogger("anyapi");
39
+
40
+ interface AnyApiModel {
41
+ id: string;
42
+ name?: string;
43
+ context_length?: number;
44
+ max_completion_tokens?: number | null;
45
+ top_provider?: {
46
+ context_length?: number | null;
47
+ max_completion_tokens?: number | null;
48
+ };
49
+ pricing?: {
50
+ prompt?: string | number | null;
51
+ completion?: string | number | null;
52
+ input_cache_read?: string | number | null;
53
+ input_cache_write?: string | number | null;
54
+ };
55
+ architecture?: {
56
+ input_modalities?: string[] | null;
57
+ output_modalities?: string[] | null;
58
+ };
59
+ supported_parameters?: string[] | null;
60
+ tags?: string[];
61
+ isFree?: boolean;
62
+ }
63
+
64
+ const ANYAPI_METADATA_VERSION = 1;
65
+
66
+ type AnyApiProviderModel = ProviderModelConfig & {
67
+ _pricingKnown?: boolean;
68
+ _freeKnown?: boolean;
69
+ _isFree?: boolean;
70
+ _anyapiMetadataVersion?: number;
71
+ };
72
+
73
+ function hasPricing(model: AnyApiModel): boolean {
74
+ return (
75
+ (model.pricing?.prompt !== null && model.pricing?.prompt !== undefined) ||
76
+ (model.pricing?.completion !== null &&
77
+ model.pricing?.completion !== undefined) ||
78
+ (model.pricing?.input_cache_read !== null &&
79
+ model.pricing?.input_cache_read !== undefined) ||
80
+ (model.pricing?.input_cache_write !== null &&
81
+ model.pricing?.input_cache_write !== undefined)
82
+ );
83
+ }
84
+
85
+ function normalizePrice(
86
+ value: string | number | null | undefined,
87
+ ): string | null {
88
+ return value === null || value === undefined ? null : String(value);
89
+ }
90
+
91
+ /**
92
+ * Detect AnyAPI's explicitly free model labels without treating every model
93
+ * with omitted pricing as free. The API may also expose an authoritative flag
94
+ * or zero pricing, both of which are handled here.
95
+ */
96
+ export function isAnyApiFreeModel(model: AnyApiModel): boolean {
97
+ if (typeof model.isFree === "boolean") return model.isFree;
98
+
99
+ const label = `${model.id} ${model.name ?? ""}`.toLowerCase();
100
+ if (/\bfree\b/.test(label)) return true;
101
+
102
+ if (!hasPricing(model)) return false;
103
+ const input = Number(model.pricing?.prompt);
104
+ const output = Number(model.pricing?.completion);
105
+ return input === 0 && output === 0;
106
+ }
107
+
108
+ export function mapAnyApiModel(model: AnyApiModel): AnyApiProviderModel {
109
+ const name = model.name ?? model.id;
110
+ const pricingKnown = hasPricing(model);
111
+ const freeKnown =
112
+ typeof model.isFree === "boolean" ||
113
+ /\bfree\b/i.test(`${model.id} ${name}`) ||
114
+ (pricingKnown && isAnyApiFreeModel(model));
115
+
116
+ const tags = model.tags ?? [];
117
+ const supportsReasoning =
118
+ tags.includes("reasoning") || tags.includes("chat_completions:reasoning");
119
+ const supportsVision =
120
+ tags.includes("vision") || tags.includes("chat_completions:vision");
121
+
122
+ const mapped = mapOpenRouterModel({
123
+ ...model,
124
+ name,
125
+ supported_parameters:
126
+ model.supported_parameters ?? (supportsReasoning ? ["reasoning"] : []),
127
+ architecture: model.architecture ?? {
128
+ input_modalities: supportsVision ? ["text", "image"] : ["text"],
129
+ output_modalities: ["text"],
130
+ },
131
+ pricing: model.pricing
132
+ ? {
133
+ prompt: normalizePrice(model.pricing.prompt),
134
+ completion: normalizePrice(model.pricing.completion),
135
+ input_cache_read: normalizePrice(model.pricing.input_cache_read),
136
+ input_cache_write: normalizePrice(model.pricing.input_cache_write),
137
+ }
138
+ : undefined,
139
+ });
140
+
141
+ return {
142
+ ...mapped,
143
+ _pricingKnown: pricingKnown,
144
+ ...(freeKnown && {
145
+ _freeKnown: true,
146
+ _isFree: isAnyApiFreeModel(model),
147
+ }),
148
+ };
149
+ }
150
+
151
+ function isTextModel(model: AnyApiModel): boolean {
152
+ const outputModalities = model.architecture?.output_modalities ?? [];
153
+ if (outputModalities.length > 0 && !outputModalities.includes("text")) {
154
+ return false;
155
+ }
156
+
157
+ const tags = model.tags;
158
+ if (!tags || tags.length === 0) return true;
159
+ return tags.some((tag) => tag.startsWith("chat_completions:"));
160
+ }
161
+
162
+ async function fetchAnyApiModels(
163
+ apiKey: string,
164
+ ): Promise<AnyApiProviderModel[]> {
165
+ const response = await fetchWithRetry(
166
+ `${BASE_URL_ANYAPI}/models`,
167
+ {
168
+ headers: {
169
+ Authorization: `Bearer ${apiKey}`,
170
+ Accept: "application/json",
171
+ "Content-Type": "application/json",
172
+ },
173
+ },
174
+ 3,
175
+ 1000,
176
+ DEFAULT_FETCH_TIMEOUT_MS,
177
+ );
178
+
179
+ if (!response.ok) {
180
+ throw new Error(
181
+ `AnyAPI API error: ${response.status} ${response.statusText}`,
182
+ );
183
+ }
184
+
185
+ const json = (await response.json()) as { data?: AnyApiModel[] };
186
+ const models = (json.data ?? []).flatMap((model) => {
187
+ if (!model.id || !isTextModel(model)) return [];
188
+ return [mapAnyApiModel(model)];
189
+ });
190
+
191
+ _logger.info(`[anyapi] Fetched ${models.length} text models`);
192
+
193
+ // AnyAPI's /models response omits context and output limits for its
194
+ // catalog. Use the global models.dev catalog so canonical model IDs such
195
+ // as qwen/qwen3-coder:free do not fall back to the generic 4096-token
196
+ // defaults in mapOpenRouterModel. The AnyAPI-scoped models.dev entry only
197
+ // contains a small paid subset and does not cover the free catalog.
198
+ const enriched = await safeEnrichModelsWithModelsDev(models);
199
+ return applyHidden(
200
+ enriched.map((model) => ({
201
+ ...model,
202
+ _anyapiMetadataVersion: ANYAPI_METADATA_VERSION,
203
+ })),
204
+ PROVIDER_ANYAPI,
205
+ ) as AnyApiProviderModel[];
206
+ }
207
+
208
+ export default async function anyapiProvider(pi: ExtensionAPI) {
209
+ const apiKey = getAnyapiApiKey();
210
+ if (!apiKey) {
211
+ _logger.info("[anyapi] Skipping — ANYAPI_API_KEY not set.");
212
+ return;
213
+ }
214
+
215
+ const cachedModels = loadProviderCache(PROVIDER_ANYAPI) as
216
+ | AnyApiProviderModel[]
217
+ | undefined;
218
+ const needsMetadataMigration = cachedModels?.some(
219
+ (model) => model._anyapiMetadataVersion !== ANYAPI_METADATA_VERSION,
220
+ );
221
+ const allModels = await loadCachedOrFetchModels(
222
+ PROVIDER_ANYAPI,
223
+ () => fetchAnyApiModels(apiKey),
224
+ // Force one refresh for caches written before context metadata was added;
225
+ // subsequent warm startups continue using the normal one-hour cache.
226
+ needsMetadataMigration ? { ttlMs: -1 } : undefined,
227
+ );
228
+ if (allModels.length === 0) {
229
+ _logger.warn("[anyapi] No text models available");
230
+ return;
231
+ }
232
+
233
+ const freeModels = allModels.filter((model) =>
234
+ isFreeModel({ ...model, provider: PROVIDER_ANYAPI }, allModels),
235
+ );
236
+ const stored = { free: freeModels, all: allModels };
237
+
238
+ _logger.info(
239
+ `[anyapi] Registered ${allModels.length} models (${freeModels.length} free)`,
240
+ );
241
+
242
+ const reRegister = createReRegister(pi, {
243
+ providerId: PROVIDER_ANYAPI,
244
+ baseUrl: BASE_URL_ANYAPI,
245
+ apiKey,
246
+ });
247
+
248
+ registerWithGlobalToggle(PROVIDER_ANYAPI, stored, reRegister, true);
249
+
250
+ setupProvider(
251
+ pi,
252
+ {
253
+ providerId: PROVIDER_ANYAPI,
254
+ initialShowPaid: getAnyapiShowPaid(),
255
+ hasKey: true,
256
+ tosUrl: "https://anyapi.ai/terms-of-service",
257
+ reRegister: (models, current) => {
258
+ if (current) {
259
+ stored.free = current.free;
260
+ stored.all = current.all;
261
+ }
262
+ reRegister(models);
263
+ },
264
+ },
265
+ stored,
266
+ );
267
+
268
+ const showPaid = getAnyapiShowPaid();
269
+ reRegister(showPaid ? stored.all : stored.free);
270
+ }
@@ -226,12 +226,7 @@ export default async function kiloProvider(pi: ExtensionAPI) {
226
226
 
227
227
  // Register with global toggle system
228
228
  const hasKiloKey = !!kiloApiKey;
229
- registerWithGlobalToggle(
230
- PROVIDER_KILO,
231
- stored,
232
- reRegister,
233
- hasKiloKey,
234
- );
229
+ registerWithGlobalToggle(PROVIDER_KILO, stored, reRegister, hasKiloKey);
235
230
 
236
231
  // OAuth config for Kilo
237
232
  const oauthConfig = {
@@ -10,6 +10,7 @@ import type {
10
10
  AssistantMessageEventStream,
11
11
  Context,
12
12
  Model,
13
+ ProviderHeaders,
13
14
  SimpleStreamOptions,
14
15
  } from "@earendil-works/pi-ai";
15
16
  import type { ProviderConfig } from "@earendil-works/pi-coding-agent";
@@ -83,8 +84,8 @@ export type OpenCodeSessionTracker = ReturnType<
83
84
 
84
85
  export function createOpenCodeHeaders(
85
86
  tracker: OpenCodeSessionTracker,
86
- existingHeaders?: Record<string, string>,
87
- ): Record<string, string> {
87
+ existingHeaders?: ProviderHeaders,
88
+ ): ProviderHeaders {
88
89
  return {
89
90
  ...existingHeaders,
90
91
  ...OPENCODE_STATIC_HEADERS,
@@ -115,17 +116,30 @@ type StreamSimpleFn<TApi extends Api> = (
115
116
  options?: SimpleStreamOptions,
116
117
  ) => AssistantMessageEventStream;
117
118
 
118
- type AnthropicStreamModule = {
119
- streamSimpleAnthropic: StreamSimpleFn<"anthropic-messages">;
119
+ type StreamSimpleModule<TApi extends Api> = {
120
+ streamSimple?: StreamSimpleFn<TApi>;
121
+ [key: string]: unknown;
120
122
  };
121
123
 
122
- type OpenAICompletionsStreamModule = {
123
- streamSimpleOpenAICompletions: StreamSimpleFn<"openai-completions">;
124
- };
124
+ type AnthropicStreamModule = StreamSimpleModule<"anthropic-messages">;
125
+ type OpenAICompletionsStreamModule = StreamSimpleModule<"openai-completions">;
126
+
127
+ function getStreamSimple<TApi extends Api>(
128
+ module: StreamSimpleModule<TApi>,
129
+ legacyExport: string,
130
+ ): StreamSimpleFn<TApi> {
131
+ const streamSimple = module.streamSimple ?? module[legacyExport];
132
+ if (typeof streamSimple !== "function") {
133
+ throw new Error(
134
+ `Pi AI module does not export ${legacyExport} or streamSimple`,
135
+ );
136
+ }
137
+ return streamSimple as StreamSimpleFn<TApi>;
138
+ }
125
139
 
126
140
  const piAiSubpathCache = new Map<string, Promise<unknown>>();
127
141
 
128
- async function importPiAiSubpath<T>(subpath: string): Promise<T> {
142
+ function importPiAiSubpath<T>(subpath: string): Promise<T> {
129
143
  const specifier = `@earendil-works/pi-ai/${subpath}`;
130
144
  const cached = piAiSubpathCache.get(specifier) as Promise<T> | undefined;
131
145
  if (cached) return cached;
@@ -157,6 +171,9 @@ async function importPiAiRootFallback<T>(
157
171
  ): Promise<T | undefined> {
158
172
  const subpath = specifier.replace("@earendil-works/pi-ai/", "");
159
173
  const requiredExport: Record<string, string> = {
174
+ "api/anthropic-messages": "streamSimpleAnthropic",
175
+ "api/openai-completions": "streamSimpleOpenAICompletions",
176
+ // Keep compatibility with pre-0.80 Pi AI packages.
160
177
  anthropic: "streamSimpleAnthropic",
161
178
  "openai-completions": "streamSimpleOpenAICompletions",
162
179
  };
@@ -199,6 +216,37 @@ function findPiAiPackageDir(requireBase: string): string | undefined {
199
216
  return undefined;
200
217
  }
201
218
 
219
+ function resolvePiAiExportTarget(
220
+ exportsMap: Record<string, unknown> | undefined,
221
+ subpath: string,
222
+ ): string | undefined {
223
+ if (!exportsMap) return undefined;
224
+
225
+ const getTarget = (entry: unknown): string | undefined => {
226
+ if (typeof entry === "string") return entry;
227
+ if (!entry || typeof entry !== "object") return undefined;
228
+ const conditions = entry as Record<string, unknown>;
229
+ const target = conditions.import ?? conditions.default;
230
+ return typeof target === "string" ? target : undefined;
231
+ };
232
+
233
+ const exactTarget = getTarget(exportsMap[`./${subpath}`]);
234
+ if (exactTarget) return exactTarget;
235
+
236
+ for (const [pattern, entry] of Object.entries(exportsMap)) {
237
+ if (!pattern.endsWith("/*")) continue;
238
+ const prefix = pattern.slice(2, -1);
239
+ if (subpath.startsWith(prefix)) {
240
+ const target = getTarget(entry);
241
+ if (target) {
242
+ return target.replaceAll("*", subpath.slice(prefix.length));
243
+ }
244
+ }
245
+ }
246
+
247
+ return undefined;
248
+ }
249
+
202
250
  function resolvePiAiSubpathFromPackage(specifier: string): string | undefined {
203
251
  const subpath = specifier.replace("@earendil-works/pi-ai/", "");
204
252
  const candidates = [process.argv[1], import.meta.url].filter(
@@ -212,11 +260,8 @@ function resolvePiAiSubpathFromPackage(specifier: string): string | undefined {
212
260
  const pkg = JSON.parse(
213
261
  readFileSync(join(pkgDir, "package.json"), "utf-8"),
214
262
  );
215
- const exportEntry = pkg.exports?.[`./${subpath}`];
216
- const targetPath = exportEntry?.import ?? exportEntry?.default;
217
- if (typeof targetPath === "string") {
218
- return join(pkgDir, targetPath);
219
- }
263
+ const targetPath = resolvePiAiExportTarget(pkg.exports, subpath);
264
+ if (targetPath) return join(pkgDir, targetPath);
220
265
  } catch {
221
266
  // Try the next resolution base.
222
267
  }
@@ -367,8 +412,12 @@ export function createOpenCodeStreamSimple(
367
412
  void (async () => {
368
413
  try {
369
414
  if (isAnthropicOpenCodeEndpoint(model)) {
370
- const { streamSimpleAnthropic } =
371
- await importPiAiSubpath<AnthropicStreamModule>("anthropic");
415
+ const streamSimpleAnthropic = getStreamSimple(
416
+ await importPiAiSubpath<AnthropicStreamModule>(
417
+ "api/anthropic-messages",
418
+ ),
419
+ "streamSimpleAnthropic",
420
+ );
372
421
  await pipeStream(
373
422
  stream,
374
423
  streamSimpleAnthropic(
@@ -383,10 +432,12 @@ export function createOpenCodeStreamSimple(
383
432
  return;
384
433
  }
385
434
 
386
- const { streamSimpleOpenAICompletions } =
435
+ const streamSimpleOpenAICompletions = getStreamSimple(
387
436
  await importPiAiSubpath<OpenAICompletionsStreamModule>(
388
- "openai-completions",
389
- );
437
+ "api/openai-completions",
438
+ ),
439
+ "streamSimpleOpenAICompletions",
440
+ );
390
441
  await pipeStream(
391
442
  stream,
392
443
  streamSimpleOpenAICompletions(
@@ -26,10 +26,8 @@ import type {
26
26
  SimpleStreamOptions,
27
27
  ThinkingContent,
28
28
  } from "@earendil-works/pi-ai";
29
- import {
30
- createAssistantMessageEventStream,
31
- streamSimpleOpenAICompletions,
32
- } from "@earendil-works/pi-ai";
29
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
30
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
33
31
  import {
34
32
  getTokenrouterApiKey,
35
33
  getTokenrouterShowPaid,
@@ -56,6 +54,7 @@ import {
56
54
  } from "../../provider-helper.ts";
57
55
 
58
56
  const _logger = createLogger("tokenrouter");
57
+ const streamSimpleOpenAICompletions = openAICompletionsApi().streamSimple;
59
58
 
60
59
  // =============================================================================
61
60
  // Reasoning cleanup
@@ -335,7 +334,7 @@ function createTokenRouterOpenAIStream(
335
334
  ): AssistantMessageEventStream {
336
335
  const forcePatch = isTokenRouterMinimaxModel(model.id);
337
336
  return streamSimpleOpenAICompletions(
338
- { ...model, api: "openai-completions" },
337
+ { ...model, api: "openai-completions" } as Model<"openai-completions">,
339
338
  context,
340
339
  {
341
340
  ...options,