pi-freeflow 1.44.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +11 -13
  3. package/package.json +1 -1
  4. package/src/catalog.ts +38 -14
  5. package/src/client-recovery.ts +238 -0
  6. package/src/client-staging.ts +316 -0
  7. package/src/client.ts +117 -472
  8. package/src/cline-accounts.ts +26 -1223
  9. package/src/cline-device-auth.ts +5 -3
  10. package/src/cline-pool.ts +691 -0
  11. package/src/cline-roll.ts +642 -0
  12. package/src/commands-cline.ts +283 -0
  13. package/src/commands-relay.ts +808 -0
  14. package/src/commands-startup.ts +264 -0
  15. package/src/commands.ts +119 -1202
  16. package/src/config-paths.ts +254 -0
  17. package/src/config.ts +6 -687
  18. package/src/daemon.ts +16 -1
  19. package/src/deploy.ts +3 -1
  20. package/src/fingerprint-env.ts +334 -0
  21. package/src/fingerprint-tools.ts +348 -0
  22. package/src/index.ts +10 -6
  23. package/src/lane-translation.ts +263 -0
  24. package/src/models.ts +70 -112
  25. package/src/opencode-fingerprint.ts +29 -1310
  26. package/src/opencode-identity.ts +464 -0
  27. package/src/proxy-lanes.ts +1125 -0
  28. package/src/proxy-ports.ts +317 -0
  29. package/src/proxy.ts +74 -1385
  30. package/src/relay-codec.ts +365 -0
  31. package/src/relay-state.ts +10 -367
  32. package/src/relay.ts +78 -31
  33. package/src/sse-aggregator.ts +659 -0
  34. package/src/stream-cloak.ts +882 -0
  35. package/src/stream-pipe.ts +22 -871
  36. package/src/system-ca-fetch.ts +15 -4
  37. package/src/token.ts +7 -2
  38. package/src/tool-args.ts +10 -3
  39. package/src/tool-translation.ts +1 -236
  40. package/src/toolcall-audit.ts +20 -7
  41. package/src/update-checker.ts +17 -1
  42. package/src/update-core.ts +5 -2
  43. package/src/updater.ts +8 -3
  44. package/src/upstream-health.ts +22 -2
  45. package/src/zen-sidecar.ts +1 -0
  46. package/src/zen-transport.ts +48 -18
package/src/proxy.ts CHANGED
@@ -6,32 +6,25 @@
6
6
  */
7
7
 
8
8
  import { randomUUID } from "node:crypto";
9
- import fs from "node:fs";
10
- import { execSync } from "node:child_process";
11
9
  import * as http from "node:http";
12
- import * as https from "node:https";
13
- import net from "node:net";
14
- import { handleHealthRequest, isDaemonIdentityBody, isLoopbackIP } from "./health.ts";
15
- import type { DaemonHealthSnapshot } from "./health.ts";
10
+ import { handleHealthRequest, isLoopbackIP } from "./health.ts";
16
11
  import { getLeaseCount, registerClient, renewClient, touchActivity, unregisterClient } from "./lease.ts";
17
- import { ensureToken, isAuthorizedBearer, tokenAuthHeaders } from "./token.ts";
12
+ import {
13
+ ensureToken,
14
+ isAuthorizedBearer,
15
+ } from "./token.ts";
18
16
  import { Readable } from "node:stream";
19
17
  import type { ReadableStream as WebReadableStream } from "node:stream/web";
20
18
 
