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.
- package/CHANGELOG.md +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- 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
|
|
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 {
|
|
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
|
|
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 {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
-
|
|
601
|
+
incrementActiveRequests();
|
|
1923
602
|
res.on("close", () => {
|
|
1924
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
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
|
-
|
|
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;
|