pi-freeflow 1.24.0 → 1.26.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/src/proxy.ts CHANGED
@@ -1,1504 +1,1509 @@
1
- /**
2
- * Single-port local HTTP proxy and dynamic upstream router for pi-freeflow
3
- *
4
- * Provides loopback proxying on port 28180 (shared across parent and subagents),
5
- * intelligent routing to OpenCode Zen and KiloCode Gateway, and failover support.
6
- */
7
-
8
- import { randomUUID } from "node:crypto";
9
- import fs from "node:fs";
10
- import { execSync } from "node:child_process";
11
- import * as http from "node:http";
12
- import * as https from "node:https";
13
- import { handleHealthRequest, isLoopbackIP } from "./health.ts";
14
- import type { DaemonHealthSnapshot } from "./health.ts";
15
- import { registerClient, renewClient, touchActivity, unregisterClient } from "./lease.ts";
16
- import { Readable } from "node:stream";
17
- import type { ReadableStream as WebReadableStream } from "node:stream/web";
18
-
19
- import { getAliveCatalog } from "./catalog.ts";
20
- import {
21
- ALLOWED_METHODS,
22
- ALLOWED_PATH_PATTERN,
23
- BASE_PORT_REPROBE_MS,
24
- CLINE_CHAT_URL,
25
- HOST,
26
- KILO_CHAT_URL,
27
- KILO_RESPONSES_URL,
28
- PATH_TRAVERSAL_PATTERN,
29
- MAX_BODY_BYTES,
30
- PORT,
31
- UPSTREAM_HEADER_TIMEOUT_MS,
32
- UPSTREAM_OPENCODE,
33
- opencodeHeaders,
34
- } from "./config.ts";
35
-
36
- import {
37
- convertSseToJson,
38
- enforceOpencodeFingerprint,
39
- sseToChatCompletionJson,
40
- type CaseRestoreMap,
41
- type FindGlobRestore,
42
- } from "./opencode-fingerprint.ts";
43
- import { isDebugEnabled, log } from "./logger.ts";
44
- import { getModelUpstream, isClineModel, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
45
- import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
46
- import { rollChat } from "./cline-accounts.ts";
47
- import { fetchWithSystemCA } from "./system-ca-fetch.ts";
48
- import {
49
- chatResponsesJsonFromChatCompletion,
50
- chatResponsesSseFromChatCompletion,
51
- clineChatBodyFromResponsesBody,
52
- translateToolsForPath,
53
- } from "./tool-translation.ts";
54
- // normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
55
- import { relayFetch } from "./relay.ts";
56
- import { getActiveRelayState, orderedRelayCandidates } from "./relay-state.ts";
57
- import {
58
- issuerRelayFor,
59
- prepareResponsesFailoverBody,
60
- rejectedReasoningCount,
61
- rememberIssuerRelay,
62
- rememberRejectedReasoning,
63
- responsesConversationKey,
64
- stripRejectedReasoning,
65
- stripReasoningEncryption,
66
- isReasoningCallerMismatch,
67
- } from "./responses.ts";
68
- import { pipeUpstreamStream } from "./stream-pipe.ts";
69
- import {
70
- decideZenRoute,
71
- isUnprovenSession,
72
- isUpstreamGated,
73
- pickFailoverModel,
74
- recordUpstreamFailure,
75
- recordUpstreamSuccess,
76
- rememberGateRejection,
77
- sessionKeyOf,
78
- wasGateRejected,
79
- withFreeTierHint,
80
- } from "./upstream-health.ts";
81
-
82
-
83
- let shutdownShouldExit = false;
84
- export function setShutdownShouldExit(v: boolean): void {
85
- shutdownShouldExit = v;
86
- }
87
-
88
- /**
89
- * Natural-429 hint throttle: the deploy guidance hint is attached to upstream
90
- * 429 passthroughs at most once per 10 minutes per process so repeated
91
- * rate-limit responses don't spam clients.
92
- */
93
- let last429HintAt = 0;
94
- function shouldShow429Hint(): boolean {
95
- const now = Date.now();
96
- if (now - last429HintAt < 10 * 60 * 1000) return false;
97
- last429HintAt = now;
98
- return true;
99
- }
100
- /** Test-only: reset 429 hint throttle */
101
- export function _reset429HintForTest(): void { last429HintAt = 0; }
102
-
103
- /** Deploy guidance attached to a natural upstream 429 once the throttle allows. */
104
- const RATE_LIMIT_HINT =
105
- "Shared free-tier IP quota reached. Add your own relay egress: /freeflow deploy (Vercel 1M/mo recommended)";
106
-
107
- /**
108
- * Attach the deploy hint to a natural upstream 429 JSON body. Anything else —
109
- * non-429 statuses, non-JSON bodies — passes through untouched without
110
- * consuming the throttle slot.
111
- */
112
- function withRateLimitHint(status: number, data: string): string {
113
- if (status !== 429) return data;
114
- try {
115
- const parsed: unknown = JSON.parse(data);
116
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && shouldShow429Hint()) {
117
- return JSON.stringify({ ...(parsed as Record<string, unknown>), hint: RATE_LIMIT_HINT });
118
- }
119
- } catch { }
120
- return data;
121
- }
122
- /** Cline marker errors: each status carries account-actionable guidance. */
123
- const CLINE_RATE_LIMIT_HINT =
124
- "Cline free-use limit reached for this login. Wait for the reset or switch models.";
125
- const CLINE_FORBIDDEN_HINT =
126
- "Cline refused this request. Check the Cline login and retry.";
127
- const CLINE_AUTH_HINT =
128
- "Cline login expired or missing. Reconnect the Cline login and retry.";
129
- const CLINE_MODEL_HINT =
130
- "Cline has no such model. Refresh the model list and retry.";
131
-
132
- /**
133
- * What the roll knows about a free-limit exhaustion, for the 429 hint: how many
134
- * saved logins answered the cap and the nearest reset any of them stated.
135
- */
136
- export interface ClineLimitHint {
137
- logins: number;
138
- /** Epoch ms, or null when no login's body stated a reset. */
139
- resetAt: number | null;
140
- }
141
-
142
- /** Coarse reset delay ("20h 4m", "35m") — the hint names what the body stated. */
143
- function formatResetDelay(resetAt: number): string {
144
- const minutes = Math.max(1, Math.round((resetAt - Date.now()) / 60_000));
145
- const hours = Math.floor(minutes / 60);
146
- const rest = minutes % 60;
147
- if (hours === 0) return `${rest}m`;
148
- return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
149
- }
150
-
151
- /** Every saved login is out of free use for this model: say what helps next. */
152
- function clineAllLimitedHint(limit: ClineLimitHint): string {
153
- const reset = limit.resetAt === null ? "" : ` Nearest reset in about ${formatResetDelay(limit.resetAt)}.`;
154
- return `Cline's daily free limit is used up on all ${limit.logins} saved logins.${reset} Switch models, or add another login with /freeflow cline login.`;
155
- }
156
-
157
- /**
158
- * Attach the matching hint to a Cline marker error JSON body (free-limit
159
- * 429, 403, 401, model 404). Anything else — other statuses, non-JSON
160
- * bodies — passes through untouched.
161
- *
162
- * `limit` is present only when the roll tried every saved login and every one
163
- * of them answered the daily free cap; then "for this login" would be wrong and
164
- * hide that another login is the way out.
165
- */
166
- export function mapClineError(status: number, data: string, limit?: ClineLimitHint): string {
167
- // Synthetic pool-exhausted bodies already tell the user exactly what to do
168
- // (sign in again) — a rate-limit hint would mislead.
169
- if (data.includes("cline_pool_exhausted")) return data;
170
- const hint = status === 429
171
- ? (limit ? clineAllLimitedHint(limit) : CLINE_RATE_LIMIT_HINT)
172
- : status === 403
173
- ? CLINE_FORBIDDEN_HINT
174
- : status === 401
175
- ? CLINE_AUTH_HINT
176
- : status === 404
177
- ? CLINE_MODEL_HINT
178
- : null;
179
- if (hint === null) return data;
180
- try {
181
- const parsed: unknown = JSON.parse(data);
182
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
183
- return JSON.stringify({ ...(parsed as Record<string, unknown>), hint });
184
- }
185
- } catch { }
186
- return data;
187
- }
188
- /**
189
- * Device-login refresher for the Cline pool: one refresh per stale slot per
190
- * turn. A grant Cline rejects outright (HTTP 4xx, or a 200 without usable
191
- * tokens) is dead — it resolves null so the pool stops trying that slot until
192
- * the user logs in again. Anything else (network fault, upstream 5xx) throws,
193
- * which keeps the stale bearer for this turn.
194
- */
195
- async function clineRefreshImpl(refreshToken: string): Promise<{ token: string; refreshToken?: string; expiresAt?: number } | null> {
196
- try {
197
- const creds = await refreshClineToken(undefined, refreshToken);
198
- return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
199
- } catch (e) {
200
- if (e instanceof ClineAuthError && typeof e.status === "number" && e.status >= 400 && e.status < 500) {
201
- log("warn", "cline refresh grant rejected — slot needs a fresh login", { status: e.status });
202
- return null;
203
- }
204
- if (e instanceof Error && /^Invalid token response|^Token response did not include a refresh token/.test(e.message)) {
205
- log("warn", "cline refresh returned no usable tokens — slot needs a fresh login", { reason: e.message });
206
- return null;
207
- }
208
- throw e;
209
- }
210
- }
211
-
212
- /**
213
- * Serve a Cline-model request direct (never relay pool, no opencode
214
- * fingerprint). Cline serves chat completions only: responses-path bodies
215
- * translate to chat upstream and the chat answer translates back, re-emitted
216
- * as SSE when the caller streamed. Upstream always streams; callers that
217
- * asked for plain JSON get the aggregated object.
218
- */
219
- async function handleClineRequest(opts: {
220
- parsedBody: Record<string, unknown>;
221
- pathname: string;
222
- req: http.IncomingMessage;
223
- res: http.ServerResponse;
224
- reqId: string;
225
- clientRequestedStream: boolean;
226
- }): Promise<void> {
227
- const { parsedBody, pathname, req, res, reqId, clientRequestedStream } = opts;
228
- const responsesRequest = pathname.endsWith("/responses");
229
- const model = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
230
- let chatBody: Record<string, unknown>;
231
- if (responsesRequest) {
232
- chatBody = clineChatBodyFromResponsesBody(parsedBody);
233
- } else {
234
- chatBody = { ...parsedBody };
235
- if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
236
- delete chatBody.prompt_cache_key;
237
- }
238
- chatBody.stream = true;
239
- let upstreamRes: Response;
240
- let limitHint: ClineLimitHint | undefined;
241
- try {
242
- const result = await rollChat({ body: JSON.stringify(chatBody), chatUrl: CLINE_CHAT_URL, refreshImpl: clineRefreshImpl, fetchImpl: fetchWithSystemCA });
243
- upstreamRes = result.res;
244
- if (typeof result.slot === "string" && result.slot.length > 0) {
245
- log("debug", `cline served by slot ${result.slot}`, { model }, reqId);
246
- }
247
- // Every saved login answered the daily free cap: name that instead of
248
- // blaming "this login", which hides that another login or another model works.
249
- if (result.limitOnly && result.logins > 1) {
250
- limitHint = { logins: result.logins, resetAt: result.earliestResetAt };
251
- }
252
- } catch (e) {
253
- log("error", "cline pool error", { error: String(e), model }, reqId);
254
- if (!res.headersSent) {
255
- res.writeHead(502, { "content-type": "application/json" });
256
- res.end(JSON.stringify({ error: "upstream error" }));
257
- }
258
- return;
259
- }
260
- if (res.writableEnded) {
261
- try { await upstreamRes.body?.cancel(); } catch { }
262
- return;
263
- }
264
- if (!upstreamRes.ok) {
265
- log("warn", `cline upstream ${upstreamRes.status} for model ${model}`, { status: upstreamRes.status, model }, reqId);
266
- const raw = await upstreamRes.text().catch(() => null);
267
- if (raw === null) {
268
- log("error", "cline upstream body unreadable", { status: upstreamRes.status, model }, reqId);
269
- if (!res.headersSent) {
270
- res.writeHead(502, { "content-type": "application/json" });
271
- res.end(JSON.stringify({ error: "upstream error" }));
272
- } else if (!res.writableEnded) {
273
- res.end();
274
- }
275
- return;
276
- }
277
- const data = mapClineError(upstreamRes.status, raw, limitHint);
278
- if (!res.headersSent) {
279
- res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
280
- res.end(data);
281
- } else if (!res.writableEnded) {
282
- res.end();
283
- }
284
- return;
285
- }
286
- if (!responsesRequest && clientRequestedStream && upstreamRes.body) {
287
- res.writeHead(upstreamRes.status, {
288
- "content-type": upstreamRes.headers.get("content-type") || "text/event-stream",
289
- "cache-control": "no-cache, no-transform",
290
- connection: "keep-alive",
291
- "x-accel-buffering": "no",
292
- });
293
- pipeUpstreamStream(
294
- Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
295
- res,
296
- req,
297
- reqId,
298
- "direct",
299
- );
300
- return;
301
- }
302
- const sseText = await upstreamRes.text().catch(() => null);
303
- if (sseText === null) {
304
- log("error", "cline upstream stream unreadable", { status: upstreamRes.status, model }, reqId);
305
- if (!res.headersSent) {
306
- res.writeHead(502, { "content-type": "application/json" });
307
- res.end(JSON.stringify({ error: "upstream error" }));
308
- } else if (!res.writableEnded) {
309
- res.end();
310
- }
311
- return;
312
- }
313
- if (!responsesRequest) {
314
- const data = convertSseToJson(sseText, pathname);
315
- if (!res.headersSent) {
316
- res.writeHead(upstreamRes.status, { "content-type": "application/json" });
317
- res.end(data);
318
- } else if (!res.writableEnded) {
319
- res.end();
320
- }
321
- return;
322
- }
323
- const chat = sseToChatCompletionJson(sseText);
324
- const resp = chatResponsesJsonFromChatCompletion(chat, model);
325
- if (clientRequestedStream) {
326
- if (!res.headersSent) {
327
- res.writeHead(200, {
328
- "content-type": "text/event-stream",
329
- "cache-control": "no-cache, no-transform",
330
- connection: "keep-alive",
331
- "x-accel-buffering": "no",
332
- });
333
- res.end(chatResponsesSseFromChatCompletion(resp));
334
- } else if (!res.writableEnded) {
335
- res.end();
336
- }
337
- return;
338
- }
339
- if (!res.headersSent) {
340
- res.writeHead(200, { "content-type": "application/json" });
341
- res.end(JSON.stringify(resp));
342
- } else if (!res.writableEnded) {
343
- res.end();
344
- }
345
- }
346
-
347
- /**
348
- * Relay eligibility: only free-catalog models ride relay egress. Returns true
349
- * when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
350
- * (Kilo traffic leaves via its own branch regardless), and when the model is
351
- * missing/empty (e.g. GET /v1/models carries no body — preserve routing).
352
- * Cline models are direct-only and never relay-eligible. Anything else
353
- * (unknown/paid model ids, non-strings) goes direct upstream.
354
- */
355
- export function isRelayEligibleModel(model: unknown): boolean {
356
- if (model === undefined || model === null) return true;
357
- if (typeof model !== "string") return false;
358
- if (model.trim() === "") return true;
359
- const canonical = resolveCanonicalModelId(model.trim());
360
- if (isClineModel(canonical)) return false;
361
- return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical);
362
- }
363
-
364
- /**
365
- * Failing-over decision for a canonicalized request body. Returns failover
366
- * with a Kilo model id when a healthy Kilo model is available and either the
367
- * session already ate a gate 403 (retried/resumed refusals fail over at once,
368
- * even ungated — replaying a known refusal can never succeed), or every gate
369
- * holds: Zen upstream model, a path whose body shape matches (chat bodies on
370
- * chat completions, responses bodies on responses — Kilo serves both natively
371
- * but a crossed body would 400), Zen currently gated, session never proven
372
- * working, and the health machine routes this session to failover. Canary
373
- * sessions pass through carrying the recovery probe; anything else passes
374
- * through untouched — working sessions are never rerouted.
375
- */
376
- export type ZenFailoverDecision =
377
- | { action: "failover"; model: string }
378
- | { action: "canary" }
379
- | { action: "passthrough" };
380
- export function classifyZenFailover(
381
- parsedBody: Record<string, unknown> | null,
382
- pathname: string,
383
- ): ZenFailoverDecision {
384
- if (!parsedBody || typeof parsedBody.model !== "string") return { action: "passthrough" };
385
- if (getModelUpstream(parsedBody.model) !== "opencode") return { action: "passthrough" };
386
- const isChatPath = pathname.endsWith("/chat/completions");
387
- const isResponsesPath = pathname.endsWith("/responses");
388
- if (!isChatPath && !isResponsesPath) return { action: "passthrough" };
389
- if (isChatPath && !Array.isArray(parsedBody.messages)) return { action: "passthrough" };
390
- if (isResponsesPath && parsedBody.input === undefined) return { action: "passthrough" };
391
- if (!wasGateRejected(parsedBody, pathname)) {
392
- if (!isUpstreamGated("zen")) return { action: "passthrough" };
393
- if (!isUnprovenSession(parsedBody, pathname)) return { action: "passthrough" };
394
- const route = decideZenRoute(parsedBody, pathname);
395
- if (route === "canary") {
396
- log("debug", "zen gated — canary session passes through to zen");
397
- return { action: "canary" };
398
- }
399
- if (route !== "failover") return { action: "passthrough" };
400
- } else {
401
- log("debug", "zen refused this session before — failing over to Kilo");
402
- }
403
- const pick = pickFailoverModel();
404
- if (!pick) return { action: "passthrough" };
405
- return { action: "failover", model: pick };
406
- }
407
-
408
- /**
409
- * Extract client IP address from incoming HTTP request.
410
- */
411
- export function getClientIP(req: http.IncomingMessage): string {
412
- const addr = req.socket.remoteAddress;
413
- if (!addr) return "unknown";
414
- return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
415
- }
416
-
417
- /**
418
- * Validate that the request URL matches allowed API path patterns and prevents path traversal.
419
- */
420
- export function validatePath(rawUrl: string): URL | null {
421
- const cleaned = rawUrl.replace(/^\/+/, "");
422
- if (!ALLOWED_PATH_PATTERN.test(`/${cleaned}`)) return null;
423
- if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
424
- try {
425
- const decoded = decodeURIComponent(cleaned);
426
- if (PATH_TRAVERSAL_PATTERN.test(decoded)) return null;
427
- if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`)) {
428
- return null;
429
- }
430
- } catch {
431
- return null;
432
- }
433
- try {
434
- return new URL(cleaned, `${UPSTREAM_OPENCODE}/`);
435
- } catch {
436
- return null;
437
- }
438
- }
439
-
440
- /**
441
- * Sanitize and inject standard headers before forwarding request to upstream.
442
- */
443
- export function sanitizeHeaders(
444
- incoming: http.IncomingHttpHeaders,
445
- targetHost: string,
446
- ): Record<string, string> {
447
- // authorization is deliberately NOT forwarded: the host provider registers
448
- // with a dummy key (placeholder), and every free upstream here is keyless —
449
- // sending that fake key upstream gets 401 Invalid API key. Kilo's own branch
450
- // sends no Authorization either (its gateway rejects a placeholder bearer).
451
- const allowed: Record<string, true> = {
452
- "content-type": true,
453
- accept: true,
454
- "x-request-id": true,
455
- };
456
- const sanitized: Record<string, string> = {};
457
- for (const [key, value] of Object.entries(incoming)) {
458
- const lower = key.toLowerCase();
459
- if (lower.startsWith(":")) continue;
460
- if (!allowed[lower] && !lower.startsWith("x-opencode-")) continue;
461
- if (typeof value === "string") sanitized[lower] = value;
462
- else if (Array.isArray(value)) sanitized[lower] = value.join(", ");
463
- }
464
- sanitized.host = targetHost;
465
- // Drop the client's own user-agent so opencodeHeaders() cannot produce a
466
- // duplicate (case-differing) User-Agent pair — upstream resets connections
467
- // that send two conflicting User-Agent headers.
468
- delete sanitized["user-agent"];
469
- Object.assign(sanitized, opencodeHeaders());
470
- sanitized["accept-encoding"] = "identity";
471
- sanitized.connection = "keep-alive";
472
- return sanitized;
473
- }
474
- export async function isProxyAlive(port: number): Promise<boolean> {
475
- if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
476
- try {
477
- const res = await fetch(`http://${HOST}:${port}/v1/models`, {
478
- signal: AbortSignal.timeout(1500),
479
- });
480
- const ct = res.headers.get("content-type") || "";
481
- return res.ok && ct.includes("application/json");
482
- } catch {
483
- return false;
484
- }
485
- }
486
-
487
- let activeRequests = 0;
488
-
489
- /** Current in-flight proxied requests (guard for stale-daemon replacement). */
490
- export function getActiveRequests(): number {
491
- return activeRequests;
492
- }
493
-
494
- /**
495
- * Fetch the full health snapshot from a running daemon.
496
- * `activeRequests` is undefined on daemons older than the busy-tracking
497
- * feature (1.9.0) — callers treat that as "cannot verify usage".
498
- * `sseDegraded`/`sseRate`/`lastBytesAt` are undefined on daemons older than
499
- * the failed-SSE window — callers treat missing as "not degraded".
500
- */
501
- export async function getDaemonHealth(
502
- port: number,
503
- ): Promise<DaemonHealthSnapshot> {
504
- if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
505
- try {
506
- const res = await fetch(`http://${HOST}:${port}/_health`, {
507
- signal: AbortSignal.timeout(2500),
508
- });
509
- if (!res.ok) return null;
510
- const data: unknown = await res.json();
511
- if (data && typeof data === "object") {
512
- const v =
513
- "version" in data && typeof data.version === "string" && data.version
514
- ? data.version
515
- : "";
516
- const ar = "activeRequests" in data ? data.activeRequests : undefined;
517
- const sr = "sseRate" in data ? data.sseRate : undefined;
518
- const sd = "sseDegraded" in data ? data.sseDegraded : undefined;
519
- const lb = "lastBytesAt" in data ? data.lastBytesAt : undefined;
520
- return {
521
- version: v,
522
- activeRequests: typeof ar === "number" ? ar : undefined,
523
- sseRate: typeof sr === "number" ? sr : undefined,
524
- sseDegraded: typeof sd === "boolean" ? sd : undefined,
525
- lastBytesAt: typeof lb === "number" ? lb : undefined,
526
- };
527
- }
528
- return null;
529
- } catch {
530
- return null;
531
- }
532
- }
533
- /** Version of the daemon on `port`; "" when alive but pre-version-field, null when not alive. */
534
- export async function getDaemonVersion(port: number): Promise<string | null> {
535
- const h = await getDaemonHealth(port);
536
- return h ? h.version : null;
537
- }
538
-
539
- /**
540
- * Linux fallback when neither lsof nor fuser exists: resolve the LISTEN
541
- * socket inode for `port` via /proc/net/tcp{,6}, then find the owning pid by
542
- * scanning /proc fds. Dependency-free, same-user only, never self-kills.
543
- */
544
- function killViaProcNet(port: number): boolean {
545
- if (process.platform !== "linux") return false;
546
- try {
547
- const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
548
- const inodes = new Set<string>();
549
- for (const table of ["/proc/net/tcp", "/proc/net/tcp6"]) {
550
- let text = "";
551
- try {
552
- text = fs.readFileSync(table, "utf8");
553
- } catch {
554
- continue;
555
- }
556
- for (const line of text.split("\n").slice(1)) {
557
- const cols = line.trim().split(/\s+/);
558
- if (cols.length < 10) continue;
559
- const addr = cols[1].split(":");
560
- if (addr.length !== 2 || addr[1].toUpperCase() !== hexPort) continue;
561
- if (cols[3] !== "0A") continue; // LISTEN only
562
- inodes.add(cols[9]);
563
- }
564
- }
565
- if (inodes.size === 0) return false;
566
- for (const pid of fs.readdirSync("/proc")) {
567
- if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue;
568
- let fds: string[];
569
- try {
570
- fds = fs.readdirSync(`/proc/${pid}/fd`);
571
- } catch {
572
- continue;
573
- }
574
- for (const fd of fds) {
575
- let link = "";
576
- try {
577
- link = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
578
- } catch {
579
- continue;
580
- }
581
- const sock = link.match(/^socket:\[(\d+)\]$/);
582
- if (sock && inodes.has(sock[1])) {
583
- try {
584
- process.kill(Number(pid), "SIGKILL");
585
- return true;
586
- } catch {
587
- return false;
588
- }
589
- }
590
- }
591
- }
592
- } catch { }
593
- return false;
594
- }
595
-
596
- export async function killPortHolder(port: number): Promise<boolean> {
597
- if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
598
- try {
599
- if (process.platform === "win32") {
600
- try {
601
- const out = execSync(`netstat -ano | findstr :${port}`, {
602
- encoding: "utf8",
603
- timeout: 3000,
604
- windowsHide: true,
605
- }) as string;
606
- for (const line of out.split("\n")) {
607
- if (!line.includes("LISTENING")) continue;
608
- const parts = line.trim().split(/\s+/);
609
- // Match the port on the Local Address column only — a raw
610
- // substring match can hit longer ports (:2818 vs :28180).
611
- if (!parts[1] || !parts[1].endsWith(`:${port}`)) continue;
612
- const pid = parts[parts.length - 1];
613
- if (!pid || !/^\d+$/.test(pid)) continue;
614
- // Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
615
- if (Number(pid) === process.pid) continue; // never self-kill
616
- execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
617
- return true;
618
- }
619
- } catch { }
620
- return false;
621
- }
622
- try {
623
- const out = execSync(`lsof -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
624
- encoding: "utf8",
625
- timeout: 3000,
626
- }) as string;
627
- const pids = out.trim().split(/\s+/).filter((p) => /^\d+$/.test(p) && Number(p) !== process.pid);
628
- // lsof matches both ends of a connection: our own recent probe socket
629
- // (TIME_WAIT) can sort first, so skip self instead of taking [0].
630
- if (pids.length === 0) return killViaProcNet(port);
631
- execSync(`kill -9 ${pids[0]}`, { timeout: 3000, stdio: "ignore" });
632
- return true;
633
- } catch {
634
- return killViaProcNet(port);
635
- }
636
- } catch {
637
- return false;
638
- }
639
- }
640
-
641
- /**
642
- * Loopback-only client lease + control endpoints used by the detached-daemon
643
- * protocol (src/client.ts). Returns false when the request is not a control
644
- * endpoint (caller continues). Control writes are async (chunked JSON).
645
- */
646
- export function handleControlRequest(
647
- req: http.IncomingMessage,
648
- res: http.ServerResponse,
649
- reqPathname: string | null,
650
- closeServer: () => void,
651
- ): boolean {
652
- if (reqPathname === null) return false;
653
- const isShutdown = reqPathname === "/_shutdown";
654
- const isClientRoute = reqPathname.startsWith("/_client/");
655
- if (!isShutdown && !isClientRoute) return false;
656
- const clientIP = req.socket.remoteAddress ?? "";
657
- if (!isLoopbackIP(clientIP)) {
658
- res.writeHead(403, { "content-type": "application/json" });
659
- res.end(JSON.stringify({ error: "loopback only" }));
660
- return true;
661
- }
662
- if (isShutdown) {
663
- log("info", "control /_shutdown received — closing proxy");
664
- res.writeHead(200, { "content-type": "application/json" });
665
- res.end(JSON.stringify({ ok: true }));
666
- setTimeout(() => {
667
- try { closeServer(); } catch { }
668
- if (shutdownShouldExit) process.exit(0);
669
- }, 50);
670
- return true;
671
- }
672
- const chunks: Buffer[] = [];
673
- let total = 0;
674
- req.on("data", (c: Buffer) => {
675
- total += c.length;
676
- if (total > 4096) { req.destroy(); return; }
677
- chunks.push(c);
678
- });
679
- req.on("end", () => {
680
- let clientId = "";
681
- try {
682
- const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
683
- if (parsed && typeof parsed === "object" && "id" in parsed) {
684
- const candidate = parsed.id;
685
- if (typeof candidate === "string" && candidate) clientId = candidate;
686
- }
687
- } catch { }
688
- if (!clientId) {
689
- res.writeHead(400, { "content-type": "application/json" });
690
- res.end(JSON.stringify({ error: "missing client id" }));
691
- return;
692
- }
693
- if (reqPathname === "/_client/attach") registerClient(clientId);
694
- else if (reqPathname === "/_client/heartbeat") {
695
- // Unknown id = daemon restarted since attach. Report ok:false so the
696
- // client re-attaches instead of heartbeating lease-less.
697
- if (!renewClient(clientId)) {
698
- res.writeHead(200, { "content-type": "application/json" });
699
- res.end(JSON.stringify({ ok: false }));
700
- return;
701
- }
702
- } else if (reqPathname === "/_client/detach") unregisterClient(clientId);
703
- else {
704
- res.writeHead(404, { "content-type": "application/json" });
705
- res.end(JSON.stringify({ error: "not found" }));
706
- return;
707
- }
708
- res.writeHead(200, { "content-type": "application/json" });
709
- res.end(JSON.stringify({ ok: true }));
710
- });
711
- return true;
712
- }
713
-
714
- /**
715
- * Tagged abort reason for the proxy-internal header-wait timeout.
716
- * relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
717
- * code FF_INTERNAL_ABORT as "our abort, not a relay fault" — so neither
718
- * rolls nor penalizes a healthy relay when the request merely ran slow.
719
- */
720
- function upstreamTimeoutError(): Error & { code: string } {
721
- const err = new Error(`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`) as Error & { code: string };
722
- err.name = "AbortError";
723
- err.code = "FF_INTERNAL_ABORT";
724
- return err;
725
- }
726
-
727
- /**
728
- * Recover from upstream rejecting a replayed reasoning blob as foreign.
729
- *
730
- * Upstream binds each reasoning `encrypted_content` blob to the service
731
- * instance that issued it. When the conversation is later served by a different
732
- * instance the whole replayed history is rejected with a 400, which the relay
733
- * pool never retries (400 is not a roll status) and the host repeats until the
734
- * session dies. Measured on 2026-09-13: same relay returned 200 and 400 one
735
- * second apart, the direct path returned the same 400, and a 45s burst hit ten
736
- * conversations.
737
- *
738
- * The rejected blob cannot be identified from the response, so this drops every
739
- * blob, retries once, and then remembers the hashes of the blobs that were in
740
- * the failing body: later turns drop only those and keep whatever the current
741
- * instance issued, so the conversation keeps its recent reasoning.
742
- *
743
- * The 400 body is inspected through a clone: a successful streaming response
744
- * (the normal path) is never consumed here.
745
- */
746
- async function retryWithoutReasoningEncryption(
747
- response: Response,
748
- sentBody: Buffer,
749
- resend: (body: Buffer) => Promise<Response>,
750
- reqId: string,
751
- conversationKey: string | null,
752
- ): Promise<Response> {
753
- if (response.status !== 400) return response;
754
- if ((response.headers.get("content-type") ?? "").includes("text/event-stream")) {
755
- return response;
756
- }
757
- let errorText: string;
758
- try {
759
- errorText = await response.clone().text();
760
- } catch {
761
- return response;
762
- }
763
- if (!isReasoningCallerMismatch(errorText)) return response;
764
-
765
- const portable = stripReasoningEncryption(sentBody);
766
- if (!portable) {
767
- log(
768
- "warn",
769
- "upstream rejected caller-bound reasoning but the request carries no encrypted_content to strip",
770
- { status: 400 },
771
- reqId,
772
- );
773
- return response;
774
- }
775
-
776
- log(
777
- "warn",
778
- "upstream rejected caller-bound reasoning (blobs issued by another instance); retrying with encrypted_content stripped",
779
- { status: 400, tracked: conversationKey !== null },
780
- reqId,
781
- );
782
- const retried = await resend(portable);
783
- if (retried.ok && conversationKey) {
784
- const remembered = rememberRejectedReasoning(sentBody, conversationKey);
785
- log(
786
- "info",
787
- `conversation will drop ${remembered} rejected reasoning blob(s) on later turns`,
788
- undefined,
789
- reqId,
790
- );
791
- }
792
- return retried;
793
- }
794
-
795
- /**
796
- * Start the local HTTP proxy daemon.
797
- * Implements master/worker single-port reuse: if port 28180 is already held by a live
798
- * parent or sibling OMP session, resolves immediately with { server: null, port: 28180 }.
799
- */
800
- export function startProxy(
801
- overridePort?: number,
802
- ): Promise<{ server: http.Server | null; port: number }> {
803
- const basePort = overridePort ?? PORT;
804
-
805
- const server = http.createServer((req, res) => {
806
- const clientIP = getClientIP(req);
807
- const reqId = randomUUID().slice(0, 8);
808
- if (isDebugEnabled()) {
809
- log(
810
- "debug",
811
- `incoming ${req.method} ${req.url} from ${clientIP}`,
812
- { ip: clientIP, method: req.method, url: req.url },
813
- reqId,
814
- );
815
- }
816
-
817
- if (!ALLOWED_METHODS.has(req.method ?? "")) {
818
- res.writeHead(405, { "content-type": "application/json" });
819
- res.end(JSON.stringify({ error: "method not allowed" }));
820
- return;
821
- }
822
-
823
- if (req.method === "OPTIONS") {
824
- // No CORS headers: loopback proxy is not a cross-origin resource.
825
- // Keep a bare 204 so OPTIONS never reaches validatePath/upstream.
826
- res.writeHead(204);
827
- res.end();
828
- return;
829
- }
830
-
831
- let reqPathname: string | null = null;
832
- try {
833
- reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
834
- } catch { }
835
- if (handleControlRequest(req, res, reqPathname, () => server.close())) return;
836
- // Loopback-only health endpoint — always accessible even when widget hidden
837
- if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
838
- const addr = server.address();
839
- const realPort = addr && typeof addr === "object" ? (addr as { port: number }).port : basePort;
840
- if (handleHealthRequest(req, res, realPort, getActiveRequests())) return;
841
- }
842
- if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
843
- const alive = getAliveCatalog();
844
- const body = JSON.stringify({
845
- object: "list",
846
- data: alive.map((m) => ({
847
- id: m.id,
848
- object: "model",
849
- created: 0,
850
- owned_by: m.source === "kilo" ? "kilocode" : m.source === "cline" ? "clinecode" : "opencode",
851
- })),
852
- });
853
- res.writeHead(200, {
854
- "content-type": "application/json",
855
- "content-length": Buffer.byteLength(body),
856
- });
857
- res.end(body);
858
- return;
859
- }
860
-
861
- const target = validatePath(req.url ?? "/");
862
- if (!target) {
863
- const cleaned = (req.url ?? "/").replace(/^\/+/, "");
864
- if (PATH_TRAVERSAL_PATTERN.test(cleaned)) {
865
- res.writeHead(403, { "content-type": "application/json" });
866
- res.end(JSON.stringify({ error: "forbidden" }));
867
- } else {
868
- res.writeHead(404, { "content-type": "application/json" });
869
- res.end(JSON.stringify({ error: "not found" }));
870
- }
871
- return;
872
- }
873
- touchActivity();
874
- // Buffer request body to inspect model ID for upstream routing
875
- const bodyChunks: Buffer[] = [];
876
- activeRequests += 1;
877
- res.on("close", () => {
878
- activeRequests -= 1;
879
- });
880
-
881
- // Reject oversized bodies before buffering starts: the client declares
882
- // the size in content-length, so no transfer cost is wasted.
883
- const declaredLength = Number(req.headers["content-length"] ?? 0);
884
- if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
885
- res.writeHead(413, { "content-type": "application/json" });
886
- res.end(JSON.stringify({ error: "payload too large" }));
887
- return;
888
- }
889
- let bufferedBytes = 0;
890
- req.on("error", (err) => {
891
- log(
892
- "warn",
893
- "client request error during body buffering",
894
- { error: String(err) },
895
- reqId,
896
- );
897
- if (!res.headersSent) {
898
- res.writeHead(400, { "content-type": "application/json" });
899
- }
900
- res.end(JSON.stringify({ error: "bad request" }));
901
- });
902
-
903
- // Running cap for chunked/undeclared bodies: stop buffering the instant
904
- // the limit is crossed instead of holding the whole payload in memory.
905
- req.on("data", (chunk: Buffer) => {
906
- bufferedBytes += chunk.length;
907
- if (bufferedBytes > MAX_BODY_BYTES) {
908
- req.destroy();
909
- if (!res.headersSent) {
910
- res.writeHead(413, { "content-type": "application/json" });
911
- res.end(JSON.stringify({ error: "payload too large" }));
912
- }
913
- return;
914
- }
915
- bodyChunks.push(chunk);
916
- });
917
-
918
- req.on("end", async () => {
919
- const bodyStr = Buffer.concat(bodyChunks).toString();
920
- let isKilo = false;
921
- let isCline = false;
922
- let parsedBody: Record<string, unknown> | null = null;
923
- let clientRequestedStream = false;
924
-
925
- try {
926
- parsedBody = JSON.parse(bodyStr);
927
- if (typeof parsedBody?.model === "string") {
928
- const canonical = resolveCanonicalModelId(parsedBody.model);
929
- if (isClineModel(canonical)) {
930
- isCline = true;
931
- } else if (KILO_MODEL_IDS.has(canonical)) {
932
- isKilo = true;
933
- }
934
- }
935
- if (parsedBody && typeof parsedBody === "object") {
936
- clientRequestedStream = parsedBody.stream === true;
937
- }
938
- } catch { }
939
-
940
- const sessionKey = sessionKeyOf(parsedBody);
941
- // Zen free-tier gate failover: refused or fresh Zen sessions ride a
942
- // healthy Kilo model on the same wire API via the existing Kilo branch.
943
- // Working sessions pass through untouched; canary sessions pass through
944
- // carrying the recovery probe (their success clears the gate). Responses
945
- // bodies are scrubbed of Zen-bound state (chain pointer, reasoning blobs)
946
- // Kilo cannot honor before the model swap.
947
- const zenFailover = classifyZenFailover(parsedBody, target.pathname);
948
- const isCanary = zenFailover.action === "canary";
949
- if (zenFailover.action === "failover" && parsedBody) {
950
- const from = String(parsedBody.model);
951
- parsedBody.model = zenFailover.model;
952
- if (target.pathname.endsWith("/responses")) prepareResponsesFailoverBody(parsedBody);
953
- isKilo = true;
954
- log("info", `zen gated — session failed over ${from} -> ${zenFailover.model}`, { model: from, failover: zenFailover.model }, reqId);
955
- }
956
-
957
- // OpenCode Zen free-tier fingerprint: upstream mandates stream: true and the
958
- // placeholder sextet {bash, glob, grep, read, edit, write}. Missing tools (such as on
959
- // subagent or advisor watchdog turns) trigger 403 FreeTierError.
960
- let bodyModified = false;
961
- let callerHadTools = true;
962
- let callerCaseRestore: CaseRestoreMap | undefined;
963
- let callerFindGlob: FindGlobRestore | undefined;
964
- let callerInjected: string[] | undefined;
965
- if (!isKilo && !isCline && parsedBody) {
966
- const fp = enforceOpencodeFingerprint(parsedBody, target.pathname);
967
- callerHadTools = fp.callerHadTools;
968
- callerCaseRestore = fp.caseRestore;
969
- callerFindGlob = fp.findGlob;
970
- callerInjected = fp.injected;
971
- bodyModified = true;
972
- }
973
-
974
- const isStream = clientRequestedStream;
975
-
976
- // Stale-registration guard: each model's declared api decides its upstream
977
- // path — responses models (muse-spark-*) reach /v1/responses and
978
- // anthropic-messages models reach /v1/messages. A request on the wrong path
979
- // means the host still holds a pre-fix provider registration (stale disk
980
- // cache or no restart after upgrade) and upstream answers 500.
981
- if (!isKilo && !isCline && typeof parsedBody?.model === "string") {
982
- const knownDef = MODEL_MAP.get(String(parsedBody.model));
983
- if (target.pathname.endsWith("/chat/completions") && knownDef?.api && knownDef.api !== "openai-completions") {
984
- log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
985
- } else if (target.pathname.endsWith("/messages") && knownDef?.api && knownDef.api !== "anthropic-messages") {
986
- log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
987
- } else if (target.pathname.endsWith("/responses") && knownDef?.api && knownDef.api !== "openai-responses") {
988
- log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
989
- }
990
- }
991
- try {
992
- if (isCline && parsedBody) {
993
- // Cline serves chat completions only, direct (never relay pool, no
994
- // opencode fingerprint). The pool module owns the per-user Bearer
995
- // token: the proxy only passes the body and maps the answer.
996
- await handleClineRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream });
997
- } else if (isKilo && parsedBody) {
998
- // Header-wait timeout + client-disconnect abort: once headers
999
- // arrive the timer is cleared so a long stream is not killed at
1000
- // the timeout ceiling; the stream phase is owned by
1001
- // pipeUpstreamStream and its close handling.
1002
- const kiloController = new AbortController();
1003
- const kiloTimeoutId = setTimeout(
1004
- () => kiloController.abort(upstreamTimeoutError()),
1005
- UPSTREAM_HEADER_TIMEOUT_MS,
1006
- );
1007
- const abortKiloOnClientGone = () => {
1008
- if (!res.writableEnded) kiloController.abort();
1009
- };
1010
- res.once("close", abortKiloOnClientGone);
1011
- req.once("error", abortKiloOnClientGone);
1012
- let response: Response;
1013
- // Kilo serves both wire APIs natively: responses-shaped bodies go to
1014
- // its responses endpoint, chat bodies to chat completions. Sending a
1015
- // responses body to the chat URL (or vice versa) would 400.
1016
- const kiloUrl = target.pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
1017
- try {
1018
- response = await relayFetch(
1019
- kiloUrl,
1020
- {
1021
- method: "POST",
1022
- headers: {
1023
- // Keyless, like Zen: Kilo's gateway rejects a placeholder bearer
1024
- // (live 2026-09-22 — "Bearer kilo-free" answers 401 INVALID_TOKEN,
1025
- // omitting Authorization answers 200 for every free catalog model).
1026
- "Content-Type": "application/json",
1027
- },
1028
- body: JSON.stringify(parsedBody),
1029
- signal: kiloController.signal,
1030
- },
1031
- reqId,
1032
- );
1033
- } finally {
1034
- clearTimeout(kiloTimeoutId);
1035
- res.off("close", abortKiloOnClientGone);
1036
- req.off("error", abortKiloOnClientGone);
1037
- }
1038
- if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
1039
-
1040
- if (isStream && response.ok && response.body) {
1041
- const ct =
1042
- response.headers.get("content-type") || "text/event-stream";
1043
- res.writeHead(response.status, {
1044
- "content-type": ct,
1045
- "cache-control": "no-cache, no-transform",
1046
- connection: "keep-alive",
1047
- "x-accel-buffering": "no",
1048
- });
1049
- // Kilo is fetched directly (not via the relay pool), so pass undefined:
1050
- // attributing kilo-side stream failures to an unrelated opencode relay
1051
- // would mark a healthy relay as failed.
1052
- pipeUpstreamStream(
1053
- Readable.fromWeb(
1054
- response.body as unknown as WebReadableStream,
1055
- ),
1056
- res,
1057
- req,
1058
- reqId,
1059
- undefined,
1060
- );
1061
- } else {
1062
- const data = withRateLimitHint(response.status, await response.text());
1063
- const ct =
1064
- response.headers.get("content-type") || "application/json";
1065
- res.writeHead(response.status, { "content-type": ct });
1066
- res.end(data);
1067
- }
1068
- } else {
1069
- // OpenCode routing — relay egress is for free-catalog models
1070
- // only; unknown/paid models go direct upstream.
1071
- const relayState = getActiveRelayState();
1072
- const relayPoolOn =
1073
- relayState.mode !== "off" &&
1074
- relayState.enabled !== false &&
1075
- Boolean(relayState.url || (relayState.relays && relayState.relays.length > 0));
1076
- const relayEligible = isRelayEligibleModel(parsedBody?.model);
1077
- const shouldUseRelay = relayPoolOn && relayEligible;
1078
- if (relayPoolOn && !relayEligible) {
1079
- log("info", `relay bypass for non-catalog model ${String(parsedBody?.model ?? "?")} — routing direct upstream`, { model: parsedBody?.model }, reqId);
1080
- }
1081
- if (shouldUseRelay) {
1082
- const fullUrl = `${UPSTREAM_OPENCODE}${req.url ?? "/"}`;
1083
- const activeHost = relayState.url
1084
- ? new URL(relayState.url).host
1085
- : "opencode.ai";
1086
- const relayHeaders = sanitizeHeaders(req.headers, activeHost);
1087
-
1088
- try {
1089
- if (parsedBody) {
1090
- const requestBody = bodyModified
1091
- ? Buffer.from(JSON.stringify(parsedBody), "utf8")
1092
- : Buffer.concat(bodyChunks);
1093
- // Reasoning `encrypted_content` is bound to the caller that
1094
- // requested it, and a relay roll changes that caller. A
1095
- // conversation already rejected for foreign blobs keeps its
1096
- // history portable from then on.
1097
- const conversationKey = responsesConversationKey(parsedBody);
1098
- const responsesRequest = target.pathname.endsWith("/responses");
1099
- // Per-conversation affinity: reasoning blobs are only readable
1100
- // by the backend that issued them, so stay on the issuing relay
1101
- // while it is healthy.
1102
- const issuer = responsesRequest && conversationKey !== null
1103
- ? issuerRelayFor(conversationKey)
1104
- : undefined;
1105
- const candidates = orderedRelayCandidates(
1106
- typeof issuer === "string" ? issuer : undefined,
1107
- );
1108
- const servingRelay = candidates[0] ?? null;
1109
- // Affinity cannot be honored (issuer cooling, dropped from the
1110
- // pool, or the path changed relay<->direct): the next backend
1111
- // cannot read this history, so send it portable instead of
1112
- // letting upstream reject the whole request.
1113
- const issuerChanged = issuer !== undefined && issuer !== servingRelay;
1114
- // Drop only the blobs upstream already rejected, so everything
1115
- // the current backend issued still flows verbatim.
1116
- const stripRejected = responsesRequest &&
1117
- conversationKey !== null &&
1118
- rejectedReasoningCount(conversationKey) > 0;
1119
- const bodyForUpstream = issuerChanged
1120
- ? (stripReasoningEncryption(requestBody) ?? requestBody)
1121
- : stripRejected
1122
- ? (stripRejectedReasoning(requestBody, conversationKey) ?? requestBody)
1123
- : requestBody;
1124
- if (issuerChanged && conversationKey !== null) {
1125
- // These blobs belong to a backend this conversation is
1126
- // leaving, so never replay them again.
1127
- rememberRejectedReasoning(requestBody, conversationKey);
1128
- log(
1129
- "info",
1130
- `reasoning issuer changed (${issuer ?? "direct"} -> ${servingRelay ?? "direct"}); sending history without caller-bound reasoning`,
1131
- undefined,
1132
- reqId,
1133
- );
1134
- }
1135
-
1136
- // One attempt, with its own header-wait timeout and
1137
- // client-disconnect abort; the timer is cleared once headers
1138
- // arrive so streams are not killed at the timeout ceiling.
1139
- // Aborts caused by our own timeout are tagged
1140
- // FF_INTERNAL_ABORT so stream-pipe never penalizes the relay.
1141
- let servedIssuer: string | null = null;
1142
- let issuerReported = false;
1143
- const sendViaRelay = async (payload: Buffer): Promise<Response> => {
1144
- const relayController = new AbortController();
1145
- const relayTimeoutId = setTimeout(
1146
- () => relayController.abort(upstreamTimeoutError()),
1147
- UPSTREAM_HEADER_TIMEOUT_MS,
1148
- );
1149
- const abortRelayOnClientGone = () => {
1150
- if (!res.writableEnded) relayController.abort();
1151
- };
1152
- res.once("close", abortRelayOnClientGone);
1153
- req.once("error", abortRelayOnClientGone);
1154
- try {
1155
- return await relayFetch(
1156
- fullUrl,
1157
- {
1158
- method: req.method || "POST",
1159
- headers: relayHeaders,
1160
- body: payload,
1161
- signal: relayController.signal,
1162
- } as unknown as RequestInit,
1163
- reqId,
1164
- {
1165
- preferred: typeof issuer === "string" ? issuer : undefined,
1166
- onServed: (relay) => {
1167
- servedIssuer = relay;
1168
- issuerReported = true;
1169
- },
1170
- },
1171
- );
1172
- } finally {
1173
- clearTimeout(relayTimeoutId);
1174
- res.off("close", abortRelayOnClientGone);
1175
- req.off("error", abortRelayOnClientGone);
1176
- }
1177
- };
1178
-
1179
- let response = await sendViaRelay(bodyForUpstream);
1180
- // Retry must stay available on every responses request: even a
1181
- // proactively stripped body can still carry a blob the current
1182
- // instance cannot read.
1183
- if (responsesRequest) {
1184
- response = await retryWithoutReasoningEncryption(
1185
- response,
1186
- requestBody,
1187
- sendViaRelay,
1188
- reqId,
1189
- conversationKey,
1190
- );
1191
- }
1192
- // Record the backend that actually served this turn. Reading
1193
- // the sticky active relay here would be wrong now that
1194
- // affinity deliberately leaves it untouched.
1195
- if (
1196
- responsesRequest &&
1197
- conversationKey !== null &&
1198
- response.ok &&
1199
- issuerReported
1200
- ) {
1201
- rememberIssuerRelay(conversationKey, servedIssuer);
1202
- }
1203
- // Upstream degradation tracking: successes prove Zen reachable,
1204
- // 403 free-tier gates feed the health state machine (a 403 never
1205
- // rolls — it is a terminal upstream verdict, not a relay fault).
1206
- if (response.ok) {
1207
- recordUpstreamSuccess("zen", { sessionKey, canary: isCanary });
1208
- } else if (response.status === 403 && (response.headers.get("content-type") ?? "").includes("application/json")) {
1209
- try {
1210
- const gateText = await response.clone().text();
1211
- recordUpstreamFailure("zen", 403, gateText);
1212
- rememberGateRejection(parsedBody, target.pathname, 403, gateText);
1213
- } catch { }
1214
- }
1215
-
1216
- if (isStream && response.ok && response.body) {
1217
- const ct =
1218
- response.headers.get("content-type") ||
1219
- "text/event-stream";
1220
- res.writeHead(response.status, {
1221
- "content-type": ct,
1222
- "cache-control": "no-cache, no-transform",
1223
- connection: "keep-alive",
1224
- "x-accel-buffering": "no",
1225
- });
1226
- pipeUpstreamStream(
1227
- Readable.fromWeb(
1228
- response.body as unknown as WebReadableStream,
1229
- ),
1230
- res,
1231
- req,
1232
- reqId,
1233
- relayState.url,
1234
- );
1235
- } else {
1236
- if (!response.ok) {
1237
- log("warn", `upstream ${response.status} for model ${String((parsedBody as Record<string, unknown> | null)?.model ?? "?")} via relay`, { status: response.status, model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url }, reqId);
1238
- }
1239
- let rawText = await response.text();
1240
- let ct =
1241
- response.headers.get("content-type") ||
1242
- "application/json";
1243
- if (response.ok && (ct.includes("text/event-stream") || rawText.includes("data:"))) {
1244
- rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected);
1245
- ct = "application/json";
1246
- }
1247
- const data = withFreeTierHint(response.status, withRateLimitHint(response.status, rawText));
1248
- res.writeHead(response.status, { "content-type": ct });
1249
- res.end(data);
1250
- }
1251
- return; // relay handled successfully
1252
- }
1253
- } catch (e) {
1254
- log(
1255
- "warn",
1256
- "opencode relay failed, falling back to direct upstream",
1257
- { error: String(e) },
1258
- reqId,
1259
- );
1260
- if (res.headersSent) return; // cannot recover mid-stream
1261
- }
1262
- }
1263
-
1264
- // Direct path — send the fingerprinted body if modified, else raw bytes
1265
- const directBody = bodyModified
1266
- ? Buffer.from(JSON.stringify(parsedBody), "utf8")
1267
- : Buffer.concat(bodyChunks);
1268
-
1269
- // Same backend-bound blobs as the relay path: leaving the issuing
1270
- // relay for the direct route (or vice versa) makes the history
1271
- // unreadable, so send it portable instead.
1272
- const directConversationKey = responsesConversationKey(parsedBody);
1273
- const directResponsesRequest = target.pathname.endsWith("/responses");
1274
- const directIssuer = directResponsesRequest && directConversationKey !== null
1275
- ? issuerRelayFor(directConversationKey)
1276
- : undefined;
1277
- const directIssuerChanged = directIssuer !== undefined && directIssuer !== null;
1278
- const directStripRejected = directResponsesRequest &&
1279
- directConversationKey !== null &&
1280
- rejectedReasoningCount(directConversationKey) > 0;
1281
- const directBodyForUpstream = directIssuerChanged
1282
- ? (stripReasoningEncryption(directBody) ?? directBody)
1283
- : directStripRejected
1284
- ? (stripRejectedReasoning(directBody, directConversationKey) ?? directBody)
1285
- : directBody;
1286
- if (directIssuerChanged && directConversationKey !== null) {
1287
- rememberRejectedReasoning(directBody, directConversationKey);
1288
- log(
1289
- "info",
1290
- `reasoning issuer changed (${directIssuer} -> direct); sending history without caller-bound reasoning`,
1291
- undefined,
1292
- reqId,
1293
- );
1294
- }
1295
-
1296
- if (isDebugEnabled()) {
1297
- log(
1298
- "debug",
1299
- `direct upstream ${target.hostname}${target.pathname} (${directBody.length}B)`,
1300
- { model: parsedBody?.model, isKilo },
1301
- reqId,
1302
- );
1303
- }
1304
-
1305
- const fwd = sanitizeHeaders(req.headers, target.hostname);
1306
- fwd["connection"] = "keep-alive";
1307
-
1308
- const sendDirect = async (payload: Buffer): Promise<Response> => {
1309
- const headers = { ...fwd };
1310
- if (payload.length > 0) {
1311
- headers["content-length"] = String(payload.byteLength);
1312
- }
1313
- const controller = new AbortController();
1314
- const timeoutId = setTimeout(() => controller.abort(upstreamTimeoutError()), UPSTREAM_HEADER_TIMEOUT_MS);
1315
- const onClientClose = () => {
1316
- if (!res.writableEnded) controller.abort();
1317
- };
1318
- const onReqError = () => controller.abort();
1319
- res.on("close", onClientClose);
1320
- req.on("error", onReqError);
1321
- try {
1322
- return await fetch(target.href, {
1323
- method: req.method,
1324
- headers,
1325
- body: payload.length > 0 ? payload : undefined,
1326
- signal: controller.signal,
1327
- } as unknown as RequestInit);
1328
- } finally {
1329
- clearTimeout(timeoutId);
1330
- res.off("close", onClientClose);
1331
- req.off("error", onReqError);
1332
- }
1333
- };
1334
-
1335
- try {
1336
- let upstreamRes = await sendDirect(directBodyForUpstream);
1337
- if (directResponsesRequest) {
1338
- upstreamRes = await retryWithoutReasoningEncryption(
1339
- upstreamRes,
1340
- directBody,
1341
- sendDirect,
1342
- reqId,
1343
- directConversationKey,
1344
- );
1345
- }
1346
- // The direct route is its own backend: remember it so a later
1347
- // switch back to relays ships a portable history.
1348
- if (directResponsesRequest && directConversationKey !== null && upstreamRes.ok) {
1349
- rememberIssuerRelay(directConversationKey, null);
1350
- }
1351
- if (upstreamRes.status >= 400) {
1352
- log("warn", `direct upstream ${upstreamRes.status} for model ${String(parsedBody?.model ?? "?")} ${target.pathname}`, { status: upstreamRes.status, model: parsedBody?.model, path: target.pathname }, reqId);
1353
- }
1354
- // Upstream degradation tracking, mirroring the relay path: successes
1355
- // prove Zen reachable, 403 free-tier gates feed the health machine.
1356
- if (upstreamRes.ok) {
1357
- recordUpstreamSuccess("zen", { sessionKey, canary: isCanary });
1358
- } else if (upstreamRes.status === 403 && (upstreamRes.headers.get("content-type") ?? "").includes("application/json")) {
1359
- try {
1360
- const gateText = await upstreamRes.clone().text();
1361
- recordUpstreamFailure("zen", 403, gateText);
1362
- rememberGateRejection(parsedBody, target.pathname, 403, gateText);
1363
- } catch { }
1364
- }
1365
-
1366
- // Natural 429: every relay plus direct is rate-limited — buffer the
1367
- // JSON error and attach the deploy hint instead of piping it
1368
- // through as a stream body.
1369
- if (upstreamRes.status === 429) {
1370
- const data = withRateLimitHint(429, await upstreamRes.text());
1371
- const ct429 = upstreamRes.headers.get("content-type") || "application/json";
1372
- res.writeHead(429, { "content-type": ct429 });
1373
- res.end(data);
1374
- return;
1375
- }
1376
- // Gated upstream: a 403 free-tier gate is a final answer, not a
1377
- // stream — buffer it and attach the recovery hint instead of
1378
- // piping it through as a stream body.
1379
- if (upstreamRes.status === 403) {
1380
- const data = withFreeTierHint(403, await upstreamRes.text());
1381
- const ct403 = upstreamRes.headers.get("content-type") || "application/json";
1382
- res.writeHead(403, { "content-type": ct403 });
1383
- res.end(data);
1384
- return;
1385
- }
1386
-
1387
- const outHeaders: Record<string, string> = {};
1388
- for (const h of ["content-type", "cache-control", "x-request-id"] as const) {
1389
- const v = upstreamRes.headers.get(h);
1390
- if (v) outHeaders[h] = v;
1391
- }
1392
- outHeaders["x-content-type-options"] = "nosniff";
1393
- outHeaders["connection"] = "keep-alive";
1394
- const ka = upstreamRes.headers.get("keep-alive");
1395
- if (ka) outHeaders["keep-alive"] = ka;
1396
-
1397
- if (isStream && upstreamRes.body) {
1398
- res.writeHead(upstreamRes.status, outHeaders);
1399
- pipeUpstreamStream(
1400
- Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
1401
- res,
1402
- req,
1403
- reqId,
1404
- "direct",
1405
- );
1406
- } else if (upstreamRes.body) {
1407
- let rawText = await upstreamRes.text();
1408
- if (upstreamRes.ok && (outHeaders["content-type"]?.includes("text/event-stream") || rawText.includes("data:"))) {
1409
- rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected);
1410
- outHeaders["content-type"] = "application/json";
1411
- }
1412
- res.writeHead(upstreamRes.status, outHeaders);
1413
- res.end(rawText);
1414
- } else {
1415
- res.writeHead(upstreamRes.status, outHeaders);
1416
- res.end();
1417
- }
1418
- } catch (proxyErr) {
1419
- log("error", "proxy socket error", { error: String(proxyErr) }, reqId);
1420
- if (!res.headersSent) {
1421
- res.writeHead(502, { "content-type": "application/json" });
1422
- res.end(JSON.stringify({ error: "upstream error" }));
1423
- } else if (!res.writableEnded) {
1424
- res.end();
1425
- }
1426
- }
1427
- }
1428
- } catch (err) {
1429
- log("error", "proxy error", { error: String(err) }, reqId);
1430
- if (!res.headersSent) {
1431
- res.writeHead(502, { "content-type": "application/json" });
1432
- }
1433
- res.end(JSON.stringify({ error: "internal error" }));
1434
- }
1435
- });
1436
- });
1437
-
1438
- return new Promise<{ server: http.Server | null; port: number }>(
1439
- (resolve, reject) => {
1440
- let attempt = 0;
1441
- let settled = false;
1442
-
1443
- const tryListen = async (port: number) => {
1444
- server.removeAllListeners("error");
1445
- server.once("error", async (err: NodeJS.ErrnoException) => {
1446
- if (settled) return;
1447
- if (err.code === "EADDRINUSE") {
1448
- // Cold-start race: a sibling may be binding the base port
1449
- // right now. Re-probe it for up to 3s before accepting a
1450
- // walked port so parallel startups converge on :28180.
1451
- if (await reprobeBasePortAlive(basePort, BASE_PORT_REPROBE_MS)) {
1452
- settled = true;
1453
- log(
1454
- "info",
1455
- `attached to running proxy on http://${HOST}:${basePort}`,
1456
- );
1457
- resolve({ server: null, port: basePort });
1458
- return;
1459
- }
1460
- if (attempt < 20) {
1461
- attempt++;
1462
- log("warn", `port ${port} taken — trying ${port + 1}`);
1463
- tryListen(port + 1);
1464
- return;
1465
- }
1466
- }
1467
- settled = true;
1468
- log("error", "server error", { code: err.code, message: err.message });
1469
- reject(err);
1470
- });
1471
- server.listen(port, HOST, () => {
1472
- if (settled) return;
1473
- settled = true;
1474
- try {
1475
- server.unref();
1476
- } catch { }
1477
- const addr = server.address();
1478
- const realPort = addr && typeof addr === "object" ? addr.port : port;
1479
- if (realPort !== basePort) {
1480
- log("warn", `base port ${basePort} busy — proxy listening on walked port http://${HOST}:${realPort}`);
1481
- } else {
1482
- log("info", `proxy listening on http://${HOST}:${realPort}`);
1483
- }
1484
- resolve({ server, port: realPort });
1485
- });
1486
- };
1487
-
1488
- tryListen(basePort);
1489
- },
1490
- );
1491
- }
1492
-
1493
- /**
1494
- * Poll the base port until it answers or the budget expires. Returns true as
1495
- * soon as the port is alive (caller attaches instead of walking).
1496
- */
1497
- export async function reprobeBasePortAlive(port: number, timeoutMs: number): Promise<boolean> {
1498
- const deadline = Date.now() + Math.max(0, timeoutMs);
1499
- for (; ;) {
1500
- if (await isProxyAlive(port)) return true;
1501
- if (Date.now() >= deadline) return false;
1502
- await new Promise<void>((r) => setTimeout(r, 200));
1503
- }
1504
- }
1
+ /**
2
+ * Single-port local HTTP proxy and dynamic upstream router for pi-freeflow
3
+ *
4
+ * Provides loopback proxying on port 28180 (shared across parent and subagents),
5
+ * intelligent routing to OpenCode Zen and KiloCode Gateway, and failover support.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import fs from "node:fs";
10
+ import { execSync } from "node:child_process";
11
+ import * as http from "node:http";
12
+ import * as https from "node:https";
13
+ import { handleHealthRequest, isLoopbackIP } from "./health.ts";
14
+ import type { DaemonHealthSnapshot } from "./health.ts";
15
+ import { registerClient, renewClient, touchActivity, unregisterClient } from "./lease.ts";
16
+ import { Readable } from "node:stream";
17
+ import type { ReadableStream as WebReadableStream } from "node:stream/web";
18
+
19
+ import { getAliveCatalog } from "./catalog.ts";
20
+ import {
21
+ ALLOWED_METHODS,
22
+ ALLOWED_PATH_PATTERN,
23
+ BASE_PORT_REPROBE_MS,
24
+ CLINE_CHAT_URL,
25
+ HOST,
26
+ KILO_CHAT_URL,
27
+ KILO_RESPONSES_URL,
28
+ PATH_TRAVERSAL_PATTERN,
29
+ MAX_BODY_BYTES,
30
+ PORT,
31
+ UPSTREAM_HEADER_TIMEOUT_MS,
32
+ UPSTREAM_OPENCODE,
33
+ opencodeHeaders,
34
+ } from "./config.ts";
35
+
36
+ import {
37
+ convertSseToJson,
38
+ enforceOpencodeFingerprint,
39
+ sseToChatCompletionJson,
40
+ type CaseRestoreMap,
41
+ type FindGlobRestore,
42
+ } from "./opencode-fingerprint.ts";
43
+ import { isDebugEnabled, log } from "./logger.ts";
44
+ import { getModelUpstream, isClineModel, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
45
+ import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
46
+ import { rollChat } from "./cline-accounts.ts";
47
+ import { fetchWithSystemCA } from "./system-ca-fetch.ts";
48
+ import {
49
+ chatResponsesJsonFromChatCompletion,
50
+ chatResponsesSseFromChatCompletion,
51
+ clineChatBodyFromResponsesBody,
52
+ translateToolsForPath,
53
+ } from "./tool-translation.ts";
54
+ // normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
55
+ import { relayFetch } from "./relay.ts";
56
+ import { getActiveRelayState, orderedRelayCandidates } from "./relay-state.ts";
57
+ import {
58
+ issuerRelayFor,
59
+ prepareResponsesFailoverBody,
60
+ rejectedReasoningCount,
61
+ rememberIssuerRelay,
62
+ rememberRejectedReasoning,
63
+ responsesConversationKey,
64
+ stripRejectedReasoning,
65
+ stripReasoningEncryption,
66
+ isReasoningCallerMismatch,
67
+ } from "./responses.ts";
68
+ import { pipeUpstreamStream } from "./stream-pipe.ts";
69
+ import {
70
+ decideZenRoute,
71
+ isUnprovenSession,
72
+ isUpstreamGated,
73
+ pickFailoverModel,
74
+ recordUpstreamFailure,
75
+ recordUpstreamSuccess,
76
+ rememberGateRejection,
77
+ sessionKeyOf,
78
+ wasGateRejected,
79
+ withFreeTierHint,
80
+ attachHintForDisplay,
81
+ } from "./upstream-health.ts";
82
+
83
+
84
+ let shutdownShouldExit = false;
85
+ export function setShutdownShouldExit(v: boolean): void {
86
+ shutdownShouldExit = v;
87
+ }
88
+
89
+ /**
90
+ * Natural-429 hint throttle: the deploy guidance hint is attached to upstream
91
+ * 429 passthroughs at most once per 10 minutes per process so repeated
92
+ * rate-limit responses don't spam clients.
93
+ */
94
+ let last429HintAt = 0;
95
+ function shouldShow429Hint(): boolean {
96
+ const now = Date.now();
97
+ if (now - last429HintAt < 10 * 60 * 1000) return false;
98
+ last429HintAt = now;
99
+ return true;
100
+ }
101
+ /** Test-only: reset 429 hint throttle */
102
+ export function _reset429HintForTest(): void { last429HintAt = 0; }
103
+
104
+ /** Deploy guidance attached to a natural upstream 429 once the throttle allows. */
105
+ const RATE_LIMIT_HINT =
106
+ "Shared free-tier IP quota reached. Add your own relay egress: /freeflow deploy (Vercel 1M/mo recommended)";
107
+
108
+ /**
109
+ * Attach the deploy hint to a natural upstream 429 JSON body. Anything else —
110
+ * non-429 statuses, non-JSON bodies — passes through untouched without
111
+ * consuming the throttle slot.
112
+ */
113
+ function withRateLimitHint(status: number, data: string): string {
114
+ if (status !== 429) return data;
115
+ try {
116
+ const parsed: unknown = JSON.parse(data);
117
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && shouldShow429Hint()) {
118
+ return JSON.stringify(attachHintForDisplay(parsed as Record<string, unknown>, RATE_LIMIT_HINT));
119
+ }
120
+ } catch { }
121
+ return data;
122
+ }
123
+ /** Cline marker errors: each status carries account-actionable guidance. */
124
+ const CLINE_RATE_LIMIT_HINT =
125
+ "Cline free-use limit reached for this login. Wait for the reset or switch models.";
126
+ const CLINE_FORBIDDEN_HINT =
127
+ "Cline refused this request. Check the Cline login and retry.";
128
+ const CLINE_AUTH_HINT =
129
+ "Cline login expired or missing. Reconnect the Cline login and retry.";
130
+ const CLINE_MODEL_HINT =
131
+ "Cline has no such model. Refresh the model list and retry.";
132
+
133
+ /**
134
+ * What the roll knows about a free-limit exhaustion, for the 429 hint: how many
135
+ * saved logins answered the cap and the nearest reset any of them stated.
136
+ */
137
+ export interface ClineLimitHint {
138
+ logins: number;
139
+ /** Epoch ms, or null when no login's body stated a reset. */
140
+ resetAt: number | null;
141
+ }
142
+
143
+ /** Coarse reset delay ("20h 4m", "35m") — the hint names what the body stated. */
144
+ function formatResetDelay(resetAt: number): string {
145
+ const minutes = Math.max(1, Math.round((resetAt - Date.now()) / 60_000));
146
+ const hours = Math.floor(minutes / 60);
147
+ const rest = minutes % 60;
148
+ if (hours === 0) return `${rest}m`;
149
+ return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
150
+ }
151
+
152
+ /** Every saved login is out of free use for this model: say what helps next. */
153
+ function clineAllLimitedHint(limit: ClineLimitHint): string {
154
+ const reset = limit.resetAt === null ? "" : ` Nearest reset in about ${formatResetDelay(limit.resetAt)}.`;
155
+ return `Cline's daily free limit is used up on all ${limit.logins} saved logins.${reset} Switch models, or add another login with /freeflow cline login.`;
156
+ }
157
+
158
+ /**
159
+ * Attach the matching hint to a Cline marker error JSON body (free-limit
160
+ * 429, 403, 401, model 404). Anything else — other statuses, non-JSON
161
+ * bodies — passes through untouched.
162
+ *
163
+ * `limit` is present only when the roll tried every saved login and every one
164
+ * of them answered the daily free cap; then "for this login" would be wrong and
165
+ * hide that another login is the way out.
166
+ */
167
+ export function mapClineError(status: number, data: string, limit?: ClineLimitHint): string {
168
+ // Synthetic pool-exhausted bodies already tell the user exactly what to do
169
+ // (sign in again) — a rate-limit hint would mislead.
170
+ if (data.includes("cline_pool_exhausted")) return data;
171
+ const hint = status === 429
172
+ ? (limit ? clineAllLimitedHint(limit) : CLINE_RATE_LIMIT_HINT)
173
+ : status === 403
174
+ ? CLINE_FORBIDDEN_HINT
175
+ : status === 401
176
+ ? CLINE_AUTH_HINT
177
+ : status === 404
178
+ ? CLINE_MODEL_HINT
179
+ : null;
180
+ if (hint === null) return data;
181
+ try {
182
+ const parsed: unknown = JSON.parse(data);
183
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
184
+ // Hosts render error.message and ignore the sibling hint field, so the
185
+ // guidance must live in the visible message too (hint kept for compat).
186
+ return JSON.stringify(attachHintForDisplay(parsed as Record<string, unknown>, hint));
187
+ }
188
+ } catch { }
189
+ return data;
190
+ }
191
+ /**
192
+ * Device-login refresher for the Cline pool: one refresh per stale slot per
193
+ * turn. A grant Cline rejects outright (HTTP 4xx, or a 200 without usable
194
+ * tokens) is dead — it resolves null so the pool stops trying that slot until
195
+ * the user logs in again. Anything else (network fault, upstream 5xx) throws,
196
+ * which keeps the stale bearer for this turn.
197
+ */
198
+ async function clineRefreshImpl(refreshToken: string): Promise<{ token: string; refreshToken?: string; expiresAt?: number } | null> {
199
+ try {
200
+ const creds = await refreshClineToken(undefined, refreshToken);
201
+ return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
202
+ } catch (e) {
203
+ if (e instanceof ClineAuthError && typeof e.status === "number" && e.status >= 400 && e.status < 500) {
204
+ log("warn", "cline refresh grant rejected — slot needs a fresh login", { status: e.status });
205
+ return null;
206
+ }
207
+ if (e instanceof Error && /^Invalid token response|^Token response did not include a refresh token/.test(e.message)) {
208
+ log("warn", "cline refresh returned no usable tokens — slot needs a fresh login", { reason: e.message });
209
+ return null;
210
+ }
211
+ throw e;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Serve a Cline-model request direct (never relay pool, no opencode
217
+ * fingerprint). Cline serves chat completions only: responses-path bodies
218
+ * translate to chat upstream and the chat answer translates back, re-emitted
219
+ * as SSE when the caller streamed. Upstream always streams; callers that
220
+ * asked for plain JSON get the aggregated object.
221
+ */
222
+ async function handleClineRequest(opts: {
223
+ parsedBody: Record<string, unknown>;
224
+ pathname: string;
225
+ req: http.IncomingMessage;
226
+ res: http.ServerResponse;
227
+ reqId: string;
228
+ clientRequestedStream: boolean;
229
+ }): Promise<void> {
230
+ const { parsedBody, pathname, req, res, reqId, clientRequestedStream } = opts;
231
+ const responsesRequest = pathname.endsWith("/responses");
232
+ const model = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
233
+ let chatBody: Record<string, unknown>;
234
+ if (responsesRequest) {
235
+ chatBody = clineChatBodyFromResponsesBody(parsedBody);
236
+ } else {
237
+ chatBody = { ...parsedBody };
238
+ if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
239
+ delete chatBody.prompt_cache_key;
240
+ }
241
+ chatBody.stream = true;
242
+ let upstreamRes: Response;
243
+ let limitHint: ClineLimitHint | undefined;
244
+ try {
245
+ const result = await rollChat({ body: JSON.stringify(chatBody), chatUrl: CLINE_CHAT_URL, refreshImpl: clineRefreshImpl, fetchImpl: fetchWithSystemCA });
246
+ upstreamRes = result.res;
247
+ // The roll returns the last upstream failure when the pool is exhausted,
248
+ // so "served by" must only log on an actual success.
249
+ if (upstreamRes.ok && typeof result.slot === "string" && result.slot.length > 0) {
250
+ log("debug", `cline served by slot ${result.slot}`, { model }, reqId);
251
+ }
252
+ // Every saved login answered the daily free cap: name that instead of
253
+ // blaming "this login", which hides that another login or another model works.
254
+ if (result.limitOnly && result.logins > 1) {
255
+ limitHint = { logins: result.logins, resetAt: result.earliestResetAt };
256
+ }
257
+ } catch (e) {
258
+ log("error", "cline pool error", { error: String(e), model }, reqId);
259
+ if (!res.headersSent) {
260
+ res.writeHead(502, { "content-type": "application/json" });
261
+ res.end(JSON.stringify({ error: "upstream error" }));
262
+ }
263
+ return;
264
+ }
265
+ if (res.writableEnded) {
266
+ try { await upstreamRes.body?.cancel(); } catch { }
267
+ return;
268
+ }
269
+ if (!upstreamRes.ok) {
270
+ log("warn", `cline upstream ${upstreamRes.status} for model ${model}`, { status: upstreamRes.status, model }, reqId);
271
+ const raw = await upstreamRes.text().catch(() => null);
272
+ if (raw === null) {
273
+ log("error", "cline upstream body unreadable", { status: upstreamRes.status, model }, reqId);
274
+ if (!res.headersSent) {
275
+ res.writeHead(502, { "content-type": "application/json" });
276
+ res.end(JSON.stringify({ error: "upstream error" }));
277
+ } else if (!res.writableEnded) {
278
+ res.end();
279
+ }
280
+ return;
281
+ }
282
+ const data = mapClineError(upstreamRes.status, raw, limitHint);
283
+ if (!res.headersSent) {
284
+ res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
285
+ res.end(data);
286
+ } else if (!res.writableEnded) {
287
+ res.end();
288
+ }
289
+ return;
290
+ }
291
+ if (!responsesRequest && clientRequestedStream && upstreamRes.body) {
292
+ res.writeHead(upstreamRes.status, {
293
+ "content-type": upstreamRes.headers.get("content-type") || "text/event-stream",
294
+ "cache-control": "no-cache, no-transform",
295
+ connection: "keep-alive",
296
+ "x-accel-buffering": "no",
297
+ });
298
+ pipeUpstreamStream(
299
+ Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
300
+ res,
301
+ req,
302
+ reqId,
303
+ "direct",
304
+ );
305
+ return;
306
+ }
307
+ const sseText = await upstreamRes.text().catch(() => null);
308
+ if (sseText === null) {
309
+ log("error", "cline upstream stream unreadable", { status: upstreamRes.status, model }, reqId);
310
+ if (!res.headersSent) {
311
+ res.writeHead(502, { "content-type": "application/json" });
312
+ res.end(JSON.stringify({ error: "upstream error" }));
313
+ } else if (!res.writableEnded) {
314
+ res.end();
315
+ }
316
+ return;
317
+ }
318
+ if (!responsesRequest) {
319
+ const data = convertSseToJson(sseText, pathname);
320
+ if (!res.headersSent) {
321
+ res.writeHead(upstreamRes.status, { "content-type": "application/json" });
322
+ res.end(data);
323
+ } else if (!res.writableEnded) {
324
+ res.end();
325
+ }
326
+ return;
327
+ }
328
+ const chat = sseToChatCompletionJson(sseText);
329
+ const resp = chatResponsesJsonFromChatCompletion(chat, model);
330
+ if (clientRequestedStream) {
331
+ if (!res.headersSent) {
332
+ res.writeHead(200, {
333
+ "content-type": "text/event-stream",
334
+ "cache-control": "no-cache, no-transform",
335
+ connection: "keep-alive",
336
+ "x-accel-buffering": "no",
337
+ });
338
+ res.end(chatResponsesSseFromChatCompletion(resp));
339
+ } else if (!res.writableEnded) {
340
+ res.end();
341
+ }
342
+ return;
343
+ }
344
+ if (!res.headersSent) {
345
+ res.writeHead(200, { "content-type": "application/json" });
346
+ res.end(JSON.stringify(resp));
347
+ } else if (!res.writableEnded) {
348
+ res.end();
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Relay eligibility: only free-catalog models ride relay egress. Returns true
354
+ * when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
355
+ * (Kilo traffic leaves via its own branch regardless), and when the model is
356
+ * missing/empty (e.g. GET /v1/models carries no body — preserve routing).
357
+ * Cline models are direct-only and never relay-eligible. Anything else
358
+ * (unknown/paid model ids, non-strings) goes direct upstream.
359
+ */
360
+ export function isRelayEligibleModel(model: unknown): boolean {
361
+ if (model === undefined || model === null) return true;
362
+ if (typeof model !== "string") return false;
363
+ if (model.trim() === "") return true;
364
+ const canonical = resolveCanonicalModelId(model.trim());
365
+ if (isClineModel(canonical)) return false;
366
+ return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical);
367
+ }
368
+
369
+ /**
370
+ * Failing-over decision for a canonicalized request body. Returns failover
371
+ * with a Kilo model id when a healthy Kilo model is available and either the
372
+ * session already ate a gate 403 (retried/resumed refusals fail over at once,
373
+ * even ungated — replaying a known refusal can never succeed), or every gate
374
+ * holds: Zen upstream model, a path whose body shape matches (chat bodies on
375
+ * chat completions, responses bodies on responses — Kilo serves both natively
376
+ * but a crossed body would 400), Zen currently gated, session never proven
377
+ * working, and the health machine routes this session to failover. Canary
378
+ * sessions pass through carrying the recovery probe; anything else passes
379
+ * through untouched — working sessions are never rerouted.
380
+ */
381
+ export type ZenFailoverDecision =
382
+ | { action: "failover"; model: string }
383
+ | { action: "canary" }
384
+ | { action: "passthrough" };
385
+ export function classifyZenFailover(
386
+ parsedBody: Record<string, unknown> | null,
387
+ pathname: string,
388
+ ): ZenFailoverDecision {
389
+ if (!parsedBody || typeof parsedBody.model !== "string") return { action: "passthrough" };
390
+ if (getModelUpstream(parsedBody.model) !== "opencode") return { action: "passthrough" };
391
+ const isChatPath = pathname.endsWith("/chat/completions");
392
+ const isResponsesPath = pathname.endsWith("/responses");
393
+ if (!isChatPath && !isResponsesPath) return { action: "passthrough" };
394
+ if (isChatPath && !Array.isArray(parsedBody.messages)) return { action: "passthrough" };
395
+ if (isResponsesPath && parsedBody.input === undefined) return { action: "passthrough" };
396
+ if (!wasGateRejected(parsedBody, pathname)) {
397
+ if (!isUpstreamGated("zen")) return { action: "passthrough" };
398
+ if (!isUnprovenSession(parsedBody, pathname)) return { action: "passthrough" };
399
+ const route = decideZenRoute(parsedBody, pathname);
400
+ if (route === "canary") {
401
+ log("debug", "zen gated — canary session passes through to zen");
402
+ return { action: "canary" };
403
+ }
404
+ if (route !== "failover") return { action: "passthrough" };
405
+ } else {
406
+ log("debug", "zen refused this session before — failing over to Kilo");
407
+ }
408
+ const pick = pickFailoverModel();
409
+ if (!pick) return { action: "passthrough" };
410
+ return { action: "failover", model: pick };
411
+ }
412
+
413
+ /**
414
+ * Extract client IP address from incoming HTTP request.
415
+ */
416
+ export function getClientIP(req: http.IncomingMessage): string {
417
+ const addr = req.socket.remoteAddress;
418
+ if (!addr) return "unknown";
419
+ return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
420
+ }
421
+
422
+ /**
423
+ * Validate that the request URL matches allowed API path patterns and prevents path traversal.
424
+ */
425
+ export function validatePath(rawUrl: string): URL | null {
426
+ const cleaned = rawUrl.replace(/^\/+/, "");
427
+ if (!ALLOWED_PATH_PATTERN.test(`/${cleaned}`)) return null;
428
+ if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
429
+ try {
430
+ const decoded = decodeURIComponent(cleaned);
431
+ if (PATH_TRAVERSAL_PATTERN.test(decoded)) return null;
432
+ if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`)) {
433
+ return null;
434
+ }
435
+ } catch {
436
+ return null;
437
+ }
438
+ try {
439
+ return new URL(cleaned, `${UPSTREAM_OPENCODE}/`);
440
+ } catch {
441
+ return null;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Sanitize and inject standard headers before forwarding request to upstream.
447
+ */
448
+ export function sanitizeHeaders(
449
+ incoming: http.IncomingHttpHeaders,
450
+ targetHost: string,
451
+ ): Record<string, string> {
452
+ // authorization is deliberately NOT forwarded: the host provider registers
453
+ // with a dummy key (placeholder), and every free upstream here is keyless —
454
+ // sending that fake key upstream gets 401 Invalid API key. Kilo's own branch
455
+ // sends no Authorization either (its gateway rejects a placeholder bearer).
456
+ const allowed: Record<string, true> = {
457
+ "content-type": true,
458
+ accept: true,
459
+ "x-request-id": true,
460
+ };
461
+ const sanitized: Record<string, string> = {};
462
+ for (const [key, value] of Object.entries(incoming)) {
463
+ const lower = key.toLowerCase();
464
+ if (lower.startsWith(":")) continue;
465
+ if (!allowed[lower] && !lower.startsWith("x-opencode-")) continue;
466
+ if (typeof value === "string") sanitized[lower] = value;
467
+ else if (Array.isArray(value)) sanitized[lower] = value.join(", ");
468
+ }
469
+ sanitized.host = targetHost;
470
+ // Drop the client's own user-agent so opencodeHeaders() cannot produce a
471
+ // duplicate (case-differing) User-Agent pair — upstream resets connections
472
+ // that send two conflicting User-Agent headers.
473
+ delete sanitized["user-agent"];
474
+ Object.assign(sanitized, opencodeHeaders());
475
+ sanitized["accept-encoding"] = "identity";
476
+ sanitized.connection = "keep-alive";
477
+ return sanitized;
478
+ }
479
+ export async function isProxyAlive(port: number): Promise<boolean> {
480
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
481
+ try {
482
+ const res = await fetch(`http://${HOST}:${port}/v1/models`, {
483
+ signal: AbortSignal.timeout(1500),
484
+ });
485
+ const ct = res.headers.get("content-type") || "";
486
+ return res.ok && ct.includes("application/json");
487
+ } catch {
488
+ return false;
489
+ }
490
+ }
491
+
492
+ let activeRequests = 0;
493
+
494
+ /** Current in-flight proxied requests (guard for stale-daemon replacement). */
495
+ export function getActiveRequests(): number {
496
+ return activeRequests;
497
+ }
498
+
499
+ /**
500
+ * Fetch the full health snapshot from a running daemon.
501
+ * `activeRequests` is undefined on daemons older than the busy-tracking
502
+ * feature (1.9.0) — callers treat that as "cannot verify usage".
503
+ * `sseDegraded`/`sseRate`/`lastBytesAt` are undefined on daemons older than
504
+ * the failed-SSE window — callers treat missing as "not degraded".
505
+ */
506
+ export async function getDaemonHealth(
507
+ port: number,
508
+ ): Promise<DaemonHealthSnapshot> {
509
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
510
+ try {
511
+ const res = await fetch(`http://${HOST}:${port}/_health`, {
512
+ signal: AbortSignal.timeout(2500),
513
+ });
514
+ if (!res.ok) return null;
515
+ const data: unknown = await res.json();
516
+ if (data && typeof data === "object") {
517
+ const v =
518
+ "version" in data && typeof data.version === "string" && data.version
519
+ ? data.version
520
+ : "";
521
+ const ar = "activeRequests" in data ? data.activeRequests : undefined;
522
+ const sr = "sseRate" in data ? data.sseRate : undefined;
523
+ const sd = "sseDegraded" in data ? data.sseDegraded : undefined;
524
+ const lb = "lastBytesAt" in data ? data.lastBytesAt : undefined;
525
+ return {
526
+ version: v,
527
+ activeRequests: typeof ar === "number" ? ar : undefined,
528
+ sseRate: typeof sr === "number" ? sr : undefined,
529
+ sseDegraded: typeof sd === "boolean" ? sd : undefined,
530
+ lastBytesAt: typeof lb === "number" ? lb : undefined,
531
+ };
532
+ }
533
+ return null;
534
+ } catch {
535
+ return null;
536
+ }
537
+ }
538
+ /** Version of the daemon on `port`; "" when alive but pre-version-field, null when not alive. */
539
+ export async function getDaemonVersion(port: number): Promise<string | null> {
540
+ const h = await getDaemonHealth(port);
541
+ return h ? h.version : null;
542
+ }
543
+
544
+ /**
545
+ * Linux fallback when neither lsof nor fuser exists: resolve the LISTEN
546
+ * socket inode for `port` via /proc/net/tcp{,6}, then find the owning pid by
547
+ * scanning /proc fds. Dependency-free, same-user only, never self-kills.
548
+ */
549
+ function killViaProcNet(port: number): boolean {
550
+ if (process.platform !== "linux") return false;
551
+ try {
552
+ const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
553
+ const inodes = new Set<string>();
554
+ for (const table of ["/proc/net/tcp", "/proc/net/tcp6"]) {
555
+ let text = "";
556
+ try {
557
+ text = fs.readFileSync(table, "utf8");
558
+ } catch {
559
+ continue;
560
+ }
561
+ for (const line of text.split("\n").slice(1)) {
562
+ const cols = line.trim().split(/\s+/);
563
+ if (cols.length < 10) continue;
564
+ const addr = cols[1].split(":");
565
+ if (addr.length !== 2 || addr[1].toUpperCase() !== hexPort) continue;
566
+ if (cols[3] !== "0A") continue; // LISTEN only
567
+ inodes.add(cols[9]);
568
+ }
569
+ }
570
+ if (inodes.size === 0) return false;
571
+ for (const pid of fs.readdirSync("/proc")) {
572
+ if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue;
573
+ let fds: string[];
574
+ try {
575
+ fds = fs.readdirSync(`/proc/${pid}/fd`);
576
+ } catch {
577
+ continue;
578
+ }
579
+ for (const fd of fds) {
580
+ let link = "";
581
+ try {
582
+ link = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
583
+ } catch {
584
+ continue;
585
+ }
586
+ const sock = link.match(/^socket:\[(\d+)\]$/);
587
+ if (sock && inodes.has(sock[1])) {
588
+ try {
589
+ process.kill(Number(pid), "SIGKILL");
590
+ return true;
591
+ } catch {
592
+ return false;
593
+ }
594
+ }
595
+ }
596
+ }
597
+ } catch { }
598
+ return false;
599
+ }
600
+
601
+ export async function killPortHolder(port: number): Promise<boolean> {
602
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
603
+ try {
604
+ if (process.platform === "win32") {
605
+ try {
606
+ const out = execSync(`netstat -ano | findstr :${port}`, {
607
+ encoding: "utf8",
608
+ timeout: 3000,
609
+ windowsHide: true,
610
+ }) as string;
611
+ for (const line of out.split("\n")) {
612
+ if (!line.includes("LISTENING")) continue;
613
+ const parts = line.trim().split(/\s+/);
614
+ // Match the port on the Local Address column only — a raw
615
+ // substring match can hit longer ports (:2818 vs :28180).
616
+ if (!parts[1] || !parts[1].endsWith(`:${port}`)) continue;
617
+ const pid = parts[parts.length - 1];
618
+ if (!pid || !/^\d+$/.test(pid)) continue;
619
+ // Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
620
+ if (Number(pid) === process.pid) continue; // never self-kill
621
+ execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
622
+ return true;
623
+ }
624
+ } catch { }
625
+ return false;
626
+ }
627
+ try {
628
+ const out = execSync(`lsof -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
629
+ encoding: "utf8",
630
+ timeout: 3000,
631
+ }) as string;
632
+ const pids = out.trim().split(/\s+/).filter((p) => /^\d+$/.test(p) && Number(p) !== process.pid);
633
+ // lsof matches both ends of a connection: our own recent probe socket
634
+ // (TIME_WAIT) can sort first, so skip self instead of taking [0].
635
+ if (pids.length === 0) return killViaProcNet(port);
636
+ execSync(`kill -9 ${pids[0]}`, { timeout: 3000, stdio: "ignore" });
637
+ return true;
638
+ } catch {
639
+ return killViaProcNet(port);
640
+ }
641
+ } catch {
642
+ return false;
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Loopback-only client lease + control endpoints used by the detached-daemon
648
+ * protocol (src/client.ts). Returns false when the request is not a control
649
+ * endpoint (caller continues). Control writes are async (chunked JSON).
650
+ */
651
+ export function handleControlRequest(
652
+ req: http.IncomingMessage,
653
+ res: http.ServerResponse,
654
+ reqPathname: string | null,
655
+ closeServer: () => void,
656
+ ): boolean {
657
+ if (reqPathname === null) return false;
658
+ const isShutdown = reqPathname === "/_shutdown";
659
+ const isClientRoute = reqPathname.startsWith("/_client/");
660
+ if (!isShutdown && !isClientRoute) return false;
661
+ const clientIP = req.socket.remoteAddress ?? "";
662
+ if (!isLoopbackIP(clientIP)) {
663
+ res.writeHead(403, { "content-type": "application/json" });
664
+ res.end(JSON.stringify({ error: "loopback only" }));
665
+ return true;
666
+ }
667
+ if (isShutdown) {
668
+ log("info", "control /_shutdown received — closing proxy");
669
+ res.writeHead(200, { "content-type": "application/json" });
670
+ res.end(JSON.stringify({ ok: true }));
671
+ setTimeout(() => {
672
+ try { closeServer(); } catch { }
673
+ if (shutdownShouldExit) process.exit(0);
674
+ }, 50);
675
+ return true;
676
+ }
677
+ const chunks: Buffer[] = [];
678
+ let total = 0;
679
+ req.on("data", (c: Buffer) => {
680
+ total += c.length;
681
+ if (total > 4096) { req.destroy(); return; }
682
+ chunks.push(c);
683
+ });
684
+ req.on("end", () => {
685
+ let clientId = "";
686
+ try {
687
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
688
+ if (parsed && typeof parsed === "object" && "id" in parsed) {
689
+ const candidate = parsed.id;
690
+ if (typeof candidate === "string" && candidate) clientId = candidate;
691
+ }
692
+ } catch { }
693
+ if (!clientId) {
694
+ res.writeHead(400, { "content-type": "application/json" });
695
+ res.end(JSON.stringify({ error: "missing client id" }));
696
+ return;
697
+ }
698
+ if (reqPathname === "/_client/attach") registerClient(clientId);
699
+ else if (reqPathname === "/_client/heartbeat") {
700
+ // Unknown id = daemon restarted since attach. Report ok:false so the
701
+ // client re-attaches instead of heartbeating lease-less.
702
+ if (!renewClient(clientId)) {
703
+ res.writeHead(200, { "content-type": "application/json" });
704
+ res.end(JSON.stringify({ ok: false }));
705
+ return;
706
+ }
707
+ } else if (reqPathname === "/_client/detach") unregisterClient(clientId);
708
+ else {
709
+ res.writeHead(404, { "content-type": "application/json" });
710
+ res.end(JSON.stringify({ error: "not found" }));
711
+ return;
712
+ }
713
+ res.writeHead(200, { "content-type": "application/json" });
714
+ res.end(JSON.stringify({ ok: true }));
715
+ });
716
+ return true;
717
+ }
718
+
719
+ /**
720
+ * Tagged abort reason for the proxy-internal header-wait timeout.
721
+ * relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
722
+ * code FF_INTERNAL_ABORT as "our abort, not a relay fault" — so neither
723
+ * rolls nor penalizes a healthy relay when the request merely ran slow.
724
+ */
725
+ function upstreamTimeoutError(): Error & { code: string } {
726
+ const err = new Error(`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`) as Error & { code: string };
727
+ err.name = "AbortError";
728
+ err.code = "FF_INTERNAL_ABORT";
729
+ return err;
730
+ }
731
+
732
+ /**
733
+ * Recover from upstream rejecting a replayed reasoning blob as foreign.
734
+ *
735
+ * Upstream binds each reasoning `encrypted_content` blob to the service
736
+ * instance that issued it. When the conversation is later served by a different
737
+ * instance the whole replayed history is rejected with a 400, which the relay
738
+ * pool never retries (400 is not a roll status) and the host repeats until the
739
+ * session dies. Measured on 2026-09-13: same relay returned 200 and 400 one
740
+ * second apart, the direct path returned the same 400, and a 45s burst hit ten
741
+ * conversations.
742
+ *
743
+ * The rejected blob cannot be identified from the response, so this drops every
744
+ * blob, retries once, and then remembers the hashes of the blobs that were in
745
+ * the failing body: later turns drop only those and keep whatever the current
746
+ * instance issued, so the conversation keeps its recent reasoning.
747
+ *
748
+ * The 400 body is inspected through a clone: a successful streaming response
749
+ * (the normal path) is never consumed here.
750
+ */
751
+ async function retryWithoutReasoningEncryption(
752
+ response: Response,
753
+ sentBody: Buffer,
754
+ resend: (body: Buffer) => Promise<Response>,
755
+ reqId: string,
756
+ conversationKey: string | null,
757
+ ): Promise<Response> {
758
+ if (response.status !== 400) return response;
759
+ if ((response.headers.get("content-type") ?? "").includes("text/event-stream")) {
760
+ return response;
761
+ }
762
+ let errorText: string;
763
+ try {
764
+ errorText = await response.clone().text();
765
+ } catch {
766
+ return response;
767
+ }
768
+ if (!isReasoningCallerMismatch(errorText)) return response;
769
+
770
+ const portable = stripReasoningEncryption(sentBody);
771
+ if (!portable) {
772
+ log(
773
+ "warn",
774
+ "upstream rejected caller-bound reasoning but the request carries no encrypted_content to strip",
775
+ { status: 400 },
776
+ reqId,
777
+ );
778
+ return response;
779
+ }
780
+
781
+ log(
782
+ "warn",
783
+ "upstream rejected caller-bound reasoning (blobs issued by another instance); retrying with encrypted_content stripped",
784
+ { status: 400, tracked: conversationKey !== null },
785
+ reqId,
786
+ );
787
+ const retried = await resend(portable);
788
+ if (retried.ok && conversationKey) {
789
+ const remembered = rememberRejectedReasoning(sentBody, conversationKey);
790
+ log(
791
+ "info",
792
+ `conversation will drop ${remembered} rejected reasoning blob(s) on later turns`,
793
+ undefined,
794
+ reqId,
795
+ );
796
+ }
797
+ return retried;
798
+ }
799
+
800
+ /**
801
+ * Start the local HTTP proxy daemon.
802
+ * Implements master/worker single-port reuse: if port 28180 is already held by a live
803
+ * parent or sibling OMP session, resolves immediately with { server: null, port: 28180 }.
804
+ */
805
+ export function startProxy(
806
+ overridePort?: number,
807
+ ): Promise<{ server: http.Server | null; port: number }> {
808
+ const basePort = overridePort ?? PORT;
809
+
810
+ const server = http.createServer((req, res) => {
811
+ const clientIP = getClientIP(req);
812
+ const reqId = randomUUID().slice(0, 8);
813
+ if (isDebugEnabled()) {
814
+ log(
815
+ "debug",
816
+ `incoming ${req.method} ${req.url} from ${clientIP}`,
817
+ { ip: clientIP, method: req.method, url: req.url },
818
+ reqId,
819
+ );
820
+ }
821
+
822
+ if (!ALLOWED_METHODS.has(req.method ?? "")) {
823
+ res.writeHead(405, { "content-type": "application/json" });
824
+ res.end(JSON.stringify({ error: "method not allowed" }));
825
+ return;
826
+ }
827
+
828
+ if (req.method === "OPTIONS") {
829
+ // No CORS headers: loopback proxy is not a cross-origin resource.
830
+ // Keep a bare 204 so OPTIONS never reaches validatePath/upstream.
831
+ res.writeHead(204);
832
+ res.end();
833
+ return;
834
+ }
835
+
836
+ let reqPathname: string | null = null;
837
+ try {
838
+ reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
839
+ } catch { }
840
+ if (handleControlRequest(req, res, reqPathname, () => server.close())) return;
841
+ // Loopback-only health endpoint — always accessible even when widget hidden
842
+ if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
843
+ const addr = server.address();
844
+ const realPort = addr && typeof addr === "object" ? (addr as { port: number }).port : basePort;
845
+ if (handleHealthRequest(req, res, realPort, getActiveRequests())) return;
846
+ }
847
+ if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
848
+ const alive = getAliveCatalog();
849
+ const body = JSON.stringify({
850
+ object: "list",
851
+ data: alive.map((m) => ({
852
+ id: m.id,
853
+ object: "model",
854
+ created: 0,
855
+ owned_by: m.source === "kilo" ? "kilocode" : m.source === "cline" ? "clinecode" : "opencode",
856
+ })),
857
+ });
858
+ res.writeHead(200, {
859
+ "content-type": "application/json",
860
+ "content-length": Buffer.byteLength(body),
861
+ });
862
+ res.end(body);
863
+ return;
864
+ }
865
+
866
+ const target = validatePath(req.url ?? "/");
867
+ if (!target) {
868
+ const cleaned = (req.url ?? "/").replace(/^\/+/, "");
869
+ if (PATH_TRAVERSAL_PATTERN.test(cleaned)) {
870
+ res.writeHead(403, { "content-type": "application/json" });
871
+ res.end(JSON.stringify({ error: "forbidden" }));
872
+ } else {
873
+ res.writeHead(404, { "content-type": "application/json" });
874
+ res.end(JSON.stringify({ error: "not found" }));
875
+ }
876
+ return;
877
+ }
878
+ touchActivity();
879
+ // Buffer request body to inspect model ID for upstream routing
880
+ const bodyChunks: Buffer[] = [];
881
+ activeRequests += 1;
882
+ res.on("close", () => {
883
+ activeRequests -= 1;
884
+ });
885
+
886
+ // Reject oversized bodies before buffering starts: the client declares
887
+ // the size in content-length, so no transfer cost is wasted.
888
+ const declaredLength = Number(req.headers["content-length"] ?? 0);
889
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
890
+ res.writeHead(413, { "content-type": "application/json" });
891
+ res.end(JSON.stringify({ error: "payload too large" }));
892
+ return;
893
+ }
894
+ let bufferedBytes = 0;
895
+ req.on("error", (err) => {
896
+ log(
897
+ "warn",
898
+ "client request error during body buffering",
899
+ { error: String(err) },
900
+ reqId,
901
+ );
902
+ if (!res.headersSent) {
903
+ res.writeHead(400, { "content-type": "application/json" });
904
+ }
905
+ res.end(JSON.stringify({ error: "bad request" }));
906
+ });
907
+
908
+ // Running cap for chunked/undeclared bodies: stop buffering the instant
909
+ // the limit is crossed instead of holding the whole payload in memory.
910
+ req.on("data", (chunk: Buffer) => {
911
+ bufferedBytes += chunk.length;
912
+ if (bufferedBytes > MAX_BODY_BYTES) {
913
+ req.destroy();
914
+ if (!res.headersSent) {
915
+ res.writeHead(413, { "content-type": "application/json" });
916
+ res.end(JSON.stringify({ error: "payload too large" }));
917
+ }
918
+ return;
919
+ }
920
+ bodyChunks.push(chunk);
921
+ });
922
+
923
+ req.on("end", async () => {
924
+ const bodyStr = Buffer.concat(bodyChunks).toString();
925
+ let isKilo = false;
926
+ let isCline = false;
927
+ let parsedBody: Record<string, unknown> | null = null;
928
+ let clientRequestedStream = false;
929
+
930
+ try {
931
+ parsedBody = JSON.parse(bodyStr);
932
+ if (typeof parsedBody?.model === "string") {
933
+ const canonical = resolveCanonicalModelId(parsedBody.model);
934
+ if (isClineModel(canonical)) {
935
+ isCline = true;
936
+ } else if (KILO_MODEL_IDS.has(canonical)) {
937
+ isKilo = true;
938
+ }
939
+ }
940
+ if (parsedBody && typeof parsedBody === "object") {
941
+ clientRequestedStream = parsedBody.stream === true;
942
+ }
943
+ } catch { }
944
+
945
+ const sessionKey = sessionKeyOf(parsedBody);
946
+ // Zen free-tier gate failover: refused or fresh Zen sessions ride a
947
+ // healthy Kilo model on the same wire API via the existing Kilo branch.
948
+ // Working sessions pass through untouched; canary sessions pass through
949
+ // carrying the recovery probe (their success clears the gate). Responses
950
+ // bodies are scrubbed of Zen-bound state (chain pointer, reasoning blobs)
951
+ // Kilo cannot honor before the model swap.
952
+ const zenFailover = classifyZenFailover(parsedBody, target.pathname);
953
+ const isCanary = zenFailover.action === "canary";
954
+ if (zenFailover.action === "failover" && parsedBody) {
955
+ const from = String(parsedBody.model);
956
+ parsedBody.model = zenFailover.model;
957
+ if (target.pathname.endsWith("/responses")) prepareResponsesFailoverBody(parsedBody);
958
+ isKilo = true;
959
+ log("info", `zen gated — session failed over ${from} -> ${zenFailover.model}`, { model: from, failover: zenFailover.model }, reqId);
960
+ }
961
+
962
+ // OpenCode Zen free-tier fingerprint: upstream mandates stream: true and the
963
+ // placeholder sextet {bash, glob, grep, read, edit, write}. Missing tools (such as on
964
+ // subagent or advisor watchdog turns) trigger 403 FreeTierError.
965
+ let bodyModified = false;
966
+ let callerHadTools = true;
967
+ let callerCaseRestore: CaseRestoreMap | undefined;
968
+ let callerFindGlob: FindGlobRestore | undefined;
969
+ let callerInjected: string[] | undefined;
970
+ if (!isKilo && !isCline && parsedBody) {
971
+ const fp = enforceOpencodeFingerprint(parsedBody, target.pathname);
972
+ callerHadTools = fp.callerHadTools;
973
+ callerCaseRestore = fp.caseRestore;
974
+ callerFindGlob = fp.findGlob;
975
+ callerInjected = fp.injected;
976
+ bodyModified = true;
977
+ }
978
+
979
+ const isStream = clientRequestedStream;
980
+
981
+ // Stale-registration guard: each model's declared api decides its upstream
982
+ // path — responses models (muse-spark-*) reach /v1/responses and
983
+ // anthropic-messages models reach /v1/messages. A request on the wrong path
984
+ // means the host still holds a pre-fix provider registration (stale disk
985
+ // cache or no restart after upgrade) and upstream answers 500.
986
+ if (!isKilo && !isCline && typeof parsedBody?.model === "string") {
987
+ const knownDef = MODEL_MAP.get(String(parsedBody.model));
988
+ if (target.pathname.endsWith("/chat/completions") && knownDef?.api && knownDef.api !== "openai-completions") {
989
+ log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
990
+ } else if (target.pathname.endsWith("/messages") && knownDef?.api && knownDef.api !== "anthropic-messages") {
991
+ log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
992
+ } else if (target.pathname.endsWith("/responses") && knownDef?.api && knownDef.api !== "openai-responses") {
993
+ log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
994
+ }
995
+ }
996
+ try {
997
+ if (isCline && parsedBody) {
998
+ // Cline serves chat completions only, direct (never relay pool, no
999
+ // opencode fingerprint). The pool module owns the per-user Bearer
1000
+ // token: the proxy only passes the body and maps the answer.
1001
+ await handleClineRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream });
1002
+ } else if (isKilo && parsedBody) {
1003
+ // Header-wait timeout + client-disconnect abort: once headers
1004
+ // arrive the timer is cleared so a long stream is not killed at
1005
+ // the timeout ceiling; the stream phase is owned by
1006
+ // pipeUpstreamStream and its close handling.
1007
+ const kiloController = new AbortController();
1008
+ const kiloTimeoutId = setTimeout(
1009
+ () => kiloController.abort(upstreamTimeoutError()),
1010
+ UPSTREAM_HEADER_TIMEOUT_MS,
1011
+ );
1012
+ const abortKiloOnClientGone = () => {
1013
+ if (!res.writableEnded) kiloController.abort();
1014
+ };
1015
+ res.once("close", abortKiloOnClientGone);
1016
+ req.once("error", abortKiloOnClientGone);
1017
+ let response: Response;
1018
+ // Kilo serves both wire APIs natively: responses-shaped bodies go to
1019
+ // its responses endpoint, chat bodies to chat completions. Sending a
1020
+ // responses body to the chat URL (or vice versa) would 400.
1021
+ const kiloUrl = target.pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
1022
+ try {
1023
+ response = await relayFetch(
1024
+ kiloUrl,
1025
+ {
1026
+ method: "POST",
1027
+ headers: {
1028
+ // Keyless, like Zen: Kilo's gateway rejects a placeholder bearer
1029
+ // (live 2026-09-22 — "Bearer kilo-free" answers 401 INVALID_TOKEN,
1030
+ // omitting Authorization answers 200 for every free catalog model).
1031
+ "Content-Type": "application/json",
1032
+ },
1033
+ body: JSON.stringify(parsedBody),
1034
+ signal: kiloController.signal,
1035
+ },
1036
+ reqId,
1037
+ );
1038
+ } finally {
1039
+ clearTimeout(kiloTimeoutId);
1040
+ res.off("close", abortKiloOnClientGone);
1041
+ req.off("error", abortKiloOnClientGone);
1042
+ }
1043
+ if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
1044
+
1045
+ if (isStream && response.ok && response.body) {
1046
+ const ct =
1047
+ response.headers.get("content-type") || "text/event-stream";
1048
+ res.writeHead(response.status, {
1049
+ "content-type": ct,
1050
+ "cache-control": "no-cache, no-transform",
1051
+ connection: "keep-alive",
1052
+ "x-accel-buffering": "no",
1053
+ });
1054
+ // Kilo is fetched directly (not via the relay pool), so pass undefined:
1055
+ // attributing kilo-side stream failures to an unrelated opencode relay
1056
+ // would mark a healthy relay as failed.
1057
+ pipeUpstreamStream(
1058
+ Readable.fromWeb(
1059
+ response.body as unknown as WebReadableStream,
1060
+ ),
1061
+ res,
1062
+ req,
1063
+ reqId,
1064
+ undefined,
1065
+ );
1066
+ } else {
1067
+ const data = withRateLimitHint(response.status, await response.text());
1068
+ const ct =
1069
+ response.headers.get("content-type") || "application/json";
1070
+ res.writeHead(response.status, { "content-type": ct });
1071
+ res.end(data);
1072
+ }
1073
+ } else {
1074
+ // OpenCode routing — relay egress is for free-catalog models
1075
+ // only; unknown/paid models go direct upstream.
1076
+ const relayState = getActiveRelayState();
1077
+ const relayPoolOn =
1078
+ relayState.mode !== "off" &&
1079
+ relayState.enabled !== false &&
1080
+ Boolean(relayState.url || (relayState.relays && relayState.relays.length > 0));
1081
+ const relayEligible = isRelayEligibleModel(parsedBody?.model);
1082
+ const shouldUseRelay = relayPoolOn && relayEligible;
1083
+ if (relayPoolOn && !relayEligible) {
1084
+ log("info", `relay bypass for non-catalog model ${String(parsedBody?.model ?? "?")} — routing direct upstream`, { model: parsedBody?.model }, reqId);
1085
+ }
1086
+ if (shouldUseRelay) {
1087
+ const fullUrl = `${UPSTREAM_OPENCODE}${req.url ?? "/"}`;
1088
+ const activeHost = relayState.url
1089
+ ? new URL(relayState.url).host
1090
+ : "opencode.ai";
1091
+ const relayHeaders = sanitizeHeaders(req.headers, activeHost);
1092
+
1093
+ try {
1094
+ if (parsedBody) {
1095
+ const requestBody = bodyModified
1096
+ ? Buffer.from(JSON.stringify(parsedBody), "utf8")
1097
+ : Buffer.concat(bodyChunks);
1098
+ // Reasoning `encrypted_content` is bound to the caller that
1099
+ // requested it, and a relay roll changes that caller. A
1100
+ // conversation already rejected for foreign blobs keeps its
1101
+ // history portable from then on.
1102
+ const conversationKey = responsesConversationKey(parsedBody);
1103
+ const responsesRequest = target.pathname.endsWith("/responses");
1104
+ // Per-conversation affinity: reasoning blobs are only readable
1105
+ // by the backend that issued them, so stay on the issuing relay
1106
+ // while it is healthy.
1107
+ const issuer = responsesRequest && conversationKey !== null
1108
+ ? issuerRelayFor(conversationKey)
1109
+ : undefined;
1110
+ const candidates = orderedRelayCandidates(
1111
+ typeof issuer === "string" ? issuer : undefined,
1112
+ );
1113
+ const servingRelay = candidates[0] ?? null;
1114
+ // Affinity cannot be honored (issuer cooling, dropped from the
1115
+ // pool, or the path changed relay<->direct): the next backend
1116
+ // cannot read this history, so send it portable instead of
1117
+ // letting upstream reject the whole request.
1118
+ const issuerChanged = issuer !== undefined && issuer !== servingRelay;
1119
+ // Drop only the blobs upstream already rejected, so everything
1120
+ // the current backend issued still flows verbatim.
1121
+ const stripRejected = responsesRequest &&
1122
+ conversationKey !== null &&
1123
+ rejectedReasoningCount(conversationKey) > 0;
1124
+ const bodyForUpstream = issuerChanged
1125
+ ? (stripReasoningEncryption(requestBody) ?? requestBody)
1126
+ : stripRejected
1127
+ ? (stripRejectedReasoning(requestBody, conversationKey) ?? requestBody)
1128
+ : requestBody;
1129
+ if (issuerChanged && conversationKey !== null) {
1130
+ // These blobs belong to a backend this conversation is
1131
+ // leaving, so never replay them again.
1132
+ rememberRejectedReasoning(requestBody, conversationKey);
1133
+ log(
1134
+ "info",
1135
+ `reasoning issuer changed (${issuer ?? "direct"} -> ${servingRelay ?? "direct"}); sending history without caller-bound reasoning`,
1136
+ undefined,
1137
+ reqId,
1138
+ );
1139
+ }
1140
+
1141
+ // One attempt, with its own header-wait timeout and
1142
+ // client-disconnect abort; the timer is cleared once headers
1143
+ // arrive so streams are not killed at the timeout ceiling.
1144
+ // Aborts caused by our own timeout are tagged
1145
+ // FF_INTERNAL_ABORT so stream-pipe never penalizes the relay.
1146
+ let servedIssuer: string | null = null;
1147
+ let issuerReported = false;
1148
+ const sendViaRelay = async (payload: Buffer): Promise<Response> => {
1149
+ const relayController = new AbortController();
1150
+ const relayTimeoutId = setTimeout(
1151
+ () => relayController.abort(upstreamTimeoutError()),
1152
+ UPSTREAM_HEADER_TIMEOUT_MS,
1153
+ );
1154
+ const abortRelayOnClientGone = () => {
1155
+ if (!res.writableEnded) relayController.abort();
1156
+ };
1157
+ res.once("close", abortRelayOnClientGone);
1158
+ req.once("error", abortRelayOnClientGone);
1159
+ try {
1160
+ return await relayFetch(
1161
+ fullUrl,
1162
+ {
1163
+ method: req.method || "POST",
1164
+ headers: relayHeaders,
1165
+ body: payload,
1166
+ signal: relayController.signal,
1167
+ } as unknown as RequestInit,
1168
+ reqId,
1169
+ {
1170
+ preferred: typeof issuer === "string" ? issuer : undefined,
1171
+ onServed: (relay) => {
1172
+ servedIssuer = relay;
1173
+ issuerReported = true;
1174
+ },
1175
+ },
1176
+ );
1177
+ } finally {
1178
+ clearTimeout(relayTimeoutId);
1179
+ res.off("close", abortRelayOnClientGone);
1180
+ req.off("error", abortRelayOnClientGone);
1181
+ }
1182
+ };
1183
+
1184
+ let response = await sendViaRelay(bodyForUpstream);
1185
+ // Retry must stay available on every responses request: even a
1186
+ // proactively stripped body can still carry a blob the current
1187
+ // instance cannot read.
1188
+ if (responsesRequest) {
1189
+ response = await retryWithoutReasoningEncryption(
1190
+ response,
1191
+ requestBody,
1192
+ sendViaRelay,
1193
+ reqId,
1194
+ conversationKey,
1195
+ );
1196
+ }
1197
+ // Record the backend that actually served this turn. Reading
1198
+ // the sticky active relay here would be wrong now that
1199
+ // affinity deliberately leaves it untouched.
1200
+ if (
1201
+ responsesRequest &&
1202
+ conversationKey !== null &&
1203
+ response.ok &&
1204
+ issuerReported
1205
+ ) {
1206
+ rememberIssuerRelay(conversationKey, servedIssuer);
1207
+ }
1208
+ // Upstream degradation tracking: successes prove Zen reachable,
1209
+ // 403 free-tier gates feed the health state machine (a 403 never
1210
+ // rolls — it is a terminal upstream verdict, not a relay fault).
1211
+ if (response.ok) {
1212
+ recordUpstreamSuccess("zen", { sessionKey, canary: isCanary });
1213
+ } else if (response.status === 403 && (response.headers.get("content-type") ?? "").includes("application/json")) {
1214
+ try {
1215
+ const gateText = await response.clone().text();
1216
+ recordUpstreamFailure("zen", 403, gateText);
1217
+ rememberGateRejection(parsedBody, target.pathname, 403, gateText);
1218
+ } catch { }
1219
+ }
1220
+
1221
+ if (isStream && response.ok && response.body) {
1222
+ const ct =
1223
+ response.headers.get("content-type") ||
1224
+ "text/event-stream";
1225
+ res.writeHead(response.status, {
1226
+ "content-type": ct,
1227
+ "cache-control": "no-cache, no-transform",
1228
+ connection: "keep-alive",
1229
+ "x-accel-buffering": "no",
1230
+ });
1231
+ pipeUpstreamStream(
1232
+ Readable.fromWeb(
1233
+ response.body as unknown as WebReadableStream,
1234
+ ),
1235
+ res,
1236
+ req,
1237
+ reqId,
1238
+ relayState.url,
1239
+ );
1240
+ } else {
1241
+ if (!response.ok) {
1242
+ log("warn", `upstream ${response.status} for model ${String((parsedBody as Record<string, unknown> | null)?.model ?? "?")} via relay`, { status: response.status, model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url }, reqId);
1243
+ }
1244
+ let rawText = await response.text();
1245
+ let ct =
1246
+ response.headers.get("content-type") ||
1247
+ "application/json";
1248
+ if (response.ok && (ct.includes("text/event-stream") || rawText.includes("data:"))) {
1249
+ rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected);
1250
+ ct = "application/json";
1251
+ }
1252
+ const data = withFreeTierHint(response.status, withRateLimitHint(response.status, rawText));
1253
+ res.writeHead(response.status, { "content-type": ct });
1254
+ res.end(data);
1255
+ }
1256
+ return; // relay handled successfully
1257
+ }
1258
+ } catch (e) {
1259
+ log(
1260
+ "warn",
1261
+ "opencode relay failed, falling back to direct upstream",
1262
+ { error: String(e) },
1263
+ reqId,
1264
+ );
1265
+ if (res.headersSent) return; // cannot recover mid-stream
1266
+ }
1267
+ }
1268
+
1269
+ // Direct path — send the fingerprinted body if modified, else raw bytes
1270
+ const directBody = bodyModified
1271
+ ? Buffer.from(JSON.stringify(parsedBody), "utf8")
1272
+ : Buffer.concat(bodyChunks);
1273
+
1274
+ // Same backend-bound blobs as the relay path: leaving the issuing
1275
+ // relay for the direct route (or vice versa) makes the history
1276
+ // unreadable, so send it portable instead.
1277
+ const directConversationKey = responsesConversationKey(parsedBody);
1278
+ const directResponsesRequest = target.pathname.endsWith("/responses");
1279
+ const directIssuer = directResponsesRequest && directConversationKey !== null
1280
+ ? issuerRelayFor(directConversationKey)
1281
+ : undefined;
1282
+ const directIssuerChanged = directIssuer !== undefined && directIssuer !== null;
1283
+ const directStripRejected = directResponsesRequest &&
1284
+ directConversationKey !== null &&
1285
+ rejectedReasoningCount(directConversationKey) > 0;
1286
+ const directBodyForUpstream = directIssuerChanged
1287
+ ? (stripReasoningEncryption(directBody) ?? directBody)
1288
+ : directStripRejected
1289
+ ? (stripRejectedReasoning(directBody, directConversationKey) ?? directBody)
1290
+ : directBody;
1291
+ if (directIssuerChanged && directConversationKey !== null) {
1292
+ rememberRejectedReasoning(directBody, directConversationKey);
1293
+ log(
1294
+ "info",
1295
+ `reasoning issuer changed (${directIssuer} -> direct); sending history without caller-bound reasoning`,
1296
+ undefined,
1297
+ reqId,
1298
+ );
1299
+ }
1300
+
1301
+ if (isDebugEnabled()) {
1302
+ log(
1303
+ "debug",
1304
+ `direct upstream ${target.hostname}${target.pathname} (${directBody.length}B)`,
1305
+ { model: parsedBody?.model, isKilo },
1306
+ reqId,
1307
+ );
1308
+ }
1309
+
1310
+ const fwd = sanitizeHeaders(req.headers, target.hostname);
1311
+ fwd["connection"] = "keep-alive";
1312
+
1313
+ const sendDirect = async (payload: Buffer): Promise<Response> => {
1314
+ const headers = { ...fwd };
1315
+ if (payload.length > 0) {
1316
+ headers["content-length"] = String(payload.byteLength);
1317
+ }
1318
+ const controller = new AbortController();
1319
+ const timeoutId = setTimeout(() => controller.abort(upstreamTimeoutError()), UPSTREAM_HEADER_TIMEOUT_MS);
1320
+ const onClientClose = () => {
1321
+ if (!res.writableEnded) controller.abort();
1322
+ };
1323
+ const onReqError = () => controller.abort();
1324
+ res.on("close", onClientClose);
1325
+ req.on("error", onReqError);
1326
+ try {
1327
+ return await fetch(target.href, {
1328
+ method: req.method,
1329
+ headers,
1330
+ body: payload.length > 0 ? payload : undefined,
1331
+ signal: controller.signal,
1332
+ } as unknown as RequestInit);
1333
+ } finally {
1334
+ clearTimeout(timeoutId);
1335
+ res.off("close", onClientClose);
1336
+ req.off("error", onReqError);
1337
+ }
1338
+ };
1339
+
1340
+ try {
1341
+ let upstreamRes = await sendDirect(directBodyForUpstream);
1342
+ if (directResponsesRequest) {
1343
+ upstreamRes = await retryWithoutReasoningEncryption(
1344
+ upstreamRes,
1345
+ directBody,
1346
+ sendDirect,
1347
+ reqId,
1348
+ directConversationKey,
1349
+ );
1350
+ }
1351
+ // The direct route is its own backend: remember it so a later
1352
+ // switch back to relays ships a portable history.
1353
+ if (directResponsesRequest && directConversationKey !== null && upstreamRes.ok) {
1354
+ rememberIssuerRelay(directConversationKey, null);
1355
+ }
1356
+ if (upstreamRes.status >= 400) {
1357
+ log("warn", `direct upstream ${upstreamRes.status} for model ${String(parsedBody?.model ?? "?")} ${target.pathname}`, { status: upstreamRes.status, model: parsedBody?.model, path: target.pathname }, reqId);
1358
+ }
1359
+ // Upstream degradation tracking, mirroring the relay path: successes
1360
+ // prove Zen reachable, 403 free-tier gates feed the health machine.
1361
+ if (upstreamRes.ok) {
1362
+ recordUpstreamSuccess("zen", { sessionKey, canary: isCanary });
1363
+ } else if (upstreamRes.status === 403 && (upstreamRes.headers.get("content-type") ?? "").includes("application/json")) {
1364
+ try {
1365
+ const gateText = await upstreamRes.clone().text();
1366
+ recordUpstreamFailure("zen", 403, gateText);
1367
+ rememberGateRejection(parsedBody, target.pathname, 403, gateText);
1368
+ } catch { }
1369
+ }
1370
+
1371
+ // Natural 429: every relay plus direct is rate-limited — buffer the
1372
+ // JSON error and attach the deploy hint instead of piping it
1373
+ // through as a stream body.
1374
+ if (upstreamRes.status === 429) {
1375
+ const data = withRateLimitHint(429, await upstreamRes.text());
1376
+ const ct429 = upstreamRes.headers.get("content-type") || "application/json";
1377
+ res.writeHead(429, { "content-type": ct429 });
1378
+ res.end(data);
1379
+ return;
1380
+ }
1381
+ // Gated upstream: a 403 free-tier gate is a final answer, not a
1382
+ // stream — buffer it and attach the recovery hint instead of
1383
+ // piping it through as a stream body.
1384
+ if (upstreamRes.status === 403) {
1385
+ const data = withFreeTierHint(403, await upstreamRes.text());
1386
+ const ct403 = upstreamRes.headers.get("content-type") || "application/json";
1387
+ res.writeHead(403, { "content-type": ct403 });
1388
+ res.end(data);
1389
+ return;
1390
+ }
1391
+
1392
+ const outHeaders: Record<string, string> = {};
1393
+ for (const h of ["content-type", "cache-control", "x-request-id"] as const) {
1394
+ const v = upstreamRes.headers.get(h);
1395
+ if (v) outHeaders[h] = v;
1396
+ }
1397
+ outHeaders["x-content-type-options"] = "nosniff";
1398
+ outHeaders["connection"] = "keep-alive";
1399
+ const ka = upstreamRes.headers.get("keep-alive");
1400
+ if (ka) outHeaders["keep-alive"] = ka;
1401
+
1402
+ if (isStream && upstreamRes.body) {
1403
+ res.writeHead(upstreamRes.status, outHeaders);
1404
+ pipeUpstreamStream(
1405
+ Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
1406
+ res,
1407
+ req,
1408
+ reqId,
1409
+ "direct",
1410
+ );
1411
+ } else if (upstreamRes.body) {
1412
+ let rawText = await upstreamRes.text();
1413
+ if (upstreamRes.ok && (outHeaders["content-type"]?.includes("text/event-stream") || rawText.includes("data:"))) {
1414
+ rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected);
1415
+ outHeaders["content-type"] = "application/json";
1416
+ }
1417
+ res.writeHead(upstreamRes.status, outHeaders);
1418
+ res.end(rawText);
1419
+ } else {
1420
+ res.writeHead(upstreamRes.status, outHeaders);
1421
+ res.end();
1422
+ }
1423
+ } catch (proxyErr) {
1424
+ log("error", "proxy socket error", { error: String(proxyErr) }, reqId);
1425
+ if (!res.headersSent) {
1426
+ res.writeHead(502, { "content-type": "application/json" });
1427
+ res.end(JSON.stringify({ error: "upstream error" }));
1428
+ } else if (!res.writableEnded) {
1429
+ res.end();
1430
+ }
1431
+ }
1432
+ }
1433
+ } catch (err) {
1434
+ log("error", "proxy error", { error: String(err) }, reqId);
1435
+ if (!res.headersSent) {
1436
+ res.writeHead(502, { "content-type": "application/json" });
1437
+ }
1438
+ res.end(JSON.stringify({ error: "internal error" }));
1439
+ }
1440
+ });
1441
+ });
1442
+
1443
+ return new Promise<{ server: http.Server | null; port: number }>(
1444
+ (resolve, reject) => {
1445
+ let attempt = 0;
1446
+ let settled = false;
1447
+
1448
+ const tryListen = async (port: number) => {
1449
+ server.removeAllListeners("error");
1450
+ server.once("error", async (err: NodeJS.ErrnoException) => {
1451
+ if (settled) return;
1452
+ if (err.code === "EADDRINUSE") {
1453
+ // Cold-start race: a sibling may be binding the base port
1454
+ // right now. Re-probe it for up to 3s before accepting a
1455
+ // walked port so parallel startups converge on :28180.
1456
+ if (await reprobeBasePortAlive(basePort, BASE_PORT_REPROBE_MS)) {
1457
+ settled = true;
1458
+ log(
1459
+ "info",
1460
+ `attached to running proxy on http://${HOST}:${basePort}`,
1461
+ );
1462
+ resolve({ server: null, port: basePort });
1463
+ return;
1464
+ }
1465
+ if (attempt < 20) {
1466
+ attempt++;
1467
+ log("warn", `port ${port} taken — trying ${port + 1}`);
1468
+ tryListen(port + 1);
1469
+ return;
1470
+ }
1471
+ }
1472
+ settled = true;
1473
+ log("error", "server error", { code: err.code, message: err.message });
1474
+ reject(err);
1475
+ });
1476
+ server.listen(port, HOST, () => {
1477
+ if (settled) return;
1478
+ settled = true;
1479
+ try {
1480
+ server.unref();
1481
+ } catch { }
1482
+ const addr = server.address();
1483
+ const realPort = addr && typeof addr === "object" ? addr.port : port;
1484
+ if (realPort !== basePort) {
1485
+ log("warn", `base port ${basePort} busy — proxy listening on walked port http://${HOST}:${realPort}`);
1486
+ } else {
1487
+ log("info", `proxy listening on http://${HOST}:${realPort}`);
1488
+ }
1489
+ resolve({ server, port: realPort });
1490
+ });
1491
+ };
1492
+
1493
+ tryListen(basePort);
1494
+ },
1495
+ );
1496
+ }
1497
+
1498
+ /**
1499
+ * Poll the base port until it answers or the budget expires. Returns true as
1500
+ * soon as the port is alive (caller attaches instead of walking).
1501
+ */
1502
+ export async function reprobeBasePortAlive(port: number, timeoutMs: number): Promise<boolean> {
1503
+ const deadline = Date.now() + Math.max(0, timeoutMs);
1504
+ for (; ;) {
1505
+ if (await isProxyAlive(port)) return true;
1506
+ if (Date.now() >= deadline) return false;
1507
+ await new Promise<void>((r) => setTimeout(r, 200));
1508
+ }
1509
+ }