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
|
@@ -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
|
+
}
|