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
@@ -0,0 +1,1125 @@
1
+ /**
2
+ * Non-Zen proxy routing lanes (Cline, OVH, Kilo) and lane classification helpers.
3
+ */
4
+
5
+ import type * as http from "node:http";
6
+ import { Readable } from "node:stream";
7
+ import type { ReadableStream as WebReadableStream } from "node:stream/web";
8
+
9
+ import {
10
+ CLINE_CHAT_URL,
11
+ KILO_CHAT_URL,
12
+ KILO_RESPONSES_URL,
13
+ OVH_CHAT_URL,
14
+ UPSTREAM_HEADER_TIMEOUT_MS,
15
+ } from "./config.ts";
16
+
17
+ import { log } from "./logger.ts";
18
+ import {
19
+ getModelUpstream,
20
+ isClineModel,
21
+ isOvhModel,
22
+ KILO_MODEL_IDS,
23
+ MODEL_MAP,
24
+ ovhUpstreamModelId,
25
+ resolveCanonicalModelId,
26
+ } from "./models.ts";
27
+ import { isFreeCatalogId } from "./catalog.ts";
28
+ import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
29
+ import {
30
+ clineUpstreamModelId,
31
+ keepClineTokensAlive,
32
+ refreshClineVersion,
33
+ rollChat,
34
+ } from "./cline-accounts.ts";
35
+ import { fetchWithSystemCA } from "./system-ca-fetch.ts";
36
+ import type { ZenTransportMode } from "./zen-transport.ts";
37
+ import { pipeUpstreamStream } from "./stream-pipe.ts";
38
+ import type { ToolcallRecorder, ToolcallWire } from "./toolcall-audit.ts";
39
+ import {
40
+ buildFindGlobRestore,
41
+ chatBodyFromResponsesBody,
42
+ chatResponsesJsonFromChatCompletion,
43
+ chatResponsesSseFromChatCompletion,
44
+ translateToolsForPath,
45
+ } from "./tool-translation.ts";
46
+ import { buildArgRepairSchemas } from "./tool-args.ts";
47
+ import {
48
+ convertSseToJson,
49
+ looksLikeSse,
50
+ repairJsonToolArguments,
51
+ sseToChatCompletionJson,
52
+ } from "./opencode-fingerprint.ts";
53
+ import { relayFetch } from "./relay.ts";
54
+ import { getActiveRelayState } from "./relay-state.ts";
55
+ import {
56
+ attachHintForDisplay,
57
+ decideZenRoute,
58
+ isFreeUsageShapeError,
59
+ isUnprovenSession,
60
+ isUpstreamGated,
61
+ isZenFailoverEnabled,
62
+ pickFailoverModel,
63
+ recordUpstreamSuccess,
64
+ wasGateRejected,
65
+ } from "./upstream-health.ts";
66
+
67
+ /**
68
+ * Natural-429 hint throttle: the deploy guidance hint is attached to upstream
69
+ * 429 passthroughs at most once per 10 minutes per process so repeated
70
+ * rate-limit responses don't spam clients.
71
+ */
72
+ let last429HintAt = 0;
73
+ export function shouldShow429Hint(): boolean {
74
+ const now = Date.now();
75
+ if (now - last429HintAt < 10 * 60 * 1000) return false;
76
+ last429HintAt = now;
77
+ return true;
78
+ }
79
+ /** Test-only: reset 429 hint throttle */
80
+ export function _reset429HintForTest(): void { last429HintAt = 0; }
81
+
82
+ /** Deploy guidance attached to a natural upstream 429 once the throttle allows. */
83
+ export const RATE_LIMIT_HINT =
84
+ "Shared free-tier IP quota reached. Add your own relay egress: /freeflow deploy (Cloudflare recommended, 100k req/day free)";
85
+
86
+ /**
87
+ * Attach the deploy hint to a natural upstream 429 JSON body. Anything else —
88
+ * non-429 statuses, non-JSON bodies — passes through untouched without
89
+ * consuming the throttle slot.
90
+ */
91
+ export function withRateLimitHint(status: number, data: string): string {
92
+ if (status !== 429) return data;
93
+ // Shape 429 (FreeUsageLimitError without quota burn) is our fingerprint's
94
+ // fault, not the pool's: the deploy hint would mislead, so it stays off.
95
+ if (isFreeUsageShapeError(status, data)) return data;
96
+ try {
97
+ const parsed: unknown = JSON.parse(data);
98
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && shouldShow429Hint()) {
99
+ return JSON.stringify(attachHintForDisplay(parsed as Record<string, unknown>, RATE_LIMIT_HINT));
100
+ }
101
+ } catch { }
102
+ return data;
103
+ }
104
+
105
+ /** Cline marker errors: each status carries account-actionable guidance. */
106
+ const CLINE_RATE_LIMIT_HINT =
107
+ "Cline free-use limit reached for this login. Wait for the reset or switch models.";
108
+ const CLINE_FORBIDDEN_HINT =
109
+ "Cline refused this request. Check the Cline login and retry.";
110
+ const CLINE_AUTH_HINT =
111
+ "Cline login expired or missing. Reconnect the Cline login and retry.";
112
+ const CLINE_MODEL_HINT =
113
+ "Cline has no such model. Refresh the model list and retry.";
114
+
115
+ const CLINE_REFRESH_INVALID_TOKEN_RE = /^Invalid token response|^Token response did not include a refresh token/;
116
+
117
+ /**
118
+ * What the roll knows about a free-limit exhaustion, for the 429 hint: how many
119
+ * saved logins answered the cap and the nearest reset any of them stated.
120
+ */
121
+ export interface ClineLimitHint {
122
+ logins: number;
123
+ /** Epoch ms, or null when no login's body stated a reset. */
124
+ resetAt: number | null;
125
+ }
126
+
127
+ /** Coarse reset delay ("20h 4m", "35m") — the hint names what the body stated. */
128
+ function formatResetDelay(resetAt: number): string {
129
+ const minutes = Math.max(1, Math.round((resetAt - Date.now()) / 60_000));
130
+ const hours = Math.floor(minutes / 60);
131
+ const rest = minutes % 60;
132
+ if (hours === 0) return `${rest}m`;
133
+ return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
134
+ }
135
+
136
+ /** Every saved login is out of free use for this model: say what helps next. */
137
+ function clineAllLimitedHint(limit: ClineLimitHint): string {
138
+ const reset = limit.resetAt === null ? "" : ` Nearest reset in about ${formatResetDelay(limit.resetAt)}.`;
139
+ 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.`;
140
+ }
141
+
142
+ /**
143
+ * Attach the matching hint to a Cline marker error JSON body (free-limit
144
+ * 429, 403, 401, model 404). Anything else — other statuses, non-JSON
145
+ * bodies — passes through untouched.
146
+ *
147
+ * `limit` is present only when the roll tried every saved login and every one
148
+ * of them answered the daily free cap; then "for this login" would be wrong and
149
+ * hide that another login is the way out.
150
+ */
151
+ export function mapClineError(status: number, data: string, limit?: ClineLimitHint): string {
152
+ // Synthetic pool-exhausted bodies already tell the user exactly what to do
153
+ // (sign in again) — a rate-limit hint would mislead.
154
+ if (data.includes("cline_pool_exhausted")) return data;
155
+ const hint = status === 429
156
+ ? (limit ? clineAllLimitedHint(limit) : CLINE_RATE_LIMIT_HINT)
157
+ : status === 403
158
+ ? CLINE_FORBIDDEN_HINT
159
+ : status === 401
160
+ ? CLINE_AUTH_HINT
161
+ : status === 404
162
+ ? CLINE_MODEL_HINT
163
+ : null;
164
+ if (hint === null) return data;
165
+ try {
166
+ const parsed: unknown = JSON.parse(data);
167
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
168
+ // Hosts render error.message and ignore the sibling hint field, so the
169
+ // guidance must live in the visible message too (hint kept for compat).
170
+ return JSON.stringify(attachHintForDisplay(parsed as Record<string, unknown>, hint));
171
+ }
172
+ } catch { }
173
+ return data;
174
+ }
175
+
176
+ /**
177
+ * Device-login refresher for the Cline pool: one refresh per stale slot per
178
+ * turn. A grant Cline rejects outright (HTTP 4xx, or a 200 without usable
179
+ * tokens) is dead — it resolves null so the pool stops trying that slot until
180
+ * the user logs in again. Anything else (network fault, upstream 5xx) throws,
181
+ * which keeps the stale bearer for this turn.
182
+ */
183
+ async function clineRefreshImpl(refreshToken: string): Promise<{ token: string; refreshToken?: string; expiresAt?: number } | null> {
184
+ try {
185
+ const creds = await refreshClineToken(undefined, refreshToken);
186
+ return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
187
+ } catch (e) {
188
+ if (e instanceof ClineAuthError && typeof e.status === "number" && e.status >= 400 && e.status < 500) {
189
+ log("warn", "cline refresh grant rejected — slot needs a fresh login", { status: e.status });
190
+ return null;
191
+ }
192
+ if (e instanceof Error && CLINE_REFRESH_INVALID_TOKEN_RE.test(e.message)) {
193
+ log("warn", "cline refresh returned no usable tokens — slot needs a fresh login", { reason: e.message });
194
+ return null;
195
+ }
196
+ throw e;
197
+ }
198
+ }
199
+
200
+ let clineKeepAliveTimer: NodeJS.Timeout | null = null;
201
+ const CLINE_KEEP_ALIVE_INTERVAL_MS = 15 * 60 * 1000;
202
+
203
+ export function startClineKeepAlive(): void {
204
+ if (clineKeepAliveTimer !== null) return;
205
+ void keepClineTokensAlive().catch(() => {});
206
+ void refreshClineVersion().catch(() => {});
207
+ clineKeepAliveTimer = setInterval(() => {
208
+ void keepClineTokensAlive().catch(() => {});
209
+ void refreshClineVersion().catch(() => {});
210
+ }, CLINE_KEEP_ALIVE_INTERVAL_MS);
211
+ clineKeepAliveTimer.unref?.();
212
+ }
213
+
214
+ export function stopClineKeepAlive(): void {
215
+ if (clineKeepAliveTimer !== null) {
216
+ clearInterval(clineKeepAliveTimer);
217
+ clineKeepAliveTimer = null;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Bound on the caller tool names the audit summary carries (the recorder
223
+ * applies the same caps itself; bounding here keeps a hostile body from
224
+ * building a large array first).
225
+ */
226
+ const AUDIT_MAX_DECLARED = 32;
227
+ const AUDIT_MAX_NAME = 64;
228
+
229
+ /**
230
+ * Caller-declared tool names from a request body, in all three wire shapes:
231
+ * chat `{type:"function",function:{name}}`, responses `{type:"function",name}`
232
+ * and messages `{name}`. Names only — the audit never records schemas.
233
+ */
234
+ export function declaredToolNames(body: Record<string, unknown>): string[] {
235
+ const names: string[] = [];
236
+ const tools = body.tools;
237
+ if (!Array.isArray(tools)) return names;
238
+ for (const raw of tools) {
239
+ if (names.length >= AUDIT_MAX_DECLARED) break;
240
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
241
+ const rec = raw as Record<string, unknown>;
242
+ const fn = rec.function;
243
+ const fnRec = fn !== null && typeof fn === "object" && !Array.isArray(fn)
244
+ ? (fn as Record<string, unknown>)
245
+ : null;
246
+ const rawName = typeof fnRec?.name === "string" ? fnRec.name : rec.name;
247
+ if (typeof rawName !== "string" || rawName === "") continue;
248
+ const name = rawName.length > AUDIT_MAX_NAME ? rawName.slice(0, AUDIT_MAX_NAME) : rawName;
249
+ if (!names.includes(name)) names.push(name);
250
+ }
251
+ return names;
252
+ }
253
+
254
+ /** Audit wire of a request path: the body shape upstream is asked for. */
255
+ export function auditWireFor(pathname: string): ToolcallWire {
256
+ if (pathname.endsWith("/responses")) return "responses";
257
+ if (pathname.endsWith("/messages")) return "messages";
258
+ return "chat";
259
+ }
260
+
261
+ /**
262
+ * Classify an already-assembled chat completion for the audit (non-stream
263
+ * path). Never throws: the audit is best-effort and must stay off the request
264
+ * path. A body without `choices` records a tool-less turn, which is exactly
265
+ * what the caller received.
266
+ */
267
+ export function auditChatCompletion(audit: ToolcallRecorder | undefined, completion: unknown): void {
268
+ if (audit === undefined || !audit.active) return;
269
+ try {
270
+ const parsed = completion !== null && typeof completion === "object" && !Array.isArray(completion)
271
+ ? (completion as Record<string, unknown>)
272
+ : {};
273
+ const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
274
+ const first = choices.length > 0 && choices[0] !== null && typeof choices[0] === "object" && !Array.isArray(choices[0])
275
+ ? (choices[0] as Record<string, unknown>)
276
+ : undefined;
277
+ const message = first !== undefined && first.message !== null && typeof first.message === "object" && !Array.isArray(first.message)
278
+ ? (first.message as Record<string, unknown>)
279
+ : {};
280
+ const finishReason = typeof first?.finish_reason === "string" ? (first.finish_reason as string) : null;
281
+ audit.finishFromChat(message, finishReason);
282
+ } catch {
283
+ // Unreadable body: nothing to record.
284
+ }
285
+ }
286
+
287
+ /** `auditChatCompletion` over serialized JSON (the aggregated non-stream body). */
288
+ export function auditChatBody(audit: ToolcallRecorder | undefined, json: string): void {
289
+ if (audit === undefined || !audit.active) return;
290
+ try {
291
+ auditChatCompletion(audit, JSON.parse(json));
292
+ } catch {
293
+ // Not JSON: nothing to record.
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Classify a non-stream turn of a Responses/Anthropic wire: the recorder reads
299
+ * the raw SSE text itself. A body that carried no SSE frames is left
300
+ * unrecorded rather than guessed as a tool-less turn — the recorder has no
301
+ * reader for a plain object of those wires.
302
+ */
303
+ export function auditSseBody(audit: ToolcallRecorder | undefined, text: string): void {
304
+ if (audit === undefined || !audit.active) return;
305
+ if (!text.includes("data:")) return;
306
+ try {
307
+ audit.observe(text);
308
+ audit.finish();
309
+ } catch {
310
+ // Best-effort by contract.
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Non-stream final classification: a chat-wire body parses directly; the
316
+ * responses/messages wires are classified from the raw upstream SSE text.
317
+ */
318
+ export function auditFinalBody(
319
+ audit: ToolcallRecorder | undefined,
320
+ pathname: string,
321
+ rawUpstream: string,
322
+ served: string,
323
+ ): void {
324
+ if (auditWireFor(pathname) === "chat") auditChatBody(audit, served);
325
+ else auditSseBody(audit, rawUpstream);
326
+ }
327
+
328
+ export function upstreamTimeoutError(): Error & { code: string } {
329
+ const err = new Error(`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`) as Error & { code: string };
330
+ err.name = "AbortError";
331
+ err.code = "FF_INTERNAL_ABORT";
332
+ return err;
333
+ }
334
+
335
+ export interface HandleClineRequestOptions {
336
+ parsedBody: Record<string, unknown>;
337
+ pathname: string;
338
+ req: http.IncomingMessage;
339
+ res: http.ServerResponse;
340
+ reqId: string;
341
+ clientRequestedStream: boolean;
342
+ audit?: ToolcallRecorder;
343
+ }
344
+
345
+ /**
346
+ * Serve a Cline-model request direct (never relay pool, no opencode
347
+ * fingerprint). Cline serves chat completions only: responses-path bodies
348
+ * translate to chat upstream and the chat answer translates back, re-emitted
349
+ * as SSE when the caller streamed. Upstream always streams; callers that
350
+ * asked for plain JSON get the aggregated object.
351
+ */
352
+ export async function handleClineRequest(opts: HandleClineRequestOptions): Promise<void> {
353
+ const { parsedBody, pathname, req, res, reqId, clientRequestedStream, audit } = opts;
354
+ const responsesRequest = pathname.endsWith("/responses");
355
+ const model = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
356
+ let chatBody: Record<string, unknown>;
357
+ if (responsesRequest) {
358
+ chatBody = chatBodyFromResponsesBody(parsedBody);
359
+ } else {
360
+ chatBody = { ...parsedBody };
361
+ if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
362
+ delete chatBody.prompt_cache_key;
363
+ }
364
+ chatBody.model = clineUpstreamModelId(model);
365
+ // Cline bypasses the fingerprint, but its arguments still malform the same
366
+ // way: capture the caller's own schemas before translation for repair.
367
+ const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
368
+ // Translation renames the caller's `find` to `glob` upstream and folds any
369
+ // name the upstream shape forbids; without this map the answer returns under
370
+ // a name Pi never registered.
371
+ const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
372
+ chatBody.stream = true;
373
+ let upstreamRes: Response;
374
+ let limitHint: ClineLimitHint | undefined;
375
+ // Client-gone abort: a roll left running after the caller's socket died
376
+ // leaves its upstream body paused forever and the handler never finishes.
377
+ // `res.writableEnded` is false for a destroyed socket, so the roll carries
378
+ // our signal (injected through the roll's fetch) and the resolved body is
379
+ // cancelled at the check below.
380
+ const clineController = new AbortController();
381
+ const abortClineOnClientGone = (): void => {
382
+ if (!res.writableEnded) clineController.abort();
383
+ };
384
+ res.once("close", abortClineOnClientGone);
385
+ req.once("error", abortClineOnClientGone);
386
+ try {
387
+ const result = await rollChat({
388
+ body: JSON.stringify(chatBody),
389
+ chatUrl: CLINE_CHAT_URL,
390
+ refreshImpl: clineRefreshImpl,
391
+ fetchImpl: (url, init) => fetchWithSystemCA(url, { ...init, signal: clineController.signal }),
392
+ });
393
+ upstreamRes = result.res;
394
+ // The roll returns the last upstream failure when the pool is exhausted,
395
+ // so "served by" must only log on an actual success.
396
+ if (upstreamRes.ok && typeof result.slot === "string" && result.slot.length > 0) {
397
+ log("debug", `cline served by slot ${result.slot}`, { model }, reqId);
398
+ }
399
+ // Every saved login answered the daily free cap: name that instead of
400
+ // blaming "this login", which hides that another login or another model works.
401
+ if (result.limitOnly && result.logins > 1) {
402
+ limitHint = { logins: result.logins, resetAt: result.earliestResetAt };
403
+ }
404
+ } catch (e) {
405
+ log("error", "cline pool error", { error: String(e), model }, reqId);
406
+ if (!res.headersSent) {
407
+ res.writeHead(502, { "content-type": "application/json" });
408
+ res.end(JSON.stringify({ error: "upstream error" }));
409
+ }
410
+ return;
411
+ } finally {
412
+ res.off("close", abortClineOnClientGone);
413
+ req.off("error", abortClineOnClientGone);
414
+ }
415
+ if (res.destroyed || res.writableEnded) {
416
+ try { await upstreamRes.body?.cancel(); } catch { }
417
+ return;
418
+ }
419
+ if (!upstreamRes.ok) {
420
+ log("warn", `cline upstream ${upstreamRes.status} for model ${model}`, { status: upstreamRes.status, model }, reqId);
421
+ // A tools request rejected upstream (e.g. a tool-schema 400) is the verdict
422
+ // the host will see; record it so the audit names the cause.
423
+ audit?.attemptFailure({ attempt: 0, status: upstreamRes.status, reason: "final-upstream-status" });
424
+ const raw = await upstreamRes.text().catch(() => null);
425
+ if (raw === null) {
426
+ log("error", "cline upstream body unreadable", { status: upstreamRes.status, model }, reqId);
427
+ if (!res.headersSent) {
428
+ res.writeHead(502, { "content-type": "application/json" });
429
+ res.end(JSON.stringify({ error: "upstream error" }));
430
+ } else if (!res.writableEnded) {
431
+ res.end();
432
+ }
433
+ return;
434
+ }
435
+ const data = mapClineError(upstreamRes.status, raw, limitHint);
436
+ if (!res.headersSent) {
437
+ res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
438
+ res.end(data);
439
+ } else if (!res.writableEnded) {
440
+ res.end();
441
+ }
442
+ return;
443
+ }
444
+ if (!responsesRequest && clientRequestedStream && upstreamRes.body) {
445
+ res.writeHead(upstreamRes.status, {
446
+ "content-type": upstreamRes.headers.get("content-type") || "text/event-stream",
447
+ "cache-control": "no-cache, no-transform",
448
+ connection: "keep-alive",
449
+ "x-accel-buffering": "no",
450
+ });
451
+ pipeUpstreamStream(
452
+ Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
453
+ res,
454
+ req,
455
+ reqId,
456
+ "direct",
457
+ findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
458
+ ? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
459
+ : undefined,
460
+ { upstreamContentType: upstreamRes.headers.get("content-type"), audit: audit?.active ? audit : undefined },
461
+ );
462
+ return;
463
+ }
464
+ const sseText = await upstreamRes.text().catch(() => null);
465
+ if (sseText === null) {
466
+ log("error", "cline upstream stream unreadable", { status: upstreamRes.status, model }, reqId);
467
+ if (!res.headersSent) {
468
+ res.writeHead(502, { "content-type": "application/json" });
469
+ res.end(JSON.stringify({ error: "upstream error" }));
470
+ } else if (!res.writableEnded) {
471
+ res.end();
472
+ }
473
+ return;
474
+ }
475
+ if (!responsesRequest) {
476
+ const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
477
+ auditChatBody(audit, data);
478
+ if (!res.headersSent) {
479
+ res.writeHead(upstreamRes.status, { "content-type": "application/json" });
480
+ res.end(data);
481
+ } else if (!res.writableEnded) {
482
+ res.end();
483
+ }
484
+ return;
485
+ }
486
+ const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
487
+ // Classify the chat object before it is mapped: finishFromChat reads the
488
+ // assistant message the model actually produced.
489
+ auditChatCompletion(audit, chat);
490
+ const resp = chatResponsesJsonFromChatCompletion(chat, model);
491
+ if (clientRequestedStream) {
492
+ if (!res.headersSent) {
493
+ res.writeHead(200, {
494
+ "content-type": "text/event-stream",
495
+ "cache-control": "no-cache, no-transform",
496
+ connection: "keep-alive",
497
+ "x-accel-buffering": "no",
498
+ });
499
+ res.end(chatResponsesSseFromChatCompletion(resp));
500
+ } else if (!res.writableEnded) {
501
+ res.end();
502
+ }
503
+ return;
504
+ }
505
+ if (!res.headersSent) {
506
+ res.writeHead(200, { "content-type": "application/json" });
507
+ res.end(JSON.stringify(resp));
508
+ } else if (!res.writableEnded) {
509
+ res.end();
510
+ }
511
+ }
512
+
513
+ // ── OVH lane quirks ───────────────────────────────────────────────────
514
+ // Two catalog models have upstream-side quirks this lane must absorb. Both
515
+ // checks key off the canonical catalog id, never the wire id: the wire id has
516
+ // already lost its `ovh-free/` prefix, and a short alias (`qwen3.8-27b`,
517
+ // `mistral-small-3.2`) must resolve to the same model.
518
+
519
+ /** Qwen3.8-27B on OVH: `reasoning_effort` starves the model into an empty turn. */
520
+ const OVH_NO_EFFORT_MODEL = "ovh-free/Qwen3.8-27B" as const;
521
+ /** Mistral Small 3.2 on OVH: replayed tool-call ids must be 9 alphanumerics. */
522
+ const OVH_STRICT_TOOL_CALL_ID_MODEL = "ovh-free/Mistral-Small-3.2-24B-Instruct-2506" as const;
523
+ const OVH_TOOL_CALL_ID_RE = /^[A-Za-z0-9]{9}$/;
524
+
525
+ /**
526
+ * Rewrite the non-conforming tool-call ids in a chat body's messages.
527
+ *
528
+ * Upstream validates every replayed id against "a-z, A-Z, 0-9, length 9" and
529
+ * drops the whole turn otherwise: a 400 `Tool call id was ll_loop_1 but must be
530
+ * a-z, A-Z, 0-9, with a length of 9` for a non-streamed call, and the same
531
+ * rejection as a silent `data: [DONE]`-only 200 when streaming (measured
532
+ * 2026-10-22). Upstream mints conforming ids itself (observed `hCCedplH4`), so
533
+ * only host-supplied history needs fixing. Replacements are deterministic
534
+ * (`t` + 8-character zero-padded base36 of the request-local sequence) and the
535
+ * same map is applied to the matching `role:"tool"` message, so the pair a host
536
+ * replays next turn stays equal. Conforming ids pass through untouched.
537
+ */
538
+ export function normalizeOvhToolCallIds(messages: unknown): void {
539
+ if (!Array.isArray(messages)) return;
540
+ const taken = new Set<string>();
541
+ const broken: string[] = [];
542
+ const note = (id: unknown): void => {
543
+ if (typeof id !== "string" || id.length === 0) return;
544
+ if (OVH_TOOL_CALL_ID_RE.test(id)) taken.add(id);
545
+ else if (!broken.includes(id)) broken.push(id);
546
+ };
547
+ for (const message of messages) {
548
+ if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
549
+ const rec = message as Record<string, unknown>;
550
+ if (Array.isArray(rec.tool_calls)) {
551
+ for (const call of rec.tool_calls) {
552
+ if (call !== null && typeof call === "object" && !Array.isArray(call)) note((call as Record<string, unknown>).id);
553
+ }
554
+ }
555
+ if (rec.role === "tool") note(rec.tool_call_id);
556
+ }
557
+ if (broken.length === 0) return;
558
+ const remap = new Map<string, string>();
559
+ let seq = 0;
560
+ for (const id of broken) {
561
+ let next = `t${seq.toString(36).padStart(8, "0")}`;
562
+ while (taken.has(next)) {
563
+ seq += 1;
564
+ next = `t${seq.toString(36).padStart(8, "0")}`;
565
+ }
566
+ seq += 1;
567
+ taken.add(next);
568
+ remap.set(id, next);
569
+ }
570
+ for (const message of messages) {
571
+ if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
572
+ const rec = message as Record<string, unknown>;
573
+ if (Array.isArray(rec.tool_calls)) {
574
+ for (const call of rec.tool_calls) {
575
+ if (call === null || typeof call !== "object" || Array.isArray(call)) continue;
576
+ const callRec = call as Record<string, unknown>;
577
+ const next = remap.get(callRec.id as string);
578
+ if (next !== undefined) callRec.id = next;
579
+ }
580
+ }
581
+ if (rec.role === "tool") {
582
+ const next = remap.get(rec.tool_call_id as string);
583
+ if (next !== undefined) rec.tool_call_id = next;
584
+ }
585
+ }
586
+ }
587
+
588
+ /**
589
+ * True when an aggregated chat answer carries neither assistant text nor a
590
+ * tool call — the exact shape OVH produces when it starves a request (Qwen
591
+ * with a `reasoning_effort` field, Mistral with a malformed replayed tool-call
592
+ * id) and closes the stream after a bare `data: [DONE]`. Aggregating that
593
+ * yields `content: null, finish_reason: "stop"`, which the host reads as a
594
+ * finished empty turn; the lane must surface it instead of fabricating it.
595
+ */
596
+ export function ovhAnswerIsEmpty(chat: Record<string, unknown>): boolean {
597
+ const choices = Array.isArray(chat.choices) ? chat.choices : [];
598
+ const first = choices.length > 0 && choices[0] !== null && typeof choices[0] === "object" && !Array.isArray(choices[0])
599
+ ? choices[0] as Record<string, unknown>
600
+ : undefined;
601
+ const message = first !== undefined && first.message !== null && typeof first.message === "object" && !Array.isArray(first.message)
602
+ ? first.message as Record<string, unknown>
603
+ : undefined;
604
+ if (message === undefined) return true;
605
+ if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return false;
606
+ const content = message.content;
607
+ if (typeof content === "string") return content.length === 0;
608
+ if (Array.isArray(content)) return content.length === 0;
609
+ return true;
610
+ }
611
+
612
+ /** `ovhAnswerIsEmpty` over the JSON text `convertSseToJson` serialized. */
613
+ export function ovhAggregateIsEmpty(data: string): boolean {
614
+ let parsed: unknown;
615
+ try {
616
+ parsed = JSON.parse(data);
617
+ } catch {
618
+ return false;
619
+ }
620
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
621
+ return ovhAnswerIsEmpty(parsed as Record<string, unknown>);
622
+ }
623
+
624
+ /**
625
+ * Answer an empty upstream stream as an error. Never emit the aggregated
626
+ * `chatcmpl-freeflow` shell for it: that is a fabricated 200 completion with
627
+ * `content: null` and `finish_reason: "stop"`, the very thing that hid these
628
+ * upstream failures from the host.
629
+ */
630
+ export function respondOvhEmptyStream(res: http.ServerResponse, reqId: string, model: string): void {
631
+ log("warn", `ovh upstream returned an empty stream for model ${model}`, { model }, reqId);
632
+ if (!res.headersSent) {
633
+ res.writeHead(502, { "content-type": "application/json" });
634
+ res.end(JSON.stringify({ error: "upstream error", detail: "upstream returned an empty stream" }));
635
+ } else if (!res.writableEnded) {
636
+ res.end();
637
+ }
638
+ }
639
+
640
+ export interface HandleOvhRequestOptions {
641
+ parsedBody: Record<string, unknown>;
642
+ pathname: string;
643
+ req: http.IncomingMessage;
644
+ res: http.ServerResponse;
645
+ reqId: string;
646
+ clientRequestedStream: boolean;
647
+ audit?: ToolcallRecorder;
648
+ }
649
+
650
+ /**
651
+ * Serve an OVH-model request direct (never relay pool, no opencode
652
+ * fingerprint). Chat-only upstream: OVH's responses endpoint rejects every
653
+ * array `input` shape with 422 "input: data did not match any variant of
654
+ * untagged enum ResponseInput" and accepts only a bare string (probed
655
+ * 2026-10-13), so a responses-path body translates to chat upstream and the
656
+ * chat answer translates back — the same shape mapping the Cline lane uses.
657
+ * Keyless: no Authorization header, because OVH answers anonymous callers.
658
+ * Upstream always streams; callers that asked for plain JSON get the
659
+ * aggregated object, and a streamed responses caller gets SSE re-emitted
660
+ * from the mapped Responses object.
661
+ */
662
+ export async function handleOvhRequest(opts: HandleOvhRequestOptions): Promise<void> {
663
+ const { parsedBody, pathname, req, res, reqId, clientRequestedStream, audit } = opts;
664
+ const responsesRequest = pathname.endsWith("/responses");
665
+ const requestedModel = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
666
+ let chatBody: Record<string, unknown>;
667
+ if (responsesRequest) {
668
+ chatBody = chatBodyFromResponsesBody(parsedBody);
669
+ } else {
670
+ chatBody = { ...parsedBody };
671
+ if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
672
+ delete chatBody.prompt_cache_key;
673
+ }
674
+ // OVH knows only the bare id. Canonicalise before stripping: a short alias
675
+ // (`llama-3.3-70b`) must resolve to `ovh-free/Meta-Llama-3_3-70B-Instruct`
676
+ // first, or it reaches OVH unrecognised and answers 404 model_not_found.
677
+ chatBody.model = ovhUpstreamModelId(requestedModel);
678
+ // Lane quirks, keyed by canonical catalog id so an alias resolves to the same
679
+ // model (the wire id above has already lost its `ovh-free/` prefix).
680
+ const canonicalModelId = resolveCanonicalModelId(requestedModel);
681
+ // OVH's OpenAI-compatible surface rejects Qwen chat-template controls
682
+ // (`400 feature 'extra arguments: {"chat_template_kwargs":…}' is not
683
+ // currently supported`). The host classifies this loopback proxy as a local
684
+ // OpenAI-compatible backend, treats `Qwen3.x` models as template-dialect
685
+ // Qwen and injects them; the lane has no such feature. Strip defensively so
686
+ // no host version can leak them upstream.
687
+ delete chatBody.chat_template_kwargs;
688
+ delete chatBody.enable_thinking;
689
+ delete chatBody.preserve_thinking;
690
+ // OVH's OpenAI-compatible surface rejects Responses-native caps (`400 feature
691
+ // 'extra arguments: {"max_output_tokens":…}' is not currently supported`)
692
+ // when a responses-path request was translated via chatBodyFromResponsesBody.
693
+ // Strip defensively so the cap is not passed as an unknown extra argument.
694
+ delete chatBody.max_output_tokens;
695
+ const modelDef = MODEL_MAP.get(canonicalModelId) ?? MODEL_MAP.get(requestedModel);
696
+ if (canonicalModelId === OVH_NO_EFFORT_MODEL || (modelDef && !modelDef.reasoning)) {
697
+ // Qwen3.8-27B on OVH starves when reasoning_effort is present.
698
+ // Non-reasoning OVH models (Llama 3.3, Mistral Small) return HTTP 400
699
+ // "feature 'reasoning_effort for this model' is not currently supported".
700
+ delete chatBody.reasoning_effort;
701
+ }
702
+ if (canonicalModelId === OVH_STRICT_TOOL_CALL_ID_MODEL) {
703
+ // Quirk documented on normalizeOvhToolCallIds: a host-supplied history id
704
+ // that is not 9 alphanumerics kills the turn upstream (400 non-streamed,
705
+ // silent `[DONE]`-only 200 streamed).
706
+ normalizeOvhToolCallIds(chatBody.messages);
707
+ }
708
+ // Bypassing the fingerprint does not exempt OVH from malformed tool
709
+ // arguments: capture the caller's own schemas before translation for repair.
710
+ const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
711
+ // Translation renames the caller's `find` to `glob` upstream and folds any
712
+ // name the upstream shape forbids; without this map the answer returns under
713
+ // a name Pi never registered.
714
+ const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
715
+ chatBody.stream = true;
716
+ // Header-wait timeout + client-disconnect abort, cleared once headers arrive
717
+ // so a long stream is not killed at the timeout ceiling.
718
+ const controller = new AbortController();
719
+ const timeoutId = setTimeout(
720
+ () => controller.abort(upstreamTimeoutError()),
721
+ UPSTREAM_HEADER_TIMEOUT_MS,
722
+ );
723
+ const abortOnClientGone = (): void => {
724
+ if (!res.writableEnded) controller.abort();
725
+ };
726
+ res.once("close", abortOnClientGone);
727
+ req.once("error", abortOnClientGone);
728
+ let upstreamRes: Response;
729
+ let servedRelay: string | null = null;
730
+ try {
731
+ const relayState = getActiveRelayState();
732
+ const relayPoolOn =
733
+ relayState.mode !== "off" &&
734
+ relayState.enabled !== false &&
735
+ Boolean(relayState.url || (relayState.relays && relayState.relays.length > 0));
736
+
737
+ if (relayPoolOn) {
738
+ upstreamRes = await relayFetch(
739
+ OVH_CHAT_URL,
740
+ {
741
+ method: "POST",
742
+ headers: { "Content-Type": "application/json" },
743
+ body: JSON.stringify(chatBody),
744
+ signal: controller.signal,
745
+ },
746
+ reqId,
747
+ {
748
+ roundRobin: true,
749
+ onServed: (url) => { servedRelay = url; },
750
+ },
751
+ (failure) => audit?.attemptFailure({ attempt: failure.attempt, status: failure.status, reason: failure.reason }),
752
+ );
753
+ } else {
754
+ upstreamRes = await fetch(OVH_CHAT_URL, {
755
+ method: "POST",
756
+ headers: { "Content-Type": "application/json" },
757
+ body: JSON.stringify(chatBody),
758
+ signal: controller.signal,
759
+ });
760
+ }
761
+ } catch (e) {
762
+ log("error", "ovh upstream fetch failed", { error: String(e), model: requestedModel }, reqId);
763
+ if (!res.headersSent) {
764
+ res.writeHead(502, { "content-type": "application/json" });
765
+ res.end(JSON.stringify({ error: "upstream error" }));
766
+ } else if (!res.writableEnded) {
767
+ res.end();
768
+ }
769
+ return;
770
+ } finally {
771
+ clearTimeout(timeoutId);
772
+ res.off("close", abortOnClientGone);
773
+ req.off("error", abortOnClientGone);
774
+ }
775
+ if (res.writableEnded) {
776
+ try { await upstreamRes.body?.cancel(); } catch { }
777
+ return;
778
+ }
779
+ if (!upstreamRes.ok) {
780
+ log("warn", `ovh upstream ${upstreamRes.status} for model ${requestedModel}`, { status: upstreamRes.status, model: requestedModel }, reqId);
781
+ audit?.attemptFailure({ attempt: 0, status: upstreamRes.status, reason: "final-upstream-status" });
782
+ const raw = await upstreamRes.text().catch(() => null);
783
+ if (raw === null) {
784
+ log("error", "ovh upstream body unreadable", { status: upstreamRes.status, model: requestedModel }, reqId);
785
+ if (!res.headersSent) {
786
+ res.writeHead(502, { "content-type": "application/json" });
787
+ res.end(JSON.stringify({ error: "upstream error" }));
788
+ } else if (!res.writableEnded) {
789
+ res.end();
790
+ }
791
+ return;
792
+ }
793
+ if (!res.headersSent) {
794
+ res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
795
+ res.end(raw);
796
+ } else if (!res.writableEnded) {
797
+ res.end();
798
+ }
799
+ return;
800
+ }
801
+ if (!responsesRequest && clientRequestedStream && upstreamRes.body) {
802
+ res.writeHead(upstreamRes.status, {
803
+ "content-type": upstreamRes.headers.get("content-type") || "text/event-stream",
804
+ "cache-control": "no-cache, no-transform",
805
+ connection: "keep-alive",
806
+ "x-accel-buffering": "no",
807
+ });
808
+ // Fetched via relay pool or direct fallback.
809
+ pipeUpstreamStream(
810
+ Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
811
+ res,
812
+ req,
813
+ reqId,
814
+ servedRelay ?? "direct",
815
+ {
816
+ callerHadTools: Array.isArray(parsedBody.tools) && parsedBody.tools.length > 0,
817
+ argSchemas,
818
+ findGlob,
819
+ pathname: "/v1/chat/completions",
820
+ normalizeReasoning: true,
821
+ },
822
+ { upstreamContentType: upstreamRes.headers.get("content-type"), audit: audit?.active ? audit : undefined },
823
+ );
824
+ return;
825
+ }
826
+ const sseText = await upstreamRes.text().catch(() => null);
827
+ if (sseText === null) {
828
+ log("error", "ovh upstream stream unreadable", { status: upstreamRes.status, model: requestedModel }, reqId);
829
+ if (!res.headersSent) {
830
+ res.writeHead(502, { "content-type": "application/json" });
831
+ res.end(JSON.stringify({ error: "upstream error" }));
832
+ } else if (!res.writableEnded) {
833
+ res.end();
834
+ }
835
+ return;
836
+ }
837
+ // The emptiness guard below is about a real upstream *stream*: a 200 body
838
+ // that is not SSE at all is passed through by `convertSseToJson` by
839
+ // contract, and says nothing about starvation.
840
+ const upstreamSpokeSse = looksLikeSse(sseText.trim());
841
+ if (!responsesRequest) {
842
+ const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
843
+ // Classified before the starvation guard: a starved turn is exactly the
844
+ // no_tool_calls record worth keeping.
845
+ auditChatBody(audit, data);
846
+ // An upstream that starved the request closes with a bare `data: [DONE]`:
847
+ // the aggregate is an empty assistant turn, which must surface as an error
848
+ // rather than as a finished (and silently blank) completion.
849
+ if (upstreamSpokeSse && ovhAggregateIsEmpty(data)) {
850
+ respondOvhEmptyStream(res, reqId, requestedModel);
851
+ return;
852
+ }
853
+ if (!res.headersSent) {
854
+ res.writeHead(upstreamRes.status, { "content-type": "application/json" });
855
+ res.end(data);
856
+ } else if (!res.writableEnded) {
857
+ res.end();
858
+ }
859
+ return;
860
+ }
861
+ const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
862
+ auditChatCompletion(audit, chat);
863
+ if (upstreamSpokeSse && ovhAnswerIsEmpty(chat)) {
864
+ respondOvhEmptyStream(res, reqId, requestedModel);
865
+ return;
866
+ }
867
+ const resp = chatResponsesJsonFromChatCompletion(chat, requestedModel);
868
+ if (clientRequestedStream) {
869
+ if (!res.headersSent) {
870
+ res.writeHead(200, {
871
+ "content-type": "text/event-stream",
872
+ "cache-control": "no-cache, no-transform",
873
+ connection: "keep-alive",
874
+ "x-accel-buffering": "no",
875
+ });
876
+ res.end(chatResponsesSseFromChatCompletion(resp));
877
+ } else if (!res.writableEnded) {
878
+ res.end();
879
+ }
880
+ return;
881
+ }
882
+ if (!res.headersSent) {
883
+ res.writeHead(200, { "content-type": "application/json" });
884
+ res.end(JSON.stringify(resp));
885
+ } else if (!res.writableEnded) {
886
+ res.end();
887
+ }
888
+ }
889
+
890
+ export interface HandleKiloRequestOptions {
891
+ parsedBody: Record<string, unknown>;
892
+ pathname: string;
893
+ req: http.IncomingMessage;
894
+ res: http.ServerResponse;
895
+ reqId: string;
896
+ clientRequestedStream: boolean;
897
+ sessionKey?: string | null;
898
+ audit?: ToolcallRecorder;
899
+ }
900
+
901
+ /**
902
+ * Keyless gateway handling for Kilo models (both direct Kilo models and
903
+ * Zen sessions failed over to Kilo). Serves chat completions and responses
904
+ * natively, repairs tool arguments, and recovers from 400 token overflows.
905
+ */
906
+ export async function handleKiloRequest(opts: HandleKiloRequestOptions): Promise<void> {
907
+ const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey, audit } = opts;
908
+ const isStream = clientRequestedStream;
909
+ const kiloController = new AbortController();
910
+ const kiloArgSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
911
+ const kiloTimeoutId = setTimeout(
912
+ () => kiloController.abort(upstreamTimeoutError()),
913
+ UPSTREAM_HEADER_TIMEOUT_MS,
914
+ );
915
+ const abortKiloOnClientGone = (): void => {
916
+ if (!res.writableEnded) kiloController.abort();
917
+ };
918
+ res.once("close", abortKiloOnClientGone);
919
+ req.once("error", abortKiloOnClientGone);
920
+ let response: Response;
921
+ let servedRelay: string | null = null;
922
+ const kiloUrl = pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
923
+ try {
924
+ response = await relayFetch(
925
+ kiloUrl,
926
+ {
927
+ method: "POST",
928
+ headers: {
929
+ "Content-Type": "application/json",
930
+ },
931
+ body: JSON.stringify(parsedBody),
932
+ signal: kiloController.signal,
933
+ },
934
+ reqId,
935
+ { onServed: (r) => { servedRelay = r; } },
936
+ (info) => audit?.attemptFailure(info),
937
+ );
938
+ if (response.status === 400 && !res.writableEnded) {
939
+ // Token-clamp retry only. The model is NEVER reassigned: a 400 must
940
+ // surface as upstream's verdict for the model the caller asked for, not
941
+ // be answered from another model's stream with only the `model` field as
942
+ // a trace (a silent substitution the product forbids).
943
+ const retryBody = { ...parsedBody };
944
+ let changed = false;
945
+ if (typeof retryBody.max_output_tokens === "number" && retryBody.max_output_tokens > 4096) {
946
+ retryBody.max_output_tokens = 4096;
947
+ changed = true;
948
+ }
949
+ if (typeof retryBody.max_tokens === "number" && retryBody.max_tokens > 4096) {
950
+ retryBody.max_tokens = 4096;
951
+ changed = true;
952
+ }
953
+ if (changed) {
954
+ log("warn", `kilo 400 for model ${String(parsedBody.model)} — retrying with clamped tokens`, { model: parsedBody.model }, reqId);
955
+ try {
956
+ try { await response.body?.cancel(); } catch { }
957
+ const retryRes = await relayFetch(
958
+ kiloUrl,
959
+ {
960
+ method: "POST",
961
+ headers: {
962
+ "Content-Type": "application/json",
963
+ },
964
+ body: JSON.stringify(retryBody),
965
+ signal: kiloController.signal,
966
+ },
967
+ reqId,
968
+ { onServed: (r) => { servedRelay = r; } },
969
+ (info) => audit?.attemptFailure(info),
970
+ );
971
+ if (retryRes.ok) {
972
+ log("info", `kilo 400 recovery succeeded with clamped tokens for model ${String(retryBody.model)}`, { model: retryBody.model }, reqId);
973
+ response = retryRes;
974
+ }
975
+ } catch { }
976
+ }
977
+ }
978
+ } catch (e) {
979
+ log("error", "kilo upstream fetch failed", { error: String(e), model: String(parsedBody.model) }, reqId);
980
+ if (!res.headersSent) {
981
+ res.writeHead(502, { "content-type": "application/json" });
982
+ res.end(JSON.stringify({ error: "upstream error" }));
983
+ } else if (!res.writableEnded) {
984
+ res.end();
985
+ }
986
+ return;
987
+ } finally {
988
+ clearTimeout(kiloTimeoutId);
989
+ res.off("close", abortKiloOnClientGone);
990
+ req.off("error", abortKiloOnClientGone);
991
+ }
992
+ if (res.writableEnded) return;
993
+ if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
994
+ else audit?.attemptFailure({ attempt: 0, status: response.status, reason: "final-upstream-status" });
995
+
996
+ if (isStream && response.ok && response.body) {
997
+ const ct = response.headers.get("content-type") || "text/event-stream";
998
+ res.writeHead(response.status, {
999
+ "content-type": ct,
1000
+ "cache-control": "no-cache, no-transform",
1001
+ connection: "keep-alive",
1002
+ "x-accel-buffering": "no",
1003
+ });
1004
+ pipeUpstreamStream(
1005
+ Readable.fromWeb(
1006
+ response.body as unknown as WebReadableStream,
1007
+ ),
1008
+ res,
1009
+ req,
1010
+ reqId,
1011
+ servedRelay ?? "direct",
1012
+ kiloArgSchemas.size > 0
1013
+ ? {
1014
+ callerHadTools: true,
1015
+ argSchemas: kiloArgSchemas,
1016
+ pathname: pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
1017
+ }
1018
+ : undefined,
1019
+ { upstreamContentType: response.headers.get("content-type"), audit: audit?.active ? audit : undefined },
1020
+ );
1021
+ } else {
1022
+ const rawUpstreamText = await response.text();
1023
+ let rawText = rawUpstreamText;
1024
+ if (response.ok && kiloArgSchemas.size > 0) {
1025
+ rawText = convertSseToJson(rawText, pathname, true, undefined, undefined, undefined, kiloArgSchemas);
1026
+ rawText = repairJsonToolArguments(rawText, pathname, kiloArgSchemas);
1027
+ }
1028
+ if (response.ok) auditFinalBody(audit, pathname, rawUpstreamText, rawText);
1029
+ const data = withRateLimitHint(response.status, rawText);
1030
+ const ct = response.headers.get("content-type") || "application/json";
1031
+ res.writeHead(response.status, { "content-type": ct });
1032
+ res.end(data);
1033
+ }
1034
+ }
1035
+
1036
+ /**
1037
+ * Relay eligibility: only free-catalog models ride relay egress. Returns true
1038
+ * when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
1039
+ * (Kilo traffic leaves via its own branch regardless), and when the model is
1040
+ * missing/empty (e.g. GET /v1/models carries no body — preserve routing).
1041
+ * Cline and OVH models are direct-only and never relay-eligible. Anything else
1042
+ * (unknown/paid model ids, non-strings) goes direct upstream.
1043
+ */
1044
+ export function isRelayEligibleModel(model: unknown): boolean {
1045
+ if (model === undefined || model === null) return true;
1046
+ if (typeof model !== "string") return false;
1047
+ if (model.trim() === "") return true;
1048
+ const canonical = resolveCanonicalModelId(model.trim());
1049
+ if (isClineModel(canonical)) return false;
1050
+ return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical) || isFreeCatalogId(canonical) || isOvhModel(canonical);
1051
+ }
1052
+
1053
+ /**
1054
+ * True when the resolved Zen transport forbids relay egress: a relay would
1055
+ * re-originate the connection and forfeit the sidecar's fingerprint, so the
1056
+ * request must ride the direct leg the transport was chosen for.
1057
+ */
1058
+ export function relayBypassedForTransport(mode: ZenTransportMode): boolean {
1059
+ return mode === "bun-sidecar";
1060
+ }
1061
+
1062
+ /**
1063
+ * Failing-over decision for a canonicalized request body. Only active when
1064
+ * the PI_FREEFLOW_ZEN_KILO_FAILOVER opt-in is set; otherwise passthrough.
1065
+ * Returns failover
1066
+ * with a Kilo model id when a healthy Kilo model is available and either the
1067
+ * session already ate a gate 403 (retried/resumed refusals fail over at once,
1068
+ * even ungated — replaying a known refusal can never succeed), or every gate
1069
+ * holds: Zen upstream model, a path whose body shape matches (chat bodies on
1070
+ * chat completions, responses bodies on responses — Kilo serves both natively
1071
+ * but a crossed body would 400), Zen currently gated, session never proven
1072
+ * working, and the health machine routes this session to failover. Canary
1073
+ * sessions pass through carrying the recovery probe; anything else passes
1074
+ * through untouched — working sessions are never rerouted.
1075
+ */
1076
+ export type ZenFailoverDecision =
1077
+ | { action: "failover"; model: string }
1078
+ | { action: "canary" }
1079
+ | { action: "passthrough" };
1080
+
1081
+ export function classifyZenFailover(
1082
+ parsedBody: Record<string, unknown> | null,
1083
+ pathname: string,
1084
+ ): ZenFailoverDecision {
1085
+ if (!isZenFailoverEnabled()) return { action: "passthrough" };
1086
+ if (!parsedBody || typeof parsedBody.model !== "string") return { action: "passthrough" };
1087
+ if (getModelUpstream(parsedBody.model) !== "opencode") return { action: "passthrough" };
1088
+ const isChatPath = pathname.endsWith("/chat/completions");
1089
+ const isResponsesPath = pathname.endsWith("/responses");
1090
+ if (!isChatPath && !isResponsesPath) return { action: "passthrough" };
1091
+ if (isChatPath && !Array.isArray(parsedBody.messages)) return { action: "passthrough" };
1092
+ if (isResponsesPath && parsedBody.input === undefined) return { action: "passthrough" };
1093
+ if (!wasGateRejected(parsedBody, pathname)) {
1094
+ if (!isUpstreamGated("zen")) return { action: "passthrough" };
1095
+ if (!isUnprovenSession(parsedBody, pathname)) return { action: "passthrough" };
1096
+ const route = decideZenRoute(parsedBody, pathname);
1097
+ if (route === "canary") {
1098
+ log("debug", "zen gated — canary session passes through to zen");
1099
+ return { action: "canary" };
1100
+ }
1101
+ if (route !== "failover") return { action: "passthrough" };
1102
+ } else {
1103
+ log("debug", "zen refused this session before — failing over to Kilo");
1104
+ }
1105
+ const pick = pickFailoverModel();
1106
+ if (!pick) return { action: "passthrough" };
1107
+ return { action: "failover", model: pick };
1108
+ }
1109
+
1110
+ const OPENCODE_STALE_VERSION_RE = /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;
1111
+
1112
+ /**
1113
+ * True when an opencode-lane upstream verdict names the client version as the
1114
+ * refusal reason — a stale pinned UA that a refresh may repair. 426 always
1115
+ * qualifies; 400/403 qualify only on explicit version/upgrade language, and a
1116
+ * FreeTierError gate body never qualifies (that is the fingerprint gate, not
1117
+ * version drift, and retrying it with a fresh UA would replay the refusal).
1118
+ */
1119
+ export function isStaleOpenCodeVersionError(status: number, bodyText: unknown): boolean {
1120
+ if (typeof bodyText !== "string" || bodyText.length === 0) return status === 426;
1121
+ if (bodyText.includes("FreeTierError")) return false;
1122
+ if (status === 426) return true;
1123
+ if (status !== 400 && status !== 403) return false;
1124
+ return OPENCODE_STALE_VERSION_RE.test(bodyText);
1125
+ }