21
- import { getAliveCatalog, isFreeCatalogId } from "./catalog.ts";
19
+ import { getAliveCatalog } from "./catalog.ts";
22
20
  import {
23
21
  ALLOWED_METHODS,
24
22
  ALLOWED_PATH_PATTERN,
25
23
  BASE_PORT_REPROBE_MS,
26
- CLINE_CHAT_URL,
27
- DEFAULT_PORT,
28
24
  HOST,
29
- KILO_CHAT_URL,
30
- KILO_RESPONSES_URL,
31
25
  PATH_TRAVERSAL_PATTERN,
32
26
  MAX_BODY_BYTES,
33
27
  PORT,
34
- OVH_CHAT_URL,
35
28
  UPSTREAM_HEADER_TIMEOUT_MS,
36
29
  UPSTREAM_OPENCODE,
37
30
  OPENCODE_ORIGIN,
@@ -47,27 +40,20 @@ import {
47
40
  looksLikeSse,
48
41
  normalizePromptCacheKey,
49
42
  repairJsonToolArguments,
50
- sseToChatCompletionJson,
51
43
  type CaseRestoreMap,
52
44
  type FindGlobRestore,
53
45
  } from "./opencode-fingerprint.ts";
54
46
  import { isDebugEnabled, log } from "./logger.ts";
55
- import { getModelUpstream, isClineModel, isOvhModel, KILO_MODEL_IDS, MODEL_MAP, ovhUpstreamModelId, resolveCanonicalModelId } from "./models.ts";
56
- import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
57
- import { rollChat, clineUpstreamModelId, keepClineTokensAlive, refreshClineVersion } from "./cline-accounts.ts";
58
- import { fetchWithSystemCA } from "./system-ca-fetch.ts";
47
+ import {
48
+ getModelUpstream,
49
+ isClineModel,
50
+ isOvhModel,
51
+ KILO_MODEL_IDS,
52
+ MODEL_MAP,
53
+ resolveCanonicalModelId,
54
+ } from "./models.ts";
59
55
  import { fetchZenDirectViaSidecar, resolveZenSidecarScript, resolveZenTransport } from "./zen-transport.ts";
60
56
  import type { ZenTransportMode } from "./zen-transport.ts";
61
- import {
62
- buildFindGlobRestore,
63
- chatBodyFromResponsesBody,
64
- chatResponsesJsonFromChatCompletion,
65
- chatResponsesSseFromChatCompletion,
66
- restoreToolNameForCaller,
67
- translateToolsForPath,
68
- } from "./tool-translation.ts";
69
- import { buildArgRepairSchemas } from "./tool-args.ts";
70
- // normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
71
57
  import { relayFetch } from "./relay.ts";
72
58
  import { getActiveRelayState, markRelayFailure, orderedRelayCandidates } from "./relay-state.ts";
73
59
  import {
@@ -91,66 +77,51 @@ import {
91
77
  import { pipeUpstreamStream, type StreamCloakOptions } from "./stream-pipe.ts";
92
78
  import { createToolcallRecorder, type ToolcallRecorder, type ToolcallWire } from "./toolcall-audit.ts";
93
79
  import {
94
- decideZenRoute,
95
80
  isFreeTierGate,
96
81
  isFreeUsageShapeError,
97
- isUnprovenSession,
98
82
  isUpstreamGated,
99
- isZenFailoverEnabled,
100
- pickFailoverModel,
101
83
  recordUpstreamFailure,
102
84
  recordUpstreamSuccess,
103
85
  rejectionFingerprint,
104
86
  rememberGateRejection,
105
87
  sessionKeyOf,
106
- wasGateRejected,
107
88
  withFreeTierHint,
108
- attachHintForDisplay,
109
89
  } from "./upstream-health.ts";
110
90
 
91
+ import {
92
+ decrementActiveRequests,
93
+ getActiveRequests,
94
+ incrementActiveRequests,
95
+ isProxyAlive,
96
+ } from "./proxy-ports.ts";
97
+
98
+ import {
99
+ auditFinalBody,
100
+ auditWireFor,
101
+ classifyZenFailover,
102
+ declaredToolNames,
103
+ handleClineRequest,
104
+ handleKiloRequest,
105
+ handleOvhRequest,
106
+ isRelayEligibleModel,
107
+ isStaleOpenCodeVersionError,
108
+ relayBypassedForTransport,
109
+ stopClineKeepAlive,
110
+ upstreamTimeoutError,
111
+ withRateLimitHint,
112
+ } from "./proxy-lanes.ts";
113
+
114
+ export * from "./proxy-ports.ts";
115
+ export * from "./proxy-lanes.ts";
116
+
117
+ // Rate-limit hint string (defined in proxy-lanes.ts): "Shared free-tier IP quota reached. Add your own relay egress: /freeflow deploy (Cloudflare recommended, 100k req/day free)"
118
+ // Process executions in proxy-ports.ts use windowsHide: true
111
119
 
112
120
  let shutdownShouldExit = false;
113
121
  export function setShutdownShouldExit(v: boolean): void {
114
122
  shutdownShouldExit = v;
115
123
  }
116
124
 
117
- /**
118
- * Natural-429 hint throttle: the deploy guidance hint is attached to upstream
119
- * 429 passthroughs at most once per 10 minutes per process so repeated
120
- * rate-limit responses don't spam clients.
121
- */
122
- let last429HintAt = 0;
123
- function shouldShow429Hint(): boolean {
124
- const now = Date.now();
125
- if (now - last429HintAt < 10 * 60 * 1000) return false;
126
- last429HintAt = now;
127
- return true;
128
- }
129
- /** Test-only: reset 429 hint throttle */
130
- export function _reset429HintForTest(): void { last429HintAt = 0; }
131
-
132
- /** Deploy guidance attached to a natural upstream 429 once the throttle allows. */
133
- const RATE_LIMIT_HINT =
134
- "Shared free-tier IP quota reached. Add your own relay egress: /freeflow deploy (Cloudflare recommended, 100k req/day free)";
135
-
136
- /**
137
- * Attach the deploy hint to a natural upstream 429 JSON body. Anything else —
138
- * non-429 statuses, non-JSON bodies — passes through untouched without
139
- * consuming the throttle slot.
140
- */
141
- function withRateLimitHint(status: number, data: string): string {
142
- if (status !== 429) return data;
143
- // Shape 429 (FreeUsageLimitError without quota burn) is our fingerprint's
144
- // fault, not the pool's: the deploy hint would mislead, so it stays off.
145
- if (isFreeUsageShapeError(status, data)) return data;
146
- try {
147
- const parsed: unknown = JSON.parse(data);
148
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && shouldShow429Hint()) {
149
- return JSON.stringify(attachHintForDisplay(parsed as Record<string, unknown>, RATE_LIMIT_HINT));
150
- }
151
- } catch { }
152
- return data;
153
- }
154
125
  /**
155
126
  * Exhausted-disabled relay verdict from the relay layer (HTTP 503 JSON with
156
127
  * `error.code === "relay_disabled"`): every candidate rolled as disabled and
@@ -172,983 +143,6 @@ export function isRelayDisabledError(status: number, data: string): boolean {
172
143
  return false;
173
144
  }
174
145
  }
175
- /** Cline marker errors: each status carries account-actionable guidance. */
176
- const CLINE_RATE_LIMIT_HINT =
177
- "Cline free-use limit reached for this login. Wait for the reset or switch models.";
178
- const CLINE_FORBIDDEN_HINT =
179
- "Cline refused this request. Check the Cline login and retry.";
180
- const CLINE_AUTH_HINT =
181
- "Cline login expired or missing. Reconnect the Cline login and retry.";
182
- const CLINE_MODEL_HINT =
183
- "Cline has no such model. Refresh the model list and retry.";
184
-
185
- /**
186
- * What the roll knows about a free-limit exhaustion, for the 429 hint: how many
187
- * saved logins answered the cap and the nearest reset any of them stated.
188
- */
189
- export interface ClineLimitHint {
190
- logins: number;
191
- /** Epoch ms, or null when no login's body stated a reset. */
192
- resetAt: number | null;
193
- }
194
-
195
- /** Coarse reset delay ("20h 4m", "35m") — the hint names what the body stated. */
196
- function formatResetDelay(resetAt: number): string {
197
- const minutes = Math.max(1, Math.round((resetAt - Date.now()) / 60_000));
198
- const hours = Math.floor(minutes / 60);
199
- const rest = minutes % 60;
200
- if (hours === 0) return `${rest}m`;
201
- return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
202
- }
203
-
204
- /** Every saved login is out of free use for this model: say what helps next. */
205
- function clineAllLimitedHint(limit: ClineLimitHint): string {
206
- const reset = limit.resetAt === null ? "" : ` Nearest reset in about ${formatResetDelay(limit.resetAt)}.`;
207
- 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.`;
208
- }
209
-
210
- /**
211
- * Attach the matching hint to a Cline marker error JSON body (free-limit
212
- * 429, 403, 401, model 404). Anything else — other statuses, non-JSON
213
- * bodies — passes through untouched.
214
- *
215
- * `limit` is present only when the roll tried every saved login and every one
216
- * of them answered the daily free cap; then "for this login" would be wrong and
217
- * hide that another login is the way out.
218
- */
219
- export function mapClineError(status: number, data: string, limit?: ClineLimitHint): string {
220
- // Synthetic pool-exhausted bodies already tell the user exactly what to do
221
- // (sign in again) — a rate-limit hint would mislead.
222
- if (data.includes("cline_pool_exhausted")) return data;
223
- const hint = status === 429
224
- ? (limit ? clineAllLimitedHint(limit) : CLINE_RATE_LIMIT_HINT)
225
- : status === 403
226
- ? CLINE_FORBIDDEN_HINT
227
- : status === 401
228
- ? CLINE_AUTH_HINT
229
- : status === 404
230
- ? CLINE_MODEL_HINT
231
- : null;
232
- if (hint === null) return data;
233
- try {
234
- const parsed: unknown = JSON.parse(data);
235
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
236
- // Hosts render error.message and ignore the sibling hint field, so the
237
- // guidance must live in the visible message too (hint kept for compat).
238
- return JSON.stringify(attachHintForDisplay(parsed as Record<string, unknown>, hint));
239
- }
240
- } catch { }
241
- return data;
242
- }
243
- /**
244
- * Device-login refresher for the Cline pool: one refresh per stale slot per
245
- * turn. A grant Cline rejects outright (HTTP 4xx, or a 200 without usable
246
- * tokens) is dead — it resolves null so the pool stops trying that slot until
247
- * the user logs in again. Anything else (network fault, upstream 5xx) throws,
248
- * which keeps the stale bearer for this turn.
249
- */
250
- async function clineRefreshImpl(refreshToken: string): Promise<{ token: string; refreshToken?: string; expiresAt?: number } | null> {
251
- try {
252
- const creds = await refreshClineToken(undefined, refreshToken);
253
- return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
254
- } catch (e) {
255
- if (e instanceof ClineAuthError && typeof e.status === "number" && e.status >= 400 && e.status < 500) {
256
- log("warn", "cline refresh grant rejected — slot needs a fresh login", { status: e.status });
257
- return null;
258
- }
259
- if (e instanceof Error && /^Invalid token response|^Token response did not include a refresh token/.test(e.message)) {
260
- log("warn", "cline refresh returned no usable tokens — slot needs a fresh login", { reason: e.message });
261
- return null;
262
- }
263
- throw e;
264
- }
265
- }
266
-
267
- let clineKeepAliveTimer: NodeJS.Timeout | null = null;
268
- const CLINE_KEEP_ALIVE_INTERVAL_MS = 15 * 60 * 1000;
269
-
270
- export function startClineKeepAlive(): void {
271
- if (clineKeepAliveTimer !== null) return;
272
- void keepClineTokensAlive().catch(() => {});
273
- void refreshClineVersion().catch(() => {});
274
- clineKeepAliveTimer = setInterval(() => {
275
- void keepClineTokensAlive().catch(() => {});
276
- void refreshClineVersion().catch(() => {});
277
- }, CLINE_KEEP_ALIVE_INTERVAL_MS);
278
- clineKeepAliveTimer.unref?.();
279
- }
280
-
281
- export function stopClineKeepAlive(): void {
282
- if (clineKeepAliveTimer !== null) {
283
- clearInterval(clineKeepAliveTimer);
284
- clineKeepAliveTimer = null;
285
- }
286
- }
287
-
288
- /**
289
- * Bound on the caller tool names the audit summary carries (the recorder
290
- * applies the same caps itself; bounding here keeps a hostile body from
291
- * building a large array first).
292
- */
293
- const AUDIT_MAX_DECLARED = 32;
294
- const AUDIT_MAX_NAME = 64;
295
-
296
- /**
297
- * Caller-declared tool names from a request body, in all three wire shapes:
298
- * chat `{type:"function",function:{name}}`, responses `{type:"function",name}`
299
- * and messages `{name}`. Names only — the audit never records schemas.
300
- */
301
- function declaredToolNames(body: Record<string, unknown>): string[] {
302
- const names: string[] = [];
303
- const tools = body.tools;
304
- if (!Array.isArray(tools)) return names;
305
- for (const raw of tools) {
306
- if (names.length >= AUDIT_MAX_DECLARED) break;
307
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
308
- const rec = raw as Record<string, unknown>;
309
- const fn = rec.function;
310
- const fnRec = fn !== null && typeof fn === "object" && !Array.isArray(fn)
311
- ? (fn as Record<string, unknown>)
312
- : null;
313
- const rawName = typeof fnRec?.name === "string" ? fnRec.name : rec.name;
314
- if (typeof rawName !== "string" || rawName === "") continue;
315
- const name = rawName.length > AUDIT_MAX_NAME ? rawName.slice(0, AUDIT_MAX_NAME) : rawName;
316
- if (!names.includes(name)) names.push(name);
317
- }
318
- return names;
319
- }
320
-
321
- /** Audit wire of a request path: the body shape upstream is asked for. */
322
- function auditWireFor(pathname: string): ToolcallWire {
323
- if (pathname.endsWith("/responses")) return "responses";
324
- if (pathname.endsWith("/messages")) return "messages";
325
- return "chat";
326
- }
327
-
328
- /**
329
- * Classify an already-assembled chat completion for the audit (non-stream
330
- * path). Never throws: the audit is best-effort and must stay off the request
331
- * path. A body without `choices` records a tool-less turn, which is exactly
332
- * what the caller received.
333
- */
334
- function auditChatCompletion(audit: ToolcallRecorder | undefined, completion: unknown): void {
335
- if (audit === undefined || !audit.active) return;
336
- try {
337
- const parsed = completion !== null && typeof completion === "object" && !Array.isArray(completion)
338
- ? (completion as Record<string, unknown>)
339
- : {};
340
- const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
341
- const first = choices.length > 0 && choices[0] !== null && typeof choices[0] === "object" && !Array.isArray(choices[0])
342
- ? (choices[0] as Record<string, unknown>)
343
- : undefined;
344
- const message = first !== undefined && first.message !== null && typeof first.message === "object" && !Array.isArray(first.message)
345
- ? (first.message as Record<string, unknown>)
346
- : {};
347
- const finishReason = typeof first?.finish_reason === "string" ? (first.finish_reason as string) : null;
348
- audit.finishFromChat(message, finishReason);
349
- } catch {
350
- // Unreadable body: nothing to record.
351
- }
352
- }
353
-
354
- /** `auditChatCompletion` over serialized JSON (the aggregated non-stream body). */
355
- function auditChatBody(audit: ToolcallRecorder | undefined, json: string): void {
356
- if (audit === undefined || !audit.active) return;
357
- try {
358
- auditChatCompletion(audit, JSON.parse(json));
359
- } catch {
360
- // Not JSON: nothing to record.
361
- }
362
- }
363
-
364
- /**
365
- * Classify a non-stream turn of a Responses/Anthropic wire: the recorder reads
366
- * the raw SSE text itself. A body that carried no SSE frames is left
367
- * unrecorded rather than guessed as a tool-less turn — the recorder has no
368
- * reader for a plain object of those wires.
369
- */
370
- function auditSseBody(audit: ToolcallRecorder | undefined, text: string): void {
371
- if (audit === undefined || !audit.active) return;
372
- if (!text.includes("data:")) return;
373
- try {
374
- audit.observe(text);
375
- audit.finish();
376
- } catch {
377
- // Best-effort by contract.
378
- }
379
- }
380
-
381
- /**
382
- * Non-stream final classification: a chat-wire body parses directly; the
383
- * responses/messages wires are classified from the raw upstream SSE text.
384
- */
385
- function auditFinalBody(
386
- audit: ToolcallRecorder | undefined,
387
- pathname: string,
388
- rawUpstream: string,
389
- served: string,
390
- ): void {
391
- if (auditWireFor(pathname) === "chat") auditChatBody(audit, served);
392
- else auditSseBody(audit, rawUpstream);
393
- }
394
-
395
- /**
396
- * Serve a Cline-model request direct (never relay pool, no opencode
397
- * fingerprint). Cline serves chat completions only: responses-path bodies
398
- * translate to chat upstream and the chat answer translates back, re-emitted
399
- * as SSE when the caller streamed. Upstream always streams; callers that
400
- * asked for plain JSON get the aggregated object.
401
- */
402
- async function handleClineRequest(opts: {
403
- parsedBody: Record<string, unknown>;
404
- pathname: string;
405
- req: http.IncomingMessage;
406
- res: http.ServerResponse;
407
- reqId: string;
408
- clientRequestedStream: boolean;
409
- audit?: ToolcallRecorder;
410
- }): Promise<void> {
411
- const { parsedBody, pathname, req, res, reqId, clientRequestedStream, audit } = opts;
412
- const responsesRequest = pathname.endsWith("/responses");
413
- const model = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
414
- let chatBody: Record<string, unknown>;
415
- if (responsesRequest) {
416
- chatBody = chatBodyFromResponsesBody(parsedBody);
417
- } else {
418
- chatBody = { ...parsedBody };
419
- if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
420
- delete chatBody.prompt_cache_key;
421
- }
422
- chatBody.model = clineUpstreamModelId(model);
423
- // Cline bypasses the fingerprint, but its arguments still malform the same
424
- // way: capture the caller's own schemas before translation for repair.
425
- const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
426
- // Translation renames the caller's `find` to `glob` upstream and folds any
427
- // name the upstream shape forbids; without this map the answer returns under
428
- // a name Pi never registered.
429
- const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
430
- chatBody.stream = true;
431
- let upstreamRes: Response;
432
- let limitHint: ClineLimitHint | undefined;
433
- // Client-gone abort: a roll left running after the caller's socket died
434
- // leaves its upstream body paused forever and the handler never finishes.
435
- // `res.writableEnded` is false for a destroyed socket, so the roll carries
436
- // our signal (injected through the roll's fetch) and the resolved body is
437
- // cancelled at the check below.
438
- const clineController = new AbortController();
439
- const abortClineOnClientGone = () => {
440
- if (!res.writableEnded) clineController.abort();
441
- };
442
- res.once("close", abortClineOnClientGone);
443
- req.once("error", abortClineOnClientGone);
444
- try {
445
- const result = await rollChat({
446
- body: JSON.stringify(chatBody),
447
- chatUrl: CLINE_CHAT_URL,
448
- refreshImpl: clineRefreshImpl,
449
- fetchImpl: (url, init) => fetchWithSystemCA(url, { ...init, signal: clineController.signal }),
450
- });
451
- upstreamRes = result.res;
452
- // The roll returns the last upstream failure when the pool is exhausted,
453
- // so "served by" must only log on an actual success.
454
- if (upstreamRes.ok && typeof result.slot === "string" && result.slot.length > 0) {
455
- log("debug", `cline served by slot ${result.slot}`, { model }, reqId);
456
- }
457
- // Every saved login answered the daily free cap: name that instead of
458
- // blaming "this login", which hides that another login or another model works.
459
- if (result.limitOnly && result.logins > 1) {
460
- limitHint = { logins: result.logins, resetAt: result.earliestResetAt };
461
- }
462
- } catch (e) {
463
- log("error", "cline pool error", { error: String(e), model }, reqId);
464
- if (!res.headersSent) {
465
- res.writeHead(502, { "content-type": "application/json" });
466
- res.end(JSON.stringify({ error: "upstream error" }));
467
- }
468
- return;
469
- } finally {
470
- res.off("close", abortClineOnClientGone);
471
- req.off("error", abortClineOnClientGone);
472
- }
473
- if (res.destroyed || res.writableEnded) {
474
- try { await upstreamRes.body?.cancel(); } catch { }
475
- return;
476
- }
477
- if (!upstreamRes.ok) {
478
- log("warn", `cline upstream ${upstreamRes.status} for model ${model}`, { status: upstreamRes.status, model }, reqId);
479
- // A tools request rejected upstream (e.g. a tool-schema 400) is the verdict
480
- // the host will see; record it so the audit names the cause.
481
- audit?.attemptFailure({ attempt: 0, status: upstreamRes.status, reason: "final-upstream-status" });
482
- const raw = await upstreamRes.text().catch(() => null);
483
- if (raw === null) {
484
- log("error", "cline upstream body unreadable", { status: upstreamRes.status, model }, reqId);
485
- if (!res.headersSent) {
486
- res.writeHead(502, { "content-type": "application/json" });
487
- res.end(JSON.stringify({ error: "upstream error" }));
488
- } else if (!res.writableEnded) {
489
- res.end();
490
- }
491
- return;
492
- }
493
- const data = mapClineError(upstreamRes.status, raw, limitHint);
494
- if (!res.headersSent) {
495
- res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
496
- res.end(data);
497
- } else if (!res.writableEnded) {
498
- res.end();
499
- }
500
- return;
501
- }
502
- if (!responsesRequest && clientRequestedStream && upstreamRes.body) {
503
- res.writeHead(upstreamRes.status, {
504
- "content-type": upstreamRes.headers.get("content-type") || "text/event-stream",
505
- "cache-control": "no-cache, no-transform",
506
- connection: "keep-alive",
507
- "x-accel-buffering": "no",
508
- });
509
- pipeUpstreamStream(
510
- Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
511
- res,
512
- req,
513
- reqId,
514
- "direct",
515
- findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
516
- ? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
517
- : undefined,
518
- { upstreamContentType: upstreamRes.headers.get("content-type"), audit: audit?.active ? audit : undefined },
519
- );
520
- return;
521
- }
522
- const sseText = await upstreamRes.text().catch(() => null);
523
- if (sseText === null) {
524
- log("error", "cline upstream stream unreadable", { status: upstreamRes.status, model }, reqId);
525
- if (!res.headersSent) {
526
- res.writeHead(502, { "content-type": "application/json" });
527
- res.end(JSON.stringify({ error: "upstream error" }));
528
- } else if (!res.writableEnded) {
529
- res.end();
530
- }
531
- return;
532
- }
533
- if (!responsesRequest) {
534
- const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
535
- auditChatBody(audit, data);
536
- if (!res.headersSent) {
537
- res.writeHead(upstreamRes.status, { "content-type": "application/json" });
538
- res.end(data);
539
- } else if (!res.writableEnded) {
540
- res.end();
541
- }
542
- return;
543
- }
544
- const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
545
- // Classify the chat object before it is mapped: finishFromChat reads the
546
- // assistant message the model actually produced.
547
- auditChatCompletion(audit, chat);
548
- const resp = chatResponsesJsonFromChatCompletion(chat, model);
549
- if (clientRequestedStream) {
550
- if (!res.headersSent) {
551
- res.writeHead(200, {
552
- "content-type": "text/event-stream",
553
- "cache-control": "no-cache, no-transform",
554
- connection: "keep-alive",
555
- "x-accel-buffering": "no",
556
- });
557
- res.end(chatResponsesSseFromChatCompletion(resp));
558
- } else if (!res.writableEnded) {
559
- res.end();
560
- }
561
- return;
562
- }
563
- if (!res.headersSent) {
564
- res.writeHead(200, { "content-type": "application/json" });
565
- res.end(JSON.stringify(resp));
566
- } else if (!res.writableEnded) {
567
- res.end();
568
- }
569
- }
570
-
571
- // ── OVH lane quirks ───────────────────────────────────────────────────
572
- // Two catalog models have upstream-side quirks this lane must absorb. Both
573
- // checks key off the canonical catalog id, never the wire id: the wire id has
574
- // already lost its `ovh-free/` prefix, and a short alias (`qwen3.8-27b`,
575
- // `mistral-small-3.2`) must resolve to the same model.
576
-
577
- /** Qwen3.8-27B on OVH: `reasoning_effort` starves the model into an empty turn. */
578
- const OVH_NO_EFFORT_MODEL = "ovh-free/Qwen3.8-27B";
579
- /** Mistral Small 3.2 on OVH: replayed tool-call ids must be 9 alphanumerics. */
580
- const OVH_STRICT_TOOL_CALL_ID_MODEL = "ovh-free/Mistral-Small-3.2-24B-Instruct-2506";
581
- const OVH_TOOL_CALL_ID_RE = /^[A-Za-z0-9]{9}$/;
582
-
583
- /**
584
- * Rewrite the non-conforming tool-call ids in a chat body's messages.
585
- *
586
- * Upstream validates every replayed id against "a-z, A-Z, 0-9, length 9" and
587
- * drops the whole turn otherwise: a 400 `Tool call id was ll_loop_1 but must be
588
- * a-z, A-Z, 0-9, with a length of 9` for a non-streamed call, and the same
589
- * rejection as a silent `data: [DONE]`-only 200 when streaming (measured
590
- * 2026-10-22). Upstream mints conforming ids itself (observed `hCCedplH4`), so
591
- * only host-supplied history needs fixing. Replacements are deterministic
592
- * (`t` + 8-character zero-padded base36 of the request-local sequence) and the
593
- * same map is applied to the matching `role:"tool"` message, so the pair a host
594
- * replays next turn stays equal. Conforming ids pass through untouched.
595
- */
596
- function normalizeOvhToolCallIds(messages: unknown): void {
597
- if (!Array.isArray(messages)) return;
598
- const taken = new Set<string>();
599
- const broken: string[] = [];
600
- const note = (id: unknown): void => {
601
- if (typeof id !== "string" || id.length === 0) return;
602
- if (OVH_TOOL_CALL_ID_RE.test(id)) taken.add(id);
603
- else if (!broken.includes(id)) broken.push(id);
604
- };
605
- for (const message of messages) {
606
- if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
607
- const rec = message as Record<string, unknown>;
608
- if (Array.isArray(rec.tool_calls)) {
609
- for (const call of rec.tool_calls) {
610
- if (call !== null && typeof call === "object" && !Array.isArray(call)) note((call as Record<string, unknown>).id);
611
- }
612
- }
613
- if (rec.role === "tool") note(rec.tool_call_id);
614
- }
615
- if (broken.length === 0) return;
616
- const remap = new Map<string, string>();
617
- let seq = 0;
618
- for (const id of broken) {
619
- let next = `t${seq.toString(36).padStart(8, "0")}`;
620
- while (taken.has(next)) {
621
- seq += 1;
622
- next = `t${seq.toString(36).padStart(8, "0")}`;
623
- }
624
- seq += 1;
625
- taken.add(next);
626
- remap.set(id, next);
627
- }
628
- for (const message of messages) {
629
- if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
630
- const rec = message as Record<string, unknown>;
631
- if (Array.isArray(rec.tool_calls)) {
632
- for (const call of rec.tool_calls) {
633
- if (call === null || typeof call !== "object" || Array.isArray(call)) continue;
634
- const callRec = call as Record<string, unknown>;
635
- const next = remap.get(callRec.id as string);
636
- if (next !== undefined) callRec.id = next;
637
- }
638
- }
639
- if (rec.role === "tool") {
640
- const next = remap.get(rec.tool_call_id as string);
641
- if (next !== undefined) rec.tool_call_id = next;
642
- }
643
- }
644
- }
645
-
646
- /**
647
- * True when an aggregated chat answer carries neither assistant text nor a
648
- * tool call — the exact shape OVH produces when it starves a request (Qwen
649
- * with a `reasoning_effort` field, Mistral with a malformed replayed tool-call
650
- * id) and closes the stream after a bare `data: [DONE]`. Aggregating that
651
- * yields `content: null, finish_reason: "stop"`, which the host reads as a
652
- * finished empty turn; the lane must surface it instead of fabricating it.
653
- */
654
- function ovhAnswerIsEmpty(chat: Record<string, unknown>): boolean {
655
- const choices = Array.isArray(chat.choices) ? chat.choices : [];
656
- const first = choices.length > 0 && choices[0] !== null && typeof choices[0] === "object" && !Array.isArray(choices[0])
657
- ? choices[0] as Record<string, unknown>
658
- : undefined;
659
- const message = first !== undefined && first.message !== null && typeof first.message === "object" && !Array.isArray(first.message)
660
- ? first.message as Record<string, unknown>
661
- : undefined;
662
- if (message === undefined) return true;
663
- if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return false;
664
- const content = message.content;
665
- if (typeof content === "string") return content.length === 0;
666
- if (Array.isArray(content)) return content.length === 0;
667
- return true;
668
- }
669
-
670
- /** `ovhAnswerIsEmpty` over the JSON text `convertSseToJson` serialized. */
671
- function ovhAggregateIsEmpty(data: string): boolean {
672
- let parsed: unknown;
673
- try {
674
- parsed = JSON.parse(data);
675
- } catch {
676
- return false;
677
- }
678
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
679
- return ovhAnswerIsEmpty(parsed as Record<string, unknown>);
680
- }
681
-
682
- /**
683
- * Answer an empty upstream stream as an error. Never emit the aggregated
684
- * `chatcmpl-freeflow` shell for it: that is a fabricated 200 completion with
685
- * `content: null` and `finish_reason: "stop"`, the very thing that hid these
686
- * upstream failures from the host.
687
- */
688
- function respondOvhEmptyStream(res: http.ServerResponse, reqId: string, model: string): void {
689
- log("warn", `ovh upstream returned an empty stream for model ${model}`, { model }, reqId);
690
- if (!res.headersSent) {
691
- res.writeHead(502, { "content-type": "application/json" });
692
- res.end(JSON.stringify({ error: "upstream error", detail: "upstream returned an empty stream" }));
693
- } else if (!res.writableEnded) {
694
- res.end();
695
- }
696
- }
697
-
698
- /**
699
- * Serve an OVH-model request direct (never relay pool, no opencode
700
- * fingerprint). Chat-only upstream: OVH's responses endpoint rejects every
701
- * array `input` shape with 422 "input: data did not match any variant of
702
- * untagged enum ResponseInput" and accepts only a bare string (probed
703
- * 2026-10-13), so a responses-path body translates to chat upstream and the
704
- * chat answer translates back — the same shape mapping the Cline lane uses.
705
- * Keyless: no Authorization header, because OVH answers anonymous callers.
706
- * Upstream always streams; callers that asked for plain JSON get the
707
- * aggregated object, and a streamed responses caller gets SSE re-emitted
708
- * from the mapped Responses object.
709
- */
710
- async function handleOvhRequest(opts: {
711
- parsedBody: Record<string, unknown>;
712
- pathname: string;
713
- req: http.IncomingMessage;
714
- res: http.ServerResponse;
715
- reqId: string;
716
- clientRequestedStream: boolean;
717
- audit?: ToolcallRecorder;
718
- }): Promise<void> {
719
- const { parsedBody, pathname, req, res, reqId, clientRequestedStream, audit } = opts;
720
- const responsesRequest = pathname.endsWith("/responses");
721
- const requestedModel = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
722
- let chatBody: Record<string, unknown>;
723
- if (responsesRequest) {
724
- chatBody = chatBodyFromResponsesBody(parsedBody);
725
- } else {
726
- chatBody = { ...parsedBody };
727
- if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
728
- delete chatBody.prompt_cache_key;
729
- }
730
- // OVH knows only the bare id. Canonicalise before stripping: a short alias
731
- // (`llama-3.3-70b`) must resolve to `ovh-free/Meta-Llama-3_3-70B-Instruct`
732
- // first, or it reaches OVH unrecognised and answers 404 model_not_found.
733
- chatBody.model = ovhUpstreamModelId(requestedModel);
734
- // Lane quirks, keyed by canonical catalog id so an alias resolves to the same
735
- // model (the wire id above has already lost its `ovh-free/` prefix).
736
- const canonicalModelId = resolveCanonicalModelId(requestedModel);
737
- // OVH's OpenAI-compatible surface rejects Qwen chat-template controls
738
- // (`400 feature 'extra arguments: {"chat_template_kwargs":…}' is not
739
- // currently supported`). The host classifies this loopback proxy as a local
740
- // OpenAI-compatible backend, treats `Qwen3.x` models as template-dialect
741
- // Qwen and injects them; the lane has no such feature. Strip defensively so
742
- // no host version can leak them upstream.
743
- delete chatBody.chat_template_kwargs;
744
- delete chatBody.enable_thinking;
745
- delete chatBody.preserve_thinking;
746
- // OVH's OpenAI-compatible surface rejects Responses-native caps (`400 feature
747
- // 'extra arguments: {"max_output_tokens":…}' is not currently supported`)
748
- // when a responses-path request was translated via chatBodyFromResponsesBody.
749
- // Strip defensively so the cap is not passed as an unknown extra argument.
750
- delete chatBody.max_output_tokens;
751
- const modelDef = MODEL_MAP.get(canonicalModelId) ?? MODEL_MAP.get(requestedModel);
752
- if (canonicalModelId === OVH_NO_EFFORT_MODEL || (modelDef && !modelDef.reasoning)) {
753
- // Qwen3.8-27B on OVH starves when reasoning_effort is present.
754
- // Non-reasoning OVH models (Llama 3.3, Mistral Small) return HTTP 400
755
- // "feature 'reasoning_effort for this model' is not currently supported".
756
- delete chatBody.reasoning_effort;
757
- }
758
- if (canonicalModelId === OVH_STRICT_TOOL_CALL_ID_MODEL) {
759
- // Quirk documented on normalizeOvhToolCallIds: a host-supplied history id
760
- // that is not 9 alphanumerics kills the turn upstream (400 non-streamed,
761
- // silent `[DONE]`-only 200 streamed).
762
- normalizeOvhToolCallIds(chatBody.messages);
763
- }
764
- // Bypassing the fingerprint does not exempt OVH from malformed tool
765
- // arguments: capture the caller's own schemas before translation for repair.
766
- const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
767
- // Translation renames the caller's `find` to `glob` upstream and folds any
768
- // name the upstream shape forbids; without this map the answer returns under
769
- // a name Pi never registered.
770
- const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
771
- chatBody.stream = true;
772
- // Header-wait timeout + client-disconnect abort, cleared once headers arrive
773
- // so a long stream is not killed at the timeout ceiling.
774
- const controller = new AbortController();
775
- const timeoutId = setTimeout(
776
- () => controller.abort(upstreamTimeoutError()),
777
- UPSTREAM_HEADER_TIMEOUT_MS,
778
- );
779
- const abortOnClientGone = () => {
780
- if (!res.writableEnded) controller.abort();
781
- };
782
- res.once("close", abortOnClientGone);
783
- req.once("error", abortOnClientGone);
784
- let upstreamRes: Response;
785
- let servedRelay: string | null = null;
786
- try {
787
- const relayState = getActiveRelayState();
788
- const relayPoolOn =
789
- relayState.mode !== "off" &&
790
- relayState.enabled !== false &&
791
- Boolean(relayState.url || (relayState.relays && relayState.relays.length > 0));
792
-
793
- if (relayPoolOn) {
794
- upstreamRes = await relayFetch(
795
- OVH_CHAT_URL,
796
- {
797
- method: "POST",
798
- headers: { "Content-Type": "application/json" },
799
- body: JSON.stringify(chatBody),
800
- signal: controller.signal,
801
- },
802
- reqId,
803
- {
804
- roundRobin: true,
805
- onServed: (url) => { servedRelay = url; },
806
- },
807
- (failure) => audit?.attemptFailure({ attempt: failure.attempt, status: failure.status, reason: failure.reason }),
808
- );
809
- } else {
810
- upstreamRes = await fetch(OVH_CHAT_URL, {
811
- method: "POST",
812
- headers: { "Content-Type": "application/json" },
813
- body: JSON.stringify(chatBody),
814
- signal: controller.signal,
815
- });
816
- }
817
- } catch (e) {
818
- log("error", "ovh upstream fetch failed", { error: String(e), model: requestedModel }, reqId);
819
- if (!res.headersSent) {
820
- res.writeHead(502, { "content-type": "application/json" });
821
- res.end(JSON.stringify({ error: "upstream error" }));
822
- } else if (!res.writableEnded) {
823
- res.end();
824
- }
825
- return;
826
- } finally {
827
- clearTimeout(timeoutId);
828
- res.off("close", abortOnClientGone);
829
- req.off("error", abortOnClientGone);
830
- }
831
- if (res.writableEnded) {
832
- try { await upstreamRes.body?.cancel(); } catch { }
833
- return;
834
- }
835
- if (!upstreamRes.ok) {
836
- log("warn", `ovh upstream ${upstreamRes.status} for model ${requestedModel}`, { status: upstreamRes.status, model: requestedModel }, reqId);
837
- audit?.attemptFailure({ attempt: 0, status: upstreamRes.status, reason: "final-upstream-status" });
838
- const raw = await upstreamRes.text().catch(() => null);
839
- if (raw === null) {
840
- log("error", "ovh upstream body unreadable", { status: upstreamRes.status, model: requestedModel }, reqId);
841
- if (!res.headersSent) {
842
- res.writeHead(502, { "content-type": "application/json" });
843
- res.end(JSON.stringify({ error: "upstream error" }));
844
- } else if (!res.writableEnded) {
845
- res.end();
846
- }
847
- return;
848
- }
849
- if (!res.headersSent) {
850
- res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
851
- res.end(raw);
852
- } else if (!res.writableEnded) {
853
- res.end();
854
- }
855
- return;
856
- }
857
- if (!responsesRequest && clientRequestedStream && upstreamRes.body) {
858
- res.writeHead(upstreamRes.status, {
859
- "content-type": upstreamRes.headers.get("content-type") || "text/event-stream",
860
- "cache-control": "no-cache, no-transform",
861
- connection: "keep-alive",
862
- "x-accel-buffering": "no",
863
- });
864
- // Fetched via relay pool or direct fallback.
865
- pipeUpstreamStream(
866
- Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
867
- res,
868
- req,
869
- reqId,
870
- servedRelay ?? "direct",
871
- {
872
- callerHadTools: Array.isArray(parsedBody.tools) && parsedBody.tools.length > 0,
873
- argSchemas,
874
- findGlob,
875
- pathname: "/v1/chat/completions",
876
- normalizeReasoning: true,
877
- },
878
- { upstreamContentType: upstreamRes.headers.get("content-type"), audit: audit?.active ? audit : undefined },
879
- );
880
- return;
881
- }
882
- const sseText = await upstreamRes.text().catch(() => null);
883
- if (sseText === null) {
884
- log("error", "ovh upstream stream unreadable", { status: upstreamRes.status, model: requestedModel }, reqId);
885
- if (!res.headersSent) {
886
- res.writeHead(502, { "content-type": "application/json" });
887
- res.end(JSON.stringify({ error: "upstream error" }));
888
- } else if (!res.writableEnded) {
889
- res.end();
890
- }
891
- return;
892
- }
893
- // The emptiness guard below is about a real upstream *stream*: a 200 body
894
- // that is not SSE at all is passed through by `convertSseToJson` by
895
- // contract, and says nothing about starvation.
896
- const upstreamSpokeSse = looksLikeSse(sseText.trim());
897
- if (!responsesRequest) {
898
- const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
899
- // Classified before the starvation guard: a starved turn is exactly the
900
- // no_tool_calls record worth keeping.
901
- auditChatBody(audit, data);
902
- // An upstream that starved the request closes with a bare `data: [DONE]`:
903
- // the aggregate is an empty assistant turn, which must surface as an error
904
- // rather than as a finished (and silently blank) completion.
905
- if (upstreamSpokeSse && ovhAggregateIsEmpty(data)) {
906
- respondOvhEmptyStream(res, reqId, requestedModel);
907
- return;
908
- }
909
- if (!res.headersSent) {
910
- res.writeHead(upstreamRes.status, { "content-type": "application/json" });
911
- res.end(data);
912
- } else if (!res.writableEnded) {
913
- res.end();
914
- }
915
- return;
916
- }
917
- const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
918
- auditChatCompletion(audit, chat);
919
- if (upstreamSpokeSse && ovhAnswerIsEmpty(chat)) {
920
- respondOvhEmptyStream(res, reqId, requestedModel);
921
- return;
922
- }
923
- const resp = chatResponsesJsonFromChatCompletion(chat, requestedModel);
924
- if (clientRequestedStream) {
925
- if (!res.headersSent) {
926
- res.writeHead(200, {
927
- "content-type": "text/event-stream",
928
- "cache-control": "no-cache, no-transform",
929
- connection: "keep-alive",
930
- "x-accel-buffering": "no",
931
- });
932
- res.end(chatResponsesSseFromChatCompletion(resp));
933
- } else if (!res.writableEnded) {
934
- res.end();
935
- }
936
- return;
937
- }
938
- if (!res.headersSent) {
939
- res.writeHead(200, { "content-type": "application/json" });
940
- res.end(JSON.stringify(resp));
941
- } else if (!res.writableEnded) {
942
- res.end();
943
- }
944
- }
945
-
946
- /**
947
- * Keyless gateway handling for Kilo models (both direct Kilo models and
948
- * Zen sessions failed over to Kilo). Serves chat completions and responses
949
- * natively, repairs tool arguments, and recovers from 400 token overflows.
950
- */
951
- async function handleKiloRequest(opts: {
952
- parsedBody: Record<string, unknown>;
953
- pathname: string;
954
- req: http.IncomingMessage;
955
- res: http.ServerResponse;
956
- reqId: string;
957
- clientRequestedStream: boolean;
958
- sessionKey?: string | null;
959
- audit?: ToolcallRecorder;
960
- }): Promise<void> {
961
- const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey, audit } = opts;
962
- const isStream = clientRequestedStream;
963
- const kiloController = new AbortController();
964
- const kiloArgSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
965
- const kiloTimeoutId = setTimeout(
966
- () => kiloController.abort(upstreamTimeoutError()),
967
- UPSTREAM_HEADER_TIMEOUT_MS,
968
- );
969
- const abortKiloOnClientGone = () => {
970
- if (!res.writableEnded) kiloController.abort();
971
- };
972
- res.once("close", abortKiloOnClientGone);
973
- req.once("error", abortKiloOnClientGone);
974
- let response: Response;
975
- const kiloUrl = pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
976
- try {
977
- response = await relayFetch(
978
- kiloUrl,
979
- {
980
- method: "POST",
981
- headers: {
982
- "Content-Type": "application/json",
983
- },
984
- body: JSON.stringify(parsedBody),
985
- signal: kiloController.signal,
986
- },
987
- reqId,
988
- undefined,
989
- (info) => audit?.attemptFailure(info),
990
- );
991
- if (response.status === 400 && !res.writableEnded) {
992
- // Token-clamp retry only. The model is NEVER reassigned: a 400 must
993
- // surface as upstream's verdict for the model the caller asked for, not
994
- // be answered from another model's stream with only the `model` field as
995
- // a trace (a silent substitution the product forbids).
996
- const retryBody = { ...parsedBody };
997
- let changed = false;
998
- if (typeof retryBody.max_output_tokens === "number" && retryBody.max_output_tokens > 4096) {
999
- retryBody.max_output_tokens = 4096;
1000
- changed = true;
1001
- }
1002
- if (typeof retryBody.max_tokens === "number" && retryBody.max_tokens > 4096) {
1003
- retryBody.max_tokens = 4096;
1004
- changed = true;
1005
- }
1006
- if (changed) {
1007
- log("warn", `kilo 400 for model ${String(parsedBody.model)} — retrying with clamped tokens`, { model: parsedBody.model }, reqId);
1008
- try {
1009
- const retryRes = await relayFetch(
1010
- kiloUrl,
1011
- {
1012
- method: "POST",
1013
- headers: {
1014
- "Content-Type": "application/json",
1015
- },
1016
- body: JSON.stringify(retryBody),
1017
- signal: kiloController.signal,
1018
- },
1019
- reqId,
1020
- undefined,
1021
- (info) => audit?.attemptFailure(info),
1022
- );
1023
- if (retryRes.ok) {
1024
- log("info", `kilo 400 recovery succeeded with clamped tokens for model ${String(retryBody.model)}`, { model: retryBody.model }, reqId);
1025
- response = retryRes;
1026
- }
1027
- } catch { }
1028
- }
1029
- }
1030
- } finally {
1031
- clearTimeout(kiloTimeoutId);
1032
- res.off("close", abortKiloOnClientGone);
1033
- req.off("error", abortKiloOnClientGone);
1034
- }
1035
- if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
1036
- else audit?.attemptFailure({ attempt: 0, status: response.status, reason: "final-upstream-status" });
1037
-
1038
- if (isStream && response.ok && response.body) {
1039
- const ct = response.headers.get("content-type") || "text/event-stream";
1040
- res.writeHead(response.status, {
1041
- "content-type": ct,
1042
- "cache-control": "no-cache, no-transform",
1043
- connection: "keep-alive",
1044
- "x-accel-buffering": "no",
1045
- });
1046
- pipeUpstreamStream(
1047
- Readable.fromWeb(
1048
- response.body as unknown as WebReadableStream,
1049
- ),
1050
- res,
1051
- req,
1052
- reqId,
1053
- undefined,
1054
- kiloArgSchemas.size > 0
1055
- ? {
1056
- callerHadTools: true,
1057
- argSchemas: kiloArgSchemas,
1058
- pathname: pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
1059
- }
1060
- : undefined,
1061
- { upstreamContentType: response.headers.get("content-type"), audit: audit?.active ? audit : undefined },
1062
- );
1063
- } else {
1064
- const rawUpstreamText = await response.text();
1065
- let rawText = rawUpstreamText;
1066
- if (response.ok && kiloArgSchemas.size > 0) {
1067
- rawText = convertSseToJson(rawText, pathname, true, undefined, undefined, undefined, kiloArgSchemas);
1068
- rawText = repairJsonToolArguments(rawText, pathname, kiloArgSchemas);
1069
- }
1070
- if (response.ok) auditFinalBody(audit, pathname, rawUpstreamText, rawText);
1071
- const data = withRateLimitHint(response.status, rawText);
1072
- const ct = response.headers.get("content-type") || "application/json";
1073
- res.writeHead(response.status, { "content-type": ct });
1074
- res.end(data);
1075
- }
1076
- }
1077
-
1078
-
1079
- /**
1080
- * Relay eligibility: only free-catalog models ride relay egress. Returns true
1081
- * when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
1082
- * (Kilo traffic leaves via its own branch regardless), and when the model is
1083
- * missing/empty (e.g. GET /v1/models carries no body — preserve routing).
1084
- * Cline and OVH models are direct-only and never relay-eligible. Anything else
1085
- * (unknown/paid model ids, non-strings) goes direct upstream.
1086
- */
1087
- export function isRelayEligibleModel(model: unknown): boolean {
1088
- if (model === undefined || model === null) return true;
1089
- if (typeof model !== "string") return false;
1090
- if (model.trim() === "") return true;
1091
- const canonical = resolveCanonicalModelId(model.trim());
1092
- if (isClineModel(canonical)) return false;
1093
- return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical) || isFreeCatalogId(canonical) || isOvhModel(canonical);
1094
- }
1095
-
1096
- /**
1097
- * True when the resolved Zen transport forbids relay egress: a relay would
1098
- * re-originate the connection and forfeit the sidecar's fingerprint, so the
1099
- * request must ride the direct leg the transport was chosen for.
1100
- */
1101
- export function relayBypassedForTransport(mode: ZenTransportMode): boolean {
1102
- return mode === "bun-sidecar";
1103
- }
1104
-
1105
- /**
1106
- * Failing-over decision for a canonicalized request body. Only active when
1107
- * the PI_FREEFLOW_ZEN_KILO_FAILOVER opt-in is set; otherwise passthrough.
1108
- * Returns failover
1109
- * with a Kilo model id when a healthy Kilo model is available and either the
1110
- * session already ate a gate 403 (retried/resumed refusals fail over at once,
1111
- * even ungated — replaying a known refusal can never succeed), or every gate
1112
- * holds: Zen upstream model, a path whose body shape matches (chat bodies on
1113
- * chat completions, responses bodies on responses — Kilo serves both natively
1114
- * but a crossed body would 400), Zen currently gated, session never proven
1115
- * working, and the health machine routes this session to failover. Canary
1116
- * sessions pass through carrying the recovery probe; anything else passes
1117
- * through untouched — working sessions are never rerouted.
1118
- */
1119
- export type ZenFailoverDecision =
1120
- | { action: "failover"; model: string }
1121
- | { action: "canary" }
1122
- | { action: "passthrough" };
1123
- export function classifyZenFailover(
1124
- parsedBody: Record<string, unknown> | null,
1125
- pathname: string,
1126
- ): ZenFailoverDecision {
1127
- if (!isZenFailoverEnabled()) return { action: "passthrough" };
1128
- if (!parsedBody || typeof parsedBody.model !== "string") return { action: "passthrough" };
1129
- if (getModelUpstream(parsedBody.model) !== "opencode") return { action: "passthrough" };
1130
- const isChatPath = pathname.endsWith("/chat/completions");
1131
- const isResponsesPath = pathname.endsWith("/responses");
1132
- if (!isChatPath && !isResponsesPath) return { action: "passthrough" };
1133
- if (isChatPath && !Array.isArray(parsedBody.messages)) return { action: "passthrough" };
1134
- if (isResponsesPath && parsedBody.input === undefined) return { action: "passthrough" };
1135
- if (!wasGateRejected(parsedBody, pathname)) {
1136
- if (!isUpstreamGated("zen")) return { action: "passthrough" };
1137
- if (!isUnprovenSession(parsedBody, pathname)) return { action: "passthrough" };
1138
- const route = decideZenRoute(parsedBody, pathname);
1139
- if (route === "canary") {
1140
- log("debug", "zen gated — canary session passes through to zen");
1141
- return { action: "canary" };
1142
- }
1143
- if (route !== "failover") return { action: "passthrough" };
1144
- } else {
1145
- log("debug", "zen refused this session before — failing over to Kilo");
1146
- }
1147
- const pick = pickFailoverModel();
1148
- if (!pick) return { action: "passthrough" };
1149
- return { action: "failover", model: pick };
1150
- }
1151
-
1152
146
  /**
1153
147
  * Extract client IP address from incoming HTTP request.
1154
148
  */
