pi-freerouter 0.1.0

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/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # pi-freerouter
2
+
3
+ Pi coding agent extension that routes every request through OpenRouter's free model tier — no paid API key needed beyond your OpenRouter account.
4
+
5
+ ## Quick start
6
+
7
+ **1. Install**
8
+
9
+ ```bash
10
+ pi install npm:pi-freerouter
11
+ ```
12
+
13
+ **2. Set your OpenRouter API key**
14
+
15
+ ```bash
16
+ export OPENROUTER_API_KEY=sk-or-...
17
+ ```
18
+
19
+ Free key at [openrouter.ai/keys](https://openrouter.ai/keys). No credit card required.
20
+
21
+ **3. Start Pi**
22
+
23
+ FreeRouter appears in the model picker and is set as the default automatically.
24
+
25
+ ---
26
+
27
+ ## How free model routing works
28
+
29
+ OpenRouter exposes dozens of free models (models with a `:free` suffix). Each has its own rate limit — typically a few requests per minute per model. The trick is to spread load across all of them automatically.
30
+
31
+ ### Parallel racing
32
+
33
+ Every time Pi sends a request, pi-freerouter doesn't pick one model and hope for the best. It picks the **next 3 available models** from its sorted list and fires all three requests simultaneously.
34
+
35
+ ```
36
+ Request arrives
37
+
38
+ ├── model A ──────────────────── first token ──▶ WINNER → stream to Pi
39
+ ├── model B ───── (slower) → aborted
40
+ └── model C ─── (even slower) → aborted
41
+ ```
42
+
43
+ Whichever model emits its first token wins. The other two are immediately cancelled. Pi sees a single clean stream — it has no idea a race happened.
44
+
45
+ ### Automatic fallback
46
+
47
+ When a model hits its rate limit (HTTP 429) or fails (5xx), it's marked as exhausted and skipped for 90 seconds. The next batch of 3 models takes over. Timed-out models (no response in 30 seconds) recover faster — after 15 seconds.
48
+
49
+ ```
50
+ Batch 1: [model A, model B, model C] → all hit quota
51
+ Batch 2: [model D, model E, model F] → model D wins
52
+ ↑ model A–C recover after 90s and rejoin the pool
53
+ ```
54
+
55
+ ### Provider priority
56
+
57
+ Free models are sorted so the lowest-latency inference providers are always tried first:
58
+
59
+ 1. Groq
60
+ 2. Cerebras
61
+ 3. Fireworks
62
+ 4. Together
63
+ 5. Mistral
64
+ 6. Everything else (sorted by context window ascending)
65
+
66
+ ### Model list refresh
67
+
68
+ The list of available free models is fetched at startup and refreshed every hour in the background, so long-running Pi sessions automatically pick up newly added models.
69
+
70
+ ---
71
+
72
+ ## Requirements
73
+
74
+ - [Pi coding agent](https://pi.dev) v0.78+
75
+ - OpenRouter API key (free tier is sufficient)
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,2 @@
1
+ import type { ProviderModelConfig } from "./types.js";
2
+ export declare function fetchFreeModels(apiKey: string): Promise<ProviderModelConfig[]>;
@@ -0,0 +1,47 @@
1
+ const DEFAULT_CONTEXT_WINDOW = 128_000;
2
+ const DEFAULT_MAX_TOKENS = 4_096;
3
+ // Providers known for low latency on free tier, ordered by preference
4
+ const FAST_PROVIDER_PREFIXES = [
5
+ "groq/",
6
+ "cerebras/",
7
+ "fireworks/",
8
+ "together/",
9
+ "mistralai/",
10
+ ];
11
+ function speedScore(modelId) {
12
+ const lower = modelId.toLowerCase();
13
+ const idx = FAST_PROVIDER_PREFIXES.findIndex((prefix) => lower.startsWith(prefix));
14
+ return idx === -1 ? FAST_PROVIDER_PREFIXES.length : idx;
15
+ }
16
+ export async function fetchFreeModels(apiKey) {
17
+ if (!apiKey) {
18
+ throw new Error("OPENROUTER_API_KEY is required");
19
+ }
20
+ const response = await fetch("https://openrouter.ai/api/v1/models", {
21
+ headers: { Authorization: `Bearer ${apiKey}` },
22
+ });
23
+ if (!response.ok) {
24
+ throw new Error(`Failed to fetch OpenRouter models: ${response.status} ${response.statusText}`);
25
+ }
26
+ const payload = (await response.json());
27
+ const models = (payload.data ?? []).filter((m) => m.id.includes(":free"));
28
+ // Sort: fast providers first, then by context size ascending (smaller = faster inference)
29
+ models.sort((a, b) => {
30
+ const scoreDiff = speedScore(a.id) - speedScore(b.id);
31
+ if (scoreDiff !== 0)
32
+ return scoreDiff;
33
+ const aCtx = a.context_length ?? DEFAULT_CONTEXT_WINDOW;
34
+ const bCtx = b.context_length ?? DEFAULT_CONTEXT_WINDOW;
35
+ return aCtx - bCtx;
36
+ });
37
+ return models.map((m) => ({
38
+ id: m.id,
39
+ name: m.name,
40
+ reasoning: false,
41
+ input: ["text"],
42
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
43
+ contextWindow: m.context_length ?? DEFAULT_CONTEXT_WINDOW,
44
+ maxTokens: m.top_provider?.max_completion_tokens ?? DEFAULT_MAX_TOKENS,
45
+ }));
46
+ }
47
+ //# sourceMappingURL=discovery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discovery.js","sourceRoot":"","sources":["../src/discovery.ts"],"names":[],"mappings":"AAEA,MAAM,sBAAsB,GAAG,OAAO,CAAC;AACvC,MAAM,kBAAkB,GAAG,KAAK,CAAC;AAEjC,sEAAsE;AACtE,MAAM,sBAAsB,GAAG;IAC7B,OAAO;IACP,WAAW;IACX,YAAY;IACZ,WAAW;IACX,YAAY;CACb,CAAC;AAEF,SAAS,UAAU,CAAC,OAAe;IACjC,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IACpC,MAAM,GAAG,GAAG,sBAAsB,CAAC,SAAS,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;IACnF,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,sBAAsB,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC;AAC1D,CAAC;AAaD,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,MAAc;IAClD,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IACpD,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,qCAAqC,EAAE;QAClE,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,MAAM,EAAE,EAAE;KAC/C,CAAC,CAAC;IAEH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,sCAAsC,QAAQ,CAAC,MAAM,IAAI,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC;IAClG,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAA6B,CAAC;IAEpE,MAAM,MAAM,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;IAE1E,0FAA0F;IAC1F,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACnB,MAAM,SAAS,GAAG,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACtD,IAAI,SAAS,KAAK,CAAC;YAAE,OAAO,SAAS,CAAC;QACtC,MAAM,IAAI,GAAG,CAAC,CAAC,cAAc,IAAI,sBAAsB,CAAC;QACxD,MAAM,IAAI,GAAG,CAAC,CAAC,cAAc,IAAI,sBAAsB,CAAC;QACxD,OAAO,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QACxB,EAAE,EAAE,CAAC,CAAC,EAAE;QACR,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,SAAS,EAAE,KAAK;QAChB,KAAK,EAAE,CAAC,MAAM,CAAyB;QACvC,IAAI,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE;QAC1D,aAAa,EAAE,CAAC,CAAC,cAAc,IAAI,sBAAsB;QACzD,SAAS,EAAE,CAAC,CAAC,YAAY,EAAE,qBAAqB,IAAI,kBAAkB;KACvE,CAAC,CAAC,CAAC;AACN,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { describe, it, beforeEach, afterEach } from "node:test";
3
+ describe("fetchFreeModels", () => {
4
+ let originalFetch;
5
+ beforeEach(() => {
6
+ originalFetch = globalThis.fetch;
7
+ });
8
+ afterEach(() => {
9
+ globalThis.fetch = originalFetch;
10
+ });
11
+ it("filters only :free models and maps fields", async () => {
12
+ const mockPayload = {
13
+ data: [
14
+ {
15
+ id: "meta-llama/llama-3.2-3b:free",
16
+ name: "Llama 3.2 3B (free)",
17
+ context_length: 131072,
18
+ top_provider: { max_completion_tokens: 8192 },
19
+ pricing: { prompt: "0", completion: "0" },
20
+ },
21
+ {
22
+ id: "openai/gpt-4o",
23
+ name: "GPT-4o",
24
+ context_length: 128000,
25
+ pricing: { prompt: "0.000005", completion: "0.000015" },
26
+ },
27
+ ],
28
+ };
29
+ globalThis.fetch = async () => ({
30
+ ok: true,
31
+ json: async () => mockPayload,
32
+ });
33
+ const { fetchFreeModels } = await import("./discovery.js");
34
+ const models = await fetchFreeModels("sk-or-test");
35
+ assert.equal(models.length, 1);
36
+ assert.equal(models[0].id, "meta-llama/llama-3.2-3b:free");
37
+ assert.equal(models[0].contextWindow, 131072);
38
+ assert.equal(models[0].maxTokens, 8192);
39
+ assert.deepEqual(models[0].cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
40
+ });
41
+ it("throws if apiKey is empty", async () => {
42
+ const { fetchFreeModels } = await import("./discovery.js");
43
+ await assert.rejects(() => fetchFreeModels(""), /OPENROUTER_API_KEY/);
44
+ });
45
+ it("throws if fetch response is not ok", async () => {
46
+ globalThis.fetch = async () => ({ ok: false, status: 401, statusText: "Unauthorized" });
47
+ const { fetchFreeModels } = await import("./discovery.js");
48
+ await assert.rejects(() => fetchFreeModels("sk-or-bad"), /401/);
49
+ });
50
+ it("sorts fast providers before unknown providers", async () => {
51
+ const mockPayload = {
52
+ data: [
53
+ { id: "meta-llama/llama-3.1-8b:free", name: "Llama (unknown provider)", context_length: 32768 },
54
+ { id: "groq/llama-3.3-70b:free", name: "Groq Llama", context_length: 131072 },
55
+ { id: "cerebras/llama3.1-8b:free", name: "Cerebras Llama", context_length: 8192 },
56
+ ],
57
+ };
58
+ globalThis.fetch = async () => ({ ok: true, json: async () => mockPayload });
59
+ const { fetchFreeModels } = await import("./discovery.js");
60
+ const models = await fetchFreeModels("sk-or-test");
61
+ assert.equal(models.length, 3);
62
+ // Groq (score 0) before Cerebras (score 1) before unknown (score 5)
63
+ assert.ok(models[0].id.startsWith("groq/"), `expected groq first, got ${models[0].id}`);
64
+ assert.ok(models[1].id.startsWith("cerebras/"), `expected cerebras second, got ${models[1].id}`);
65
+ assert.ok(models[2].id.startsWith("meta-llama/"), `expected meta-llama last, got ${models[2].id}`);
66
+ });
67
+ });
68
+ //# sourceMappingURL=discovery.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discovery.test.js","sourceRoot":"","sources":["../src/discovery.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from "./types.js";
2
+ export default function (pi: ExtensionAPI): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,321 @@
1
+ import { createAssistantMessageEventStream } from "./types.js";
2
+ import { fetchFreeModels } from "./discovery.js";
3
+ import { FreeRouter } from "./router.js";
4
+ import { streamFreeModel, ModelExhaustedError, ModelFatalError } from "./stream.js";
5
+ // Race this many free models simultaneously; first to stream wins.
6
+ const RACE_WIDTH = 3;
7
+ // If no candidate emits its first token within this window, abort and try the
8
+ // next batch. Free models routinely take 10-30 s to first token; 30 s ensures
9
+ // we don't thrash through the pool on legitimately slow (but live) models.
10
+ const FIRST_TOKEN_TIMEOUT_MS = 30_000;
11
+ // Re-fetch the free model list every hour so long-running Pi sessions pick up
12
+ // newly available models (and stop wasting retries on removed ones).
13
+ const REFRESH_INTERVAL_MS = 60 * 60 * 1_000;
14
+ function mergeSignals(...signals) {
15
+ const controller = new AbortController();
16
+ for (const sig of signals) {
17
+ if (!sig)
18
+ continue;
19
+ if (sig.aborted) {
20
+ controller.abort();
21
+ return controller.signal;
22
+ }
23
+ sig.addEventListener("abort", () => controller.abort(), { once: true });
24
+ }
25
+ return controller.signal;
26
+ }
27
+ /**
28
+ * Start candidateIds concurrently and forward events from the first model that
29
+ * emits `text_start` (or a valid empty `done`) to outStream; abort the rest.
30
+ *
31
+ * Correctness notes:
32
+ * - Uses Map<candidateIdx, Promise> so delete(idx) is always by candidate index,
33
+ * never by array position (which diverges after the first re-push).
34
+ * - Snapshots exhaustedIds at each return so late floating-.catch() pushes after
35
+ * raceModels returns don't corrupt the caller's view.
36
+ * - Tracks fatalError (e.g. 402) separately from quota exhaustion.
37
+ */
38
+ async function raceModels(candidateIds, context, apiKey, outStream, parentSignal, maxTokens) {
39
+ const controllers = candidateIds.map(() => new AbortController());
40
+ const proxyStreams = candidateIds.map(() => createAssistantMessageEventStream());
41
+ const exhaustedIds = [];
42
+ let fatalError;
43
+ // Start all candidates concurrently; each writes to its own isolated proxy stream.
44
+ candidateIds.forEach((modelId, i) => {
45
+ const sig = parentSignal
46
+ ? mergeSignals(parentSignal, controllers[i].signal)
47
+ : controllers[i].signal;
48
+ streamFreeModel(modelId, context, apiKey, proxyStreams[i], sig, maxTokens).catch((err) => {
49
+ if (err instanceof ModelExhaustedError) {
50
+ exhaustedIds.push(modelId);
51
+ }
52
+ else if (err instanceof ModelFatalError) {
53
+ fatalError = fatalError ?? err; // keep first fatal error
54
+ }
55
+ proxyStreams[i].end(); // ensure iterator terminates
56
+ });
57
+ });
58
+ const iterators = proxyStreams.map((s) => s[Symbol.asyncIterator]());
59
+ const buffers = candidateIds.map(() => []);
60
+ const nextFrom = (idx) => iterators[idx].next().then((result) => ({ idx, result }));
61
+ // Map<candidateIdx, Promise> so delete(idx) is always correct regardless of
62
+ // insertion order — the old array-based filter((_,j)=>j!==idx) was wrong after
63
+ // any re-push because j (array position) diverges from idx (candidate index).
64
+ const pending = new Map(candidateIds.map((_, i) => [i, nextFrom(i)]));
65
+ let timeoutHandle;
66
+ const deadline = new Promise((resolve) => {
67
+ timeoutHandle = setTimeout(() => resolve({ __timeout: true }), FIRST_TOKEN_TIMEOUT_MS);
68
+ });
69
+ try {
70
+ while (pending.size > 0) {
71
+ const resolved = await Promise.race([
72
+ ...pending.values(),
73
+ deadline,
74
+ ]);
75
+ if ("__timeout" in resolved) {
76
+ controllers.forEach((c) => c.abort());
77
+ return { winner: null, exhaustedIds: [...exhaustedIds], timedOut: true, fatalError };
78
+ }
79
+ const { idx, result } = resolved;
80
+ pending.delete(idx); // safe: keyed by candidate idx, not array position
81
+ if (result.done)
82
+ continue; // this candidate's stream ended; move on
83
+ const event = result.value;
84
+ buffers[idx].push(event);
85
+ // text_start → normal text streaming win
86
+ // toolcall_start → model is making a tool call; also a valid win
87
+ // done → valid but empty response (no text/tool content); still a winner
88
+ if (event.type === "text_start" || event.type === "toolcall_start" || event.type === "done") {
89
+ controllers.forEach((c, j) => { if (j !== idx)
90
+ c.abort(); });
91
+ for (const e of buffers[idx])
92
+ outStream.push(e);
93
+ if (event.type === "done") {
94
+ outStream.end();
95
+ return { winner: candidateIds[idx], exhaustedIds: [...exhaustedIds], timedOut: false };
96
+ }
97
+ // text_start / toolcall_start: pipe remaining events from the winner to outStream.
98
+ for await (const e of { [Symbol.asyncIterator]: () => iterators[idx] }) {
99
+ outStream.push(e);
100
+ if (e.type === "done" || e.type === "error") {
101
+ outStream.end();
102
+ break;
103
+ }
104
+ }
105
+ outStream.end(); // defensive: no-op if already ended via done/error above
106
+ return { winner: candidateIds[idx], exhaustedIds: [...exhaustedIds], timedOut: false };
107
+ }
108
+ if (event.type === "error") {
109
+ // Non-quota failure; stream ends next tick — don't re-add to pending.
110
+ continue;
111
+ }
112
+ // Any other event (e.g., thinking_start before text_start): keep consuming.
113
+ pending.set(idx, nextFrom(idx));
114
+ }
115
+ return { winner: null, exhaustedIds: [...exhaustedIds], timedOut: false, fatalError };
116
+ }
117
+ finally {
118
+ clearTimeout(timeoutHandle);
119
+ }
120
+ }
121
+ const BASE_ERROR_OUTPUT = {
122
+ role: "assistant",
123
+ api: "openrouter",
124
+ provider: "freerouter",
125
+ model: "free-router",
126
+ usage: {
127
+ input: 0,
128
+ output: 0,
129
+ cacheRead: 0,
130
+ cacheWrite: 0,
131
+ totalTokens: 0,
132
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
133
+ },
134
+ stopReason: "error",
135
+ };
136
+ export default async function (pi) {
137
+ const apiKeyRaw = process.env.OPENROUTER_API_KEY;
138
+ if (!apiKeyRaw) {
139
+ throw new Error("pi-freerouter: OPENROUTER_API_KEY is not set");
140
+ }
141
+ // Explicit string type so closures (streamSimple, refreshModels) see `string`,
142
+ // not `string | undefined` — TypeScript doesn't carry narrowing into inner fns.
143
+ const apiKey = apiKeyRaw;
144
+ const freeModels = await fetchFreeModels(apiKey);
145
+ if (freeModels.length === 0) {
146
+ throw new Error("pi-freerouter: No free models found on OpenRouter");
147
+ }
148
+ // `let` so the background refresh can replace it; each streamSimple call
149
+ // captures its own snapshot via `const localRouter = router`.
150
+ let router = new FreeRouter(freeModels.map((m) => m.id));
151
+ const maxContext = Math.max(...freeModels.map((m) => m.contextWindow));
152
+ const maxTokens = Math.max(...freeModels.map((m) => m.maxTokens));
153
+ // Hourly background refresh — picks up new free models without restarting Pi.
154
+ async function refreshModels() {
155
+ try {
156
+ const fresh = await fetchFreeModels(apiKey);
157
+ if (fresh.length > 0) {
158
+ router = new FreeRouter(fresh.map((m) => m.id));
159
+ console.log(`[pi-freerouter] Model list refreshed: ${fresh.length} free models`);
160
+ }
161
+ }
162
+ catch (err) {
163
+ console.warn("[pi-freerouter] Failed to refresh free model list:", err);
164
+ }
165
+ }
166
+ const refreshTimer = setInterval(() => { void refreshModels(); }, REFRESH_INTERVAL_MS);
167
+ // Don't keep the Node process alive solely for this timer.
168
+ if (typeof refreshTimer.unref === "function") {
169
+ refreshTimer.unref();
170
+ }
171
+ pi.registerProvider("freerouter", {
172
+ baseUrl: "https://openrouter.ai/api/v1",
173
+ apiKey,
174
+ api: "openai-completions",
175
+ models: [
176
+ {
177
+ id: "free-router",
178
+ name: "FreeRouter",
179
+ reasoning: false,
180
+ input: ["text"],
181
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
182
+ contextWindow: maxContext,
183
+ maxTokens,
184
+ },
185
+ ],
186
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
187
+ streamSimple: ((_model, context, options) => {
188
+ const stream = createAssistantMessageEventStream();
189
+ let streamClosed = false;
190
+ // Capture router at request start so mid-request refreshes don't affect
191
+ // this in-flight request's exhaustion tracking.
192
+ const localRouter = router;
193
+ (async () => {
194
+ while (true) {
195
+ // Check abort before starting each batch.
196
+ if (options?.signal?.aborted) {
197
+ const abortMsg = "Request was cancelled.";
198
+ streamClosed = true;
199
+ stream.push({
200
+ type: "error",
201
+ reason: "aborted",
202
+ error: {
203
+ ...BASE_ERROR_OUTPUT,
204
+ content: [],
205
+ errorMessage: abortMsg,
206
+ timestamp: Date.now(),
207
+ },
208
+ });
209
+ stream.end();
210
+ return;
211
+ }
212
+ const candidates = localRouter.nextModels(RACE_WIDTH);
213
+ if (candidates.length === 0)
214
+ break;
215
+ console.log(`[pi-freerouter] Racing: ${candidates.join(", ")}`);
216
+ const { winner, exhaustedIds, timedOut, fatalError } = await raceModels(candidates, context, apiKey, stream, options?.signal, options?.maxTokens);
217
+ // Quota-exceeded models: long TTL (90s).
218
+ exhaustedIds.forEach((id) => localRouter.markExhausted(id));
219
+ if (winner !== null) {
220
+ console.log(`[pi-freerouter] Winner: ${winner}`);
221
+ streamClosed = true;
222
+ return; // raceModels wrote all events and called stream.end()
223
+ }
224
+ // Fatal error (e.g., 402 Insufficient Credits) — surface immediately.
225
+ if (fatalError) {
226
+ const errMsg = fatalError.message;
227
+ streamClosed = true;
228
+ stream.push({
229
+ type: "error",
230
+ reason: "error",
231
+ error: {
232
+ ...BASE_ERROR_OUTPUT,
233
+ content: [{ type: "text", text: errMsg }],
234
+ errorMessage: errMsg,
235
+ timestamp: Date.now(),
236
+ },
237
+ });
238
+ stream.end();
239
+ return;
240
+ }
241
+ // Check abort — race may have ended because parent signal fired.
242
+ if (options?.signal?.aborted) {
243
+ const abortMsg = "Request was cancelled.";
244
+ streamClosed = true;
245
+ stream.push({
246
+ type: "error",
247
+ reason: "aborted",
248
+ error: {
249
+ ...BASE_ERROR_OUTPUT,
250
+ content: [],
251
+ errorMessage: abortMsg,
252
+ timestamp: Date.now(),
253
+ },
254
+ });
255
+ stream.end();
256
+ return;
257
+ }
258
+ // No winner — skip candidates we haven't already marked exhausted.
259
+ // Timeout → short TTL (15s): model is alive but slow, recover quickly.
260
+ // Other failure → long TTL (90s): treat as quota/error, avoid for longer.
261
+ candidates.forEach((id) => {
262
+ if (!exhaustedIds.includes(id)) {
263
+ if (timedOut) {
264
+ localRouter.markSlow(id);
265
+ }
266
+ else {
267
+ localRouter.markExhausted(id);
268
+ }
269
+ }
270
+ });
271
+ }
272
+ // All models currently in TTL cooldown.
273
+ const errMsg = "All free models exhausted. They will recover automatically — please try again in a moment.";
274
+ streamClosed = true;
275
+ stream.push({
276
+ type: "error",
277
+ reason: "error",
278
+ error: {
279
+ ...BASE_ERROR_OUTPUT,
280
+ content: [{ type: "text", text: errMsg }],
281
+ errorMessage: errMsg,
282
+ timestamp: Date.now(),
283
+ },
284
+ });
285
+ stream.end();
286
+ })().catch((err) => {
287
+ if (!streamClosed) {
288
+ const errMsg = String(err);
289
+ stream.push({
290
+ type: "error",
291
+ reason: "error",
292
+ error: {
293
+ ...BASE_ERROR_OUTPUT,
294
+ content: [],
295
+ errorMessage: errMsg,
296
+ timestamp: Date.now(),
297
+ },
298
+ });
299
+ stream.end();
300
+ }
301
+ });
302
+ // Cast: local stream satisfies the pi-ai class interface at runtime;
303
+ // private fields on the pi-ai class prevent structural assignability.
304
+ return stream;
305
+ }),
306
+ });
307
+ // Auto-activate FreeRouter as the default model on session start.
308
+ pi.on("session_start", async (_event, handlerCtx) => {
309
+ try {
310
+ const registry = handlerCtx?.modelRegistry;
311
+ const freeRouterModel = registry?.find?.("freerouter", "free-router");
312
+ if (freeRouterModel) {
313
+ await pi.setModel(freeRouterModel);
314
+ }
315
+ }
316
+ catch (err) {
317
+ console.warn("[pi-freerouter] Failed to set FreeRouter as active model:", err);
318
+ }
319
+ });
320
+ }
321
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ export declare class FreeRouter {
2
+ private readonly models;
3
+ private readonly exhaustionTtlMs;
4
+ private readonly exhausted;
5
+ constructor(models: readonly string[], exhaustionTtlMs?: number);
6
+ nextModel(): string | null;
7
+ nextModels(count: number): string[];
8
+ /** Mark model as quota-exhausted (429/5xx). Long TTL — don't retry soon. */
9
+ markExhausted(id: string): void;
10
+ /** Mark model as slow (first-token timeout). Short TTL — try others first, recover fast. */
11
+ markSlow(id: string): void;
12
+ }
package/dist/router.js ADDED
@@ -0,0 +1,53 @@
1
+ // 90 s for quota exhaustion (429/5xx) — matches OpenRouter's per-minute reset window.
2
+ const DEFAULT_EXHAUSTION_TTL_MS = 90_000;
3
+ // 15 s for a first-token timeout — model is alive but slow; back-off briefly so
4
+ // the next batch tries different candidates without burning the whole pool.
5
+ const SLOW_TTL_MS = 15_000;
6
+ export class FreeRouter {
7
+ models;
8
+ exhaustionTtlMs;
9
+ // modelId → { at: timestamp, ttl: ms }
10
+ exhausted = new Map();
11
+ constructor(models, exhaustionTtlMs = DEFAULT_EXHAUSTION_TTL_MS) {
12
+ this.models = models;
13
+ this.exhaustionTtlMs = exhaustionTtlMs;
14
+ }
15
+ nextModel() {
16
+ return this.nextModels(1)[0] ?? null;
17
+ }
18
+ nextModels(count) {
19
+ const now = Date.now();
20
+ const result = [];
21
+ for (const id of this.models) {
22
+ if (result.length >= count)
23
+ break;
24
+ const entry = this.exhausted.get(id);
25
+ if (entry !== undefined) {
26
+ if (now - entry.at < entry.ttl)
27
+ continue;
28
+ this.exhausted.delete(id); // TTL expired — back in rotation
29
+ }
30
+ result.push(id);
31
+ }
32
+ return result;
33
+ }
34
+ /** Mark model as quota-exhausted (429/5xx). Long TTL — don't retry soon. */
35
+ markExhausted(id) {
36
+ if (!this.models.includes(id)) {
37
+ console.warn(`[pi-freerouter] markExhausted called with unknown model ID: ${id}`);
38
+ return;
39
+ }
40
+ this.exhausted.set(id, { at: Date.now(), ttl: this.exhaustionTtlMs });
41
+ }
42
+ /** Mark model as slow (first-token timeout). Short TTL — try others first, recover fast. */
43
+ markSlow(id) {
44
+ if (!this.models.includes(id))
45
+ return;
46
+ // Don't downgrade an already-exhausted model to the shorter slow TTL.
47
+ const existing = this.exhausted.get(id);
48
+ if (existing && existing.ttl >= this.exhaustionTtlMs)
49
+ return;
50
+ this.exhausted.set(id, { at: Date.now(), ttl: SLOW_TTL_MS });
51
+ }
52
+ }
53
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,sFAAsF;AACtF,MAAM,yBAAyB,GAAG,MAAM,CAAC;AACzC,gFAAgF;AAChF,4EAA4E;AAC5E,MAAM,WAAW,GAAG,MAAM,CAAC;AAE3B,MAAM,OAAO,UAAU;IAKF;IACA;IALnB,uCAAuC;IACtB,SAAS,GAAG,IAAI,GAAG,EAAuC,CAAC;IAE5E,YACmB,MAAyB,EACzB,kBAAkB,yBAAyB;QAD3C,WAAM,GAAN,MAAM,CAAmB;QACzB,oBAAe,GAAf,eAAe,CAA4B;IAC3D,CAAC;IAEJ,SAAS;QACP,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;IACvC,CAAC;IAED,UAAU,CAAC,KAAa;QACtB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,MAAM,GAAa,EAAE,CAAC;QAE5B,KAAK,MAAM,EAAE,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAC7B,IAAI,MAAM,CAAC,MAAM,IAAI,KAAK;gBAAE,MAAM;YAClC,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YACrC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,IAAI,GAAG,GAAG,KAAK,CAAC,EAAE,GAAG,KAAK,CAAC,GAAG;oBAAE,SAAS;gBACzC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,iCAAiC;YAC9D,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAClB,CAAC;QAED,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,4EAA4E;IAC5E,aAAa,CAAC,EAAU;QACtB,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC;YAC9B,OAAO,CAAC,IAAI,CAAC,+DAA+D,EAAE,EAAE,CAAC,CAAC;YAClF,OAAO;QACT,CAAC;QACD,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,CAAC,CAAC;IACxE,CAAC;IAED,4FAA4F;IAC5F,QAAQ,CAAC,EAAU;QACjB,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;YAAE,OAAO;QACtC,sEAAsE;QACtE,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACxC,IAAI,QAAQ,IAAI,QAAQ,CAAC,GAAG,IAAI,IAAI,CAAC,eAAe;YAAE,OAAO;QAC7D,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,GAAG,EAAE,WAAW,EAAE,CAAC,CAAC;IAC/D,CAAC;CACF"}
@@ -0,0 +1 @@
1
+ export {};