pi-freeflow 1.2.0 → 1.3.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/LICENSE +21 -0
- package/README.md +7 -11
- package/extensions/index.ts +4 -2137
- package/package.json +30 -5
- package/src/catalog.ts +204 -0
- package/src/commands.ts +448 -0
- package/src/config.ts +145 -0
- package/src/deploy.ts +126 -0
- package/src/index.ts +244 -0
- package/src/logger.ts +327 -0
- package/src/models.ts +343 -0
- package/src/proxy.ts +473 -0
- package/src/rate-limiter.ts +148 -0
- package/src/relay-state.ts +218 -0
- package/src/relay.ts +193 -0
- package/src/stream-pipe.ts +154 -0
- package/src/types.ts +164 -0
package/extensions/index.ts
CHANGED
|
@@ -1,2141 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* pi-freeflow — Pi & Oh My Pi (OMP) Extension Entrypoint
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Lightweight bridge re-exporting the modular codebase rooted in src/
|
|
5
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 type { ReadableStream as WebReadableStream } from "node:stream/web";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
-
import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
16
6
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!process.env.FREEFLOW_API_KEY) {
|
|
20
|
-
process.env.FREEFLOW_API_KEY = "freeflow";
|
|
21
|
-
}
|
|
22
|
-
if (!process.env.BANSOS_API_KEY) {
|
|
23
|
-
process.env.BANSOS_API_KEY = "freeflow";
|
|
24
|
-
}
|
|
25
|
-
// ── Configuration ──────────────────────────────────────────────────
|
|
26
|
-
const UPSTREAM_OPENCODE = "https://opencode.ai/zen";
|
|
27
|
-
// KiloCode gateway — OpenAI-compatible; free models are keyless (200 req/hr per IP)
|
|
28
|
-
const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
|
|
29
|
-
const PORT = Number(process.env.FREEFLOW_PORT || process.env.BANSOS_PORT) || 18080;
|
|
30
|
-
const HOST = "127.0.0.1";
|
|
31
|
-
const API = `${UPSTREAM_OPENCODE}/v1`;
|
|
32
|
-
const OPENCODE_USER_AGENT = "opencode/latest/1.14.50/cli";
|
|
33
|
-
const OPENCODE_CLIENT = "cli";
|
|
34
|
-
const OPENCODE_PROJECT = "default";
|
|
35
|
-
const OPENCODE_SESSION = randomUUID();
|
|
36
|
-
|
|
37
|
-
function opencodeHeaders(): Record<string, string> {
|
|
38
|
-
return {
|
|
39
|
-
"User-Agent": OPENCODE_USER_AGENT,
|
|
40
|
-
"x-opencode-client": OPENCODE_CLIENT,
|
|
41
|
-
"x-opencode-project": OPENCODE_PROJECT,
|
|
42
|
-
"x-opencode-session": OPENCODE_SESSION,
|
|
43
|
-
"x-opencode-request": randomUUID(),
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// ── Relay egress (vercel/cloudflare worker, x-relay-target pattern) ──────────
|
|
48
|
-
// Same logic as 9router ProxyFetch: when enabled, redirect upstream calls to a
|
|
49
|
-
// relay URL and inject x-relay-target / x-relay-path headers. Body untouched →
|
|
50
|
-
// SSE streaming passes through unchanged. Toggle live via /bansos command.
|
|
51
|
-
// No built-in default relay — a published package must not bake in any one
|
|
52
|
-
// user's personal relay URL. Bring your own via /bansos deploy or /bansos url.
|
|
53
|
-
const DEFAULT_RELAY_URL = "";
|
|
54
|
-
// State lives OUTSIDE the package dir so npm updates don't wipe it.
|
|
55
|
-
// Uses ~/.pi/agent/pi-bansos-relay-state.json (stable), falls back to
|
|
56
|
-
// package-root .relay-state.json for dev/local installs.
|
|
57
|
-
function resolveRelayStatePath(): string {
|
|
58
|
-
try {
|
|
59
|
-
const primary = path.join(homedir(), ".pi", "agent", "pi-freeflow-relay-state.json");
|
|
60
|
-
const legacy = path.join(homedir(), ".pi", "agent", "pi-bansos-relay-state.json");
|
|
61
|
-
if (!fs.existsSync(primary) && fs.existsSync(legacy)) {
|
|
62
|
-
try { fs.copyFileSync(legacy, primary); } catch {}
|
|
63
|
-
}
|
|
64
|
-
return primary;
|
|
65
|
-
} catch {
|
|
66
|
-
return path.join(
|
|
67
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
68
|
-
"..",
|
|
69
|
-
".relay-state.json",
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
function resolveLogFilePath(): string {
|
|
74
|
-
try {
|
|
75
|
-
return path.join(homedir(), ".pi", "agent", "pi-freeflow.log");
|
|
76
|
-
} catch {
|
|
77
|
-
return path.join(
|
|
78
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
79
|
-
"..",
|
|
80
|
-
"pi-freeflow.log",
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
const LOG_FILE = resolveLogFilePath();
|
|
85
|
-
const RELAY_STATE_FILE = resolveRelayStatePath();
|
|
86
|
-
type KnownRelay = { url: string; label?: string; addedAt?: string };
|
|
87
|
-
type RelayState = { enabled: boolean; url: string; relays: KnownRelay[] };
|
|
88
|
-
function loadRelayState(): RelayState {
|
|
89
|
-
try {
|
|
90
|
-
const s = JSON.parse(fs.readFileSync(RELAY_STATE_FILE, "utf8"));
|
|
91
|
-
const relays: KnownRelay[] = Array.isArray(s?.relays) ? s.relays : [];
|
|
92
|
-
// Auto-on by default if saved relays exist, unless explicitly set to false
|
|
93
|
-
const enabled = s?.enabled !== undefined ? Boolean(s.enabled) : relays.length > 0;
|
|
94
|
-
return {
|
|
95
|
-
enabled,
|
|
96
|
-
url: typeof s?.url === "string" ? s.url.trim() : (relays[0]?.url || ""),
|
|
97
|
-
relays,
|
|
98
|
-
};
|
|
99
|
-
} catch {
|
|
100
|
-
return { enabled: true, url: "", relays: [] };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
function saveRelayState(s: RelayState): void {
|
|
104
|
-
try {
|
|
105
|
-
const dir = path.dirname(RELAY_STATE_FILE);
|
|
106
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
107
|
-
const tmpPath = `${RELAY_STATE_FILE}.${randomUUID()}.tmp`;
|
|
108
|
-
fs.writeFileSync(tmpPath, JSON.stringify(s, null, 2), "utf8");
|
|
109
|
-
fs.renameSync(tmpPath, RELAY_STATE_FILE);
|
|
110
|
-
} catch (e) {
|
|
111
|
-
log("warn", "could not persist relay state", { error: String(e) });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
// dedupe-add a relay to the known list
|
|
115
|
-
function ensureRelay(s: RelayState, url: string, label?: string): void {
|
|
116
|
-
if (!url || s.relays.some((r) => r.url === url)) return;
|
|
117
|
-
s.relays.push({ url, label, addedAt: new Date().toISOString() });
|
|
118
|
-
}
|
|
119
|
-
function removeRelay(s: RelayState, url: string): void {
|
|
120
|
-
s.relays = s.relays.filter((r) => r.url !== url);
|
|
121
|
-
}
|
|
122
|
-
function resolveRelayState(): RelayState {
|
|
123
|
-
const s = loadRelayState();
|
|
124
|
-
// migrate legacy {enabled,url}: seed the known list with default + active url
|
|
125
|
-
if (!s.relays.length) {
|
|
126
|
-
ensureRelay(s, DEFAULT_RELAY_URL, "9Router default");
|
|
127
|
-
if (s.url && s.url !== DEFAULT_RELAY_URL) ensureRelay(s, s.url, "previous");
|
|
128
|
-
}
|
|
129
|
-
if (!s.url) s.url = DEFAULT_RELAY_URL;
|
|
130
|
-
return s;
|
|
131
|
-
}
|
|
132
|
-
let relayState: RelayState = resolveRelayState();
|
|
133
|
-
let statusUi: ExtensionUIContext | null = null;
|
|
134
|
-
function shortRelayLabel(url: string): string {
|
|
135
|
-
const hit = relayState.relays.find((r) => r.url === url);
|
|
136
|
-
if (hit?.label) return hit.label;
|
|
137
|
-
try { return new URL(url).host.split(".")[0]; } catch { return url.slice(0, 18); }
|
|
138
|
-
}
|
|
139
|
-
function getOrderedRelayUrls(): string[] {
|
|
140
|
-
relayState = loadRelayState();
|
|
141
|
-
if (relayState.relays && relayState.relays.length > 0) {
|
|
142
|
-
const active = (relayState.url || "").trim();
|
|
143
|
-
let activeIdx = relayState.relays.findIndex((r) => r.url === active);
|
|
144
|
-
if (activeIdx < 0) activeIdx = 0;
|
|
145
|
-
const ordered: string[] = [];
|
|
146
|
-
for (let i = 0; i < relayState.relays.length; i++) {
|
|
147
|
-
const r = relayState.relays[(activeIdx + i) % relayState.relays.length];
|
|
148
|
-
if (r?.url?.trim()) ordered.push(r.url.trim());
|
|
149
|
-
}
|
|
150
|
-
return ordered.length > 0 ? ordered : [DEFAULT_RELAY_URL];
|
|
151
|
-
}
|
|
152
|
-
if (relayState.url?.trim()) return [relayState.url.trim()];
|
|
153
|
-
return [DEFAULT_RELAY_URL];
|
|
154
|
-
}
|
|
155
|
-
function isRetriableStatus(status: number): boolean {
|
|
156
|
-
return (
|
|
157
|
-
status === 429 ||
|
|
158
|
-
status === 502 ||
|
|
159
|
-
status === 503 ||
|
|
160
|
-
status === 504 ||
|
|
161
|
-
status === 408 ||
|
|
162
|
-
status === 402 ||
|
|
163
|
-
status === 403 ||
|
|
164
|
-
status === 500 ||
|
|
165
|
-
status === 400
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Catalog served at GET /v1/models — ONLY the alive free models we register.
|
|
170
|
-
// Set after health checks. Prevents paid/other upstream models from leaking
|
|
171
|
-
// through the proxy's /v1/models (opencode returns 60 models incl. 54 paid).
|
|
172
|
-
type RegisteredModel = ModelDef & { source: Upstream };
|
|
173
|
-
let aliveCatalog: RegisteredModel[] = [];
|
|
174
|
-
|
|
175
|
-
async function relayFetch(
|
|
176
|
-
url: string,
|
|
177
|
-
opts: RequestInit = {},
|
|
178
|
-
reqId?: string,
|
|
179
|
-
): Promise<Response> {
|
|
180
|
-
const rid = reqId || randomUUID().slice(0, 8);
|
|
181
|
-
if (!relayState.enabled) {
|
|
182
|
-
log("debug", `relayFetch: direct (relay disabled) -> ${url}`, undefined, rid);
|
|
183
|
-
return fetch(url, opts);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const candidates = getOrderedRelayUrls();
|
|
187
|
-
let lastResponse: Response | null = null;
|
|
188
|
-
let lastError: unknown = null;
|
|
189
|
-
const u = new URL(url);
|
|
190
|
-
const relayTarget = `${u.protocol}//${u.host}`;
|
|
191
|
-
const relayPath = `${u.pathname}${u.search}`;
|
|
192
|
-
|
|
193
|
-
const bodySizeKB =
|
|
194
|
-
typeof opts.body === "string"
|
|
195
|
-
? (opts.body.length / 1024).toFixed(1)
|
|
196
|
-
: Buffer.isBuffer(opts.body)
|
|
197
|
-
? (opts.body.length / 1024).toFixed(1)
|
|
198
|
-
: "0";
|
|
199
|
-
|
|
200
|
-
log("info", `request starting (${bodySizeKB}KB payload) -> ${url}`, undefined, rid);
|
|
201
|
-
if (isDebugEnabled()) {
|
|
202
|
-
try {
|
|
203
|
-
const bodyPreview = typeof opts.body === "string" ? opts.body.slice(0, 1200) : "";
|
|
204
|
-
const modelMatch = bodyPreview.match(/"model"\s*:\s*"([^"]+)"/);
|
|
205
|
-
const streamMatch = bodyPreview.match(/"stream"\s*:\s*(true|false)/);
|
|
206
|
-
log("debug", `request detail`, {
|
|
207
|
-
model: modelMatch?.[1],
|
|
208
|
-
stream: streamMatch?.[1],
|
|
209
|
-
sizeKB: bodySizeKB,
|
|
210
|
-
relayTarget,
|
|
211
|
-
relayPath,
|
|
212
|
-
candidates: candidates.length,
|
|
213
|
-
}, rid);
|
|
214
|
-
} catch {}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
for (let i = 0; i < candidates.length; i++) {
|
|
218
|
-
const targetUrl = candidates[i];
|
|
219
|
-
const attemptStart = Date.now();
|
|
220
|
-
try {
|
|
221
|
-
let targetHost = "opencode.ai";
|
|
222
|
-
try {
|
|
223
|
-
if (targetUrl) targetHost = new URL(targetUrl).host;
|
|
224
|
-
} catch {}
|
|
225
|
-
const headers = new Headers(opts.headers);
|
|
226
|
-
headers.set("x-relay-target", relayTarget);
|
|
227
|
-
headers.set("x-relay-path", relayPath);
|
|
228
|
-
headers.set("host", targetHost);
|
|
229
|
-
headers.set("x-request-id", rid);
|
|
230
|
-
const signal = opts.signal || AbortSignal.timeout(300_000);
|
|
231
|
-
const res = await fetch(targetUrl, { ...opts, headers, signal });
|
|
232
|
-
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
233
|
-
|
|
234
|
-
// Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
|
|
235
|
-
// Don't cycle through 5 more identical Vercel 25s timeouts. Fast fallback to direct!
|
|
236
|
-
if (res.status === 504) {
|
|
237
|
-
log(
|
|
238
|
-
"warn",
|
|
239
|
-
`relay ${targetUrl} hit HTTP 504 Gateway Timeout in ${elapsed}s (prompt evaluation exceeded Vercel 25s limit) — fast fallback to direct upstream`,
|
|
240
|
-
{ upstream: url, sizeKB: bodySizeKB },
|
|
241
|
-
rid,
|
|
242
|
-
);
|
|
243
|
-
break;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (isRetriableStatus(res.status)) {
|
|
247
|
-
lastResponse = res;
|
|
248
|
-
log(
|
|
249
|
-
"warn",
|
|
250
|
-
`relay ${targetUrl} returned HTTP ${res.status} in ${elapsed}s — rolling to next relay`,
|
|
251
|
-
{ upstream: url, status: res.status },
|
|
252
|
-
rid,
|
|
253
|
-
);
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// SUCCESS or non-retriable client error (e.g. 200, 400 Bad Request, 404):
|
|
258
|
-
// If we switched to a different relay because previous failed, update sticky active relay!
|
|
259
|
-
if (relayState.url !== targetUrl) {
|
|
260
|
-
log("info", `active relay auto-switched to ${targetUrl}`, {
|
|
261
|
-
previous: relayState.url,
|
|
262
|
-
}, rid);
|
|
263
|
-
relayState.url = targetUrl;
|
|
264
|
-
saveRelayState(relayState);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
log("info", `relay ${targetUrl} succeeded (HTTP ${res.status} in ${elapsed}s)`, undefined, rid);
|
|
268
|
-
if (isDebugEnabled()) {
|
|
269
|
-
log("debug", `relay headers`, {
|
|
270
|
-
status: res.status,
|
|
271
|
-
contentType: res.headers.get("content-type"),
|
|
272
|
-
via: res.headers.get("via") || res.headers.get("x-vercel-id") || "direct",
|
|
273
|
-
}, rid);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Update TUI status
|
|
277
|
-
const label = shortRelayLabel(targetUrl);
|
|
278
|
-
const total = relayState.relays.length || 1;
|
|
279
|
-
const pos = Math.max(
|
|
280
|
-
1,
|
|
281
|
-
relayState.relays.findIndex((r) => r.url === targetUrl) + 1,
|
|
282
|
-
);
|
|
283
|
-
statusUi?.setStatus?.("freeflow", `relay: ON | ${label} ${pos}/${total}`);
|
|
284
|
-
|
|
285
|
-
return res;
|
|
286
|
-
} catch (err) {
|
|
287
|
-
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
288
|
-
lastError = err;
|
|
289
|
-
log(
|
|
290
|
-
"warn",
|
|
291
|
-
`relay ${targetUrl} fetch error in ${elapsed}s — rolling to next relay`,
|
|
292
|
-
{ upstream: url, error: String(err) },
|
|
293
|
-
rid,
|
|
294
|
-
);
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Full fallback: attempt direct fetch to upstream
|
|
300
|
-
const directStart = Date.now();
|
|
301
|
-
try {
|
|
302
|
-
log("warn", "relays bypassed/exhausted — attempting direct fetch to upstream", {
|
|
303
|
-
upstream: url,
|
|
304
|
-
sizeKB: bodySizeKB,
|
|
305
|
-
}, rid);
|
|
306
|
-
const directHeaders = new Headers(opts.headers);
|
|
307
|
-
directHeaders.delete("x-relay-target");
|
|
308
|
-
directHeaders.delete("x-relay-path");
|
|
309
|
-
directHeaders.set("host", u.host);
|
|
310
|
-
directHeaders.set("x-request-id", rid);
|
|
311
|
-
const directRes = await fetch(url, { ...opts, headers: directHeaders });
|
|
312
|
-
const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
|
|
313
|
-
log("info", `direct fetch returned HTTP ${directRes.status} in ${directElapsed}s`, undefined, rid);
|
|
314
|
-
return directRes;
|
|
315
|
-
} catch (directErr) {
|
|
316
|
-
const directElapsed = ((Date.now() - directStart) / 1000).toFixed(1);
|
|
317
|
-
log("error", `direct fallback also failed in ${directElapsed}s`, {
|
|
318
|
-
upstream: url,
|
|
319
|
-
error: String(directErr),
|
|
320
|
-
}, rid);
|
|
321
|
-
if (lastResponse) return lastResponse;
|
|
322
|
-
throw directErr || lastError;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// ── Deploy a fresh Vercel relay (same flow as 9Router) ───────────────────────
|
|
327
|
-
// Token is used in-memory only and NEVER persisted. Resulting URL is saved to
|
|
328
|
-
// the relay state and activated. Worker uses the x-relay-target/x-relay-path
|
|
329
|
-
// pattern, identical to the cloudflare/vercel relays 9Router deploys.
|
|
330
|
-
const VERCEL_API = "https://api.vercel.com";
|
|
331
|
-
const VERCEL_RELAY_WORKER = `// Only the 2 upstreams pi-bansos talks to. Anything else = open proxy abuse.
|
|
332
|
-
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
333
|
-
export const config = { runtime: "edge" };
|
|
334
|
-
export default async function handler(req) {
|
|
335
|
-
const target = req.headers.get("x-relay-target");
|
|
336
|
-
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
337
|
-
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
338
|
-
const cleanTarget = target.replace(/\\/$/, "");
|
|
339
|
-
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
340
|
-
if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
341
|
-
const targetUrl = cleanTarget + relayPath;
|
|
342
|
-
const headers = new Headers(req.headers);
|
|
343
|
-
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
344
|
-
const response = await fetch(targetUrl, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
|
|
345
|
-
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
346
|
-
}`;
|
|
347
|
-
|
|
348
|
-
async function deployVercelRelay(
|
|
349
|
-
token: string,
|
|
350
|
-
name: string,
|
|
351
|
-
onProgress?: (msg: string) => void,
|
|
352
|
-
): Promise<string> {
|
|
353
|
-
const auth = {
|
|
354
|
-
Authorization: `Bearer ${token}`,
|
|
355
|
-
"Content-Type": "application/json",
|
|
356
|
-
};
|
|
357
|
-
// 1. create deployment (3 inline files, no git repo)
|
|
358
|
-
onProgress?.("Uploading relay to Vercel…");
|
|
359
|
-
const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
|
|
360
|
-
method: "POST",
|
|
361
|
-
headers: auth,
|
|
362
|
-
body: JSON.stringify({
|
|
363
|
-
name,
|
|
364
|
-
files: [
|
|
365
|
-
{ file: "api/relay.js", data: VERCEL_RELAY_WORKER },
|
|
366
|
-
{
|
|
367
|
-
file: "package.json",
|
|
368
|
-
data: JSON.stringify({ name, version: "1.0.0" }),
|
|
369
|
-
},
|
|
370
|
-
{
|
|
371
|
-
file: "vercel.json",
|
|
372
|
-
data: JSON.stringify({
|
|
373
|
-
rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
|
|
374
|
-
}),
|
|
375
|
-
},
|
|
376
|
-
],
|
|
377
|
-
projectSettings: { framework: null },
|
|
378
|
-
target: "production",
|
|
379
|
-
}),
|
|
380
|
-
});
|
|
381
|
-
if (!dep.ok) {
|
|
382
|
-
const e = await dep
|
|
383
|
-
.json()
|
|
384
|
-
.catch(() => ({}) as { error?: { message?: string } });
|
|
385
|
-
throw new Error(
|
|
386
|
-
e?.error?.message || `Vercel deploy failed (HTTP ${dep.status})`,
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
const depJson = await dep.json();
|
|
390
|
-
const depId = depJson.id || depJson.uid;
|
|
391
|
-
const projectId = depJson.projectId || name;
|
|
392
|
-
// 2. make the deployment public (disable SSO protection)
|
|
393
|
-
await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
|
|
394
|
-
method: "PATCH",
|
|
395
|
-
headers: auth,
|
|
396
|
-
body: JSON.stringify({ ssoProtection: null }),
|
|
397
|
-
});
|
|
398
|
-
// 3. poll until READY (3s interval, 120s timeout — same as 9Router)
|
|
399
|
-
onProgress?.("Waiting for deployment to go live…");
|
|
400
|
-
const deadline = Date.now() + 120_000;
|
|
401
|
-
while (Date.now() < deadline) {
|
|
402
|
-
const s = await fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
|
|
403
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
404
|
-
});
|
|
405
|
-
const j = await s.json();
|
|
406
|
-
if (j.readyState === "READY") return `https://${j.url}`;
|
|
407
|
-
if (j.readyState === "ERROR" || j.readyState === "CANCELED")
|
|
408
|
-
throw new Error(`Deployment failed: ${j.readyState}`);
|
|
409
|
-
await new Promise((r) => setTimeout(r, 3000));
|
|
410
|
-
}
|
|
411
|
-
throw new Error("Deployment timed out (120s)");
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// ── Model Definitions ──────────────────────────────────────────────
|
|
415
|
-
type ProviderApi = "openai-completions" | "openai-responses";
|
|
416
|
-
type Upstream = "opencode" | "kilo";
|
|
417
|
-
|
|
418
|
-
interface ModelDef {
|
|
419
|
-
id: string;
|
|
420
|
-
name: string;
|
|
421
|
-
reasoning: boolean;
|
|
422
|
-
contextWindow: number;
|
|
423
|
-
maxTokens: number;
|
|
424
|
-
api?: ProviderApi;
|
|
425
|
-
input?: ("text" | "image")[];
|
|
426
|
-
thinkingFormat?: "openrouter";
|
|
427
|
-
thinkingLevelMap?: Partial<
|
|
428
|
-
Record<
|
|
429
|
-
"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max",
|
|
430
|
-
string | null
|
|
431
|
-
>
|
|
432
|
-
>;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// OpenCode Zen free models verified against the live catalog and inference APIs.
|
|
436
|
-
const KNOWN_MODELS: ModelDef[] = [
|
|
437
|
-
{
|
|
438
|
-
id: "x-preview-f-free",
|
|
439
|
-
name: "Ox Alpha (1M)",
|
|
440
|
-
reasoning: true,
|
|
441
|
-
contextWindow: 1_048_576,
|
|
442
|
-
maxTokens: 131_072,
|
|
443
|
-
input: ["text", "image"],
|
|
444
|
-
thinkingLevelMap: {
|
|
445
|
-
off: "low",
|
|
446
|
-
minimal: "low",
|
|
447
|
-
low: "low",
|
|
448
|
-
medium: "high",
|
|
449
|
-
high: "high",
|
|
450
|
-
xhigh: "max",
|
|
451
|
-
max: "max",
|
|
452
|
-
},
|
|
453
|
-
},
|
|
454
|
-
{
|
|
455
|
-
id: "muse-spark-1.2-contributor-free",
|
|
456
|
-
name: "Muse Spark 1.2 (1M)",
|
|
457
|
-
reasoning: true,
|
|
458
|
-
contextWindow: 1_048_576,
|
|
459
|
-
maxTokens: 131_072,
|
|
460
|
-
api: "openai-responses",
|
|
461
|
-
input: ["text", "image"],
|
|
462
|
-
thinkingLevelMap: {
|
|
463
|
-
off: null,
|
|
464
|
-
minimal: "minimal",
|
|
465
|
-
low: "low",
|
|
466
|
-
medium: "medium",
|
|
467
|
-
high: "high",
|
|
468
|
-
xhigh: "xhigh",
|
|
469
|
-
max: "max",
|
|
470
|
-
},
|
|
471
|
-
},
|
|
472
|
-
{
|
|
473
|
-
id: "mimo-v2.5-free",
|
|
474
|
-
name: "MiMo V2.5 (1M)",
|
|
475
|
-
reasoning: true,
|
|
476
|
-
contextWindow: 1_048_576,
|
|
477
|
-
maxTokens: 131_072,
|
|
478
|
-
input: ["text", "image"],
|
|
479
|
-
thinkingLevelMap: {
|
|
480
|
-
off: "low",
|
|
481
|
-
minimal: "low",
|
|
482
|
-
low: "low",
|
|
483
|
-
medium: "medium",
|
|
484
|
-
high: "high",
|
|
485
|
-
xhigh: "high",
|
|
486
|
-
max: "high",
|
|
487
|
-
},
|
|
488
|
-
},
|
|
489
|
-
{
|
|
490
|
-
id: "hy3-free",
|
|
491
|
-
name: "Hy3 (262K)",
|
|
492
|
-
reasoning: true,
|
|
493
|
-
contextWindow: 262_144,
|
|
494
|
-
maxTokens: 128_000,
|
|
495
|
-
thinkingLevelMap: {
|
|
496
|
-
off: "low",
|
|
497
|
-
minimal: "low",
|
|
498
|
-
low: "low",
|
|
499
|
-
medium: "high",
|
|
500
|
-
high: "high",
|
|
501
|
-
xhigh: "max",
|
|
502
|
-
max: "max",
|
|
503
|
-
},
|
|
504
|
-
},
|
|
505
|
-
{
|
|
506
|
-
id: "nemotron-3-ultra-free",
|
|
507
|
-
name: "Nemotron 3 Ultra (1M)",
|
|
508
|
-
reasoning: true,
|
|
509
|
-
contextWindow: 1_000_000,
|
|
510
|
-
maxTokens: 128_000,
|
|
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
|
-
id: "nemotron-3.5-lightning-free",
|
|
523
|
-
name: "Nemotron 3.5 Lightning (1M)",
|
|
524
|
-
reasoning: true,
|
|
525
|
-
contextWindow: 1_000_000,
|
|
526
|
-
maxTokens: 262_144,
|
|
527
|
-
thinkingLevelMap: {
|
|
528
|
-
off: "low",
|
|
529
|
-
minimal: "low",
|
|
530
|
-
low: "low",
|
|
531
|
-
medium: "high",
|
|
532
|
-
high: "high",
|
|
533
|
-
xhigh: "max",
|
|
534
|
-
max: "max",
|
|
535
|
-
},
|
|
536
|
-
},
|
|
537
|
-
{
|
|
538
|
-
id: "big-pickle",
|
|
539
|
-
name: "Big Pickle",
|
|
540
|
-
reasoning: true,
|
|
541
|
-
contextWindow: 200_000,
|
|
542
|
-
maxTokens: 32_000,
|
|
543
|
-
thinkingLevelMap: {
|
|
544
|
-
off: "high",
|
|
545
|
-
minimal: "high",
|
|
546
|
-
low: "high",
|
|
547
|
-
medium: "high",
|
|
548
|
-
high: "high",
|
|
549
|
-
xhigh: "max",
|
|
550
|
-
max: "max",
|
|
551
|
-
},
|
|
552
|
-
},
|
|
553
|
-
{
|
|
554
|
-
id: "laguna-s-2.1-free",
|
|
555
|
-
name: "Laguna S 2.1 (1M)",
|
|
556
|
-
reasoning: true,
|
|
557
|
-
contextWindow: 1_048_576,
|
|
558
|
-
maxTokens: 131_072,
|
|
559
|
-
thinkingLevelMap: {
|
|
560
|
-
off: "low",
|
|
561
|
-
minimal: "low",
|
|
562
|
-
low: "low",
|
|
563
|
-
medium: "high",
|
|
564
|
-
high: "high",
|
|
565
|
-
xhigh: "max",
|
|
566
|
-
max: "max",
|
|
567
|
-
},
|
|
568
|
-
},
|
|
569
|
-
];
|
|
570
|
-
|
|
571
|
-
// KiloCode gateway free models (keyless — https://kilo.ai/docs/gateway).
|
|
572
|
-
const KILO_MODELS: ModelDef[] = [
|
|
573
|
-
{
|
|
574
|
-
id: "kilo-auto/free",
|
|
575
|
-
name: "Kilo Auto",
|
|
576
|
-
reasoning: false,
|
|
577
|
-
contextWindow: 256_000,
|
|
578
|
-
maxTokens: 10_000,
|
|
579
|
-
input: ["text"],
|
|
580
|
-
},
|
|
581
|
-
{
|
|
582
|
-
id: "stepfun/step-3.7-flash:free",
|
|
583
|
-
name: "Step 3.7 Flash",
|
|
584
|
-
reasoning: true,
|
|
585
|
-
contextWindow: 262_144,
|
|
586
|
-
maxTokens: 262_144,
|
|
587
|
-
input: ["text", "image"],
|
|
588
|
-
thinkingFormat: "openrouter",
|
|
589
|
-
},
|
|
590
|
-
{
|
|
591
|
-
id: "nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
592
|
-
name: "Nemotron 3 Ultra 550B (1M)",
|
|
593
|
-
reasoning: true,
|
|
594
|
-
contextWindow: 1_000_000,
|
|
595
|
-
maxTokens: 65_536,
|
|
596
|
-
input: ["text"],
|
|
597
|
-
thinkingFormat: "openrouter",
|
|
598
|
-
},
|
|
599
|
-
{
|
|
600
|
-
id: "nvidia/nemotron-3-super-120b-a12b:free",
|
|
601
|
-
name: "Nemotron 3 Super 120B",
|
|
602
|
-
reasoning: true,
|
|
603
|
-
contextWindow: 262_144,
|
|
604
|
-
maxTokens: 262_144,
|
|
605
|
-
input: ["text"],
|
|
606
|
-
thinkingFormat: "openrouter",
|
|
607
|
-
},
|
|
608
|
-
{
|
|
609
|
-
id: "dots-studio/dots-3-note-preview:free",
|
|
610
|
-
name: "Dots3-Note Preview (512K)",
|
|
611
|
-
reasoning: true,
|
|
612
|
-
contextWindow: 512_000,
|
|
613
|
-
maxTokens: 512_000,
|
|
614
|
-
input: ["text", "image"],
|
|
615
|
-
thinkingFormat: "openrouter",
|
|
616
|
-
},
|
|
617
|
-
{
|
|
618
|
-
id: "cohere/north-mini-code:free",
|
|
619
|
-
name: "North Mini Code",
|
|
620
|
-
reasoning: true,
|
|
621
|
-
contextWindow: 256_000,
|
|
622
|
-
maxTokens: 64_000,
|
|
623
|
-
input: ["text"],
|
|
624
|
-
thinkingFormat: "openrouter",
|
|
625
|
-
},
|
|
626
|
-
{
|
|
627
|
-
id: "poolside/laguna-xs-2.1:free",
|
|
628
|
-
name: "Laguna XS 2.1",
|
|
629
|
-
reasoning: true,
|
|
630
|
-
contextWindow: 262_144,
|
|
631
|
-
maxTokens: 32_768,
|
|
632
|
-
input: ["text"],
|
|
633
|
-
thinkingFormat: "openrouter",
|
|
634
|
-
},
|
|
635
|
-
{
|
|
636
|
-
id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
|
637
|
-
name: "Nemotron 3 Nano Omni",
|
|
638
|
-
reasoning: true,
|
|
639
|
-
contextWindow: 256_000,
|
|
640
|
-
maxTokens: 65_536,
|
|
641
|
-
input: ["text", "image"],
|
|
642
|
-
thinkingFormat: "openrouter",
|
|
643
|
-
},
|
|
644
|
-
{
|
|
645
|
-
id: "openrouter/free",
|
|
646
|
-
name: "OpenRouter Auto",
|
|
647
|
-
reasoning: false,
|
|
648
|
-
contextWindow: 200_000,
|
|
649
|
-
maxTokens: 65_536,
|
|
650
|
-
input: ["text"],
|
|
651
|
-
},
|
|
652
|
-
{
|
|
653
|
-
id: "nvidia/nemotron-3.5-lightning:free",
|
|
654
|
-
name: "Nemotron 3.5 Lightning (Kilo)",
|
|
655
|
-
reasoning: true,
|
|
656
|
-
contextWindow: 1_000_000,
|
|
657
|
-
maxTokens: 65_536,
|
|
658
|
-
input: ["text"],
|
|
659
|
-
thinkingFormat: "openrouter",
|
|
660
|
-
},
|
|
661
|
-
{
|
|
662
|
-
id: "nvidia/nemotron-3.5-content-safety:free",
|
|
663
|
-
name: "Nemotron Content Safety",
|
|
664
|
-
reasoning: false,
|
|
665
|
-
contextWindow: 128_000,
|
|
666
|
-
maxTokens: 8_192,
|
|
667
|
-
input: ["text"],
|
|
668
|
-
},
|
|
669
|
-
{
|
|
670
|
-
id: "tencent/hy3:free",
|
|
671
|
-
name: "Tencent Hy3 (Kilo)",
|
|
672
|
-
reasoning: true,
|
|
673
|
-
contextWindow: 262_144,
|
|
674
|
-
maxTokens: 128_000,
|
|
675
|
-
input: ["text"],
|
|
676
|
-
thinkingFormat: "openrouter",
|
|
677
|
-
},
|
|
678
|
-
{
|
|
679
|
-
id: "liquid/lfm-2.5-2.6b:free",
|
|
680
|
-
name: "Liquid LFM 2.5",
|
|
681
|
-
reasoning: true,
|
|
682
|
-
contextWindow: 65_536,
|
|
683
|
-
maxTokens: 8_192,
|
|
684
|
-
input: ["text"],
|
|
685
|
-
thinkingFormat: "openrouter",
|
|
686
|
-
},
|
|
687
|
-
{
|
|
688
|
-
id: "poolside/laguna-s-2.1:free",
|
|
689
|
-
name: "Laguna S 2.1 (Kilo)",
|
|
690
|
-
reasoning: true,
|
|
691
|
-
contextWindow: 262_144,
|
|
692
|
-
maxTokens: 32_768,
|
|
693
|
-
input: ["text"],
|
|
694
|
-
thinkingFormat: "openrouter",
|
|
695
|
-
},
|
|
696
|
-
];
|
|
697
|
-
const KILO_MODEL_IDS = new Set(KILO_MODELS.map((m) => m.id));
|
|
698
|
-
const ALL_MODELS = [...KNOWN_MODELS, ...KILO_MODELS];
|
|
699
|
-
const MODEL_MAP = new Map(ALL_MODELS.map((m) => [m.id, m]));
|
|
700
|
-
// ── Whitelists ─────────────────────────────────────────────────────
|
|
701
|
-
const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
|
|
702
|
-
const PATH_TRAVERSAL_PATTERN = /\.\./;
|
|
703
|
-
const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
|
|
704
|
-
const STRIP_HEADERS = new Set([
|
|
705
|
-
"authorization",
|
|
706
|
-
"host",
|
|
707
|
-
"content-length",
|
|
708
|
-
"x-forwarded-for",
|
|
709
|
-
"x-forwarded-host",
|
|
710
|
-
"x-forwarded-proto",
|
|
711
|
-
"x-real-ip",
|
|
712
|
-
"x-client-ip",
|
|
713
|
-
"x-originate-ip",
|
|
714
|
-
"cookie",
|
|
715
|
-
"set-cookie",
|
|
716
|
-
"proxy-connection",
|
|
717
|
-
"proxy-authorization",
|
|
718
|
-
]);
|
|
719
|
-
|
|
720
|
-
// ── Logger (structured, leveled, rotating, request-aware) ─────────
|
|
721
|
-
// Reference: pi-ai SDK diagnostics (provider streaming + thinking deltas)
|
|
722
|
-
// - api/openai-completions.js: thinkingFormat branches + reasoning_effort mapping
|
|
723
|
-
// - api/openai-responses-shared.js: reasoning block handling for Responses API
|
|
724
|
-
// - api/anthropic-messages.js: thinking/thinking_delta + signature handling
|
|
725
|
-
// This logger mirrors that trace plane for offline audit without TUI noise.
|
|
726
|
-
type LogLevel = "debug" | "info" | "warn" | "error" | "audit";
|
|
727
|
-
const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
|
|
728
|
-
debug: 0,
|
|
729
|
-
info: 1,
|
|
730
|
-
warn: 2,
|
|
731
|
-
error: 3,
|
|
732
|
-
audit: 4,
|
|
733
|
-
};
|
|
734
|
-
const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
735
|
-
const LOG_MAX_FILES = 3;
|
|
736
|
-
const DEBUG_STATE_FILE = path.join(homedir(), ".pi", "agent", "pi-freeflow-debug.json");
|
|
737
|
-
interface DebugState { debug: boolean; level?: LogLevel }
|
|
738
|
-
let cachedDebugState: DebugState | null | undefined = undefined;
|
|
739
|
-
let cachedDebugMtime = 0;
|
|
740
|
-
let cachedDebugAt = 0;
|
|
741
|
-
function loadDebugState(): DebugState | null {
|
|
742
|
-
const now = Date.now();
|
|
743
|
-
// cache for 1s to avoid per-chunk FS hit in hot pipe path
|
|
744
|
-
if (cachedDebugState !== undefined && now - cachedDebugAt < 1000) {
|
|
745
|
-
return cachedDebugState;
|
|
746
|
-
}
|
|
747
|
-
try {
|
|
748
|
-
if (!fs.existsSync(DEBUG_STATE_FILE)) {
|
|
749
|
-
cachedDebugState = null;
|
|
750
|
-
cachedDebugAt = now;
|
|
751
|
-
return null;
|
|
752
|
-
}
|
|
753
|
-
const stat = fs.statSync(DEBUG_STATE_FILE);
|
|
754
|
-
if (stat.mtimeMs === cachedDebugMtime && cachedDebugState !== undefined) {
|
|
755
|
-
cachedDebugAt = now;
|
|
756
|
-
return cachedDebugState;
|
|
757
|
-
}
|
|
758
|
-
const raw = fs.readFileSync(DEBUG_STATE_FILE, "utf8");
|
|
759
|
-
const d = JSON.parse(raw) as DebugState;
|
|
760
|
-
if (typeof d.debug === "boolean") {
|
|
761
|
-
cachedDebugState = d;
|
|
762
|
-
cachedDebugMtime = stat.mtimeMs;
|
|
763
|
-
cachedDebugAt = now;
|
|
764
|
-
return d;
|
|
765
|
-
}
|
|
766
|
-
} catch {}
|
|
767
|
-
cachedDebugState = null;
|
|
768
|
-
cachedDebugAt = now;
|
|
769
|
-
return null;
|
|
770
|
-
}
|
|
771
|
-
function saveDebugState(s: DebugState): void {
|
|
772
|
-
try {
|
|
773
|
-
const dir = path.dirname(DEBUG_STATE_FILE);
|
|
774
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
775
|
-
const tmp = `${DEBUG_STATE_FILE}.${randomUUID()}.tmp`;
|
|
776
|
-
fs.writeFileSync(tmp, JSON.stringify(s, null, 2), "utf8");
|
|
777
|
-
fs.renameSync(tmp, DEBUG_STATE_FILE);
|
|
778
|
-
// update cache
|
|
779
|
-
try {
|
|
780
|
-
const stat = fs.statSync(DEBUG_STATE_FILE);
|
|
781
|
-
cachedDebugState = s;
|
|
782
|
-
cachedDebugMtime = stat.mtimeMs;
|
|
783
|
-
cachedDebugAt = Date.now();
|
|
784
|
-
} catch {
|
|
785
|
-
cachedDebugState = s;
|
|
786
|
-
cachedDebugAt = Date.now();
|
|
787
|
-
}
|
|
788
|
-
} catch {}
|
|
789
|
-
}
|
|
790
|
-
function getMinLogLevel(): number {
|
|
791
|
-
const dbg = loadDebugState();
|
|
792
|
-
if (dbg?.debug) return LOG_LEVEL_ORDER.debug;
|
|
793
|
-
if (dbg?.level && dbg.level in LOG_LEVEL_ORDER) return LOG_LEVEL_ORDER[dbg.level];
|
|
794
|
-
const raw = (
|
|
795
|
-
process.env.FREEFLOW_LOG_LEVEL ||
|
|
796
|
-
process.env.BANSOS_LOG_LEVEL ||
|
|
797
|
-
"info"
|
|
798
|
-
).toLowerCase();
|
|
799
|
-
if (raw in LOG_LEVEL_ORDER) return LOG_LEVEL_ORDER[raw as LogLevel];
|
|
800
|
-
if (process.env.FREEFLOW_DEBUG === "1" || process.env.FREEFLOW_DEBUG === "true") {
|
|
801
|
-
return LOG_LEVEL_ORDER.debug;
|
|
802
|
-
}
|
|
803
|
-
return LOG_LEVEL_ORDER.info;
|
|
804
|
-
}
|
|
805
|
-
function shouldLog(level: LogLevel): boolean {
|
|
806
|
-
return LOG_LEVEL_ORDER[level] >= getMinLogLevel();
|
|
807
|
-
}
|
|
808
|
-
function isDebugEnabled(): boolean {
|
|
809
|
-
return LOG_LEVEL_ORDER.debug >= getMinLogLevel();
|
|
810
|
-
}
|
|
811
|
-
function rotateLogsIfNeeded(): void {
|
|
812
|
-
try {
|
|
813
|
-
if (!fs.existsSync(LOG_FILE)) return;
|
|
814
|
-
if (fs.statSync(LOG_FILE).size <= LOG_MAX_BYTES) return;
|
|
815
|
-
for (let i = LOG_MAX_FILES - 1; i >= 1; i--) {
|
|
816
|
-
const src = i === 1 ? LOG_FILE : `${LOG_FILE}.${i - 1}`;
|
|
817
|
-
const dst = `${LOG_FILE}.${i}`;
|
|
818
|
-
try {
|
|
819
|
-
if (fs.existsSync(src)) {
|
|
820
|
-
if (fs.existsSync(dst)) fs.unlinkSync(dst);
|
|
821
|
-
fs.renameSync(src, dst);
|
|
822
|
-
}
|
|
823
|
-
} catch {}
|
|
824
|
-
}
|
|
825
|
-
} catch {}
|
|
826
|
-
}
|
|
827
|
-
function formatLogMeta(
|
|
828
|
-
meta?: Record<string, unknown>,
|
|
829
|
-
reqId?: string,
|
|
830
|
-
): string {
|
|
831
|
-
const parts: string[] = [];
|
|
832
|
-
if (reqId) parts.push(`req=${reqId}`);
|
|
833
|
-
if (meta && Object.keys(meta).length > 0) {
|
|
834
|
-
const safe: Record<string, unknown> = {};
|
|
835
|
-
for (const [k, v] of Object.entries(meta)) {
|
|
836
|
-
if (typeof v === "string" && v.length > 800) {
|
|
837
|
-
safe[k] = `${v.slice(0, 800)}…(${v.length})`;
|
|
838
|
-
} else {
|
|
839
|
-
safe[k] = v;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
parts.push(JSON.stringify(safe));
|
|
843
|
-
}
|
|
844
|
-
return parts.length ? ` ${parts.join(" ")}` : "";
|
|
845
|
-
}
|
|
846
|
-
function log(
|
|
847
|
-
level: LogLevel,
|
|
848
|
-
message: string,
|
|
849
|
-
meta?: Record<string, unknown>,
|
|
850
|
-
reqId?: string,
|
|
851
|
-
): void {
|
|
852
|
-
if (!shouldLog(level)) return;
|
|
853
|
-
try {
|
|
854
|
-
const ts = new Date().toISOString();
|
|
855
|
-
const line = `[${ts}] [${level.toUpperCase()}]${reqId ? ` [${reqId}]` : ""} ${message}${formatLogMeta(meta, undefined)}\n`;
|
|
856
|
-
rotateLogsIfNeeded();
|
|
857
|
-
fs.appendFileSync(LOG_FILE, line, "utf8");
|
|
858
|
-
} catch {}
|
|
859
|
-
}
|
|
860
|
-
function logDebug(
|
|
861
|
-
message: string,
|
|
862
|
-
meta?: Record<string, unknown>,
|
|
863
|
-
reqId?: string,
|
|
864
|
-
): void {
|
|
865
|
-
log("debug", message, meta, reqId);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// ── Rate Limiter ───────────────────────────────────────────────────
|
|
869
|
-
// Kilo documents 200 free requests/hour/IP. OpenCode owns its own daily quota;
|
|
870
|
-
// local limits only stop one Pi process from flooding either upstream.
|
|
871
|
-
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
872
|
-
const RATE_LIMIT_MAX: Record<Upstream, number> = {
|
|
873
|
-
opencode: 200, // public free quota: requests per UTC day/IP
|
|
874
|
-
kilo: 200, // documented gateway quota: requests per one-hour window/IP
|
|
875
|
-
};
|
|
876
|
-
|
|
877
|
-
function rateLimitResetAt(upstream: Upstream, now: number): number {
|
|
878
|
-
if (upstream === "kilo") return now + 60 * 60_000;
|
|
879
|
-
const nextUtcDay = new Date(now);
|
|
880
|
-
nextUtcDay.setUTCHours(24, 0, 0, 0);
|
|
881
|
-
return nextUtcDay.getTime();
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
function rateLimitKey(upstream: Upstream, ip: string, now: number): string {
|
|
885
|
-
if (upstream === "kilo") return `${upstream}:${ip}`;
|
|
886
|
-
return `${upstream}:${new Date(now).toISOString().slice(0, 10)}:${ip}`;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
// ponytail: Vercel relay rejects requests that ask for very large max_tokens
|
|
890
|
-
// (response body / duration limits). Clamp at relay layer so direct mode stays
|
|
891
|
-
// unconstrained and model config stays accurate.
|
|
892
|
-
const RELAY_MAX_TOKENS = 131_072;
|
|
893
|
-
|
|
894
|
-
function checkRateLimit(ip: string, upstream: Upstream): boolean {
|
|
895
|
-
const now = Date.now();
|
|
896
|
-
const key = rateLimitKey(upstream, ip, now);
|
|
897
|
-
const entry = rateLimitMap.get(key);
|
|
898
|
-
if (!entry || entry.resetAt <= now) {
|
|
899
|
-
rateLimitMap.set(key, {
|
|
900
|
-
count: 1,
|
|
901
|
-
resetAt: rateLimitResetAt(upstream, now),
|
|
902
|
-
});
|
|
903
|
-
return true;
|
|
904
|
-
}
|
|
905
|
-
if (entry.count >= RATE_LIMIT_MAX[upstream]) return false;
|
|
906
|
-
entry.count++;
|
|
907
|
-
return true;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
// ── Health Check & Dynamic Catalog Auto-Update ────────────────────
|
|
911
|
-
const CATALOG_CACHE_FILE = path.join(
|
|
912
|
-
homedir(),
|
|
913
|
-
".pi",
|
|
914
|
-
"agent",
|
|
915
|
-
"pi-freeflow-catalog-cache.json",
|
|
916
|
-
);
|
|
917
|
-
const CATALOG_CACHE_TTL_MS = 3600_000; // 1 hour
|
|
918
|
-
|
|
919
|
-
function formatCleanDisplayName(id: string, customName?: string): string {
|
|
920
|
-
if (customName && customName.trim()) return customName.trim();
|
|
921
|
-
const known = MODEL_MAP.get(id);
|
|
922
|
-
if (known && known.name) return known.name;
|
|
923
|
-
|
|
924
|
-
// Strip provider prefix ("nvidia/", "stepfun/", "dots-studio/", etc.)
|
|
925
|
-
let clean = id.replace(/^[a-zA-Z0-9_.-]+\//, "");
|
|
926
|
-
// Strip variant suffixes
|
|
927
|
-
clean = clean.replace(/:(free|preview|exacto|default|batch)$/i, "");
|
|
928
|
-
clean = clean.replace(/-(free|contributor|preview)$/i, "");
|
|
929
|
-
|
|
930
|
-
// Capitalize words nicely
|
|
931
|
-
const parts = clean.split(/[-_]/).map((w) => {
|
|
932
|
-
const lower = w.toLowerCase();
|
|
933
|
-
if (lower === "gpt") return "GPT";
|
|
934
|
-
if (lower === "ai") return "AI";
|
|
935
|
-
if (lower === "lfm") return "LFM";
|
|
936
|
-
if (lower === "hy3") return "Hy3";
|
|
937
|
-
if (lower === "mimo") return "MiMo";
|
|
938
|
-
if (lower === "ocr") return "OCR";
|
|
939
|
-
return w.charAt(0).toUpperCase() + w.slice(1);
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
return parts.join(" ");
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
interface RawModelItem {
|
|
946
|
-
id: string;
|
|
947
|
-
context_length?: number;
|
|
948
|
-
max_output_tokens?: number;
|
|
949
|
-
[key: string]: unknown;
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredModel {
|
|
953
|
-
const known = MODEL_MAP.get(raw.id);
|
|
954
|
-
if (known) return { ...known, source };
|
|
955
|
-
|
|
956
|
-
const idLower = raw.id.toLowerCase();
|
|
957
|
-
const hasVision =
|
|
958
|
-
idLower.includes("vision") ||
|
|
959
|
-
idLower.includes("vl") ||
|
|
960
|
-
idLower.includes("omni") ||
|
|
961
|
-
idLower.includes("note") ||
|
|
962
|
-
idLower.includes("image");
|
|
963
|
-
const hasReasoning =
|
|
964
|
-
idLower.includes("reasoning") ||
|
|
965
|
-
idLower.includes("r1") ||
|
|
966
|
-
idLower.includes("o1") ||
|
|
967
|
-
idLower.includes("think") ||
|
|
968
|
-
idLower.includes("alpha") ||
|
|
969
|
-
idLower.includes("spark");
|
|
970
|
-
|
|
971
|
-
let contextWindow =
|
|
972
|
-
typeof raw.context_length === "number" ? raw.context_length : 262_144;
|
|
973
|
-
if (
|
|
974
|
-
idLower.includes("1m") ||
|
|
975
|
-
idLower.includes("ultra") ||
|
|
976
|
-
idLower.includes("lightning") ||
|
|
977
|
-
idLower.includes("mimo-v2.5") ||
|
|
978
|
-
idLower.includes("muse-spark")
|
|
979
|
-
) {
|
|
980
|
-
contextWindow = 1_048_576;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
let maxTokens =
|
|
984
|
-
typeof raw.max_output_tokens === "number"
|
|
985
|
-
? raw.max_output_tokens
|
|
986
|
-
: 65_536;
|
|
987
|
-
if (idLower.includes("ultra") || idLower.includes("lightning")) {
|
|
988
|
-
maxTokens = 131_072;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
const isResponses = raw.id === "muse-spark-1.2-contributor-free";
|
|
992
|
-
|
|
993
|
-
return {
|
|
994
|
-
id: raw.id,
|
|
995
|
-
name: formatCleanDisplayName(raw.id),
|
|
996
|
-
source,
|
|
997
|
-
reasoning: hasReasoning,
|
|
998
|
-
contextWindow,
|
|
999
|
-
maxTokens,
|
|
1000
|
-
api: isResponses ? "openai-responses" : undefined,
|
|
1001
|
-
input: hasVision ? ["text", "image"] : ["text"],
|
|
1002
|
-
thinkingFormat: source === "kilo" && hasReasoning ? "openrouter" : undefined,
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
interface CatalogCacheData {
|
|
1007
|
-
timestamp: number;
|
|
1008
|
-
opencode: string[];
|
|
1009
|
-
kilo: string[];
|
|
1010
|
-
models?: RegisteredModel[];
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
function readCatalogCache(): CatalogCacheData | null {
|
|
1014
|
-
try {
|
|
1015
|
-
if (!fs.existsSync(CATALOG_CACHE_FILE)) return null;
|
|
1016
|
-
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
1017
|
-
const data = JSON.parse(raw) as CatalogCacheData;
|
|
1018
|
-
if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
|
|
1019
|
-
return data;
|
|
1020
|
-
}
|
|
1021
|
-
} catch {}
|
|
1022
|
-
return null;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
|
|
1026
|
-
if (!force) {
|
|
1027
|
-
const disk = readCatalogCache();
|
|
1028
|
-
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
1029
|
-
aliveCatalog = disk.models;
|
|
1030
|
-
return aliveCatalog;
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// 1. Fetch OpenCode Zen models
|
|
1035
|
-
let opencodeList: RegisteredModel[] = [];
|
|
1036
|
-
try {
|
|
1037
|
-
const r = await fetch(`${API}/models`, {
|
|
1038
|
-
headers: opencodeHeaders(),
|
|
1039
|
-
signal: AbortSignal.timeout(10_000),
|
|
1040
|
-
});
|
|
1041
|
-
if (r.ok) {
|
|
1042
|
-
const d = await r.json();
|
|
1043
|
-
const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
|
|
1044
|
-
const aliveIds = new Set(items.map((m) => m.id));
|
|
1045
|
-
opencodeList = KNOWN_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
|
|
1046
|
-
...m,
|
|
1047
|
-
source: "opencode" as const,
|
|
1048
|
-
}));
|
|
1049
|
-
}
|
|
1050
|
-
} catch {}
|
|
1051
|
-
if (!opencodeList.length) {
|
|
1052
|
-
opencodeList = KNOWN_MODELS.map((m) => ({ ...m, source: "opencode" as const }));
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
// 2. Fetch KiloCode Gateway models
|
|
1056
|
-
let kiloList: RegisteredModel[] = [];
|
|
1057
|
-
try {
|
|
1058
|
-
const r = await fetch(
|
|
1059
|
-
KILO_CHAT_URL.replace("/chat/completions", "/models"),
|
|
1060
|
-
{
|
|
1061
|
-
headers: { Authorization: "Bearer kilo-free" },
|
|
1062
|
-
signal: AbortSignal.timeout(10_000),
|
|
1063
|
-
},
|
|
1064
|
-
);
|
|
1065
|
-
if (r.ok) {
|
|
1066
|
-
const d = await r.json();
|
|
1067
|
-
const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
|
|
1068
|
-
const aliveIds = new Set(items.map((m) => m.id));
|
|
1069
|
-
kiloList = KILO_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
|
|
1070
|
-
...m,
|
|
1071
|
-
source: "kilo" as const,
|
|
1072
|
-
}));
|
|
1073
|
-
}
|
|
1074
|
-
} catch {}
|
|
1075
|
-
if (!kiloList.length) {
|
|
1076
|
-
kiloList = KILO_MODELS.map((m) => ({ ...m, source: "kilo" as const }));
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
const all = [...opencodeList, ...kiloList];
|
|
1080
|
-
aliveCatalog = all;
|
|
1081
|
-
|
|
1082
|
-
// Write rich models to cache atomically
|
|
1083
|
-
try {
|
|
1084
|
-
const dir = path.dirname(CATALOG_CACHE_FILE);
|
|
1085
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1086
|
-
const data: CatalogCacheData = {
|
|
1087
|
-
timestamp: Date.now(),
|
|
1088
|
-
opencode: opencodeList.map((m) => m.id),
|
|
1089
|
-
kilo: kiloList.map((m) => m.id),
|
|
1090
|
-
models: all,
|
|
1091
|
-
};
|
|
1092
|
-
const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
|
|
1093
|
-
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
|
|
1094
|
-
fs.renameSync(tmpPath, CATALOG_CACHE_FILE);
|
|
1095
|
-
} catch {}
|
|
1096
|
-
|
|
1097
|
-
return all;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
// ── Helpers ────────────────────────────────────────────────────────
|
|
1102
|
-
function getClientIP(req: http.IncomingMessage): string {
|
|
1103
|
-
const addr = req.socket.remoteAddress;
|
|
1104
|
-
if (!addr) return "unknown";
|
|
1105
|
-
return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
function validatePath(rawUrl: string): URL | null {
|
|
1109
|
-
const cleaned = rawUrl.replace(/^\/+/, "");
|
|
1110
|
-
if (!ALLOWED_PATH_PATTERN.test(`/${cleaned}`)) return null;
|
|
1111
|
-
if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
|
|
1112
|
-
try {
|
|
1113
|
-
const decoded = decodeURIComponent(cleaned);
|
|
1114
|
-
if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`))
|
|
1115
|
-
return null;
|
|
1116
|
-
} catch {
|
|
1117
|
-
return null;
|
|
1118
|
-
}
|
|
1119
|
-
try {
|
|
1120
|
-
return new URL(cleaned, `${UPSTREAM_OPENCODE}/`);
|
|
1121
|
-
} catch {
|
|
1122
|
-
return null;
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
function sanitizeHeaders(
|
|
1127
|
-
incoming: http.IncomingHttpHeaders,
|
|
1128
|
-
targetHost: string,
|
|
1129
|
-
): Record<string, string> {
|
|
1130
|
-
const sanitized: Record<string, string> = {};
|
|
1131
|
-
for (const [key, value] of Object.entries(incoming)) {
|
|
1132
|
-
const lower = key.toLowerCase();
|
|
1133
|
-
if (STRIP_HEADERS.has(lower) || lower.startsWith(":")) continue;
|
|
1134
|
-
if (typeof value === "string") sanitized[lower] = value;
|
|
1135
|
-
else if (Array.isArray(value)) sanitized[lower] = value.join(", ");
|
|
1136
|
-
}
|
|
1137
|
-
sanitized.host = targetHost;
|
|
1138
|
-
Object.assign(sanitized, opencodeHeaders());
|
|
1139
|
-
sanitized["accept-encoding"] = "identity";
|
|
1140
|
-
sanitized.connection = "keep-alive";
|
|
1141
|
-
return sanitized;
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
function normalizeRequestBody(
|
|
1145
|
-
body: Record<string, unknown>,
|
|
1146
|
-
isRelay = false,
|
|
1147
|
-
isKilo = false,
|
|
1148
|
-
reqId?: string,
|
|
1149
|
-
): Record<string, unknown> {
|
|
1150
|
-
const DBG = isDebugEnabled();
|
|
1151
|
-
const modelId = typeof body.model === "string" ? body.model : "";
|
|
1152
|
-
const modelDef = MODEL_MAP.get(modelId);
|
|
1153
|
-
const isResponsesApi = modelId === "muse-spark-1.2-contributor-free";
|
|
1154
|
-
|
|
1155
|
-
if (DBG) {
|
|
1156
|
-
log("debug", `normalize: incoming model=${modelId} kilo=${isKilo} relay=${isRelay}`, {
|
|
1157
|
-
reasoning_effort: body.reasoning_effort,
|
|
1158
|
-
reasoning: body.reasoning,
|
|
1159
|
-
thinking: (body as Record<string, unknown>).thinking,
|
|
1160
|
-
tool_choice: body.tool_choice,
|
|
1161
|
-
toolsLen: Array.isArray(body.tools) ? body.tools.length : undefined,
|
|
1162
|
-
}, reqId);
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
// 1. Tool choice & empty tools normalization (pi-ai compat: opencode only supports auto)
|
|
1166
|
-
if (Array.isArray(body.tools) && body.tools.length === 0) {
|
|
1167
|
-
delete body.tools;
|
|
1168
|
-
delete body.tool_choice;
|
|
1169
|
-
}
|
|
1170
|
-
if (body.tool_choice === "none") {
|
|
1171
|
-
delete body.tool_choice;
|
|
1172
|
-
delete body.tools;
|
|
1173
|
-
} else if (!isKilo && body.tool_choice && body.tool_choice !== "auto") {
|
|
1174
|
-
body.tool_choice = "auto";
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// 1b. Anthropic thinking -> OpenAI reasoning_effort auto-translate
|
|
1178
|
-
// Pi sends anthropic `thinking: {type:"enabled",budget_tokens}` when provider is anthropic.
|
|
1179
|
-
// Our proxy is always openai-completions/responses upstream, so translate.
|
|
1180
|
-
// Ref: pi-ai api/anthropic-messages.js (thinking.type adaptive/enabled/disabled) -> api/openai-completions.js (reasoning_effort)
|
|
1181
|
-
const thinkingRaw = (body as Record<string, unknown>).thinking;
|
|
1182
|
-
if (thinkingRaw && typeof thinkingRaw === "object") {
|
|
1183
|
-
const th = thinkingRaw as Record<string, unknown>;
|
|
1184
|
-
if (th.type === "disabled") {
|
|
1185
|
-
delete (body as Record<string, unknown>).thinking;
|
|
1186
|
-
// Mark as off so downstream reasoning mapping can clear effort
|
|
1187
|
-
if (!body.reasoning_effort && !body.reasoning) {
|
|
1188
|
-
body.reasoning_effort = "off";
|
|
1189
|
-
}
|
|
1190
|
-
} else if (th.type === "enabled" || th.type === "adaptive") {
|
|
1191
|
-
delete (body as Record<string, unknown>).thinking;
|
|
1192
|
-
// Preserve budget as hint if no explicit effort set
|
|
1193
|
-
if (!body.reasoning_effort && typeof th.budget_tokens === "number") {
|
|
1194
|
-
const budget = th.budget_tokens as number;
|
|
1195
|
-
if (budget >= 8000) body.reasoning_effort = "xhigh";
|
|
1196
|
-
else if (budget >= 4000) body.reasoning_effort = "high";
|
|
1197
|
-
else if (budget >= 2000) body.reasoning_effort = "medium";
|
|
1198
|
-
else body.reasoning_effort = "low";
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
// 2. Reasoning normalization — per-model thinkingLevelMap aware
|
|
1204
|
-
// Ref: pi-ai api/openai-completions.js (compat.thinkingFormat branches) + api/openai-responses-shared.js
|
|
1205
|
-
const mapEffort = (rawEffort: string): string | null | undefined => {
|
|
1206
|
-
const key = rawEffort.toLowerCase() as keyof NonNullable<ModelDef["thinkingLevelMap"]>;
|
|
1207
|
-
if (modelDef?.thinkingLevelMap && key in modelDef.thinkingLevelMap) {
|
|
1208
|
-
return modelDef.thinkingLevelMap[key] as string | null;
|
|
1209
|
-
}
|
|
1210
|
-
if (rawEffort === "xhigh" || rawEffort === "max") {
|
|
1211
|
-
return isResponsesApi ? "xhigh" : (modelId === "x-preview-f-free" ? "max" : "xhigh");
|
|
1212
|
-
}
|
|
1213
|
-
if (rawEffort === "high" || rawEffort === "medium") return "high";
|
|
1214
|
-
if (rawEffort === "minimal") return "minimal";
|
|
1215
|
-
if (rawEffort === "none" || rawEffort === "off") return null;
|
|
1216
|
-
return "low";
|
|
1217
|
-
};
|
|
1218
|
-
|
|
1219
|
-
if (typeof body.reasoning_effort === "string") {
|
|
1220
|
-
const mapped = mapEffort(body.reasoning_effort);
|
|
1221
|
-
if (mapped === null || mapped === undefined) {
|
|
1222
|
-
delete body.reasoning_effort;
|
|
1223
|
-
} else {
|
|
1224
|
-
body.reasoning_effort = mapped;
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
if (body.reasoning && typeof body.reasoning === "object") {
|
|
1228
|
-
const r = body.reasoning as Record<string, unknown>;
|
|
1229
|
-
if (r.effort === "none" || r.effort === "off") {
|
|
1230
|
-
delete r.effort;
|
|
1231
|
-
} else if (typeof r.effort === "string") {
|
|
1232
|
-
const mapped = mapEffort(r.effort);
|
|
1233
|
-
if (mapped === null || mapped === undefined) {
|
|
1234
|
-
delete r.effort;
|
|
1235
|
-
} else {
|
|
1236
|
-
r.effort = mapped;
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
if (isResponsesApi && r.effort === "max") r.effort = "xhigh";
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
// 3. Max & Min tokens clamping (model-specific clamp + Vercel relay clamp)
|
|
1243
|
-
const modelMax = modelDef?.maxTokens ?? RELAY_MAX_TOKENS;
|
|
1244
|
-
const clampTokens = (val: number): number => {
|
|
1245
|
-
let clamped = val;
|
|
1246
|
-
if (!isKilo && clamped < 16) clamped = 16;
|
|
1247
|
-
if (clamped > modelMax) clamped = modelMax;
|
|
1248
|
-
if (isRelay && clamped > RELAY_MAX_TOKENS) clamped = RELAY_MAX_TOKENS;
|
|
1249
|
-
return clamped;
|
|
1250
|
-
};
|
|
1251
|
-
|
|
1252
|
-
if (typeof body.max_tokens === "number") {
|
|
1253
|
-
body.max_tokens = clampTokens(body.max_tokens);
|
|
1254
|
-
}
|
|
1255
|
-
if (typeof body.maxTokens === "number") {
|
|
1256
|
-
body.maxTokens = clampTokens(body.maxTokens);
|
|
1257
|
-
}
|
|
1258
|
-
if (typeof body.max_output_tokens === "number") {
|
|
1259
|
-
body.max_output_tokens = clampTokens(body.max_output_tokens);
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
if (DBG) {
|
|
1263
|
-
log("debug", `normalize: outgoing model=${modelId}`, {
|
|
1264
|
-
reasoning_effort: body.reasoning_effort,
|
|
1265
|
-
reasoning: body.reasoning,
|
|
1266
|
-
max_tokens: body.max_tokens,
|
|
1267
|
-
max_output_tokens: body.max_output_tokens,
|
|
1268
|
-
}, reqId);
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
return body;
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
// ponytail: shared stream pipe — upstream abort/timeout must end response,
|
|
1275
|
-
// not become an uncaught exception that crashes pi.
|
|
1276
|
-
// Adds lightweight thinking-tag sniffing for debug audit (no payload mutation).
|
|
1277
|
-
// Ref: pi-ai api/openai-completions.js (thinkingDelta) + api/openai-responses-shared.js (reasoning)
|
|
1278
|
-
function pipeUpstreamStream(
|
|
1279
|
-
nodeStream: Readable,
|
|
1280
|
-
res: http.ServerResponse,
|
|
1281
|
-
req: http.IncomingMessage,
|
|
1282
|
-
reqId?: string,
|
|
1283
|
-
): void {
|
|
1284
|
-
const rid = reqId || randomUUID().slice(0, 8);
|
|
1285
|
-
let totalChunks = 0;
|
|
1286
|
-
let totalBytes = 0;
|
|
1287
|
-
let thinkingChunks = 0;
|
|
1288
|
-
let thinkingBytes = 0;
|
|
1289
|
-
let firstChunkAt: number | null = null;
|
|
1290
|
-
const startAt = Date.now();
|
|
1291
|
-
const sniffThinking = (chunk: Buffer | string): boolean => {
|
|
1292
|
-
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8", 0, Math.min(chunk.length, 4000));
|
|
1293
|
-
return (
|
|
1294
|
-
s.includes("reasoning") ||
|
|
1295
|
-
s.includes("thinking") ||
|
|
1296
|
-
s.includes("<think>") ||
|
|
1297
|
-
s.includes("reasoning_content") ||
|
|
1298
|
-
s.includes("\"type\":\"thinking\"") ||
|
|
1299
|
-
s.includes("thinking_delta")
|
|
1300
|
-
);
|
|
1301
|
-
};
|
|
1302
|
-
|
|
1303
|
-
try {
|
|
1304
|
-
if (typeof res.flushHeaders === "function") {
|
|
1305
|
-
res.flushHeaders();
|
|
1306
|
-
}
|
|
1307
|
-
} catch {}
|
|
1308
|
-
|
|
1309
|
-
nodeStream.on("data", (chunk: Buffer | string) => {
|
|
1310
|
-
try {
|
|
1311
|
-
if (firstChunkAt === null) {
|
|
1312
|
-
firstChunkAt = Date.now();
|
|
1313
|
-
const ttfb = firstChunkAt - startAt;
|
|
1314
|
-
log("debug", `stream first chunk in ${ttfb}ms`, undefined, rid);
|
|
1315
|
-
}
|
|
1316
|
-
totalChunks++;
|
|
1317
|
-
totalBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
|
|
1318
|
-
if (sniffThinking(chunk)) {
|
|
1319
|
-
thinkingChunks++;
|
|
1320
|
-
thinkingBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
|
|
1321
|
-
if (isDebugEnabled() && thinkingChunks <= 3) {
|
|
1322
|
-
const preview = typeof chunk === "string" ? chunk.slice(0, 600) : chunk.toString("utf8", 0, 600);
|
|
1323
|
-
log("debug", `thinking chunk #${thinkingChunks}`, { preview: preview.slice(0, 400) }, rid);
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
res.write(chunk);
|
|
1327
|
-
const maybeFlush = res as unknown as { flush?: () => void };
|
|
1328
|
-
if (typeof maybeFlush.flush === "function") maybeFlush.flush();
|
|
1329
|
-
} catch {}
|
|
1330
|
-
});
|
|
1331
|
-
|
|
1332
|
-
nodeStream.on("error", (e: unknown) => {
|
|
1333
|
-
log("error", "upstream stream error", { error: String(e), totalChunks, thinkingChunks }, rid);
|
|
1334
|
-
try {
|
|
1335
|
-
if (!res.headersSent) {
|
|
1336
|
-
res.writeHead(502, { "content-type": "application/json" });
|
|
1337
|
-
}
|
|
1338
|
-
if (!res.writableEnded) {
|
|
1339
|
-
res.end();
|
|
1340
|
-
}
|
|
1341
|
-
} catch {}
|
|
1342
|
-
});
|
|
1343
|
-
nodeStream.on("end", () => {
|
|
1344
|
-
const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
|
|
1345
|
-
if (thinkingChunks > 0) {
|
|
1346
|
-
log("info", `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes/1024).toFixed(1)}KB), thinking: ${thinkingChunks} chunks (${(thinkingBytes/1024).toFixed(1)}KB)`, undefined, rid);
|
|
1347
|
-
} else if (isDebugEnabled()) {
|
|
1348
|
-
log("debug", `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes/1024).toFixed(1)}KB), no thinking detected`, undefined, rid);
|
|
1349
|
-
}
|
|
1350
|
-
try {
|
|
1351
|
-
if (!res.writableEnded) res.end();
|
|
1352
|
-
} catch {}
|
|
1353
|
-
});
|
|
1354
|
-
nodeStream.on("close", () => {
|
|
1355
|
-
try {
|
|
1356
|
-
if (!res.writableEnded) res.end();
|
|
1357
|
-
} catch {}
|
|
1358
|
-
});
|
|
1359
|
-
req.on("aborted", () => {
|
|
1360
|
-
log("warn", "client aborted — destroying upstream", { totalChunks }, rid);
|
|
1361
|
-
if (!nodeStream.destroyed) nodeStream.destroy();
|
|
1362
|
-
});
|
|
1363
|
-
req.on("close", () => {
|
|
1364
|
-
if (!nodeStream.destroyed) nodeStream.destroy();
|
|
1365
|
-
});
|
|
1366
|
-
res.on("close", () => {
|
|
1367
|
-
if (!nodeStream.destroyed) nodeStream.destroy();
|
|
1368
|
-
});
|
|
1369
|
-
res.on("error", () => {
|
|
1370
|
-
if (!nodeStream.destroyed) nodeStream.destroy();
|
|
1371
|
-
});
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
// ── Start local proxy ──────────────────────────────────────────────
|
|
1375
|
-
function startProxy(
|
|
1376
|
-
overridePort?: number,
|
|
1377
|
-
): Promise<{ server: http.Server | null; port: number }> {
|
|
1378
|
-
const basePort = overridePort ?? PORT;
|
|
1379
|
-
|
|
1380
|
-
const server = http.createServer((req, res) => {
|
|
1381
|
-
const clientIP = getClientIP(req);
|
|
1382
|
-
const reqId = randomUUID().slice(0, 8);
|
|
1383
|
-
if (isDebugEnabled()) {
|
|
1384
|
-
log("debug", `incoming ${req.method} ${req.url} from ${clientIP}`, { ip: clientIP, method: req.method, url: req.url }, reqId);
|
|
1385
|
-
}
|
|
1386
|
-
if (!ALLOWED_METHODS.has(req.method ?? "")) {
|
|
1387
|
-
res.writeHead(405, { "content-type": "application/json" });
|
|
1388
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
if (req.method === "OPTIONS") {
|
|
1393
|
-
res.writeHead(204, {
|
|
1394
|
-
"access-control-allow-origin": "*",
|
|
1395
|
-
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
1396
|
-
"access-control-max-age": "86400",
|
|
1397
|
-
});
|
|
1398
|
-
res.end();
|
|
1399
|
-
return;
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// Serve ONLY our registered free models. Never forward /v1/models to
|
|
1403
|
-
// upstream (that would leak ~54 paid models into the picker).
|
|
1404
|
-
if (
|
|
1405
|
-
req.method === "GET" &&
|
|
1406
|
-
(req.url === "/v1/models" || req.url === "/v1/models/")
|
|
1407
|
-
) {
|
|
1408
|
-
const body = JSON.stringify({
|
|
1409
|
-
object: "list",
|
|
1410
|
-
data: aliveCatalog.map((m) => ({
|
|
1411
|
-
id: m.id,
|
|
1412
|
-
object: "model",
|
|
1413
|
-
created: 0,
|
|
1414
|
-
owned_by: m.source === "kilo" ? "kilocode" : "opencode",
|
|
1415
|
-
})),
|
|
1416
|
-
});
|
|
1417
|
-
res.writeHead(200, {
|
|
1418
|
-
"content-type": "application/json",
|
|
1419
|
-
"content-length": Buffer.byteLength(body),
|
|
1420
|
-
});
|
|
1421
|
-
res.end(body);
|
|
1422
|
-
return;
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
const target = validatePath(req.url ?? "/");
|
|
1426
|
-
if (!target) {
|
|
1427
|
-
res.writeHead(403, { "content-type": "application/json" });
|
|
1428
|
-
res.end(JSON.stringify({ error: "forbidden" }));
|
|
1429
|
-
return;
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
// Read body to detect model for routing
|
|
1433
|
-
const bodyChunks: Buffer[] = [];
|
|
1434
|
-
req.on("error", (err) => {
|
|
1435
|
-
log("warn", "client request error during body buffering", { error: String(err) }, reqId);
|
|
1436
|
-
if (!res.headersSent) res.writeHead(400, { "content-type": "application/json" });
|
|
1437
|
-
res.end(JSON.stringify({ error: "bad request" }));
|
|
1438
|
-
});
|
|
1439
|
-
req.on("data", (chunk: Buffer) => bodyChunks.push(chunk));
|
|
1440
|
-
req.on("end", async () => {
|
|
1441
|
-
const bodyStr = Buffer.concat(bodyChunks).toString();
|
|
1442
|
-
let isKilo = false;
|
|
1443
|
-
let parsedBody: Record<string, unknown> | null = null;
|
|
1444
|
-
|
|
1445
|
-
try {
|
|
1446
|
-
parsedBody = JSON.parse(bodyStr);
|
|
1447
|
-
if (
|
|
1448
|
-
typeof parsedBody?.model === "string" &&
|
|
1449
|
-
KILO_MODEL_IDS.has(parsedBody.model)
|
|
1450
|
-
) {
|
|
1451
|
-
isKilo = true;
|
|
1452
|
-
}
|
|
1453
|
-
} catch {}
|
|
1454
|
-
|
|
1455
|
-
const upstream: Upstream = isKilo ? "kilo" : "opencode";
|
|
1456
|
-
const isStream = parsedBody?.stream === true;
|
|
1457
|
-
if (!checkRateLimit(clientIP, upstream)) {
|
|
1458
|
-
res.writeHead(429, { "content-type": "application/json" });
|
|
1459
|
-
res.end(JSON.stringify({ error: "rate limit exceeded" }));
|
|
1460
|
-
return;
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
try {
|
|
1464
|
-
if (isKilo && parsedBody) {
|
|
1465
|
-
const kiloBodyObj = structuredClone(parsedBody);
|
|
1466
|
-
normalizeRequestBody(kiloBodyObj, true, isKilo, reqId);
|
|
1467
|
-
const response = await relayFetch(KILO_CHAT_URL, {
|
|
1468
|
-
method: "POST",
|
|
1469
|
-
headers: {
|
|
1470
|
-
"Content-Type": "application/json",
|
|
1471
|
-
Authorization: "Bearer kilo-free",
|
|
1472
|
-
},
|
|
1473
|
-
body: JSON.stringify(kiloBodyObj),
|
|
1474
|
-
signal: AbortSignal.timeout(300_000),
|
|
1475
|
-
}, reqId);
|
|
1476
|
-
if (isStream && response.ok && response.body) {
|
|
1477
|
-
const ct =
|
|
1478
|
-
response.headers.get("content-type") || "text/event-stream";
|
|
1479
|
-
res.writeHead(response.status, {
|
|
1480
|
-
"content-type": ct,
|
|
1481
|
-
"cache-control": "no-cache, no-transform",
|
|
1482
|
-
"connection": "keep-alive",
|
|
1483
|
-
"x-accel-buffering": "no",
|
|
1484
|
-
});
|
|
1485
|
-
pipeUpstreamStream(
|
|
1486
|
-
Readable.fromWeb(
|
|
1487
|
-
response.body as unknown as WebReadableStream,
|
|
1488
|
-
),
|
|
1489
|
-
res,
|
|
1490
|
-
req,
|
|
1491
|
-
reqId,
|
|
1492
|
-
);
|
|
1493
|
-
} else {
|
|
1494
|
-
const data = await response.text();
|
|
1495
|
-
const ct =
|
|
1496
|
-
response.headers.get("content-type") || "application/json";
|
|
1497
|
-
res.writeHead(response.status, { "content-type": ct });
|
|
1498
|
-
res.end(data);
|
|
1499
|
-
}
|
|
1500
|
-
} else {
|
|
1501
|
-
// OpenCode routing — relay (fetch-based) when enabled, else direct (existing, untouched)
|
|
1502
|
-
// round-robin: any saved relay qualifies, not just single url
|
|
1503
|
-
if (relayState.enabled && (relayState.url || relayState.relays.length > 0)) {
|
|
1504
|
-
const fullUrl = `${UPSTREAM_OPENCODE}${req.url ?? "/"}`;
|
|
1505
|
-
const activeHost = relayState.url ? new URL(relayState.url).host : "opencode.ai";
|
|
1506
|
-
const relayHeaders = sanitizeHeaders(
|
|
1507
|
-
req.headers,
|
|
1508
|
-
activeHost,
|
|
1509
|
-
);
|
|
1510
|
-
try {
|
|
1511
|
-
if (parsedBody) {
|
|
1512
|
-
const relayBodyObj = structuredClone(parsedBody);
|
|
1513
|
-
normalizeRequestBody(relayBodyObj, true, isKilo, reqId);
|
|
1514
|
-
const relayBody = Buffer.from(JSON.stringify(relayBodyObj));
|
|
1515
|
-
const response = await relayFetch(fullUrl, {
|
|
1516
|
-
method: req.method || "POST",
|
|
1517
|
-
headers: relayHeaders,
|
|
1518
|
-
body: relayBody,
|
|
1519
|
-
signal: AbortSignal.timeout(300_000),
|
|
1520
|
-
}, reqId);
|
|
1521
|
-
if (isStream && response.ok && response.body) {
|
|
1522
|
-
const ct =
|
|
1523
|
-
response.headers.get("content-type") || "text/event-stream";
|
|
1524
|
-
res.writeHead(response.status, {
|
|
1525
|
-
"content-type": ct,
|
|
1526
|
-
"cache-control": "no-cache, no-transform",
|
|
1527
|
-
"connection": "keep-alive",
|
|
1528
|
-
"x-accel-buffering": "no",
|
|
1529
|
-
});
|
|
1530
|
-
pipeUpstreamStream(
|
|
1531
|
-
Readable.fromWeb(
|
|
1532
|
-
response.body as unknown as WebReadableStream,
|
|
1533
|
-
),
|
|
1534
|
-
res,
|
|
1535
|
-
req,
|
|
1536
|
-
reqId,
|
|
1537
|
-
);
|
|
1538
|
-
} else {
|
|
1539
|
-
const data = await response.text();
|
|
1540
|
-
const ct =
|
|
1541
|
-
response.headers.get("content-type") || "application/json";
|
|
1542
|
-
res.writeHead(response.status, { "content-type": ct });
|
|
1543
|
-
res.end(data);
|
|
1544
|
-
}
|
|
1545
|
-
return; // relay handled the response
|
|
1546
|
-
}
|
|
1547
|
-
} catch (e) {
|
|
1548
|
-
log("warn", "opencode relay failed, falling back to direct", {
|
|
1549
|
-
error: String(e),
|
|
1550
|
-
}, reqId);
|
|
1551
|
-
if (res.headersSent) return; // can't recover mid-stream
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
// direct path — with debug trace and thinking-aware normalize
|
|
1555
|
-
let directBody = Buffer.concat(bodyChunks);
|
|
1556
|
-
if (parsedBody) {
|
|
1557
|
-
const directBodyObj = structuredClone(parsedBody);
|
|
1558
|
-
normalizeRequestBody(directBodyObj, false, isKilo, reqId);
|
|
1559
|
-
directBody = Buffer.from(JSON.stringify(directBodyObj));
|
|
1560
|
-
}
|
|
1561
|
-
if (isDebugEnabled()) {
|
|
1562
|
-
log("debug", `direct upstream ${target.hostname}${target.pathname} (${directBody.length}B)`, { model: parsedBody?.model, isKilo }, reqId);
|
|
1563
|
-
}
|
|
1564
|
-
const fwd = sanitizeHeaders(req.headers, target.hostname);
|
|
1565
|
-
if (directBody.length > 0) {
|
|
1566
|
-
fwd["content-length"] = String(directBody.byteLength);
|
|
1567
|
-
}
|
|
1568
|
-
const proxy = https.request(
|
|
1569
|
-
{
|
|
1570
|
-
method: req.method,
|
|
1571
|
-
hostname: target.hostname,
|
|
1572
|
-
port: 443,
|
|
1573
|
-
path: target.pathname + target.search,
|
|
1574
|
-
headers: fwd,
|
|
1575
|
-
},
|
|
1576
|
-
(upstream) => {
|
|
1577
|
-
const outHeaders: Record<string, string> = {};
|
|
1578
|
-
for (const h of [
|
|
1579
|
-
"content-type",
|
|
1580
|
-
"cache-control",
|
|
1581
|
-
"x-request-id",
|
|
1582
|
-
]) {
|
|
1583
|
-
const val = upstream.headers[h];
|
|
1584
|
-
if (typeof val === "string") outHeaders[h] = val;
|
|
1585
|
-
}
|
|
1586
|
-
outHeaders["x-content-type-options"] = "nosniff";
|
|
1587
|
-
res.writeHead(upstream.statusCode ?? 502, outHeaders);
|
|
1588
|
-
upstream.on("error", (streamErr) => {
|
|
1589
|
-
log("error", "upstream stream error in direct proxy", { error: String(streamErr) }, reqId);
|
|
1590
|
-
if (!res.writableEnded) res.end();
|
|
1591
|
-
});
|
|
1592
|
-
upstream.pipe(res);
|
|
1593
|
-
},
|
|
1594
|
-
);
|
|
1595
|
-
proxy.on("error", (proxyErr) => {
|
|
1596
|
-
log("error", "proxy socket error", { error: String(proxyErr) }, reqId);
|
|
1597
|
-
if (!res.headersSent) {
|
|
1598
|
-
res.writeHead(502, { "content-type": "application/json" });
|
|
1599
|
-
res.end(JSON.stringify({ error: "upstream error" }));
|
|
1600
|
-
} else if (!res.writableEnded) {
|
|
1601
|
-
res.end();
|
|
1602
|
-
}
|
|
1603
|
-
});
|
|
1604
|
-
proxy.setTimeout(300_000, () => {
|
|
1605
|
-
proxy.destroy(new Error("timeout"));
|
|
1606
|
-
});
|
|
1607
|
-
req.on("aborted", () => {
|
|
1608
|
-
if (!proxy.destroyed) proxy.destroy();
|
|
1609
|
-
});
|
|
1610
|
-
req.on("close", () => {
|
|
1611
|
-
if (!proxy.destroyed) proxy.destroy();
|
|
1612
|
-
});
|
|
1613
|
-
res.on("close", () => {
|
|
1614
|
-
if (!proxy.destroyed) proxy.destroy();
|
|
1615
|
-
});
|
|
1616
|
-
// ponytail: body already buffered in bodyChunks above for model routing;
|
|
1617
|
-
// req is drained so pipe() would send an empty body → upstream hang → 502.
|
|
1618
|
-
proxy.end(directBody);
|
|
1619
|
-
}
|
|
1620
|
-
} catch (err) {
|
|
1621
|
-
log("error", "proxy error", { error: String(err) }, reqId);
|
|
1622
|
-
if (!res.headersSent)
|
|
1623
|
-
res.writeHead(502, { "content-type": "application/json" });
|
|
1624
|
-
res.end(JSON.stringify({ error: "internal error" }));
|
|
1625
|
-
}
|
|
1626
|
-
});
|
|
1627
|
-
});
|
|
1628
|
-
|
|
1629
|
-
return new Promise((resolve, reject) => {
|
|
1630
|
-
let attempt = 0;
|
|
1631
|
-
let settled = false;
|
|
1632
|
-
const tryListen = async (port: number) => {
|
|
1633
|
-
server.once("error", async (err: NodeJS.ErrnoException) => {
|
|
1634
|
-
if (settled) return;
|
|
1635
|
-
if (err.code === "EADDRINUSE") {
|
|
1636
|
-
// Re-check if the base port is alive (attached master race)
|
|
1637
|
-
if (await isProxyAlive(basePort)) {
|
|
1638
|
-
settled = true;
|
|
1639
|
-
log("info", `attached to running proxy on http://${HOST}:${basePort}`);
|
|
1640
|
-
resolve({ server: null, port: basePort });
|
|
1641
|
-
return;
|
|
1642
|
-
}
|
|
1643
|
-
if (attempt < 20) {
|
|
1644
|
-
attempt++;
|
|
1645
|
-
log("warn", `port ${port} taken — trying ${port + 1}`);
|
|
1646
|
-
tryListen(port + 1);
|
|
1647
|
-
return;
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
settled = true;
|
|
1651
|
-
log("error", "server error", { code: err.code, message: err.message });
|
|
1652
|
-
reject(err);
|
|
1653
|
-
});
|
|
1654
|
-
server.listen(port, HOST, () => {
|
|
1655
|
-
if (settled) return;
|
|
1656
|
-
settled = true;
|
|
1657
|
-
const addr = server.address();
|
|
1658
|
-
const realPort = addr && typeof addr === "object" ? addr.port : port;
|
|
1659
|
-
log("info", `proxy listening on http://${HOST}:${realPort}`);
|
|
1660
|
-
resolve({ server, port: realPort });
|
|
1661
|
-
});
|
|
1662
|
-
};
|
|
1663
|
-
tryListen(basePort);
|
|
1664
|
-
});
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
// Probe whether an existing freeflow proxy is already running on a port
|
|
1668
|
-
async function isProxyAlive(port: number): Promise<boolean> {
|
|
1669
|
-
try {
|
|
1670
|
-
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
1671
|
-
signal: AbortSignal.timeout(500),
|
|
1672
|
-
});
|
|
1673
|
-
const ct = res.headers.get("content-type") || "";
|
|
1674
|
-
return res.ok && ct.includes("application/json");
|
|
1675
|
-
} catch {
|
|
1676
|
-
return false;
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
// ── Main extension ─────────────────────────────────────────────────
|
|
1680
|
-
export default async function (pi: ExtensionAPI) {
|
|
1681
|
-
log("info", "extension loading...");
|
|
1682
|
-
let server: http.Server | null = null;
|
|
1683
|
-
let actualPort = PORT;
|
|
1684
|
-
|
|
1685
|
-
// Single-Port Shared Pattern: If proxy is already running on PORT (e.g. parent session),
|
|
1686
|
-
// subagents reuse http://127.0.0.1:18080 directly without spawning redundant servers!
|
|
1687
|
-
const alreadyRunning = await isProxyAlive(PORT);
|
|
1688
|
-
if (alreadyRunning) {
|
|
1689
|
-
log("info", `reusing existing freeflow proxy on http://${HOST}:${PORT}`);
|
|
1690
|
-
actualPort = PORT;
|
|
1691
|
-
} else {
|
|
1692
|
-
try {
|
|
1693
|
-
const r = await startProxy();
|
|
1694
|
-
server = r.server;
|
|
1695
|
-
actualPort = r.port;
|
|
1696
|
-
} catch {
|
|
1697
|
-
log(
|
|
1698
|
-
"error",
|
|
1699
|
-
"extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
|
|
1700
|
-
);
|
|
1701
|
-
return;
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
const aliveModels = await refreshCatalog();
|
|
1705
|
-
aliveCatalog = aliveModels;
|
|
1706
|
-
if (aliveModels.length === 0) {
|
|
1707
|
-
// Don't bail: still register /bansos below so the user can recover
|
|
1708
|
-
// (e.g. switch the relay off) instead of being stranded with no command.
|
|
1709
|
-
log(
|
|
1710
|
-
"warn",
|
|
1711
|
-
"no alive models found — provider inactive; /bansos still available to switch relay off / go direct",
|
|
1712
|
-
);
|
|
1713
|
-
} else {
|
|
1714
|
-
log(
|
|
1715
|
-
"info",
|
|
1716
|
-
`${aliveModels.length} model(s) registered: ${aliveModels.map((m) => m.id).join(", ")}`,
|
|
1717
|
-
);
|
|
1718
|
-
|
|
1719
|
-
const providerConfig = {
|
|
1720
|
-
baseUrl: `http://${HOST}:${actualPort}/v1`,
|
|
1721
|
-
apiKey: "placeholder",
|
|
1722
|
-
api: "openai-completions" as const,
|
|
1723
|
-
compat: { supportsDeveloperRole: false },
|
|
1724
|
-
models: aliveModels.map((m) => ({
|
|
1725
|
-
id: m.id,
|
|
1726
|
-
name: m.name,
|
|
1727
|
-
api: m.api,
|
|
1728
|
-
reasoning: m.reasoning,
|
|
1729
|
-
thinkingLevelMap: m.thinkingLevelMap,
|
|
1730
|
-
input: m.input ?? ["text"],
|
|
1731
|
-
contextWindow: m.contextWindow,
|
|
1732
|
-
maxTokens: m.maxTokens,
|
|
1733
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
1734
|
-
compat: m.thinkingFormat
|
|
1735
|
-
? { supportsDeveloperRole: false, thinkingFormat: m.thinkingFormat }
|
|
1736
|
-
: m.api === "openai-responses"
|
|
1737
|
-
? { sessionAffinityFormat: "openai-nosession" }
|
|
1738
|
-
: m.source === "kilo"
|
|
1739
|
-
? { supportsDeveloperRole: false, supportsReasoningEffort: false }
|
|
1740
|
-
: { supportsDeveloperRole: false, supportsReasoningEffort: true },
|
|
1741
|
-
})),
|
|
1742
|
-
};
|
|
1743
|
-
|
|
1744
|
-
pi.registerProvider("freeflow", providerConfig);
|
|
1745
|
-
}
|
|
1746
|
-
// ── /bansos command: relay + debug + logs ───
|
|
1747
|
-
const commandSpec = {
|
|
1748
|
-
description:
|
|
1749
|
-
"Relay egress: on | off | status | logs [level] [n] | debug on|off|status | url [URL] | deploy | list | use <URL> | remove <URL> | refresh",
|
|
1750
|
-
getArgumentCompletions: (prefix: string) =>
|
|
1751
|
-
["on", "off", "status", "url", "deploy", "list", "use", "remove", "refresh", "models", "logs", "debug", "trace"]
|
|
1752
|
-
.filter((s) => s.startsWith(prefix))
|
|
1753
|
-
.map((s) => ({ value: s, label: s })),
|
|
1754
|
-
handler: async (args: string, ctx) => {
|
|
1755
|
-
const parts = String(args || "")
|
|
1756
|
-
.trim()
|
|
1757
|
-
.split(/\s+/);
|
|
1758
|
-
const sub = parts[0] || "";
|
|
1759
|
-
const rest = parts.slice(1).join(" ");
|
|
1760
|
-
|
|
1761
|
-
const flash = () => {
|
|
1762
|
-
const activeLabel = shortRelayLabel(relayState.url);
|
|
1763
|
-
const activeIdx = Math.max(1, relayState.relays.findIndex((r) => r.url === relayState.url) + 1);
|
|
1764
|
-
const total = relayState.relays.length || 1;
|
|
1765
|
-
ctx.ui.notify(
|
|
1766
|
-
`Relay ${relayState.enabled ? "ON" : "OFF"}${relayState.enabled ? ` → ${activeLabel} (${activeIdx}/${total})` : " (direct)"} | saved=${relayState.relays.length} (auto-fallback rolling)`,
|
|
1767
|
-
"info",
|
|
1768
|
-
);
|
|
1769
|
-
};
|
|
1770
|
-
const persist = () => {
|
|
1771
|
-
saveRelayState(relayState);
|
|
1772
|
-
statusUi = ctx.ui;
|
|
1773
|
-
ctx.ui.setStatus("freeflow", undefined);
|
|
1774
|
-
ctx.ui.setStatus("bansos", undefined);
|
|
1775
|
-
};
|
|
1776
|
-
// mutate in place so the saved-relays list is preserved across switches
|
|
1777
|
-
const setRelay = (enabled: boolean, url: string, addLabel?: string) => {
|
|
1778
|
-
relayState.enabled = enabled;
|
|
1779
|
-
relayState.url = (url || "").trim() || DEFAULT_RELAY_URL;
|
|
1780
|
-
if (relayState.url) ensureRelay(relayState, relayState.url, addLabel);
|
|
1781
|
-
};
|
|
1782
|
-
const doDeploy = async () => {
|
|
1783
|
-
// Token prompted (not stored). pi's input has no secret mode — shows while typing.
|
|
1784
|
-
const defaultName = `relay-${Date.now().toString(36)}`;
|
|
1785
|
-
const token = (
|
|
1786
|
-
await ctx.ui.input("Vercel API token (vercel-…):", "")
|
|
1787
|
-
)?.trim();
|
|
1788
|
-
if (!token) {
|
|
1789
|
-
ctx.ui.notify("Deploy cancelled — no token", "warning");
|
|
1790
|
-
return;
|
|
1791
|
-
}
|
|
1792
|
-
const name =
|
|
1793
|
-
(
|
|
1794
|
-
await ctx.ui.input("Project name (empty = auto):", defaultName)
|
|
1795
|
-
)?.trim() || defaultName;
|
|
1796
|
-
ctx.ui.setStatus("bansos", "deploying relay…");
|
|
1797
|
-
try {
|
|
1798
|
-
const url = await deployVercelRelay(token, name, (m) =>
|
|
1799
|
-
ctx.ui.notify(m, "info"),
|
|
1800
|
-
);
|
|
1801
|
-
setRelay(true, url, `deployed ${name}`);
|
|
1802
|
-
persist();
|
|
1803
|
-
ctx.ui.notify(`✓ Deployed & active: ${url}`, "info");
|
|
1804
|
-
} catch (e) {
|
|
1805
|
-
ctx.ui.setStatus(
|
|
1806
|
-
"freeflow",
|
|
1807
|
-
`relay: ${relayState.enabled ? "ON" : "OFF"}`,
|
|
1808
|
-
);
|
|
1809
|
-
ctx.ui.notify(`Deploy failed: ${(e as Error).message}`, "error");
|
|
1810
|
-
}
|
|
1811
|
-
};
|
|
1812
|
-
const switchRelay = async () => {
|
|
1813
|
-
if (!relayState.relays.length) {
|
|
1814
|
-
ctx.ui.notify("No saved relays yet", "warning");
|
|
1815
|
-
return;
|
|
1816
|
-
}
|
|
1817
|
-
const fmt = (r: KnownRelay) =>
|
|
1818
|
-
`${r.url === relayState.url ? "★ " : " "}${r.url}${r.label ? ` (${r.label})` : ""}`;
|
|
1819
|
-
const opts = relayState.relays.map(fmt);
|
|
1820
|
-
const choice = await ctx.ui.select("Switch relay", opts);
|
|
1821
|
-
if (!choice) return;
|
|
1822
|
-
const match = relayState.relays.find((r) => fmt(r) === choice);
|
|
1823
|
-
if (!match) return;
|
|
1824
|
-
setRelay(true, match.url);
|
|
1825
|
-
persist();
|
|
1826
|
-
flash();
|
|
1827
|
-
};
|
|
1828
|
-
const showList = () => {
|
|
1829
|
-
if (!relayState.relays.length) {
|
|
1830
|
-
ctx.ui.notify("No saved relays", "info");
|
|
1831
|
-
return;
|
|
1832
|
-
}
|
|
1833
|
-
const lines = relayState.relays.map(
|
|
1834
|
-
(r) =>
|
|
1835
|
-
`${r.url === relayState.url ? "★" : " "} ${r.url}${r.label ? ` [${r.label}]` : ""}`,
|
|
1836
|
-
);
|
|
1837
|
-
ctx.ui.notify(
|
|
1838
|
-
`Saved relays (${relayState.relays.length}):\n${lines.join("\n")}`,
|
|
1839
|
-
"info",
|
|
1840
|
-
);
|
|
1841
|
-
};
|
|
1842
|
-
const removeRelayMenu = async () => {
|
|
1843
|
-
const removable = relayState.relays.filter(
|
|
1844
|
-
(r) => r.url !== relayState.url,
|
|
1845
|
-
);
|
|
1846
|
-
if (!removable.length) {
|
|
1847
|
-
ctx.ui.notify(
|
|
1848
|
-
"Nothing to remove — the active relay can't be removed (switch first)",
|
|
1849
|
-
"warning",
|
|
1850
|
-
);
|
|
1851
|
-
return;
|
|
1852
|
-
}
|
|
1853
|
-
const fmt = (r: KnownRelay) =>
|
|
1854
|
-
`${r.url}${r.label ? ` (${r.label})` : ""}`;
|
|
1855
|
-
const choice = await ctx.ui.select("Remove relay", removable.map(fmt));
|
|
1856
|
-
if (!choice) return;
|
|
1857
|
-
const match = removable.find((r) => fmt(r) === choice);
|
|
1858
|
-
if (!match) return;
|
|
1859
|
-
removeRelay(relayState, match.url);
|
|
1860
|
-
persist();
|
|
1861
|
-
ctx.ui.notify(`Removed: ${match.url}`, "info");
|
|
1862
|
-
};
|
|
1863
|
-
|
|
1864
|
-
if (sub === "on") {
|
|
1865
|
-
setRelay(true, relayState.url || DEFAULT_RELAY_URL);
|
|
1866
|
-
persist();
|
|
1867
|
-
flash();
|
|
1868
|
-
} else if (sub === "off") {
|
|
1869
|
-
relayState.enabled = false;
|
|
1870
|
-
persist();
|
|
1871
|
-
flash();
|
|
1872
|
-
} else if (sub === "status") {
|
|
1873
|
-
flash();
|
|
1874
|
-
} else if (sub === "list") {
|
|
1875
|
-
showList();
|
|
1876
|
-
} else if (sub === "use") {
|
|
1877
|
-
const url = (
|
|
1878
|
-
rest ||
|
|
1879
|
-
(await ctx.ui.input("Relay URL to activate:", "")) ||
|
|
1880
|
-
""
|
|
1881
|
-
).trim();
|
|
1882
|
-
if (!url) {
|
|
1883
|
-
ctx.ui.notify("No URL given", "warning");
|
|
1884
|
-
return;
|
|
1885
|
-
}
|
|
1886
|
-
setRelay(true, url, "manual");
|
|
1887
|
-
persist();
|
|
1888
|
-
flash();
|
|
1889
|
-
} else if (sub === "debug") {
|
|
1890
|
-
const arg = rest.trim().toLowerCase();
|
|
1891
|
-
if (arg === "on" || arg === "enable" || arg === "true") {
|
|
1892
|
-
saveDebugState({ debug: true });
|
|
1893
|
-
ctx.ui.notify("🔍 Debug ON — verbose trace enabled (level=debug). Logs now include request IDs, thinking sniffing, and payload normalize details.", "info");
|
|
1894
|
-
} else if (arg === "off" || arg === "disable" || arg === "false") {
|
|
1895
|
-
saveDebugState({ debug: false });
|
|
1896
|
-
ctx.ui.notify("🔇 Debug OFF — level restored to info. File: " + DEBUG_STATE_FILE, "info");
|
|
1897
|
-
} else if (arg.startsWith("level")) {
|
|
1898
|
-
const lvl = arg.split(/\s+/)[1] as LogLevel | undefined;
|
|
1899
|
-
if (lvl && lvl in LOG_LEVEL_ORDER) {
|
|
1900
|
-
saveDebugState({ debug: false, level: lvl });
|
|
1901
|
-
ctx.ui.notify(`Log level set to ${lvl} (persisted to ${DEBUG_STATE_FILE})`, "info");
|
|
1902
|
-
} else {
|
|
1903
|
-
ctx.ui.notify(`Unknown level: ${lvl} (use debug/info/warn/error)`, "warn");
|
|
1904
|
-
}
|
|
1905
|
-
} else {
|
|
1906
|
-
const st = loadDebugState();
|
|
1907
|
-
const cur = st?.debug ? "debug (ON)" : (st?.level || process.env.FREEFLOW_LOG_LEVEL || "info");
|
|
1908
|
-
ctx.ui.notify(`Debug status: ${cur}\nFile: ${DEBUG_STATE_FILE}\nMinLevel: ${getMinLogLevel()} | isDebug=${isDebugEnabled()}\nUsage: /freeflow debug on|off | /freeflow debug level debug`, "info");
|
|
1909
|
-
}
|
|
1910
|
-
} else if (sub === "logs" || sub === "log" || sub === "trace") {
|
|
1911
|
-
try {
|
|
1912
|
-
const rawRest = rest.trim();
|
|
1913
|
-
let filterLevel: LogLevel | null = null;
|
|
1914
|
-
let filterReqId: string | null = null;
|
|
1915
|
-
let count = 25;
|
|
1916
|
-
// trace mode: sub === trace or rest starts with trace/req
|
|
1917
|
-
if (sub === "trace" && rawRest) {
|
|
1918
|
-
filterReqId = rawRest.split(/\s+/)[0];
|
|
1919
|
-
} else if (rawRest) {
|
|
1920
|
-
const tokens = rawRest.split(/\s+/);
|
|
1921
|
-
for (const t of tokens) {
|
|
1922
|
-
const lower = t.toLowerCase();
|
|
1923
|
-
if (lower in LOG_LEVEL_ORDER) filterLevel = lower as LogLevel;
|
|
1924
|
-
else if (/^\d+$/.test(t)) count = Math.min(200, Math.max(5, parseInt(t, 10)));
|
|
1925
|
-
else if (/^[a-f0-9]{6,8}$/i.test(t) || t.startsWith("req=")) filterReqId = t.replace(/^req=/, "");
|
|
1926
|
-
else if (lower === "trace" || lower === "req") continue;
|
|
1927
|
-
else filterReqId = t;
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
const files: string[] = [LOG_FILE, `${LOG_FILE}.1`, `${LOG_FILE}.2`].filter((f) => fs.existsSync(f));
|
|
1931
|
-
if (files.length === 0) {
|
|
1932
|
-
ctx.ui.notify("Log file is empty (no logs yet)", "info");
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
let allLines: string[] = [];
|
|
1936
|
-
for (const f of files) {
|
|
1937
|
-
try {
|
|
1938
|
-
const c = fs.readFileSync(f, "utf8");
|
|
1939
|
-
const ls = c.trim().split("\n").filter(Boolean);
|
|
1940
|
-
allLines = ls.concat(allLines);
|
|
1941
|
-
} catch {}
|
|
1942
|
-
}
|
|
1943
|
-
let filtered = allLines;
|
|
1944
|
-
if (filterLevel) {
|
|
1945
|
-
const want = `[${filterLevel.toUpperCase()}]`;
|
|
1946
|
-
filtered = filtered.filter((l) => l.includes(want));
|
|
1947
|
-
}
|
|
1948
|
-
if (filterReqId) {
|
|
1949
|
-
// match [reqId] bracket or req= prefix
|
|
1950
|
-
filtered = filtered.filter((l) => l.includes(filterReqId as string) || l.includes(`[${filterReqId}]`));
|
|
1951
|
-
}
|
|
1952
|
-
const lines = filtered.slice(-count);
|
|
1953
|
-
if (lines.length === 0) {
|
|
1954
|
-
ctx.ui.notify(`No logs matched (level=${filterLevel || "any"} reqId=${filterReqId || "any"} count=${count})`, "warning");
|
|
1955
|
-
return;
|
|
1956
|
-
}
|
|
1957
|
-
const header = `pi-freeflow logs (last ${lines.length}/${filtered.length} matched, total ${allLines.length} lines, file: ${LOG_FILE}${filterLevel ? ` level=${filterLevel}` : ""}${filterReqId ? ` req=${filterReqId}` : ""}):`;
|
|
1958
|
-
ctx.ui.notify(`${header}\n\n${lines.join("\n")}`, "info");
|
|
1959
|
-
} catch (e) {
|
|
1960
|
-
ctx.ui.notify(`Could not read log file: ${(e as Error).message}`, "error");
|
|
1961
|
-
}
|
|
1962
|
-
} else if (sub === "refresh" || sub === "reload" || sub === "models") {
|
|
1963
|
-
ctx.ui.notify("Refreshing model catalog from live upstreams…", "info");
|
|
1964
|
-
const updated = await refreshCatalog(true);
|
|
1965
|
-
persist();
|
|
1966
|
-
ctx.ui.notify(
|
|
1967
|
-
`✓ Refreshed ${updated.length} models with full-spec metadata!`,
|
|
1968
|
-
"info",
|
|
1969
|
-
);
|
|
1970
|
-
} else if (sub === "remove") {
|
|
1971
|
-
const url = (
|
|
1972
|
-
rest ||
|
|
1973
|
-
(await ctx.ui.input("Relay URL to remove:", "")) ||
|
|
1974
|
-
""
|
|
1975
|
-
).trim();
|
|
1976
|
-
if (!url) {
|
|
1977
|
-
ctx.ui.notify("No URL given", "warning");
|
|
1978
|
-
return;
|
|
1979
|
-
}
|
|
1980
|
-
if (url === relayState.url) {
|
|
1981
|
-
ctx.ui.notify(
|
|
1982
|
-
"Can't remove the active relay — switch first",
|
|
1983
|
-
"warning",
|
|
1984
|
-
);
|
|
1985
|
-
return;
|
|
1986
|
-
}
|
|
1987
|
-
if (!relayState.relays.some((r) => r.url === url)) {
|
|
1988
|
-
ctx.ui.notify("Not in saved list", "warning");
|
|
1989
|
-
return;
|
|
1990
|
-
}
|
|
1991
|
-
removeRelay(relayState, url);
|
|
1992
|
-
persist();
|
|
1993
|
-
ctx.ui.notify(`Removed: ${url}`, "info");
|
|
1994
|
-
} else if (sub === "url") {
|
|
1995
|
-
const input =
|
|
1996
|
-
rest ||
|
|
1997
|
-
(await ctx.ui.input(
|
|
1998
|
-
"Relay URL (empty = default):",
|
|
1999
|
-
relayState.url || DEFAULT_RELAY_URL,
|
|
2000
|
-
));
|
|
2001
|
-
setRelay(
|
|
2002
|
-
relayState.enabled,
|
|
2003
|
-
(input || "").trim() || DEFAULT_RELAY_URL,
|
|
2004
|
-
"manual",
|
|
2005
|
-
);
|
|
2006
|
-
persist();
|
|
2007
|
-
flash();
|
|
2008
|
-
} else if (sub === "deploy") {
|
|
2009
|
-
await doDeploy();
|
|
2010
|
-
} else {
|
|
2011
|
-
const choice = await ctx.ui.select("bansos relay", [
|
|
2012
|
-
`Relay: ${relayState.enabled ? "ON" : "OFF"} → ${relayState.url || "direct"}`,
|
|
2013
|
-
"Turn ON",
|
|
2014
|
-
"Turn OFF",
|
|
2015
|
-
"Switch relay…",
|
|
2016
|
-
"Remove relay…",
|
|
2017
|
-
"Set URL",
|
|
2018
|
-
"Deploy Vercel relay…",
|
|
2019
|
-
"List saved relays",
|
|
2020
|
-
]);
|
|
2021
|
-
if (choice === "Turn ON") {
|
|
2022
|
-
setRelay(true, relayState.url || DEFAULT_RELAY_URL);
|
|
2023
|
-
persist();
|
|
2024
|
-
flash();
|
|
2025
|
-
} else if (choice === "Turn OFF") {
|
|
2026
|
-
relayState.enabled = false;
|
|
2027
|
-
persist();
|
|
2028
|
-
flash();
|
|
2029
|
-
} else if (choice === "Switch relay…") {
|
|
2030
|
-
await switchRelay();
|
|
2031
|
-
} else if (choice === "Remove relay…") {
|
|
2032
|
-
await removeRelayMenu();
|
|
2033
|
-
} else if (choice === "Set URL") {
|
|
2034
|
-
const input = await ctx.ui.input(
|
|
2035
|
-
"Relay URL (empty = default):",
|
|
2036
|
-
relayState.url || DEFAULT_RELAY_URL,
|
|
2037
|
-
);
|
|
2038
|
-
setRelay(
|
|
2039
|
-
relayState.enabled,
|
|
2040
|
-
(input || "").trim() || DEFAULT_RELAY_URL,
|
|
2041
|
-
"manual",
|
|
2042
|
-
);
|
|
2043
|
-
persist();
|
|
2044
|
-
flash();
|
|
2045
|
-
} else if (choice === "Deploy Vercel relay…") {
|
|
2046
|
-
await doDeploy();
|
|
2047
|
-
} else if (choice === "List saved relays") {
|
|
2048
|
-
showList();
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2051
|
-
},
|
|
2052
|
-
};
|
|
2053
|
-
pi.registerCommand("freeflow", commandSpec);
|
|
2054
|
-
pi.registerCommand("bansos", commandSpec);
|
|
2055
|
-
|
|
2056
|
-
function updateStatusBar(ui?: ExtensionContext["ui"], providerName?: string) {
|
|
2057
|
-
if (!ui) return;
|
|
2058
|
-
if (relayState.enabled && relayState.relays.length > 0) {
|
|
2059
|
-
const label = shortRelayLabel(relayState.url);
|
|
2060
|
-
const idx = Math.max(
|
|
2061
|
-
1,
|
|
2062
|
-
relayState.relays.findIndex((r) => r.url === relayState.url) + 1,
|
|
2063
|
-
);
|
|
2064
|
-
const total = relayState.relays.length;
|
|
2065
|
-
ui.setStatus?.("freeflow", `relay: ON | ${label} ${idx}/${total}`);
|
|
2066
|
-
} else {
|
|
2067
|
-
ui.setStatus?.("freeflow", undefined);
|
|
2068
|
-
}
|
|
2069
|
-
ui.setStatus?.("bansos", undefined);
|
|
2070
|
-
}
|
|
2071
|
-
|
|
2072
|
-
// Reload persisted state on session start/resume and show status ONLY if active model is FreeFlow
|
|
2073
|
-
pi.on?.("session_start", async (_event, ctx) => {
|
|
2074
|
-
relayState = resolveRelayState();
|
|
2075
|
-
statusUi = ctx.ui;
|
|
2076
|
-
|
|
2077
|
-
const activeModel =
|
|
2078
|
-
ctx && typeof ctx === "object" && "model" in ctx
|
|
2079
|
-
? (ctx as { model?: { provider?: string; id?: string } }).model
|
|
2080
|
-
: null;
|
|
2081
|
-
const provider = activeModel?.provider;
|
|
2082
|
-
const modelId = activeModel?.id;
|
|
2083
|
-
const isFreeFlow =
|
|
2084
|
-
provider === "freeflow" ||
|
|
2085
|
-
Boolean(modelId && aliveCatalog.some((m) => m.id === modelId));
|
|
2086
|
-
|
|
2087
|
-
if (isFreeFlow) {
|
|
2088
|
-
updateStatusBar(ctx.ui);
|
|
2089
|
-
} else {
|
|
2090
|
-
// Relay status OFF / cleared when non-freeflow model is active in session
|
|
2091
|
-
ctx.ui?.setStatus?.("freeflow", undefined);
|
|
2092
|
-
}
|
|
2093
|
-
});
|
|
2094
|
-
|
|
2095
|
-
// Update status bar immediately when user switches models
|
|
2096
|
-
pi.on?.("model_select", async (event, ctx) => {
|
|
2097
|
-
statusUi = ctx.ui;
|
|
2098
|
-
const model =
|
|
2099
|
-
event && typeof event === "object" && "model" in event
|
|
2100
|
-
? (event as { model?: { provider?: string; id?: string } }).model
|
|
2101
|
-
: null;
|
|
2102
|
-
const provider = model?.provider;
|
|
2103
|
-
const modelId = model?.id;
|
|
2104
|
-
const isFreeFlow =
|
|
2105
|
-
provider === "freeflow" ||
|
|
2106
|
-
Boolean(modelId && aliveCatalog.some((m) => m.id === modelId));
|
|
2107
|
-
|
|
2108
|
-
if (isFreeFlow) {
|
|
2109
|
-
updateStatusBar(ctx.ui);
|
|
2110
|
-
} else {
|
|
2111
|
-
// Matikan status relay seketika jika model yang dipilih bukan model FreeFlow
|
|
2112
|
-
ctx.ui?.setStatus?.("freeflow", undefined);
|
|
2113
|
-
}
|
|
2114
|
-
});
|
|
2115
|
-
|
|
2116
|
-
// Pi normally pauses after threshold compaction. Queue a follow-up while the
|
|
2117
|
-
// original run is still active so the core agent continues automatically.
|
|
2118
|
-
pi.on?.("session_compact", (event, ctx) => {
|
|
2119
|
-
if (
|
|
2120
|
-
event.reason !== "threshold" ||
|
|
2121
|
-
event.willRetry ||
|
|
2122
|
-
ctx.isIdle() ||
|
|
2123
|
-
ctx.hasPendingMessages()
|
|
2124
|
-
) {
|
|
2125
|
-
return;
|
|
2126
|
-
}
|
|
2127
|
-
pi.sendUserMessage?.(
|
|
2128
|
-
"Continue the current task from the compacted context. Do not wait for another user message; proceed with the next required step.",
|
|
2129
|
-
{ deliverAs: "followUp" },
|
|
2130
|
-
);
|
|
2131
|
-
});
|
|
2132
|
-
|
|
2133
|
-
pi.on?.("session_shutdown", () => {
|
|
2134
|
-
if (server) {
|
|
2135
|
-
log("info", "shutting down proxy...");
|
|
2136
|
-
server.close();
|
|
2137
|
-
rateLimitMap.clear();
|
|
2138
|
-
log("info", "shutdown complete");
|
|
2139
|
-
}
|
|
2140
|
-
});
|
|
2141
|
-
}
|
|
7
|
+
export { default } from "../src/index.ts";
|
|
8
|
+
export * from "../src/index.ts";
|