@@ -1273,20 +267,7 @@ export function sanitizeHeaders(
1273
267
  sanitized.connection = "keep-alive";
1274
268
  return sanitized;
1275
269
  }
1276
- /**
1277
- * True when an opencode-lane upstream verdict names the client version as the
1278
- * refusal reason — a stale pinned UA that a refresh may repair. 426 always
1279
- * qualifies; 400/403 qualify only on explicit version/upgrade language, and a
1280
- * FreeTierError gate body never qualifies (that is the fingerprint gate, not
1281
- * version drift, and retrying it with a fresh UA would replay the refusal).
1282
- */
1283
- export function isStaleOpenCodeVersionError(status: number, bodyText: unknown): boolean {
1284
- if (typeof bodyText !== "string" || bodyText.length === 0) return status === 426;
1285
- if (bodyText.includes("FreeTierError")) return false;
1286
- if (status === 426) return true;
1287
- if (status !== 400 && status !== 403) return false;
1288
- return /opencode\/\d|user-agent|outdated|update (the|your) (cli|client)|upgrade (the|your) (cli|client)|version (too old|no longer supported|not supported|unsupported|deprecated)|minimum.+version|newer version/i.test(bodyText);
1289
- }
270
+
1290
271
  /**
1291
272
  * Host header allowlist: only names that resolve to this loopback listener.
1292
273
  * A hostile page can point a hostname at 127.0.0.1 (DNS rebinding), and the
@@ -1301,300 +282,6 @@ export function isAllowedHostHeader(host: string | undefined): boolean {
1301
282
  return ALLOWED_HOST_PATTERN.test(host.trim());
1302
283
  }
1303
284
 
1304
- /**
1305
- * True when a pi-freeflow daemon answers on `port`.
1306
- *
1307
- * Uses the unauthenticated `GET /health` identity shape (service + the port it
1308
- * is bound to) instead of "any 200 with JSON": a stranger bound to the port
1309
- * cannot claim those fields, and this probe runs before the client has a token
1310
- * to prove ownership with.
1311
- */
1312
- export async function isProxyAlive(port: number, timeoutMs = 600): Promise<boolean> {
1313
- if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
1314
- try {
1315
- const res = await fetch(`http://${HOST}:${port}/health`, {
1316
- signal: AbortSignal.timeout(timeoutMs),
1317
- });
1318
- if (!res.ok) return false;
1319
- return isDaemonIdentityBody(port, await res.json());
1320
- } catch {
1321
- return false;
1322
- }
1323
- }
1324
-
1325
- /**
1326
- * True when a TCP connection to the loopback port succeeds — something holds
1327
- * the port (our daemon, a stale daemon, or an unrelated process). A free port
1328
- * refuses the connection immediately; the dark-holder reclaim path uses this
1329
- * to tell "nothing bound" apart from "bound but unresponsive" (issue #52: the
1330
- * blind kill-and-poll cost ~2s on every cold start).
1331
- */
1332
- export async function isPortBound(port: number, timeoutMs = 250): Promise<boolean> {
1333
- if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
1334
- return await new Promise<boolean>((resolve) => {
1335
- const socket = net.connect({ host: HOST, port });
1336
- let settled = false;
1337
- const settle = (value: boolean): void => {
1338
- if (settled) return;
1339
- settled = true;
1340
- socket.destroy();
1341
- resolve(value);
1342
- };
1343
- socket.setTimeout(timeoutMs, () => settle(false));
1344
- socket.once("connect", () => settle(true));
1345
- socket.once("error", () => settle(false));
1346
- });
1347
- }
1348
-
1349
-
1350
- let activeRequests = 0;
1351
-
1352
- /** Current in-flight proxied requests (guard for stale-daemon replacement). */
1353
- export function getActiveRequests(): number {
1354
- return activeRequests;
1355
- }
1356
-
1357
- /**
1358
- * Fetch the full health snapshot from a running daemon.
1359
- * `activeRequests` is undefined on daemons older than the busy-tracking
1360
- * feature (1.9.0) — callers treat that as "cannot verify usage".
1361
- * `sseDegraded`/`sseRate`/`lastBytesAt` are undefined on daemons older than
1362
- * the failed-SSE window — callers treat missing as "not degraded".
1363
- */
1364
- export async function getDaemonHealth(
1365
- port: number,
1366
- ): Promise<DaemonHealthSnapshot> {
1367
- if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
1368
- try {
1369
- const res = await fetch(`http://${HOST}:${port}/_health`, {
1370
- signal: AbortSignal.timeout(2500),
1371
- headers: tokenAuthHeaders(),
1372
- });
1373
- if (!res.ok) return null;
1374
- const data: unknown = await res.json();
1375
- if (data && typeof data === "object") {
1376
- const v =
1377
- "version" in data && typeof data.version === "string" && data.version
1378
- ? data.version
1379
- : "";
1380
- const ar = "activeRequests" in data ? data.activeRequests : undefined;
1381
- const sr = "sseRate" in data ? data.sseRate : undefined;
1382
- const sd = "sseDegraded" in data ? data.sseDegraded : undefined;
1383
- const lb = "lastBytesAt" in data ? data.lastBytesAt : undefined;
1384
- let zt: DaemonHealthSnapshot extends null ? never : NonNullable<DaemonHealthSnapshot>["zenTransport"] = undefined;
1385
- if ("zenTransport" in data && typeof data.zenTransport === "object" && data.zenTransport !== null) {
1386
- const rawZt = data.zenTransport as Record<string, unknown>;
1387
- if (
1388
- (rawZt.mode === "node" || rawZt.mode === "bun-sidecar") &&
1389
- (typeof rawZt.bunVersion === "string" || rawZt.bunVersion === null) &&
1390
- typeof rawZt.reason === "string"
1391
- ) {
1392
- zt = {
1393
- mode: rawZt.mode,
1394
- bunVersion: rawZt.bunVersion,
1395
- reason: rawZt.reason,
1396
- };
1397
- }
1398
- }
1399
- return {
1400
- version: v,
1401
- activeRequests: typeof ar === "number" ? ar : undefined,
1402
- sseRate: typeof sr === "number" ? sr : undefined,
1403
- sseDegraded: typeof sd === "boolean" ? sd : undefined,
1404
- lastBytesAt: typeof lb === "number" ? lb : undefined,
1405
- zenTransport: zt,
1406
- };
1407
- }
1408
- return null;
1409
- } catch {
1410
- return null;
1411
- }
1412
- }
1413
- /** Version of the daemon on `port`; "" when alive but pre-version-field, null when not alive. */
1414
- export async function getDaemonVersion(port: number): Promise<string | null> {
1415
- const h = await getDaemonHealth(port);
1416
- return h ? h.version : null;
1417
- }
1418
-
1419
- /**
1420
- * Linux fallback when neither lsof nor fuser exists: resolve the LISTEN
1421
- * socket inode for `port` via /proc/net/tcp{,6}, then find the owning pid by
1422
- * scanning /proc fds. Dependency-free, same-user only, never self-kills.
1423
- */
1424
- function killViaProcNet(port: number): boolean {
1425
- if (process.platform !== "linux") return false;
1426
- try {
1427
- const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
1428
- const inodes = new Set<string>();
1429
- for (const table of ["/proc/net/tcp", "/proc/net/tcp6"]) {
1430
- let text = "";
1431
- try {
1432
- text = fs.readFileSync(table, "utf8");
1433
- } catch {
1434
- continue;
1435
- }
1436
- for (const line of text.split("\n").slice(1)) {
1437
- const cols = line.trim().split(/\s+/);
1438
- if (cols.length < 10) continue;
1439
- const addr = cols[1].split(":");
1440
- if (addr.length !== 2 || addr[1].toUpperCase() !== hexPort) continue;
1441
- if (cols[3] !== "0A") continue; // LISTEN only
1442
- inodes.add(cols[9]);
1443
- }
1444
- }
1445
- if (inodes.size === 0) return false;
1446
- for (const pid of fs.readdirSync("/proc")) {
1447
- if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue;
1448
- let fds: string[];
1449
- try {
1450
- fds = fs.readdirSync(`/proc/${pid}/fd`);
1451
- } catch {
1452
- continue;
1453
- }
1454
- for (const fd of fds) {
1455
- let link = "";
1456
- try {
1457
- link = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
1458
- } catch {
1459
- continue;
1460
- }
1461
- const sock = link.match(/^socket:\[(\d+)\]$/);
1462
- if (sock && inodes.has(sock[1])) {
1463
- try {
1464
- process.kill(Number(pid), "SIGKILL");
1465
- return true;
1466
- } catch {
1467
- return false;
1468
- }
1469
- }
1470
- }
1471
- }
1472
- } catch { }
1473
- return false;
1474
- }
1475
-
1476
- /** What answers `GET /health` on a loopback port (see probePortHolder). */
1477
- export type PortHolderVerdict = "ours" | "foreign" | "silent" | "free";
1478
-
1479
- /** Walk the undici cause chain for an ECONNREFUSED on the loopback probe. */
1480
- function isRefusedConnect(err: unknown): boolean {
1481
- let cur: unknown = err;
1482
- for (let depth = 0; depth < 4 && cur && typeof cur === "object"; depth++) {
1483
- if ("code" in cur && typeof cur.code === "string" && cur.code === "ECONNREFUSED") return true;
1484
- cur = "cause" in cur ? cur.cause : undefined;
1485
- }
1486
- return false;
1487
- }
1488
-
1489
- /**
1490
- * Classify whatever holds `port` before any kill. `ours` means the
1491
- * unauthenticated identity probe answered with this daemon's service and the
1492
- * port it is bound to; `foreign` means something answered HTTP but is not a
1493
- * pi-freeflow daemon; `silent` means the TCP connection was accepted but no
1494
- * HTTP answer arrived in the budget (a wedged daemon or a non-HTTP listener);
1495
- * `free` means the connection was refused.
1496
- */
1497
- export async function probePortHolder(port: number, timeoutMs = 600): Promise<PortHolderVerdict> {
1498
- try {
1499
- const res = await fetch(`http://${HOST}:${port}/health`, {
1500
- signal: AbortSignal.timeout(timeoutMs),
1501
- });
1502
- const body: unknown = await res.json().catch(() => null);
1503
- return isDaemonIdentityBody(port, body) ? "ours" : "foreign";
1504
- } catch (e) {
1505
- return isRefusedConnect(e) ? "free" : "silent";
1506
- }
1507
- }
1508
-
1509
- export async function killPortHolder(port: number): Promise<boolean> {
1510
- if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
1511
- // Test-safety net: `node --test <file>` without test/setup.mjs runs on the
1512
- // real default port and would otherwise reap the developer's live daemon.
1513
- if (port === DEFAULT_PORT && process.env.NODE_TEST_CONTEXT) {
1514
- log("warn", `refusing to kill the default port :${port} from the test runner — run the suite via test/setup.mjs`);
1515
- return false;
1516
- }
1517
- // Never kill an identified process: probe first, and only a holder that
1518
- // claims (or cannot deny) being this daemon may be dislodged.
1519
- const holder = await probePortHolder(port);
1520
- if (holder === "free") return true;
1521
- if (holder === "foreign") {
1522
- log("warn", `refusing to kill :${port} — it answers /health as a foreign service, not a pi-freeflow daemon`);
1523
- return false;
1524
- }
1525
- const auth = tokenAuthHeaders();
1526
- try {
1527
- try {
1528
- await fetch(`http://${HOST}:${port}/_shutdown`, {
1529
- method: "POST",
1530
- headers: auth,
1531
- signal: AbortSignal.timeout(300),
1532
- });
1533
- for (let i = 0; i < 6; i++) {
1534
- await new Promise<void>((r) => setTimeout(r, 50));
1535
- if (!(await isProxyAlive(port))) return true;
1536
- }
1537
- } catch { }
1538
- if (process.platform === "win32") {
1539
- let killed = false;
1540
- const tryTaskkillPid = (rawPid: string | undefined): void => {
1541
- const pid = rawPid?.trim();
1542
- if (!pid || !/^\d+$/.test(pid)) return;
1543
- const num = Number(pid);
1544
- if (num <= 4 || num === process.pid) return; // never kill system/idle (0/4) or self
1545
- try {
1546
- execSync(`taskkill /F /T /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
1547
- killed = true;
1548
- } catch { }
1549
- };
1550
- try {
1551
- const out = execSync(`netstat -ano | findstr :${port}`, {
1552
- encoding: "utf8",
1553
- timeout: 3000,
1554
- windowsHide: true,
1555
- }) as string;
1556
- for (const line of out.split("\n")) {
1557
- if (!line.includes("LISTENING")) continue;
1558
- const parts = line.trim().split(/\s+/);
1559
- if (!parts[1] || !parts[1].endsWith(`:${port}`)) continue;
1560
- tryTaskkillPid(parts[parts.length - 1]);
1561
- }
1562
- } catch { }
1563
- if (!killed) {
1564
- // Windows 11 24H2+ (build 26300+) can report Bound TCP listeners with
1565
- // PID 0 or omit them from netstat -ano; Get-NetTCPConnection still
1566
- // resolves the owning process id reliably.
1567
- try {
1568
- const psOut = execSync(
1569
- `powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique"`,
1570
- { encoding: "utf8", timeout: 5000, windowsHide: true },
1571
- ) as string;
1572
- for (const line of psOut.split(/\r?\n/)) {
1573
- tryTaskkillPid(line);
1574
- }
1575
- } catch { }
1576
- }
1577
- return killed;
1578
- }
1579
- try {
1580
- const out = execSync(`lsof -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
1581
- encoding: "utf8",
1582
- timeout: 3000,
1583
- }) as string;
1584
- const pids = out.trim().split(/\s+/).filter((p) => /^\d+$/.test(p) && Number(p) !== process.pid);
1585
- // lsof matches both ends of a connection: our own recent probe socket
1586
- // (TIME_WAIT) can sort first, so skip self instead of taking [0].
1587
- if (pids.length === 0) return killViaProcNet(port);
1588
- execSync(`kill -9 ${pids[0]}`, { timeout: 3000, stdio: "ignore" });
1589
- return true;
1590
- } catch {
1591
- return killViaProcNet(port);
1592
- }
1593
- } catch {
1594
- return false;
1595
- }
1596
- }
1597
-
1598
285
  /**
1599
286
  * Loopback-only client lease + control endpoints used by the detached-daemon
1600
287
  * protocol (src/client.ts). Returns false when the request is not a control
@@ -1618,7 +305,7 @@ export function handleControlRequest(
1618
305
  }
1619
306
  if (isShutdown) {
1620
307
  log("info", "control /_shutdown received — closing proxy");
1621
- res.writeHead(200, { "content-type": "application/json" });
308
+ res.writeHead(200, { "content-type": "application/json", connection: "close" });
1622
309
  res.end(JSON.stringify({ ok: true }));
1623
310
  setTimeout(() => {
1624
311
  try {
@@ -1629,6 +316,7 @@ export function handleControlRequest(
1629
316
  }, 50);
1630
317
  return true;
1631
318
  }
319
+ req.on("error", () => {});
1632
320
  const chunks: Buffer[] = [];
1633
321
  let total = 0;
1634
322
  req.on("data", (c: Buffer) => {
@@ -1681,19 +369,6 @@ export function handleControlRequest(
1681
369
  return true;
1682
370
  }
1683
371
 
1684
- /**
1685
- * Tagged abort reason for the proxy-internal header-wait timeout.
1686
- * relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
1687
- * code FF_INTERNAL_ABORT as "our abort, not a relay fault" — so neither
1688
- * rolls nor penalizes a healthy relay when the request merely ran slow.
1689
- */
1690
- function upstreamTimeoutError(): Error & { code: string } {
1691
- const err = new Error(`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`) as Error & { code: string };
1692
- err.name = "AbortError";
1693
- err.code = "FF_INTERNAL_ABORT";
1694
- return err;
1695
- }
1696
-
1697
372
  /**
1698
373
  * Recover from upstream rejecting a replayed reasoning history.
1699
374
  *
@@ -1822,7 +497,7 @@ export function startProxy(
1822
497
  // Write/read the installation token before the listener is bound so the host
1823
498
  // can never observe a port that answers without a token, and so the daemon
1824
499
  // owns the value its own clients will read from the file.
1825
- const serverToken = ensureToken();
500
+ let serverToken = ensureToken();
1826
501
  if (!serverToken) {
1827
502
  log("error", "proxy token unavailable — refusing every request until the data directory is writable");
1828
503
  }
@@ -1873,12 +548,16 @@ export function startProxy(
1873
548
  // client recognise its daemon before it has a token.
1874
549
  const isIdentityProbe = req.method === "GET" && reqPathname === "/health";
1875
550
  if (!isIdentityProbe && !isAuthorizedBearer(req.headers.authorization, serverToken)) {
1876
- res.writeHead(401, { "content-type": "application/json" });
1877
- res.end(JSON.stringify({ error: "unauthorized" }));
1878
- return;
551
+ const latestToken = ensureToken();
552
+ if (latestToken && latestToken !== serverToken) serverToken = latestToken;
553
+ if (!isAuthorizedBearer(req.headers.authorization, serverToken)) {
554
+ res.writeHead(401, { "content-type": "application/json" });
555
+ res.end(JSON.stringify({ error: "unauthorized" }));
556
+ return;
557
+ }
1879
558
  }
1880
559
 
1881
- if (handleControlRequest(req, res, reqPathname, () => server.close())) return;
560
+ if (handleControlRequest(req, res, reqPathname, () => { try { server.closeIdleConnections?.(); } catch {} server.close(); })) return;
1882
561
  // Loopback-only health endpoint — always accessible even when widget hidden
1883
562
  if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
1884
563
  const addr = server.address();
@@ -1919,9 +598,9 @@ export function startProxy(
1919
598
  touchActivity();
1920
599
  // Buffer request body to inspect model ID for upstream routing
1921
600
  const bodyChunks: Buffer[] = [];
1922
- activeRequests += 1;
601
+ incrementActiveRequests();
1923
602
  res.on("close", () => {
1924
- activeRequests -= 1;
603
+ decrementActiveRequests();
1925
604
  });
1926
605
 
1927
606
  // Reject oversized bodies before buffering starts: the client declares
@@ -1951,10 +630,12 @@ export function startProxy(
1951
630
  req.on("data", (chunk: Buffer) => {
1952
631
  bufferedBytes += chunk.length;
1953
632
  if (bufferedBytes > MAX_BODY_BYTES) {
1954
- req.destroy();
633
+ bodyChunks.length = 0;
1955
634
  if (!res.headersSent) {
1956
- res.writeHead(413, { "content-type": "application/json" });
1957
- res.end(JSON.stringify({ error: "payload too large" }));
635
+ res.writeHead(413, { "content-type": "application/json", connection: "close" });
636
+ res.end(JSON.stringify({ error: "payload too large" }), () => req.destroy());
637
+ } else {
638
+ req.destroy();
1958
639
  }
1959
640
  return;
1960
641
  }
@@ -1968,17 +649,24 @@ export function startProxy(
1968
649
  let isOvh = false;
1969
650
  let parsedBody: Record<string, unknown> | null = null;
1970
651
  let clientRequestedStream = false;
652
+ let bodyModified = false;
1971
653
 
1972
654
  try {
1973
655
  parsedBody = JSON.parse(bodyStr);
1974
656
  if (typeof parsedBody?.model === "string") {
1975
657
  const canonical = resolveCanonicalModelId(parsedBody.model);
658
+ if (canonical !== parsedBody.model) {
659
+ parsedBody.model = canonical;
660
+ bodyModified = true;
661
+ } else {
662
+ parsedBody.model = canonical;
663
+ }
1976
664
  if (isClineModel(canonical)) {
1977
665
  isCline = true;
1978
- } else if (isOvhModel(canonical)) {
1979
- isOvh = true;
1980
- } else if (KILO_MODEL_IDS.has(canonical)) {
1981
- isKilo = true;
666
+ } else if (isOvhModel(canonical)) {
667
+ isOvh = true;
668
+ } else if (KILO_MODEL_IDS.has(canonical)) {
669
+ isKilo = true;
1982
670
  }
1983
671
  }
1984
672
  if (parsedBody && typeof parsedBody === "object") {
@@ -2021,6 +709,7 @@ export function startProxy(
2021
709
  if (zenFailover.action === "failover" && parsedBody) {
2022
710
  const from = String(parsedBody.model);
2023
711
  parsedBody.model = zenFailover.model;
712
+ bodyModified = true;
2024
713
  if (target.pathname.endsWith("/responses")) {
2025
714
  prepareResponsesFailoverBody(parsedBody);
2026
715
  } else {
@@ -2047,7 +736,7 @@ export function startProxy(
2047
736
  // OpenCode Zen free-tier fingerprint: upstream mandates stream: true and the
2048
737
  // live 11-tool roster. Missing tools (such as on
2049
738
  // subagent or advisor watchdog turns) trigger 403 FreeTierError.
2050
- let bodyModified = false;
739
+ // bodyModified was initialized above before model alias / failover mutations.
2051
740
  let callerHadTools = true;
2052
741
  let callerCaseRestore: CaseRestoreMap | undefined;
2053
742
  let callerFindGlob: FindGlobRestore | undefined;