pi-freeflow 1.0.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.
Files changed (3) hide show
  1. package/README.md +173 -0
  2. package/extensions/index.ts +1529 -0
  3. package/package.json +26 -0
@@ -0,0 +1,1529 @@
1
+ /**
2
+ * bansos — pi extension (with KiloCode free support)
3
+ *
4
+ * OpenCode models + KiloCode gateway free models
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import fs from "node:fs";
8
+ import http from "node:http";
9
+ import https from "node:https";
10
+ import { homedir } from "node:os";
11
+ import path from "node:path";
12
+ import { Readable } from "node:stream";
13
+ import { fileURLToPath } from "node:url";
14
+ import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
15
+
16
+ // ── Configuration ──────────────────────────────────────────────────
17
+ const UPSTREAM_OPENCODE = "https://opencode.ai/zen";
18
+ // KiloCode gateway — OpenAI-compatible; free models are keyless (200 req/hr per IP)
19
+ const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
20
+ const PORT = Number(process.env.BANSOS_PORT) || 18080;
21
+ const HOST = "127.0.0.1";
22
+ const API = `${UPSTREAM_OPENCODE}/v1`;
23
+ const OPENCODE_USER_AGENT = "opencode/latest/1.14.50/cli";
24
+ const OPENCODE_CLIENT = "cli";
25
+ const OPENCODE_PROJECT = "default";
26
+ const OPENCODE_SESSION = randomUUID();
27
+
28
+ function opencodeHeaders(): Record<string, string> {
29
+ return {
30
+ "User-Agent": OPENCODE_USER_AGENT,
31
+ "x-opencode-client": OPENCODE_CLIENT,
32
+ "x-opencode-project": OPENCODE_PROJECT,
33
+ "x-opencode-session": OPENCODE_SESSION,
34
+ "x-opencode-request": randomUUID(),
35
+ };
36
+ }
37
+
38
+ // ── Relay egress (vercel/cloudflare worker, x-relay-target pattern) ──────────
39
+ // Same logic as 9router ProxyFetch: when enabled, redirect upstream calls to a
40
+ // relay URL and inject x-relay-target / x-relay-path headers. Body untouched →
41
+ // SSE streaming passes through unchanged. Toggle live via /bansos command.
42
+ // No built-in default relay — a published package must not bake in any one
43
+ // user's personal relay URL. Bring your own via /bansos deploy or /bansos url.
44
+ const DEFAULT_RELAY_URL = "";
45
+ // State lives OUTSIDE the package dir so npm updates don't wipe it.
46
+ // Uses ~/.pi/agent/pi-bansos-relay-state.json (stable), falls back to
47
+ // package-root .relay-state.json for dev/local installs.
48
+ function resolveRelayStatePath(): string {
49
+ try {
50
+ const primary = path.join(homedir(), ".pi", "agent", "pi-freeflow-relay-state.json");
51
+ const legacy = path.join(homedir(), ".pi", "agent", "pi-bansos-relay-state.json");
52
+ if (!fs.existsSync(primary) && fs.existsSync(legacy)) {
53
+ try { fs.copyFileSync(legacy, primary); } catch {}
54
+ }
55
+ return primary;
56
+ } catch {
57
+ return path.join(
58
+ path.dirname(fileURLToPath(import.meta.url)),
59
+ "..",
60
+ ".relay-state.json",
61
+ );
62
+ }
63
+ }
64
+ function resolveLogFilePath(): string {
65
+ try {
66
+ return path.join(homedir(), ".pi", "agent", "pi-freeflow.log");
67
+ } catch {
68
+ return path.join(
69
+ path.dirname(fileURLToPath(import.meta.url)),
70
+ "..",
71
+ "pi-freeflow.log",
72
+ );
73
+ }
74
+ }
75
+ const LOG_FILE = resolveLogFilePath();
76
+ const RELAY_STATE_FILE = resolveRelayStatePath();
77
+ type KnownRelay = { url: string; label?: string; addedAt?: string };
78
+ type RelayState = { enabled: boolean; url: string; relays: KnownRelay[] };
79
+ function loadRelayState(): RelayState {
80
+ try {
81
+ const s = JSON.parse(fs.readFileSync(RELAY_STATE_FILE, "utf8"));
82
+ const relays: KnownRelay[] = Array.isArray(s?.relays) ? s.relays : [];
83
+ return {
84
+ enabled: Boolean(s?.enabled),
85
+ url: typeof s?.url === "string" ? s.url.trim() : "",
86
+ relays,
87
+ };
88
+ } catch {
89
+ return { enabled: false, url: "", relays: [] };
90
+ }
91
+ }
92
+ function saveRelayState(s: RelayState): void {
93
+ try {
94
+ fs.writeFileSync(RELAY_STATE_FILE, JSON.stringify(s));
95
+ } catch (e) {
96
+ log("warn", "could not persist relay state", { error: String(e) });
97
+ }
98
+ }
99
+ // dedupe-add a relay to the known list
100
+ function ensureRelay(s: RelayState, url: string, label?: string): void {
101
+ if (!url || s.relays.some((r) => r.url === url)) return;
102
+ s.relays.push({ url, label, addedAt: new Date().toISOString() });
103
+ }
104
+ function removeRelay(s: RelayState, url: string): void {
105
+ s.relays = s.relays.filter((r) => r.url !== url);
106
+ }
107
+ function resolveRelayState(): RelayState {
108
+ const s = loadRelayState();
109
+ // migrate legacy {enabled,url}: seed the known list with default + active url
110
+ if (!s.relays.length) {
111
+ ensureRelay(s, DEFAULT_RELAY_URL, "9Router default");
112
+ if (s.url && s.url !== DEFAULT_RELAY_URL) ensureRelay(s, s.url, "previous");
113
+ }
114
+ if (!s.url) s.url = DEFAULT_RELAY_URL;
115
+ return s;
116
+ }
117
+ let relayState: RelayState = resolveRelayState();
118
+ let statusUi: ExtensionUIContext | null = null;
119
+ function shortRelayLabel(url: string): string {
120
+ const hit = relayState.relays.find((r) => r.url === url);
121
+ if (hit?.label) return hit.label;
122
+ try { return new URL(url).host.split(".")[0]; } catch { return url.slice(0, 18); }
123
+ }
124
+ function getOrderedRelayUrls(): string[] {
125
+ relayState = loadRelayState();
126
+ if (relayState.relays && relayState.relays.length > 0) {
127
+ const active = (relayState.url || "").trim();
128
+ let activeIdx = relayState.relays.findIndex((r) => r.url === active);
129
+ if (activeIdx < 0) activeIdx = 0;
130
+ const ordered: string[] = [];
131
+ for (let i = 0; i < relayState.relays.length; i++) {
132
+ const r = relayState.relays[(activeIdx + i) % relayState.relays.length];
133
+ if (r?.url?.trim()) ordered.push(r.url.trim());
134
+ }
135
+ return ordered.length > 0 ? ordered : [DEFAULT_RELAY_URL];
136
+ }
137
+ if (relayState.url?.trim()) return [relayState.url.trim()];
138
+ return [DEFAULT_RELAY_URL];
139
+ }
140
+ function isRetriableStatus(status: number): boolean {
141
+ return (
142
+ status === 429 ||
143
+ status === 502 ||
144
+ status === 503 ||
145
+ status === 504 ||
146
+ status === 408 ||
147
+ status === 402 ||
148
+ status === 403 ||
149
+ status === 500
150
+ );
151
+ }
152
+
153
+ // Catalog served at GET /v1/models — ONLY the alive free models we register.
154
+ // Set after health checks. Prevents paid/other upstream models from leaking
155
+ // through the proxy's /v1/models (opencode returns 60 models incl. 54 paid).
156
+ type RegisteredModel = ModelDef & { source: Upstream };
157
+ let aliveCatalog: RegisteredModel[] = [];
158
+
159
+ async function relayFetch(
160
+ url: string,
161
+ opts: RequestInit = {},
162
+ ): Promise<Response> {
163
+ if (!relayState.enabled) {
164
+ return fetch(url, opts);
165
+ }
166
+
167
+ const candidates = getOrderedRelayUrls();
168
+ let lastResponse: Response | null = null;
169
+ let lastError: unknown = null;
170
+ const u = new URL(url);
171
+ const relayTarget = `${u.protocol}//${u.host}`;
172
+ const relayPath = `${u.pathname}${u.search}`;
173
+
174
+ const bodySizeKB =
175
+ typeof opts.body === "string"
176
+ ? (opts.body.length / 1024).toFixed(1)
177
+ : Buffer.isBuffer(opts.body)
178
+ ? (opts.body.length / 1024).toFixed(1)
179
+ : "0";
180
+
181
+ log("info", `request starting (${bodySizeKB}KB payload) -> ${url}`);
182
+
183
+ for (let i = 0; i < candidates.length; i++) {
184
+ const targetUrl = candidates[i];
185
+ const attemptStart = Date.now();
186
+ try {
187
+ const targetHost = new URL(targetUrl).host;
188
+ const headers = new Headers(opts.headers);
189
+ headers.set("x-relay-target", relayTarget);
190
+ headers.set("x-relay-path", relayPath);
191
+ headers.set("host", targetHost);
192
+
193
+ const signal = opts.signal || AbortSignal.timeout(300_000);
194
+ const res = await fetch(targetUrl, { ...opts, headers, signal });
195
+ const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
196
+
197
+ // Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
198
+ // Don't cycle through 5 more identical Vercel 25s timeouts. Fast fallback to direct!
199
+ if (res.status === 504) {
200
+ log(
201
+ "warn",
202
+ `relay ${targetUrl} hit HTTP 504 Gateway Timeout in ${elapsed}s (prompt evaluation exceeded Vercel 25s limit) — fast fallback to direct upstream`,
203
+ { upstream: url, sizeKB: bodySizeKB },
204
+ );
205
+ break;
206
+ }
207
+
208
+ if (isRetriableStatus(res.status)) {
209
+ lastResponse = res;
210
+ log(
211
+ "warn",
212
+ `relay ${targetUrl} returned HTTP ${res.status} in ${elapsed}s — rolling to next relay`,
213
+ { upstream: url, status: res.status },
214
+ );
215
+ continue;
216
+ }
217
+
218
+ // SUCCESS or non-retriable client error (e.g. 200, 400 Bad Request, 404):
219
+ // If we switched to a different relay because previous failed, update sticky active relay!
220
+ if (relayState.url !== targetUrl) {
221
+ log("info", `active relay auto-switched to ${targetUrl}`, {
222
+ previous: relayState.url,
223
+ });
224
+ relayState.url = targetUrl;
225
+ saveRelayState(relayState);
226
+ }
227
+
228
+ log("info", `relay ${targetUrl} succeeded (HTTP ${res.status} in ${elapsed}s)`);
229
+
230
+ // Update TUI status
231
+ const label = shortRelayLabel(targetUrl);
232
+ const total = relayState.relays.length || 1;
233
+ const pos = Math.max(
234
+ 1,
235
+ relayState.relays.findIndex((r) => r.url === targetUrl) + 1,
236
+ );
237
+ statusUi?.setStatus?.("freeflow", `relay: ON | ${label} ${pos}/${total}`);
238
+
239
+ return res;
240
+ } catch (err) {
241
+ const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
242
+ lastError = err;
243
+ log(
244
+ "warn",
245
+ `relay ${targetUrl} fetch error in ${elapsed}s — rolling to next relay`,
246
+ { upstream: url, error: String(err) },
247
+ );
248
+ continue;
249
+ }
250
+ }
251
+
252
+ // Full fallback: attempt direct fetch to upstream
253
+ const directStart = Date.now();
254
+ try {
255
+ log("warn", "relays bypassed/exhausted — attempting direct fetch to upstream", {
256
+ upstream: url,
257
+ sizeKB: bodySizeKB,
258
+ });
259
+ const directHeaders = new Headers(opts.headers);
260
+ directHeaders.delete("x-relay-target");
261
+ directHeaders.delete("x-relay-path");
262
+ directHeaders.set("host", u.host);
263
+ const directRes = await fetch(url, { ...opts, headers: directHeaders });
264
+ const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
265
+ log("info", `direct fetch returned HTTP ${directRes.status} in ${directElapsed}s`);
266
+ return directRes;
267
+ } catch (directErr) {
268
+ const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
269
+ log("error", `direct fallback also failed in ${directElapsed}s`, {
270
+ upstream: url,
271
+ error: String(directErr),
272
+ });
273
+ if (lastResponse) return lastResponse;
274
+ throw directErr || lastError;
275
+ }
276
+ }
277
+
278
+ // ── Deploy a fresh Vercel relay (same flow as 9Router) ───────────────────────
279
+ // Token is used in-memory only and NEVER persisted. Resulting URL is saved to
280
+ // the relay state and activated. Worker uses the x-relay-target/x-relay-path
281
+ // pattern, identical to the cloudflare/vercel relays 9Router deploys.
282
+ const VERCEL_API = "https://api.vercel.com";
283
+ const VERCEL_RELAY_WORKER = `// Only the 2 upstreams pi-bansos talks to. Anything else = open proxy abuse.
284
+ const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
285
+ export const config = { runtime: "edge" };
286
+ export default async function handler(req) {
287
+ const target = req.headers.get("x-relay-target");
288
+ const relayPath = req.headers.get("x-relay-path") || "/";
289
+ if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
290
+ const cleanTarget = target.replace(/\\/$/, "");
291
+ if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
292
+ if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
293
+ const targetUrl = cleanTarget + relayPath;
294
+ const headers = new Headers(req.headers);
295
+ headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
296
+ const response = await fetch(targetUrl, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
297
+ return new Response(response.body, { status: response.status, headers: response.headers });
298
+ }`;
299
+
300
+ async function deployVercelRelay(
301
+ token: string,
302
+ name: string,
303
+ onProgress?: (msg: string) => void,
304
+ ): Promise<string> {
305
+ const auth = {
306
+ Authorization: `Bearer ${token}`,
307
+ "Content-Type": "application/json",
308
+ };
309
+ // 1. create deployment (3 inline files, no git repo)
310
+ onProgress?.("Uploading relay to Vercel…");
311
+ const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
312
+ method: "POST",
313
+ headers: auth,
314
+ body: JSON.stringify({
315
+ name,
316
+ files: [
317
+ { file: "api/relay.js", data: VERCEL_RELAY_WORKER },
318
+ {
319
+ file: "package.json",
320
+ data: JSON.stringify({ name, version: "1.0.0" }),
321
+ },
322
+ {
323
+ file: "vercel.json",
324
+ data: JSON.stringify({
325
+ rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
326
+ }),
327
+ },
328
+ ],
329
+ projectSettings: { framework: null },
330
+ target: "production",
331
+ }),
332
+ });
333
+ if (!dep.ok) {
334
+ const e = await dep
335
+ .json()
336
+ .catch(() => ({}) as { error?: { message?: string } });
337
+ throw new Error(
338
+ e?.error?.message || `Vercel deploy failed (HTTP ${dep.status})`,
339
+ );
340
+ }
341
+ const depJson = await dep.json();
342
+ const depId = depJson.id || depJson.uid;
343
+ const projectId = depJson.projectId || name;
344
+ // 2. make the deployment public (disable SSO protection)
345
+ await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
346
+ method: "PATCH",
347
+ headers: auth,
348
+ body: JSON.stringify({ ssoProtection: null }),
349
+ });
350
+ // 3. poll until READY (3s interval, 120s timeout — same as 9Router)
351
+ onProgress?.("Waiting for deployment to go live…");
352
+ const deadline = Date.now() + 120_000;
353
+ while (Date.now() < deadline) {
354
+ const s = await fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
355
+ headers: { Authorization: `Bearer ${token}` },
356
+ });
357
+ const j = await s.json();
358
+ if (j.readyState === "READY") return `https://${j.url}`;
359
+ if (j.readyState === "ERROR" || j.readyState === "CANCELED")
360
+ throw new Error(`Deployment failed: ${j.readyState}`);
361
+ await new Promise((r) => setTimeout(r, 3000));
362
+ }
363
+ throw new Error("Deployment timed out (120s)");
364
+ }
365
+
366
+ // ── Model Definitions ──────────────────────────────────────────────
367
+ type ProviderApi = "openai-completions" | "openai-responses";
368
+ type Upstream = "opencode" | "kilo";
369
+
370
+ interface ModelDef {
371
+ id: string;
372
+ name: string;
373
+ reasoning: boolean;
374
+ contextWindow: number;
375
+ maxTokens: number;
376
+ api?: ProviderApi;
377
+ input?: ("text" | "image")[];
378
+ thinkingFormat?: "openrouter";
379
+ thinkingLevelMap?: Partial<
380
+ Record<
381
+ "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max",
382
+ string | null
383
+ >
384
+ >;
385
+ }
386
+
387
+ // OpenCode Zen free models verified against the live catalog and inference APIs.
388
+ const KNOWN_MODELS: ModelDef[] = [
389
+ {
390
+ id: "x-preview-f-free",
391
+ name: "Ox Alpha Free",
392
+ reasoning: true,
393
+ contextWindow: 1_048_576,
394
+ maxTokens: 131_072,
395
+ input: ["text", "image"],
396
+ thinkingLevelMap: {
397
+ off: "low",
398
+ minimal: "low",
399
+ low: "low",
400
+ medium: "high",
401
+ high: "high",
402
+ xhigh: "max",
403
+ max: "max",
404
+ },
405
+ },
406
+ {
407
+ id: "muse-spark-1.2-contributor-free",
408
+ name: "Muse Spark 1.2 Free",
409
+ reasoning: true,
410
+ contextWindow: 1_048_576,
411
+ maxTokens: 131_072,
412
+ api: "openai-responses",
413
+ input: ["text", "image"],
414
+ thinkingLevelMap: {
415
+ off: null,
416
+ minimal: "minimal",
417
+ low: "low",
418
+ medium: "medium",
419
+ high: "high",
420
+ xhigh: "xhigh",
421
+ max: "max",
422
+ },
423
+ },
424
+ {
425
+ id: "mimo-v2.5-free",
426
+ name: "MiMo V2.5 Free",
427
+ reasoning: true,
428
+ contextWindow: 1_048_576,
429
+ maxTokens: 131_072,
430
+ input: ["text", "image"],
431
+ thinkingLevelMap: {
432
+ off: "low",
433
+ minimal: "low",
434
+ low: "low",
435
+ medium: "medium",
436
+ high: "high",
437
+ xhigh: "high",
438
+ max: "high",
439
+ },
440
+ },
441
+ {
442
+ id: "hy3-free",
443
+ name: "Hy3 Free",
444
+ reasoning: true,
445
+ contextWindow: 262_144,
446
+ maxTokens: 128_000,
447
+ thinkingLevelMap: {
448
+ off: "low",
449
+ minimal: "low",
450
+ low: "low",
451
+ medium: "high",
452
+ high: "high",
453
+ xhigh: "max",
454
+ max: "max",
455
+ },
456
+ },
457
+ {
458
+ id: "nemotron-3-ultra-free",
459
+ name: "Nemotron 3 Ultra Free",
460
+ reasoning: true,
461
+ contextWindow: 1_000_000,
462
+ maxTokens: 128_000,
463
+ thinkingLevelMap: {
464
+ off: "low",
465
+ minimal: "low",
466
+ low: "low",
467
+ medium: "high",
468
+ high: "high",
469
+ xhigh: "max",
470
+ max: "max",
471
+ },
472
+ },
473
+ {
474
+ id: "nemotron-3.5-lightning-free",
475
+ name: "Nemotron 3.5 Lightning Free",
476
+ reasoning: true,
477
+ contextWindow: 1_000_000,
478
+ maxTokens: 262_144,
479
+ thinkingLevelMap: {
480
+ off: "low",
481
+ minimal: "low",
482
+ low: "low",
483
+ medium: "high",
484
+ high: "high",
485
+ xhigh: "max",
486
+ max: "max",
487
+ },
488
+ },
489
+ {
490
+ id: "big-pickle",
491
+ name: "Big Pickle",
492
+ reasoning: true,
493
+ contextWindow: 200_000,
494
+ maxTokens: 32_000,
495
+ thinkingLevelMap: {
496
+ off: "high",
497
+ minimal: "high",
498
+ low: "high",
499
+ medium: "high",
500
+ high: "high",
501
+ xhigh: "max",
502
+ max: "max",
503
+ },
504
+ },
505
+ {
506
+ id: "laguna-s-2.1-free",
507
+ name: "Laguna S 2.1 Free",
508
+ reasoning: true,
509
+ contextWindow: 1_048_576,
510
+ maxTokens: 131_072,
511
+ thinkingLevelMap: {
512
+ off: "low",
513
+ minimal: "low",
514
+ low: "low",
515
+ medium: "high",
516
+ high: "high",
517
+ xhigh: "max",
518
+ max: "max",
519
+ },
520
+ },
521
+ ];
522
+
523
+ // KiloCode gateway free models (keyless — https://kilo.ai/docs/gateway).
524
+ const KILO_MODELS: ModelDef[] = [
525
+ {
526
+ id: "kilo-auto/free",
527
+ name: "Kilo Auto Free",
528
+ reasoning: false,
529
+ contextWindow: 256_000,
530
+ maxTokens: 10_000,
531
+ input: ["text"],
532
+ },
533
+ {
534
+ id: "stepfun/step-3.7-flash:free",
535
+ name: "Step 3.7 Flash Free",
536
+ reasoning: true,
537
+ contextWindow: 262_144,
538
+ maxTokens: 262_144,
539
+ input: ["text", "image"],
540
+ thinkingFormat: "openrouter",
541
+ },
542
+ {
543
+ id: "nvidia/nemotron-3-ultra-550b-a55b:free",
544
+ name: "Nemotron 3 Ultra Free",
545
+ reasoning: true,
546
+ contextWindow: 1_000_000,
547
+ maxTokens: 65_536,
548
+ input: ["text"],
549
+ thinkingFormat: "openrouter",
550
+ },
551
+ {
552
+ id: "nvidia/nemotron-3-super-120b-a12b:free",
553
+ name: "Nemotron 3 Super Free",
554
+ reasoning: true,
555
+ contextWindow: 262_144,
556
+ maxTokens: 262_144,
557
+ input: ["text"],
558
+ thinkingFormat: "openrouter",
559
+ },
560
+ {
561
+ id: "dots-studio/dots-3-note-preview:free",
562
+ name: "Dots3-Note Preview Free",
563
+ reasoning: true,
564
+ contextWindow: 512_000,
565
+ maxTokens: 512_000,
566
+ input: ["text", "image"],
567
+ thinkingFormat: "openrouter",
568
+ },
569
+ {
570
+ id: "cohere/north-mini-code:free",
571
+ name: "North Mini Code Free",
572
+ reasoning: true,
573
+ contextWindow: 256_000,
574
+ maxTokens: 64_000,
575
+ input: ["text"],
576
+ thinkingFormat: "openrouter",
577
+ },
578
+ {
579
+ id: "poolside/laguna-xs-2.1:free",
580
+ name: "Laguna XS 2.1 Free",
581
+ reasoning: true,
582
+ contextWindow: 262_144,
583
+ maxTokens: 32_768,
584
+ input: ["text"],
585
+ thinkingFormat: "openrouter",
586
+ },
587
+ {
588
+ id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
589
+ name: "Nemotron 3 Nano Omni Free",
590
+ reasoning: true,
591
+ contextWindow: 256_000,
592
+ maxTokens: 65_536,
593
+ input: ["text", "image"],
594
+ thinkingFormat: "openrouter",
595
+ },
596
+ {
597
+ id: "openrouter/free",
598
+ name: "OpenRouter Free (auto)",
599
+ reasoning: false,
600
+ contextWindow: 200_000,
601
+ maxTokens: 65_536,
602
+ input: ["text"],
603
+ },
604
+ {
605
+ id: "nvidia/nemotron-3.5-lightning:free",
606
+ name: "Nemotron 3.5 Lightning Free",
607
+ reasoning: true,
608
+ contextWindow: 1_000_000,
609
+ maxTokens: 65_536,
610
+ input: ["text"],
611
+ thinkingFormat: "openrouter",
612
+ },
613
+ {
614
+ id: "nvidia/nemotron-3.5-content-safety:free",
615
+ name: "Nemotron 3.5 Content Safety Free",
616
+ reasoning: false,
617
+ contextWindow: 128_000,
618
+ maxTokens: 8_192,
619
+ input: ["text"],
620
+ },
621
+ {
622
+ id: "tencent/hy3:free",
623
+ name: "Tencent Hy3 Free",
624
+ reasoning: true,
625
+ contextWindow: 262_144,
626
+ maxTokens: 128_000,
627
+ input: ["text"],
628
+ thinkingFormat: "openrouter",
629
+ },
630
+ {
631
+ id: "liquid/lfm-2.5-2.6b:free",
632
+ name: "Liquid LFM 2.5 2.6B Free",
633
+ reasoning: true,
634
+ contextWindow: 65_536,
635
+ maxTokens: 8_192,
636
+ input: ["text"],
637
+ thinkingFormat: "openrouter",
638
+ },
639
+ {
640
+ id: "poolside/laguna-s-2.1:free",
641
+ name: "Laguna S 2.1 Free",
642
+ reasoning: true,
643
+ contextWindow: 262_144,
644
+ maxTokens: 32_768,
645
+ input: ["text"],
646
+ thinkingFormat: "openrouter",
647
+ },
648
+ ];
649
+ const KILO_MODEL_IDS = new Set(KILO_MODELS.map((m) => m.id));
650
+
651
+ // ── Whitelists ─────────────────────────────────────────────────────
652
+ const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
653
+ const PATH_TRAVERSAL_PATTERN = /\.\./;
654
+ const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
655
+ const STRIP_HEADERS = new Set([
656
+ "authorization",
657
+ "host",
658
+ "x-forwarded-for",
659
+ "x-forwarded-host",
660
+ "x-forwarded-proto",
661
+ "x-real-ip",
662
+ "x-client-ip",
663
+ "x-originate-ip",
664
+ "cookie",
665
+ "set-cookie",
666
+ "proxy-connection",
667
+ "proxy-authorization",
668
+ ]);
669
+
670
+ // ── Logger ─────────────────────────────────────────────────────────
671
+ type LogLevel = "info" | "warn" | "error" | "audit";
672
+ function log(level: LogLevel, message: string, meta?: Record<string, unknown>) {
673
+ try {
674
+ const ts = new Date().toISOString();
675
+ const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
676
+ const line = `[${ts}] [${level.toUpperCase()}] ${message}${metaStr}\n`;
677
+ try {
678
+ if (fs.existsSync(LOG_FILE) && fs.statSync(LOG_FILE).size > 2 * 1024 * 1024) {
679
+ const backup = `${LOG_FILE}.1`;
680
+ try { if (fs.existsSync(backup)) fs.unlinkSync(backup); } catch {}
681
+ fs.renameSync(LOG_FILE, backup);
682
+ }
683
+ } catch {}
684
+ fs.appendFileSync(LOG_FILE, line, "utf8");
685
+ } catch {}
686
+ }
687
+
688
+ // ── Rate Limiter ───────────────────────────────────────────────────
689
+ // Kilo documents 200 free requests/hour/IP. OpenCode owns its own daily quota;
690
+ // local limits only stop one Pi process from flooding either upstream.
691
+ const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
692
+ const RATE_LIMIT_MAX: Record<Upstream, number> = {
693
+ opencode: 200, // public free quota: requests per UTC day/IP
694
+ kilo: 200, // documented gateway quota: requests per one-hour window/IP
695
+ };
696
+
697
+ function rateLimitResetAt(upstream: Upstream, now: number): number {
698
+ if (upstream === "kilo") return now + 60 * 60_000;
699
+ const nextUtcDay = new Date(now);
700
+ nextUtcDay.setUTCHours(24, 0, 0, 0);
701
+ return nextUtcDay.getTime();
702
+ }
703
+
704
+ function rateLimitKey(upstream: Upstream, ip: string, now: number): string {
705
+ if (upstream === "kilo") return `${upstream}:${ip}`;
706
+ return `${upstream}:${new Date(now).toISOString().slice(0, 10)}:${ip}`;
707
+ }
708
+
709
+ // ponytail: Vercel relay rejects requests that ask for very large max_tokens
710
+ // (response body / duration limits). Clamp at relay layer so direct mode stays
711
+ // unconstrained and model config stays accurate.
712
+ const RELAY_MAX_TOKENS = 131_072;
713
+
714
+ function checkRateLimit(ip: string, upstream: Upstream): boolean {
715
+ const now = Date.now();
716
+ const key = rateLimitKey(upstream, ip, now);
717
+ const entry = rateLimitMap.get(key);
718
+ if (!entry || entry.resetAt <= now) {
719
+ rateLimitMap.set(key, {
720
+ count: 1,
721
+ resetAt: rateLimitResetAt(upstream, now),
722
+ });
723
+ return true;
724
+ }
725
+ if (entry.count >= RATE_LIMIT_MAX[upstream]) return false;
726
+ entry.count++;
727
+ return true;
728
+ }
729
+
730
+ // ── Health Check (OpenCode/Kilo catalogs; no per-model inference) ──
731
+ // Each upstream publishes a model list; fetch it ONCE (cached) and check
732
+ // membership. A 1-token chat probe per model was too slow (large models need
733
+ // 10s+ for the first token). Real usability is validated at chat time (300s).
734
+ let opencodeCatalogP: Promise<Set<string> | null> | null = null;
735
+ function opencodeCatalog(): Promise<Set<string> | null> {
736
+ if (!opencodeCatalogP)
737
+ opencodeCatalogP = (async () => {
738
+ try {
739
+ const r = await fetch(`${API}/models`, {
740
+ headers: opencodeHeaders(),
741
+ signal: AbortSignal.timeout(10_000),
742
+ });
743
+ if (!r.ok) return null;
744
+ const d = await r.json();
745
+ return new Set<string>(
746
+ (d?.data ?? []).map((m: { id: string }) => m.id),
747
+ );
748
+ } catch {
749
+ return null;
750
+ }
751
+ })();
752
+ return opencodeCatalogP;
753
+ }
754
+ let kiloCatalogP: Promise<Set<string> | null> | null = null;
755
+ function kiloCatalog(): Promise<Set<string> | null> {
756
+ if (!kiloCatalogP)
757
+ kiloCatalogP = (async () => {
758
+ try {
759
+ const r = await fetch(
760
+ KILO_CHAT_URL.replace("/chat/completions", "/models"),
761
+ {
762
+ headers: { Authorization: "Bearer kilo-free" },
763
+ signal: AbortSignal.timeout(10_000),
764
+ },
765
+ );
766
+ if (!r.ok) return null;
767
+ const d = await r.json();
768
+ return new Set<string>(
769
+ (d?.data ?? []).map((m: { id: string }) => m.id),
770
+ );
771
+ } catch {
772
+ return null;
773
+ }
774
+ })();
775
+ return kiloCatalogP;
776
+ }
777
+
778
+ async function checkModelAlive(id: string): Promise<boolean> {
779
+ try {
780
+ const cat = await opencodeCatalog();
781
+ return cat ? cat.has(id) : false;
782
+ } catch {
783
+ return false;
784
+ }
785
+ }
786
+
787
+ async function checkKiloAlive(id: string): Promise<boolean> {
788
+ try {
789
+ const cat = await kiloCatalog();
790
+ return cat ? cat.has(id) : false;
791
+ } catch {
792
+ return false;
793
+ }
794
+ }
795
+
796
+ // ── Helpers ────────────────────────────────────────────────────────
797
+ function getClientIP(req: http.IncomingMessage): string {
798
+ const addr = req.socket.remoteAddress;
799
+ if (!addr) return "unknown";
800
+ return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
801
+ }
802
+
803
+ function validatePath(rawUrl: string): URL | null {
804
+ const cleaned = rawUrl.replace(/^\/+/, "");
805
+ if (!ALLOWED_PATH_PATTERN.test(`/${cleaned}`)) return null;
806
+ if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
807
+ try {
808
+ const decoded = decodeURIComponent(cleaned);
809
+ if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`))
810
+ return null;
811
+ } catch {
812
+ return null;
813
+ }
814
+ try {
815
+ return new URL(cleaned, `${UPSTREAM_OPENCODE}/`);
816
+ } catch {
817
+ return null;
818
+ }
819
+ }
820
+
821
+ function sanitizeHeaders(
822
+ incoming: http.IncomingHttpHeaders,
823
+ targetHost: string,
824
+ ): Record<string, string> {
825
+ const sanitized: Record<string, string> = {};
826
+ for (const [key, value] of Object.entries(incoming)) {
827
+ const lower = key.toLowerCase();
828
+ if (STRIP_HEADERS.has(lower) || lower.startsWith(":")) continue;
829
+ if (typeof value === "string") sanitized[lower] = value;
830
+ else if (Array.isArray(value)) sanitized[lower] = value.join(", ");
831
+ }
832
+ sanitized.host = targetHost;
833
+ Object.assign(sanitized, opencodeHeaders());
834
+ sanitized["accept-encoding"] = "identity";
835
+ sanitized.connection = "keep-alive";
836
+ return sanitized;
837
+ }
838
+
839
+ // ponytail: shared stream pipe — upstream abort/timeout must end response,
840
+ // not become an uncaught exception that crashes pi.
841
+ function pipeUpstreamStream(
842
+ nodeStream: Readable,
843
+ res: http.ServerResponse,
844
+ req: http.IncomingMessage,
845
+ ): void {
846
+ try {
847
+ if (typeof res.flushHeaders === "function") {
848
+ res.flushHeaders();
849
+ }
850
+ } catch {}
851
+
852
+ nodeStream.on("data", (chunk: Buffer | string) => {
853
+ try {
854
+ res.write(chunk);
855
+ if (typeof (res as any).flush === "function") {
856
+ (res as any).flush();
857
+ }
858
+ } catch {}
859
+ });
860
+
861
+ nodeStream.on("error", (e: unknown) => {
862
+ log("error", "upstream stream error", { error: String(e) });
863
+ try {
864
+ if (!res.headersSent) {
865
+ res.writeHead(502, { "content-type": "application/json" });
866
+ }
867
+ if (!res.writableEnded) {
868
+ res.end();
869
+ }
870
+ } catch {}
871
+ });
872
+ nodeStream.on("end", () => {
873
+ try {
874
+ if (!res.writableEnded) res.end();
875
+ } catch {}
876
+ });
877
+ nodeStream.on("close", () => {
878
+ try {
879
+ if (!res.writableEnded) res.end();
880
+ } catch {}
881
+ });
882
+ req.on("aborted", () => {
883
+ if (!nodeStream.destroyed) nodeStream.destroy();
884
+ });
885
+ req.on("close", () => {
886
+ if (!nodeStream.destroyed) nodeStream.destroy();
887
+ });
888
+ }
889
+
890
+ // ── Start local proxy ──────────────────────────────────────────────
891
+ function startProxy(
892
+ overridePort?: number,
893
+ ): Promise<{ server: http.Server; port: number }> {
894
+ const basePort = overridePort ?? PORT;
895
+
896
+ const server = http.createServer((req, res) => {
897
+ const clientIP = getClientIP(req);
898
+
899
+ if (!ALLOWED_METHODS.has(req.method ?? "")) {
900
+ res.writeHead(405, { "content-type": "application/json" });
901
+ res.end(JSON.stringify({ error: "method not allowed" }));
902
+ return;
903
+ }
904
+
905
+ if (req.method === "OPTIONS") {
906
+ res.writeHead(204, {
907
+ "access-control-allow-origin": "*",
908
+ "access-control-allow-methods": "GET, POST, OPTIONS",
909
+ "access-control-max-age": "86400",
910
+ });
911
+ res.end();
912
+ return;
913
+ }
914
+
915
+ // Serve ONLY our registered free models. Never forward /v1/models to
916
+ // upstream (that would leak ~54 paid models into the picker).
917
+ if (
918
+ req.method === "GET" &&
919
+ (req.url === "/v1/models" || req.url === "/v1/models/")
920
+ ) {
921
+ const body = JSON.stringify({
922
+ object: "list",
923
+ data: aliveCatalog.map((m) => ({
924
+ id: m.id,
925
+ object: "model",
926
+ created: 0,
927
+ owned_by: m.source === "kilo" ? "kilocode" : "opencode",
928
+ })),
929
+ });
930
+ res.writeHead(200, {
931
+ "content-type": "application/json",
932
+ "content-length": Buffer.byteLength(body),
933
+ });
934
+ res.end(body);
935
+ return;
936
+ }
937
+
938
+ const target = validatePath(req.url ?? "/");
939
+ if (!target) {
940
+ res.writeHead(403, { "content-type": "application/json" });
941
+ res.end(JSON.stringify({ error: "forbidden" }));
942
+ return;
943
+ }
944
+
945
+ // Read body to detect model for routing
946
+ const bodyChunks: Buffer[] = [];
947
+ req.on("data", (chunk: Buffer) => bodyChunks.push(chunk));
948
+ req.on("end", async () => {
949
+ const bodyStr = Buffer.concat(bodyChunks).toString();
950
+ let isKilo = false;
951
+ let parsedBody: Record<string, unknown> | null = null;
952
+
953
+ try {
954
+ parsedBody = JSON.parse(bodyStr);
955
+ if (
956
+ typeof parsedBody?.model === "string" &&
957
+ KILO_MODEL_IDS.has(parsedBody.model)
958
+ ) {
959
+ isKilo = true;
960
+ }
961
+ } catch {}
962
+
963
+ const upstream: Upstream = isKilo ? "kilo" : "opencode";
964
+
965
+ try {
966
+ if (isKilo && parsedBody) {
967
+ // KiloCode gateway routing (free models are keyless)
968
+ const isStream = parsedBody.stream === true;
969
+ const response = await relayFetch(KILO_CHAT_URL, {
970
+ method: "POST",
971
+ headers: {
972
+ "Content-Type": "application/json",
973
+ Authorization: "Bearer kilo-free",
974
+ },
975
+ body: JSON.stringify(parsedBody),
976
+ signal: AbortSignal.timeout(300_000),
977
+ });
978
+ if (isStream && response.ok && response.body) {
979
+ const ct =
980
+ response.headers.get("content-type") || "text/event-stream";
981
+ res.writeHead(response.status, {
982
+ "content-type": ct,
983
+ "cache-control": "no-cache, no-transform",
984
+ "connection": "keep-alive",
985
+ "x-accel-buffering": "no",
986
+ });
987
+ pipeUpstreamStream(
988
+ Readable.fromWeb(
989
+ response.body as unknown as import("stream/web").ReadableStream,
990
+ ),
991
+ res,
992
+ req,
993
+ );
994
+ } else {
995
+ const data = await response.text();
996
+ const ct =
997
+ response.headers.get("content-type") || "application/json";
998
+ res.writeHead(response.status, { "content-type": ct });
999
+ res.end(data);
1000
+ }
1001
+ } else {
1002
+ // OpenCode routing — relay (fetch-based) when enabled, else direct (existing, untouched)
1003
+ // round-robin: any saved relay qualifies, not just single url
1004
+ if (relayState.enabled && (relayState.url || relayState.relays.length > 0)) {
1005
+ const fullUrl = `${UPSTREAM_OPENCODE}${req.url ?? "/"}`;
1006
+ const activeHost = new URL(relayState.url || DEFAULT_RELAY_URL).host;
1007
+ const relayHeaders = sanitizeHeaders(
1008
+ req.headers,
1009
+ activeHost,
1010
+ );
1011
+ try {
1012
+ // ponytail: clamp max_tokens for Vercel relay — large values
1013
+ // cause 400 "Upstream request failed" (response size / duration
1014
+ // limits). Direct mode stays unconstrained.
1015
+ let relayBody = bodyChunks.length
1016
+ ? Buffer.concat(bodyChunks)
1017
+ : undefined;
1018
+ if (relayBody && parsedBody) {
1019
+ const mt = parsedBody.max_tokens ?? parsedBody.maxTokens;
1020
+ if (typeof mt === "number" && mt > RELAY_MAX_TOKENS) {
1021
+ parsedBody.max_tokens = RELAY_MAX_TOKENS;
1022
+ }
1023
+ if (typeof parsedBody.reasoning_effort === "string") {
1024
+ const re = parsedBody.reasoning_effort.toLowerCase();
1025
+ if (re === "xhigh" || re === "max") {
1026
+ parsedBody.reasoning_effort = "max";
1027
+ } else if (re === "high" || re === "medium") {
1028
+ parsedBody.reasoning_effort = "high";
1029
+ } else {
1030
+ parsedBody.reasoning_effort = "low";
1031
+ }
1032
+ }
1033
+ relayBody = Buffer.from(JSON.stringify(parsedBody));
1034
+ }
1035
+ const response = await relayFetch(fullUrl, {
1036
+ method: req.method || "POST",
1037
+ headers: relayHeaders,
1038
+ body: relayBody,
1039
+ signal: AbortSignal.timeout(300_000),
1040
+ });
1041
+ const ct =
1042
+ response.headers.get("content-type") || "application/json";
1043
+ if (response.ok && response.body) {
1044
+ const ct =
1045
+ response.headers.get("content-type") || "text/event-stream";
1046
+ res.writeHead(response.status, {
1047
+ "content-type": ct,
1048
+ "cache-control": "no-cache, no-transform",
1049
+ "connection": "keep-alive",
1050
+ "x-accel-buffering": "no",
1051
+ });
1052
+ pipeUpstreamStream(
1053
+ Readable.fromWeb(
1054
+ response.body as unknown as import("stream/web").ReadableStream,
1055
+ ),
1056
+ res,
1057
+ req,
1058
+ );
1059
+ } else {
1060
+ const data = await response.text();
1061
+ const ct =
1062
+ response.headers.get("content-type") || "application/json";
1063
+ res.writeHead(response.status, { "content-type": ct });
1064
+ res.end(data);
1065
+ }
1066
+ return; // relay handled the response
1067
+ } catch (e) {
1068
+ log("warn", "opencode relay failed, falling back to direct", {
1069
+ error: String(e),
1070
+ });
1071
+ if (res.headersSent) return; // can't recover mid-stream
1072
+ }
1073
+ }
1074
+ // direct path (existing, untouched)
1075
+ let directBody = Buffer.concat(bodyChunks);
1076
+ if (parsedBody && typeof parsedBody.reasoning_effort === "string") {
1077
+ const re = parsedBody.reasoning_effort.toLowerCase();
1078
+ if (re === "xhigh" || re === "max") {
1079
+ parsedBody.reasoning_effort = "max";
1080
+ } else if (re === "high" || re === "medium") {
1081
+ parsedBody.reasoning_effort = "high";
1082
+ } else {
1083
+ parsedBody.reasoning_effort = "low";
1084
+ }
1085
+ directBody = Buffer.from(JSON.stringify(parsedBody));
1086
+ }
1087
+ const fwd = sanitizeHeaders(req.headers, target.hostname);
1088
+ if (directBody.length > 0) {
1089
+ fwd["content-length"] = String(directBody.byteLength);
1090
+ }
1091
+ const proxy = https.request(
1092
+ {
1093
+ method: req.method,
1094
+ hostname: target.hostname,
1095
+ port: 443,
1096
+ path: target.pathname + target.search,
1097
+ headers: fwd,
1098
+ },
1099
+ (upstream) => {
1100
+ const outHeaders: Record<string, string> = {};
1101
+ for (const h of [
1102
+ "content-type",
1103
+ "cache-control",
1104
+ "x-request-id",
1105
+ ]) {
1106
+ const val = upstream.headers[h];
1107
+ if (typeof val === "string") outHeaders[h] = val;
1108
+ }
1109
+ outHeaders["x-content-type-options"] = "nosniff";
1110
+ res.writeHead(upstream.statusCode ?? 502, outHeaders);
1111
+ upstream.pipe(res);
1112
+ },
1113
+ );
1114
+ proxy.on("error", () => {
1115
+ if (!res.headersSent) {
1116
+ res.writeHead(502, { "content-type": "application/json" });
1117
+ res.end(JSON.stringify({ error: "upstream error" }));
1118
+ } else if (!res.writableEnded) {
1119
+ res.end();
1120
+ }
1121
+ });
1122
+ proxy.setTimeout(30_000, () => {
1123
+ proxy.destroy(new Error("timeout"));
1124
+ });
1125
+ req.on("aborted", () => {
1126
+ if (!proxy.destroyed) proxy.destroy();
1127
+ });
1128
+ // ponytail: body already buffered in bodyChunks above for model routing;
1129
+ // req is drained so pipe() would send an empty body → upstream hang → 502.
1130
+ proxy.end(directBody);
1131
+ }
1132
+ } catch (err) {
1133
+ log("error", "proxy error", { error: String(err) });
1134
+ if (!res.headersSent)
1135
+ res.writeHead(502, { "content-type": "application/json" });
1136
+ res.end(JSON.stringify({ error: "internal error" }));
1137
+ }
1138
+ });
1139
+ });
1140
+
1141
+ return new Promise((resolve, reject) => {
1142
+ // ponytail: auto-bump to next free port so multiple pi sessions on one
1143
+ // machine don't fight over 18080. cap at 20 to avoid infinite scan.
1144
+ // use server.address() for the real port: a failed listen()'s callback
1145
+ // still fires on the next successful listen, so the closure `port` is stale.
1146
+ let attempt = 0;
1147
+ let settled = false;
1148
+ const tryListen = (port: number) => {
1149
+ server.once("error", (err: NodeJS.ErrnoException) => {
1150
+ if (settled) return;
1151
+ if (err.code === "EADDRINUSE" && attempt < 20) {
1152
+ attempt++;
1153
+ log("warn", `port ${port} taken — trying ${port + 1}`);
1154
+ tryListen(port + 1);
1155
+ return;
1156
+ }
1157
+ settled = true;
1158
+ log("error", "server error", { code: err.code, message: err.message });
1159
+ reject(err);
1160
+ });
1161
+ server.listen(port, HOST, () => {
1162
+ if (settled) return;
1163
+ settled = true;
1164
+ const addr = server.address();
1165
+ const realPort = addr && typeof addr === "object" ? addr.port : port;
1166
+ log("info", `proxy listening on http://${HOST}:${realPort}`);
1167
+ resolve({ server, port: realPort });
1168
+ });
1169
+ };
1170
+ tryListen(basePort);
1171
+ });
1172
+ }
1173
+
1174
+ // ── Main extension ─────────────────────────────────────────────────
1175
+ export default async function (pi: ExtensionAPI) {
1176
+ log("info", "extension loading...");
1177
+ let server: http.Server;
1178
+ let actualPort: number;
1179
+ try {
1180
+ const r = await startProxy();
1181
+ server = r.server;
1182
+ actualPort = r.port;
1183
+ } catch {
1184
+ log(
1185
+ "error",
1186
+ "extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
1187
+ );
1188
+ return;
1189
+ }
1190
+
1191
+ // Health check opencode models
1192
+ log("info", `checking ${KNOWN_MODELS.length} opencode model(s)...`);
1193
+ const opencodeChecks = await Promise.all(
1194
+ KNOWN_MODELS.map(async (model) => {
1195
+ const alive = await checkModelAlive(model.id);
1196
+ if (alive) log("info", `✓ ${model.id} is alive`);
1197
+ else log("warn", `✗ ${model.id} is dead — skipping`);
1198
+ return { ...model, alive, source: "opencode" as const };
1199
+ }),
1200
+ );
1201
+
1202
+ // Health check kilo models
1203
+ log("info", `checking ${KILO_MODELS.length} kilo model(s)...`);
1204
+ const kiloChecks = await Promise.all(
1205
+ KILO_MODELS.map(async (model) => {
1206
+ const alive = await checkKiloAlive(model.id);
1207
+ if (alive) log("info", `✓ ${model.id} (kilo) is alive`);
1208
+ else log("warn", `✗ ${model.id} (kilo) is dead — skipping`);
1209
+ return { ...model, alive, source: "kilo" as const };
1210
+ }),
1211
+ );
1212
+
1213
+ const aliveModels = [...opencodeChecks, ...kiloChecks].filter((m) => m.alive);
1214
+ aliveCatalog = aliveModels;
1215
+
1216
+ if (aliveModels.length === 0) {
1217
+ // Don't bail: still register /bansos below so the user can recover
1218
+ // (e.g. switch the relay off) instead of being stranded with no command.
1219
+ log(
1220
+ "warn",
1221
+ "no alive models found — provider inactive; /bansos still available to switch relay off / go direct",
1222
+ );
1223
+ } else {
1224
+ log(
1225
+ "info",
1226
+ `${aliveModels.length} model(s) registered: ${aliveModels.map((m) => m.id).join(", ")}`,
1227
+ );
1228
+
1229
+ const providerConfig = {
1230
+ baseUrl: `http://${HOST}:${actualPort}/v1`,
1231
+ apiKey: "placeholder",
1232
+ api: "openai-completions" as const,
1233
+ compat: { supportsDeveloperRole: false },
1234
+ models: aliveModels.map((m) => ({
1235
+ id: m.id,
1236
+ name: `${m.source === "kilo" ? "KiloCode" : "OpenCode"} · ${m.name}`,
1237
+ api: m.api,
1238
+ reasoning: m.reasoning,
1239
+ thinkingLevelMap: m.thinkingLevelMap,
1240
+ input: m.input ?? ["text"],
1241
+ contextWindow: m.contextWindow,
1242
+ maxTokens: m.maxTokens,
1243
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1244
+ compat: m.thinkingFormat
1245
+ ? { supportsDeveloperRole: false, thinkingFormat: m.thinkingFormat }
1246
+ : m.api === "openai-responses"
1247
+ ? { sessionAffinityFormat: "openai-nosession" }
1248
+ : m.source === "kilo"
1249
+ ? { supportsDeveloperRole: false, supportsReasoningEffort: false }
1250
+ : { supportsDeveloperRole: false, supportsReasoningEffort: true },
1251
+ })),
1252
+ };
1253
+
1254
+ pi.registerProvider("freeflow", providerConfig);
1255
+ pi.registerProvider("bansos", providerConfig);
1256
+ }
1257
+ // ── /bansos command: toggle relay egress live (on|off|status|url [URL]) ───
1258
+ const commandSpec = {
1259
+ description:
1260
+ "Relay egress: on | off | status | logs | url [URL] | deploy | list | use <URL> | remove <URL>",
1261
+ getArgumentCompletions: (prefix: string) =>
1262
+ ["on", "off", "status", "url", "deploy", "list", "use", "remove"]
1263
+ .filter((s) => s.startsWith(prefix))
1264
+ .map((s) => ({ value: s, label: s })),
1265
+ handler: async (args: string, ctx) => {
1266
+ const parts = String(args || "")
1267
+ .trim()
1268
+ .split(/\s+/);
1269
+ const sub = parts[0] || "";
1270
+ const rest = parts.slice(1).join(" ");
1271
+
1272
+ const flash = () => {
1273
+ const activeLabel = shortRelayLabel(relayState.url);
1274
+ const activeIdx = Math.max(1, relayState.relays.findIndex((r) => r.url === relayState.url) + 1);
1275
+ const total = relayState.relays.length || 1;
1276
+ ctx.ui.notify(
1277
+ `Relay ${relayState.enabled ? "ON" : "OFF"}${relayState.enabled ? ` → ${activeLabel} (${activeIdx}/${total})` : " (direct)"} | saved=${relayState.relays.length} (auto-fallback rolling)`,
1278
+ "info",
1279
+ );
1280
+ };
1281
+ const persist = () => {
1282
+ saveRelayState(relayState);
1283
+ statusUi = ctx.ui;
1284
+ ctx.ui.setStatus("freeflow", undefined);
1285
+ ctx.ui.setStatus("bansos", undefined);
1286
+ };
1287
+ // mutate in place so the saved-relays list is preserved across switches
1288
+ const setRelay = (enabled: boolean, url: string, addLabel?: string) => {
1289
+ relayState.enabled = enabled;
1290
+ relayState.url = (url || "").trim() || DEFAULT_RELAY_URL;
1291
+ if (relayState.url) ensureRelay(relayState, relayState.url, addLabel);
1292
+ };
1293
+ const doDeploy = async () => {
1294
+ // Token prompted (not stored). pi's input has no secret mode — shows while typing.
1295
+ const defaultName = `relay-${Date.now().toString(36)}`;
1296
+ const token = (
1297
+ await ctx.ui.input("Vercel API token (vercel-…):", "")
1298
+ )?.trim();
1299
+ if (!token) {
1300
+ ctx.ui.notify("Deploy cancelled — no token", "warning");
1301
+ return;
1302
+ }
1303
+ const name =
1304
+ (
1305
+ await ctx.ui.input("Project name (empty = auto):", defaultName)
1306
+ )?.trim() || defaultName;
1307
+ ctx.ui.setStatus("bansos", "deploying relay…");
1308
+ try {
1309
+ const url = await deployVercelRelay(token, name, (m) =>
1310
+ ctx.ui.notify(m, "info"),
1311
+ );
1312
+ setRelay(true, url, `deployed ${name}`);
1313
+ persist();
1314
+ ctx.ui.notify(`✓ Deployed & active: ${url}`, "info");
1315
+ } catch (e) {
1316
+ ctx.ui.setStatus(
1317
+ "freeflow",
1318
+ `relay: ${relayState.enabled ? "ON" : "OFF"}`,
1319
+ );
1320
+ ctx.ui.notify(`Deploy failed: ${(e as Error).message}`, "error");
1321
+ }
1322
+ };
1323
+ const switchRelay = async () => {
1324
+ if (!relayState.relays.length) {
1325
+ ctx.ui.notify("No saved relays yet", "warning");
1326
+ return;
1327
+ }
1328
+ const fmt = (r: KnownRelay) =>
1329
+ `${r.url === relayState.url ? "★ " : " "}${r.url}${r.label ? ` (${r.label})` : ""}`;
1330
+ const opts = relayState.relays.map(fmt);
1331
+ const choice = await ctx.ui.select("Switch relay", opts);
1332
+ if (!choice) return;
1333
+ const match = relayState.relays.find((r) => fmt(r) === choice);
1334
+ if (!match) return;
1335
+ setRelay(true, match.url);
1336
+ persist();
1337
+ flash();
1338
+ };
1339
+ const showList = () => {
1340
+ if (!relayState.relays.length) {
1341
+ ctx.ui.notify("No saved relays", "info");
1342
+ return;
1343
+ }
1344
+ const lines = relayState.relays.map(
1345
+ (r) =>
1346
+ `${r.url === relayState.url ? "★" : " "} ${r.url}${r.label ? ` [${r.label}]` : ""}`,
1347
+ );
1348
+ ctx.ui.notify(
1349
+ `Saved relays (${relayState.relays.length}):\n${lines.join("\n")}`,
1350
+ "info",
1351
+ );
1352
+ };
1353
+ const removeRelayMenu = async () => {
1354
+ const removable = relayState.relays.filter(
1355
+ (r) => r.url !== relayState.url,
1356
+ );
1357
+ if (!removable.length) {
1358
+ ctx.ui.notify(
1359
+ "Nothing to remove — the active relay can't be removed (switch first)",
1360
+ "warning",
1361
+ );
1362
+ return;
1363
+ }
1364
+ const fmt = (r: KnownRelay) =>
1365
+ `${r.url}${r.label ? ` (${r.label})` : ""}`;
1366
+ const choice = await ctx.ui.select("Remove relay", removable.map(fmt));
1367
+ if (!choice) return;
1368
+ const match = removable.find((r) => fmt(r) === choice);
1369
+ if (!match) return;
1370
+ removeRelay(relayState, match.url);
1371
+ persist();
1372
+ ctx.ui.notify(`Removed: ${match.url}`, "info");
1373
+ };
1374
+
1375
+ if (sub === "on") {
1376
+ setRelay(true, relayState.url || DEFAULT_RELAY_URL);
1377
+ persist();
1378
+ flash();
1379
+ } else if (sub === "off") {
1380
+ relayState.enabled = false;
1381
+ persist();
1382
+ flash();
1383
+ } else if (sub === "status") {
1384
+ flash();
1385
+ } else if (sub === "list") {
1386
+ showList();
1387
+ } else if (sub === "use") {
1388
+ const url = (
1389
+ rest ||
1390
+ (await ctx.ui.input("Relay URL to activate:", "")) ||
1391
+ ""
1392
+ ).trim();
1393
+ if (!url) {
1394
+ ctx.ui.notify("No URL given", "warning");
1395
+ return;
1396
+ }
1397
+ setRelay(true, url, "manual");
1398
+ persist();
1399
+ flash();
1400
+ } else if (sub === "logs" || sub === "log") {
1401
+ try {
1402
+ if (!fs.existsSync(LOG_FILE)) {
1403
+ ctx.ui.notify(`No logs recorded yet in ${LOG_FILE}`, "info");
1404
+ return;
1405
+ }
1406
+ const content = fs.readFileSync(LOG_FILE, "utf8");
1407
+ const lines = content.trim().split("\n").slice(-25);
1408
+ ctx.ui.notify(`pi-freeflow logs (last 25 lines from ${LOG_FILE}):\n\n${lines.join("\n")}`, "info");
1409
+ } catch (e) {
1410
+ ctx.ui.notify(`Could not read log file: ${(e as Error).message}`, "error");
1411
+ }
1412
+ } else if (sub === "remove") {
1413
+ const url = (
1414
+ rest ||
1415
+ (await ctx.ui.input("Relay URL to remove:", "")) ||
1416
+ ""
1417
+ ).trim();
1418
+ if (!url) {
1419
+ ctx.ui.notify("No URL given", "warning");
1420
+ return;
1421
+ }
1422
+ if (url === relayState.url) {
1423
+ ctx.ui.notify(
1424
+ "Can't remove the active relay — switch first",
1425
+ "warning",
1426
+ );
1427
+ return;
1428
+ }
1429
+ if (!relayState.relays.some((r) => r.url === url)) {
1430
+ ctx.ui.notify("Not in saved list", "warning");
1431
+ return;
1432
+ }
1433
+ removeRelay(relayState, url);
1434
+ persist();
1435
+ ctx.ui.notify(`Removed: ${url}`, "info");
1436
+ } else if (sub === "url") {
1437
+ const input =
1438
+ rest ||
1439
+ (await ctx.ui.input(
1440
+ "Relay URL (empty = default):",
1441
+ relayState.url || DEFAULT_RELAY_URL,
1442
+ ));
1443
+ setRelay(
1444
+ relayState.enabled,
1445
+ (input || "").trim() || DEFAULT_RELAY_URL,
1446
+ "manual",
1447
+ );
1448
+ persist();
1449
+ flash();
1450
+ } else if (sub === "deploy") {
1451
+ await doDeploy();
1452
+ } else {
1453
+ const choice = await ctx.ui.select("bansos relay", [
1454
+ `Relay: ${relayState.enabled ? "ON" : "OFF"} → ${relayState.url || "direct"}`,
1455
+ "Turn ON",
1456
+ "Turn OFF",
1457
+ "Switch relay…",
1458
+ "Remove relay…",
1459
+ "Set URL",
1460
+ "Deploy Vercel relay…",
1461
+ "List saved relays",
1462
+ ]);
1463
+ if (choice === "Turn ON") {
1464
+ setRelay(true, relayState.url || DEFAULT_RELAY_URL);
1465
+ persist();
1466
+ flash();
1467
+ } else if (choice === "Turn OFF") {
1468
+ relayState.enabled = false;
1469
+ persist();
1470
+ flash();
1471
+ } else if (choice === "Switch relay…") {
1472
+ await switchRelay();
1473
+ } else if (choice === "Remove relay…") {
1474
+ await removeRelayMenu();
1475
+ } else if (choice === "Set URL") {
1476
+ const input = await ctx.ui.input(
1477
+ "Relay URL (empty = default):",
1478
+ relayState.url || DEFAULT_RELAY_URL,
1479
+ );
1480
+ setRelay(
1481
+ relayState.enabled,
1482
+ (input || "").trim() || DEFAULT_RELAY_URL,
1483
+ "manual",
1484
+ );
1485
+ persist();
1486
+ flash();
1487
+ } else if (choice === "Deploy Vercel relay…") {
1488
+ await doDeploy();
1489
+ } else if (choice === "List saved relays") {
1490
+ showList();
1491
+ }
1492
+ }
1493
+ },
1494
+ };
1495
+ pi.registerCommand("freeflow", commandSpec);
1496
+ pi.registerCommand("bansos", commandSpec);
1497
+
1498
+ // Reload persisted state on session start/resume (env overrides still win).
1499
+ pi.on("session_start", async (_event, ctx) => {
1500
+ relayState = resolveRelayState();
1501
+ statusUi = ctx.ui;
1502
+ ctx.ui?.setStatus?.("freeflow", undefined);
1503
+ ctx.ui?.setStatus?.("bansos", undefined);
1504
+ });
1505
+
1506
+ // Pi normally pauses after threshold compaction. Queue a follow-up while the
1507
+ // original run is still active so the core agent continues automatically.
1508
+ pi.on("session_compact", (event, ctx) => {
1509
+ if (
1510
+ event.reason !== "threshold" ||
1511
+ event.willRetry ||
1512
+ ctx.isIdle() ||
1513
+ ctx.hasPendingMessages()
1514
+ ) {
1515
+ return;
1516
+ }
1517
+ pi.sendUserMessage(
1518
+ "Continue the current task from the compacted context. Do not wait for another user message; proceed with the next required step.",
1519
+ { deliverAs: "followUp" },
1520
+ );
1521
+ });
1522
+
1523
+ pi.on("session_shutdown", () => {
1524
+ log("info", "shutting down proxy...");
1525
+ server.close();
1526
+ rateLimitMap.clear();
1527
+ log("info", "shutdown complete");
1528
+ });
1529
+ }