pi-freeflow 1.36.0 → 1.36.3
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 +45 -0
- package/package.json +1 -1
- package/src/catalog.ts +6 -0
- package/src/config.ts +9 -4
- package/src/index.ts +1 -0
- package/src/models.ts +5 -44
- package/src/opencode-fingerprint.ts +88 -5
- package/src/probe.ts +7 -7
- package/src/proxy.ts +234 -144
- package/src/stream-pipe.ts +85 -1
- package/src/tool-args.ts +194 -52
- package/src/tool-translation.ts +179 -87
package/src/proxy.ts
CHANGED
|
@@ -35,11 +35,12 @@ import {
|
|
|
35
35
|
} from "./config.ts";
|
|
36
36
|
|
|
37
37
|
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
convertSseToJson,
|
|
39
|
+
enforceOpencodeFingerprint,
|
|
40
|
+
repairJsonToolArguments,
|
|
41
|
+
sseToChatCompletionJson,
|
|
42
|
+
type CaseRestoreMap,
|
|
43
|
+
type FindGlobRestore,
|
|
43
44
|
} from "./opencode-fingerprint.ts";
|
|
44
45
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
45
46
|
import { getModelUpstream, isClineModel, isOvhModel, KILO_MODEL_IDS, MODEL_MAP, ovhUpstreamModelId, resolveCanonicalModelId } from "./models.ts";
|
|
@@ -47,10 +48,12 @@ import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth
|
|
|
47
48
|
import { rollChat } from "./cline-accounts.ts";
|
|
48
49
|
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
49
50
|
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
buildFindGlobRestore,
|
|
52
|
+
chatBodyFromResponsesBody,
|
|
53
|
+
chatResponsesJsonFromChatCompletion,
|
|
54
|
+
chatResponsesSseFromChatCompletion,
|
|
55
|
+
restoreToolNameForCaller,
|
|
56
|
+
translateToolsForPath,
|
|
54
57
|
} from "./tool-translation.ts";
|
|
55
58
|
import { buildArgRepairSchemas } from "./tool-args.ts";
|
|
56
59
|
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
@@ -77,6 +80,7 @@ import {
|
|
|
77
80
|
import { pipeUpstreamStream, type StreamCloakOptions } from "./stream-pipe.ts";
|
|
78
81
|
import {
|
|
79
82
|
decideZenRoute,
|
|
83
|
+
isFreeTierGate,
|
|
80
84
|
isUnprovenSession,
|
|
81
85
|
isUpstreamGated,
|
|
82
86
|
pickFailoverModel,
|
|
@@ -271,6 +275,10 @@ async function handleClineRequest(opts: {
|
|
|
271
275
|
// Cline bypasses the fingerprint, but its arguments still malform the same
|
|
272
276
|
// way: capture the caller's own schemas before translation for repair.
|
|
273
277
|
const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
278
|
+
// Translation renames the caller's `find` to `glob` upstream and folds any
|
|
279
|
+
// name the upstream shape forbids; without this map the answer returns under
|
|
280
|
+
// a name Pi never registered.
|
|
281
|
+
const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
274
282
|
chatBody.stream = true;
|
|
275
283
|
let upstreamRes: Response;
|
|
276
284
|
let limitHint: ClineLimitHint | undefined;
|
|
@@ -334,8 +342,8 @@ async function handleClineRequest(opts: {
|
|
|
334
342
|
req,
|
|
335
343
|
reqId,
|
|
336
344
|
"direct",
|
|
337
|
-
argSchemas.size > 0
|
|
338
|
-
? { callerHadTools: true, argSchemas, pathname: "/v1/chat/completions" }
|
|
345
|
+
findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
|
|
346
|
+
? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
|
|
339
347
|
: undefined,
|
|
340
348
|
);
|
|
341
349
|
return;
|
|
@@ -352,7 +360,7 @@ async function handleClineRequest(opts: {
|
|
|
352
360
|
return;
|
|
353
361
|
}
|
|
354
362
|
if (!responsesRequest) {
|
|
355
|
-
|
|
363
|
+
const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
|
|
356
364
|
if (!res.headersSent) {
|
|
357
365
|
res.writeHead(upstreamRes.status, { "content-type": "application/json" });
|
|
358
366
|
res.end(data);
|
|
@@ -361,7 +369,7 @@ async function handleClineRequest(opts: {
|
|
|
361
369
|
}
|
|
362
370
|
return;
|
|
363
371
|
}
|
|
364
|
-
const chat = sseToChatCompletionJson(sseText, true, undefined,
|
|
372
|
+
const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
|
|
365
373
|
const resp = chatResponsesJsonFromChatCompletion(chat, model);
|
|
366
374
|
if (clientRequestedStream) {
|
|
367
375
|
if (!res.headersSent) {
|
|
@@ -423,6 +431,10 @@ async function handleOvhRequest(opts: {
|
|
|
423
431
|
// Bypassing the fingerprint does not exempt OVH from malformed tool
|
|
424
432
|
// arguments: capture the caller's own schemas before translation for repair.
|
|
425
433
|
const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
434
|
+
// Translation renames the caller's `find` to `glob` upstream and folds any
|
|
435
|
+
// name the upstream shape forbids; without this map the answer returns under
|
|
436
|
+
// a name Pi never registered.
|
|
437
|
+
const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
426
438
|
chatBody.stream = true;
|
|
427
439
|
// Header-wait timeout + client-disconnect abort, cleared once headers arrive
|
|
428
440
|
// so a long stream is not killed at the timeout ceiling.
|
|
@@ -501,8 +513,8 @@ async function handleOvhRequest(opts: {
|
|
|
501
513
|
req,
|
|
502
514
|
reqId,
|
|
503
515
|
"direct",
|
|
504
|
-
argSchemas.size > 0
|
|
505
|
-
? { callerHadTools: true, argSchemas, pathname: "/v1/chat/completions" }
|
|
516
|
+
findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
|
|
517
|
+
? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
|
|
506
518
|
: undefined,
|
|
507
519
|
);
|
|
508
520
|
return;
|
|
@@ -519,7 +531,7 @@ async function handleOvhRequest(opts: {
|
|
|
519
531
|
return;
|
|
520
532
|
}
|
|
521
533
|
if (!responsesRequest) {
|
|
522
|
-
|
|
534
|
+
const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
|
|
523
535
|
if (!res.headersSent) {
|
|
524
536
|
res.writeHead(upstreamRes.status, { "content-type": "application/json" });
|
|
525
537
|
res.end(data);
|
|
@@ -528,7 +540,7 @@ async function handleOvhRequest(opts: {
|
|
|
528
540
|
}
|
|
529
541
|
return;
|
|
530
542
|
}
|
|
531
|
-
const chat = sseToChatCompletionJson(sseText, true, undefined,
|
|
543
|
+
const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
|
|
532
544
|
const resp = chatResponsesJsonFromChatCompletion(chat, requestedModel);
|
|
533
545
|
if (clientRequestedStream) {
|
|
534
546
|
if (!res.headersSent) {
|
|
@@ -552,6 +564,172 @@ async function handleOvhRequest(opts: {
|
|
|
552
564
|
}
|
|
553
565
|
}
|
|
554
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Keyless gateway handling for Kilo models (both direct Kilo models and
|
|
569
|
+
* Zen sessions failed over to Kilo). Serves chat completions and responses
|
|
570
|
+
* natively, repairs tool arguments, and recovers from 400 token overflows.
|
|
571
|
+
*/
|
|
572
|
+
async function handleKiloRequest(opts: {
|
|
573
|
+
parsedBody: Record<string, unknown>;
|
|
574
|
+
pathname: string;
|
|
575
|
+
req: http.IncomingMessage;
|
|
576
|
+
res: http.ServerResponse;
|
|
577
|
+
reqId: string;
|
|
578
|
+
clientRequestedStream: boolean;
|
|
579
|
+
sessionKey?: string | null;
|
|
580
|
+
}): Promise<void> {
|
|
581
|
+
const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey } = opts;
|
|
582
|
+
const isStream = clientRequestedStream;
|
|
583
|
+
const kiloController = new AbortController();
|
|
584
|
+
const kiloArgSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
585
|
+
const kiloTimeoutId = setTimeout(
|
|
586
|
+
() => kiloController.abort(upstreamTimeoutError()),
|
|
587
|
+
UPSTREAM_HEADER_TIMEOUT_MS,
|
|
588
|
+
);
|
|
589
|
+
const abortKiloOnClientGone = () => {
|
|
590
|
+
if (!res.writableEnded) kiloController.abort();
|
|
591
|
+
};
|
|
592
|
+
res.once("close", abortKiloOnClientGone);
|
|
593
|
+
req.once("error", abortKiloOnClientGone);
|
|
594
|
+
let response: Response;
|
|
595
|
+
const kiloUrl = pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
|
|
596
|
+
try {
|
|
597
|
+
response = await relayFetch(
|
|
598
|
+
kiloUrl,
|
|
599
|
+
{
|
|
600
|
+
method: "POST",
|
|
601
|
+
headers: {
|
|
602
|
+
"Content-Type": "application/json",
|
|
603
|
+
},
|
|
604
|
+
body: JSON.stringify(parsedBody),
|
|
605
|
+
signal: kiloController.signal,
|
|
606
|
+
},
|
|
607
|
+
reqId,
|
|
608
|
+
);
|
|
609
|
+
if (response.status === 400 && !res.writableEnded) {
|
|
610
|
+
const retryBody = { ...parsedBody };
|
|
611
|
+
let changed = false;
|
|
612
|
+
if (typeof retryBody.max_output_tokens === "number" && retryBody.max_output_tokens > 4096) {
|
|
613
|
+
retryBody.max_output_tokens = 4096;
|
|
614
|
+
changed = true;
|
|
615
|
+
}
|
|
616
|
+
if (typeof retryBody.max_tokens === "number" && retryBody.max_tokens > 4096) {
|
|
617
|
+
retryBody.max_tokens = 4096;
|
|
618
|
+
changed = true;
|
|
619
|
+
}
|
|
620
|
+
if (retryBody.model === "dots-studio/dots-3-note-preview:free") {
|
|
621
|
+
retryBody.model = "nvidia/nemotron-3.5-lightning:free";
|
|
622
|
+
changed = true;
|
|
623
|
+
}
|
|
624
|
+
if (changed) {
|
|
625
|
+
log("warn", `kilo 400 for model ${String(parsedBody.model)} — retrying with clamped tokens`, { model: parsedBody.model }, reqId);
|
|
626
|
+
try {
|
|
627
|
+
const retryRes = await relayFetch(
|
|
628
|
+
kiloUrl,
|
|
629
|
+
{
|
|
630
|
+
method: "POST",
|
|
631
|
+
headers: {
|
|
632
|
+
"Content-Type": "application/json",
|
|
633
|
+
},
|
|
634
|
+
body: JSON.stringify(retryBody),
|
|
635
|
+
signal: kiloController.signal,
|
|
636
|
+
},
|
|
637
|
+
reqId,
|
|
638
|
+
);
|
|
639
|
+
if (retryRes.ok) {
|
|
640
|
+
log("info", `kilo 400 recovery succeeded with model ${String(retryBody.model)}`, { model: retryBody.model }, reqId);
|
|
641
|
+
response = retryRes;
|
|
642
|
+
}
|
|
643
|
+
} catch { }
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
} finally {
|
|
647
|
+
clearTimeout(kiloTimeoutId);
|
|
648
|
+
res.off("close", abortKiloOnClientGone);
|
|
649
|
+
req.off("error", abortKiloOnClientGone);
|
|
650
|
+
}
|
|
651
|
+
if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
|
|
652
|
+
|
|
653
|
+
if (isStream && response.ok && response.body) {
|
|
654
|
+
const ct = response.headers.get("content-type") || "text/event-stream";
|
|
655
|
+
res.writeHead(response.status, {
|
|
656
|
+
"content-type": ct,
|
|
657
|
+
"cache-control": "no-cache, no-transform",
|
|
658
|
+
connection: "keep-alive",
|
|
659
|
+
"x-accel-buffering": "no",
|
|
660
|
+
});
|
|
661
|
+
pipeUpstreamStream(
|
|
662
|
+
Readable.fromWeb(
|
|
663
|
+
response.body as unknown as WebReadableStream,
|
|
664
|
+
),
|
|
665
|
+
res,
|
|
666
|
+
req,
|
|
667
|
+
reqId,
|
|
668
|
+
undefined,
|
|
669
|
+
kiloArgSchemas.size > 0
|
|
670
|
+
? {
|
|
671
|
+
callerHadTools: true,
|
|
672
|
+
argSchemas: kiloArgSchemas,
|
|
673
|
+
pathname: pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
|
|
674
|
+
}
|
|
675
|
+
: undefined,
|
|
676
|
+
);
|
|
677
|
+
} else {
|
|
678
|
+
let rawText = await response.text();
|
|
679
|
+
if (response.ok && kiloArgSchemas.size > 0) {
|
|
680
|
+
rawText = convertSseToJson(rawText, pathname, true, undefined, undefined, undefined, kiloArgSchemas);
|
|
681
|
+
rawText = repairJsonToolArguments(rawText, pathname, kiloArgSchemas);
|
|
682
|
+
}
|
|
683
|
+
const data = withRateLimitHint(response.status, rawText);
|
|
684
|
+
const ct = response.headers.get("content-type") || "application/json";
|
|
685
|
+
res.writeHead(response.status, { "content-type": ct });
|
|
686
|
+
res.end(data);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* In-flight auto-recovery for Zen 403 FreeTierError: immediately fail over
|
|
692
|
+
* the current request to a healthy Kilo model instead of surfacing 403 to the caller.
|
|
693
|
+
* Returns true when recovery was dispatched; false when failover is impossible.
|
|
694
|
+
*/
|
|
695
|
+
async function tryZenGateAutoFailover(opts: {
|
|
696
|
+
parsedBody: Record<string, unknown> | null;
|
|
697
|
+
pathname: string;
|
|
698
|
+
req: http.IncomingMessage;
|
|
699
|
+
res: http.ServerResponse;
|
|
700
|
+
reqId: string;
|
|
701
|
+
clientRequestedStream: boolean;
|
|
702
|
+
sessionKey?: string | null;
|
|
703
|
+
}): Promise<boolean> {
|
|
704
|
+
const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey } = opts;
|
|
705
|
+
if (!parsedBody || res.headersSent || res.writableEnded) return false;
|
|
706
|
+
const fallbackModel = pickFailoverModel(parsedBody.model);
|
|
707
|
+
if (!fallbackModel) return false;
|
|
708
|
+
try {
|
|
709
|
+
const fromModel = String(parsedBody.model ?? "?");
|
|
710
|
+
parsedBody.model = fallbackModel;
|
|
711
|
+
if (pathname.endsWith("/responses")) {
|
|
712
|
+
prepareResponsesFailoverBody(parsedBody);
|
|
713
|
+
} else {
|
|
714
|
+
prepareChatFailoverBody(parsedBody);
|
|
715
|
+
}
|
|
716
|
+
log("warn", `zen 403 FreeTierError auto-fixed: failing over ${fromModel} -> ${fallbackModel}`, { model: fromModel, failover: fallbackModel }, reqId);
|
|
717
|
+
await handleKiloRequest({
|
|
718
|
+
parsedBody,
|
|
719
|
+
pathname,
|
|
720
|
+
req,
|
|
721
|
+
res,
|
|
722
|
+
reqId,
|
|
723
|
+
clientRequestedStream,
|
|
724
|
+
sessionKey,
|
|
725
|
+
});
|
|
726
|
+
return true;
|
|
727
|
+
} catch (err) {
|
|
728
|
+
log("warn", `zen 403 FreeTierError auto-failover failed: ${String(err)}`, { error: String(err) }, reqId);
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
555
733
|
/**
|
|
556
734
|
* Relay eligibility: only free-catalog models ride relay egress. Returns true
|
|
557
735
|
* when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
|
|
@@ -679,11 +857,11 @@ export function sanitizeHeaders(
|
|
|
679
857
|
sanitized.connection = "keep-alive";
|
|
680
858
|
return sanitized;
|
|
681
859
|
}
|
|
682
|
-
export async function isProxyAlive(port: number): Promise<boolean> {
|
|
860
|
+
export async function isProxyAlive(port: number, timeoutMs = 600): Promise<boolean> {
|
|
683
861
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
684
862
|
try {
|
|
685
863
|
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
686
|
-
signal: AbortSignal.timeout(
|
|
864
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
687
865
|
});
|
|
688
866
|
const ct = res.headers.get("content-type") || "";
|
|
689
867
|
return res.ok && ct.includes("application/json");
|
|
@@ -1266,129 +1444,15 @@ export function startProxy(
|
|
|
1266
1444
|
// token: the proxy only passes the body and maps the answer.
|
|
1267
1445
|
await handleClineRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream });
|
|
1268
1446
|
} else if (isKilo && parsedBody) {
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
() => kiloController.abort(upstreamTimeoutError()),
|
|
1279
|
-
UPSTREAM_HEADER_TIMEOUT_MS,
|
|
1280
|
-
);
|
|
1281
|
-
const abortKiloOnClientGone = () => {
|
|
1282
|
-
if (!res.writableEnded) kiloController.abort();
|
|
1283
|
-
};
|
|
1284
|
-
res.once("close", abortKiloOnClientGone);
|
|
1285
|
-
req.once("error", abortKiloOnClientGone);
|
|
1286
|
-
let response: Response;
|
|
1287
|
-
// Kilo serves both wire APIs natively: responses-shaped bodies go to
|
|
1288
|
-
// its responses endpoint, chat bodies to chat completions. Sending a
|
|
1289
|
-
// responses body to the chat URL (or vice versa) would 400.
|
|
1290
|
-
const kiloUrl = target.pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
|
|
1291
|
-
try {
|
|
1292
|
-
response = await relayFetch(
|
|
1293
|
-
kiloUrl,
|
|
1294
|
-
{
|
|
1295
|
-
method: "POST",
|
|
1296
|
-
headers: {
|
|
1297
|
-
// Keyless, like Zen: Kilo's gateway rejects a placeholder bearer
|
|
1298
|
-
// (live 2026-09-22 — "Bearer kilo-free" answers 401 INVALID_TOKEN,
|
|
1299
|
-
// omitting Authorization answers 200 for every free catalog model).
|
|
1300
|
-
"Content-Type": "application/json",
|
|
1301
|
-
},
|
|
1302
|
-
body: JSON.stringify(parsedBody),
|
|
1303
|
-
signal: kiloController.signal,
|
|
1304
|
-
},
|
|
1305
|
-
reqId,
|
|
1306
|
-
);
|
|
1307
|
-
// Kilo 400 recovery: if upstream Kilo rejected the body with 400 (e.g.
|
|
1308
|
-
// prompt + max_output_tokens exceeded model's operational context limit),
|
|
1309
|
-
// retry once with clamped max tokens and fallback to a 1M model.
|
|
1310
|
-
if (response.status === 400 && !res.writableEnded) {
|
|
1311
|
-
const retryBody = { ...parsedBody };
|
|
1312
|
-
let changed = false;
|
|
1313
|
-
if (typeof retryBody.max_output_tokens === "number" && retryBody.max_output_tokens > 4096) {
|
|
1314
|
-
retryBody.max_output_tokens = 4096;
|
|
1315
|
-
changed = true;
|
|
1316
|
-
}
|
|
1317
|
-
if (typeof retryBody.max_tokens === "number" && retryBody.max_tokens > 4096) {
|
|
1318
|
-
retryBody.max_tokens = 4096;
|
|
1319
|
-
changed = true;
|
|
1320
|
-
}
|
|
1321
|
-
if (retryBody.model === "dots-studio/dots-3-note-preview:free") {
|
|
1322
|
-
retryBody.model = "nvidia/nemotron-3.5-lightning:free";
|
|
1323
|
-
changed = true;
|
|
1324
|
-
}
|
|
1325
|
-
if (changed) {
|
|
1326
|
-
log("warn", `kilo 400 for model ${String(parsedBody.model)} — retrying with clamped tokens`, { model: parsedBody.model }, reqId);
|
|
1327
|
-
try {
|
|
1328
|
-
const retryRes = await relayFetch(
|
|
1329
|
-
kiloUrl,
|
|
1330
|
-
{
|
|
1331
|
-
method: "POST",
|
|
1332
|
-
headers: {
|
|
1333
|
-
"Content-Type": "application/json",
|
|
1334
|
-
},
|
|
1335
|
-
body: JSON.stringify(retryBody),
|
|
1336
|
-
signal: kiloController.signal,
|
|
1337
|
-
},
|
|
1338
|
-
reqId,
|
|
1339
|
-
);
|
|
1340
|
-
if (retryRes.ok) {
|
|
1341
|
-
log("info", `kilo 400 recovery succeeded with model ${String(retryBody.model)}`, { model: retryBody.model }, reqId);
|
|
1342
|
-
response = retryRes;
|
|
1343
|
-
}
|
|
1344
|
-
} catch { }
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
} finally {
|
|
1348
|
-
clearTimeout(kiloTimeoutId);
|
|
1349
|
-
res.off("close", abortKiloOnClientGone);
|
|
1350
|
-
req.off("error", abortKiloOnClientGone);
|
|
1351
|
-
}
|
|
1352
|
-
if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
|
|
1353
|
-
|
|
1354
|
-
if (isStream && response.ok && response.body) {
|
|
1355
|
-
const ct =
|
|
1356
|
-
response.headers.get("content-type") || "text/event-stream";
|
|
1357
|
-
res.writeHead(response.status, {
|
|
1358
|
-
"content-type": ct,
|
|
1359
|
-
"cache-control": "no-cache, no-transform",
|
|
1360
|
-
connection: "keep-alive",
|
|
1361
|
-
"x-accel-buffering": "no",
|
|
1362
|
-
});
|
|
1363
|
-
// Kilo is fetched directly (not via the relay pool), so pass undefined as
|
|
1364
|
-
// the issuer: attributing kilo-side stream failures to an unrelated
|
|
1365
|
-
// opencode relay would mark a healthy relay as failed.
|
|
1366
|
-
pipeUpstreamStream(
|
|
1367
|
-
Readable.fromWeb(
|
|
1368
|
-
response.body as unknown as WebReadableStream,
|
|
1369
|
-
),
|
|
1370
|
-
res,
|
|
1371
|
-
req,
|
|
1372
|
-
reqId,
|
|
1373
|
-
undefined,
|
|
1374
|
-
kiloArgSchemas.size > 0
|
|
1375
|
-
? {
|
|
1376
|
-
callerHadTools: true,
|
|
1377
|
-
argSchemas: kiloArgSchemas,
|
|
1378
|
-
pathname: target.pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
|
|
1379
|
-
}
|
|
1380
|
-
: undefined,
|
|
1381
|
-
);
|
|
1382
|
-
} else {
|
|
1383
|
-
let rawText = await response.text();
|
|
1384
|
-
if (response.ok && kiloArgSchemas.size > 0) {
|
|
1385
|
-
rawText = convertSseToJson(rawText, target.pathname, true, undefined, undefined, undefined, kiloArgSchemas);
|
|
1386
|
-
}
|
|
1387
|
-
const data = withRateLimitHint(response.status, rawText);
|
|
1388
|
-
const ct = response.headers.get("content-type") || "application/json";
|
|
1389
|
-
res.writeHead(response.status, { "content-type": ct });
|
|
1390
|
-
res.end(data);
|
|
1391
|
-
}
|
|
1447
|
+
await handleKiloRequest({
|
|
1448
|
+
parsedBody,
|
|
1449
|
+
pathname: target.pathname,
|
|
1450
|
+
req,
|
|
1451
|
+
res,
|
|
1452
|
+
reqId,
|
|
1453
|
+
clientRequestedStream,
|
|
1454
|
+
sessionKey,
|
|
1455
|
+
});
|
|
1392
1456
|
} else if (isOvh && parsedBody) {
|
|
1393
1457
|
// OVH serves chat upstream only (see handleOvhRequest): direct, keyless,
|
|
1394
1458
|
// no fingerprint, and a responses-path body translated to chat and back.
|
|
@@ -1651,6 +1715,18 @@ export function startProxy(
|
|
|
1651
1715
|
log("warn", `upstream ${response.status} for model ${String((parsedBody as Record<string, unknown> | null)?.model ?? "?")} via ${servedRelay === "direct" ? "direct upstream" : `relay ${servedRelay}`}`, { status: response.status, model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url, relay: servedRelay }, reqId);
|
|
1652
1716
|
}
|
|
1653
1717
|
let rawText = await response.text();
|
|
1718
|
+
if (response.status === 403 && isFreeTierGate(403, rawText)) {
|
|
1719
|
+
const recovered = await tryZenGateAutoFailover({
|
|
1720
|
+
parsedBody: parsedBody as Record<string, unknown> | null,
|
|
1721
|
+
pathname: target.pathname,
|
|
1722
|
+
req,
|
|
1723
|
+
res,
|
|
1724
|
+
reqId,
|
|
1725
|
+
clientRequestedStream,
|
|
1726
|
+
sessionKey,
|
|
1727
|
+
});
|
|
1728
|
+
if (recovered) return;
|
|
1729
|
+
}
|
|
1654
1730
|
let ct =
|
|
1655
1731
|
response.headers.get("content-type") ||
|
|
1656
1732
|
"application/json";
|
|
@@ -1809,14 +1885,28 @@ export function startProxy(
|
|
|
1809
1885
|
// Gated upstream: a 403 free-tier gate is a final answer, not a
|
|
1810
1886
|
// stream — buffer it and attach the recovery hint instead of
|
|
1811
1887
|
// piping it through as a stream body.
|
|
1888
|
+
// Gated upstream: a 403 free-tier gate is auto-recovered via Kilo failover.
|
|
1889
|
+
// If failover is impossible, buffer it and attach the recovery hint.
|
|
1812
1890
|
if (upstreamRes.status === 403) {
|
|
1813
|
-
const
|
|
1891
|
+
const rawText403 = await upstreamRes.text();
|
|
1892
|
+
if (isFreeTierGate(403, rawText403)) {
|
|
1893
|
+
const recovered = await tryZenGateAutoFailover({
|
|
1894
|
+
parsedBody: parsedBody as Record<string, unknown> | null,
|
|
1895
|
+
pathname: target.pathname,
|
|
1896
|
+
req,
|
|
1897
|
+
res,
|
|
1898
|
+
reqId,
|
|
1899
|
+
clientRequestedStream,
|
|
1900
|
+
sessionKey,
|
|
1901
|
+
});
|
|
1902
|
+
if (recovered) return;
|
|
1903
|
+
}
|
|
1904
|
+
const data = withFreeTierHint(403, rawText403);
|
|
1814
1905
|
const ct403 = upstreamRes.headers.get("content-type") || "application/json";
|
|
1815
1906
|
res.writeHead(403, { "content-type": ct403 });
|
|
1816
1907
|
res.end(data);
|
|
1817
1908
|
return;
|
|
1818
1909
|
}
|
|
1819
|
-
|
|
1820
1910
|
const outHeaders: Record<string, string> = {};
|
|
1821
1911
|
for (const h of ["content-type", "cache-control", "x-request-id"] as const) {
|
|
1822
1912
|
const v = upstreamRes.headers.get(h);
|
package/src/stream-pipe.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
WATCHDOG_SSE_MIN_SAMPLES,
|
|
16
16
|
WATCHDOG_SSE_WINDOW,
|
|
17
17
|
} from "./config.ts";
|
|
18
|
-
import type
|
|
18
|
+
import { cloakMessagesContent, type CaseRestoreMap, type FindGlobRestore } from "./opencode-fingerprint.ts";
|
|
19
19
|
import { restoreToolNameForCaller } from "./tool-translation.ts";
|
|
20
20
|
import { repairToolArguments } from "./tool-args.ts";
|
|
21
21
|
|
|
@@ -211,6 +211,52 @@ export function createSseStreamCloakState(): SseStreamCloakState {
|
|
|
211
211
|
};
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Emit every tool call still holding buffered arguments, then clear the
|
|
216
|
+
* buffers. The fragments were withheld so the packet could be judged whole
|
|
217
|
+
* (see {@link SseStreamCloakState}); the terminal event that normally releases
|
|
218
|
+
* them never arrived when upstream cut the stream without one, and without
|
|
219
|
+
* this the client would receive a tool call whose `arguments` were deleted and
|
|
220
|
+
* never re-emitted. Repaired exactly as the terminal path repairs.
|
|
221
|
+
*
|
|
222
|
+
* Shape follows the pathname: Chat re-emits one `tool_calls` delta, Anthropic
|
|
223
|
+
* one `input_json_delta` per open block. Responses carries whole arguments on
|
|
224
|
+
* its `.done` event and buffers nothing, so it has nothing to flush.
|
|
225
|
+
* Returns null when nothing is buffered.
|
|
226
|
+
*/
|
|
227
|
+
export function flushBufferedToolArgs(
|
|
228
|
+
state: SseStreamCloakState,
|
|
229
|
+
cloak: StreamCloakOptions,
|
|
230
|
+
): string | null {
|
|
231
|
+
if (state.messagesArgsByIndex.size > 0) {
|
|
232
|
+
const blocks: string[] = [];
|
|
233
|
+
for (const [index, args] of state.messagesArgsByIndex) {
|
|
234
|
+
const name = state.messagesNameByIndex.get(index) ?? "";
|
|
235
|
+
blocks.push(
|
|
236
|
+
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
237
|
+
type: "content_block_delta",
|
|
238
|
+
index,
|
|
239
|
+
delta: { type: "input_json_delta", partial_json: repairToolArguments(name, args, cloak.argSchemas) },
|
|
240
|
+
})}\n\n`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
state.messagesArgsByIndex.clear();
|
|
244
|
+
state.messagesNameByIndex.clear();
|
|
245
|
+
return blocks.join("");
|
|
246
|
+
}
|
|
247
|
+
if (state.chatArgsByIndex.size === 0) return null;
|
|
248
|
+
const calls: unknown[] = [];
|
|
249
|
+
for (const [index, args] of state.chatArgsByIndex) {
|
|
250
|
+
const name = state.chatNameByIndex.get(index);
|
|
251
|
+
if (name === undefined) continue;
|
|
252
|
+
calls.push({ index, type: "function", function: { name, arguments: repairToolArguments(name, args, cloak.argSchemas) } });
|
|
253
|
+
}
|
|
254
|
+
state.chatArgsByIndex.clear();
|
|
255
|
+
state.chatNameByIndex.clear();
|
|
256
|
+
if (calls.length === 0) return null;
|
|
257
|
+
return `data: ${JSON.stringify({ object: "chat.completion.chunk", choices: [{ index: 0, delta: { tool_calls: calls } }] })}\n\n`;
|
|
258
|
+
}
|
|
259
|
+
|
|
214
260
|
/**
|
|
215
261
|
* True when the stream needs no rewrite and must flow byte-identical:
|
|
216
262
|
* nothing injected, no casing to restore, no find->glob rename, and the
|
|
@@ -656,6 +702,27 @@ function rewriteMessagesBlock(
|
|
|
656
702
|
const stopBlock = emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
657
703
|
return `${emitSseBlock("content_block_delta", JSON.stringify(flushed), false)}\n\n${stopBlock}`;
|
|
658
704
|
}
|
|
705
|
+
// A `message` frame carries a whole finished message mid-stream. It needs the
|
|
706
|
+
// same treatment the aggregate path gives it — injected tool_use dropped,
|
|
707
|
+
// caller names restored, inputs repaired — otherwise a placeholder call leaks
|
|
708
|
+
// downstream and a malformed one reaches the host unrepaired. Any block still
|
|
709
|
+
// buffering arguments is completed first so nothing is orphaned.
|
|
710
|
+
if (type === "message") {
|
|
711
|
+
const flushed = flushBufferedToolArgs(state, cloak);
|
|
712
|
+
cloakMessagesContent(parsed.message ?? parsed, cloak.callerHadTools, cloak.caseRestore, cloak.findGlob, cloak.injected, cloak.argSchemas);
|
|
713
|
+
if (flushed === null) return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
714
|
+
return `${flushed}${emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix)}`;
|
|
715
|
+
}
|
|
716
|
+
// A tool_use block still holding buffered arguments is completed HERE, while
|
|
717
|
+
// its own event order is intact: `message_delta` / `message_stop` close the
|
|
718
|
+
// message, so the recovered `input_json_delta` has to precede them. (An
|
|
719
|
+
// end-of-stream flush would emit it after `message_stop`, where a strict
|
|
720
|
+
// Anthropic client has already closed the message.)
|
|
721
|
+
if (type === "message_delta" || type === "message_stop") {
|
|
722
|
+
const flushed = flushBufferedToolArgs(state, cloak);
|
|
723
|
+
if (flushed === null) return block;
|
|
724
|
+
return `${flushed}${emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix)}`;
|
|
725
|
+
}
|
|
659
726
|
return block;
|
|
660
727
|
}
|
|
661
728
|
|
|
@@ -1099,6 +1166,15 @@ export function pipeUpstreamStream(
|
|
|
1099
1166
|
}
|
|
1100
1167
|
}
|
|
1101
1168
|
}
|
|
1169
|
+
// Buffered tool arguments are released on the call's terminal event, which a
|
|
1170
|
+
// cut stream never sends. Flush them here: the client already saw the call
|
|
1171
|
+
// announced, so it must receive the arguments too rather than a name with no
|
|
1172
|
+
// payload. Runs before the terminal-marker decision and before the re-fire
|
|
1173
|
+
// offer, because a rescue regenerates the turn and would double the calls.
|
|
1174
|
+
if (cloakState && cloak) {
|
|
1175
|
+
const flushed = flushBufferedToolArgs(cloakState, cloak);
|
|
1176
|
+
if (flushed !== null) forwardText(flushed);
|
|
1177
|
+
}
|
|
1102
1178
|
if (hasTerminalEvent) recordOnce(false);
|
|
1103
1179
|
const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
|
|
1104
1180
|
if (!hasTerminalEvent && totalChunks > 0) {
|
|
@@ -1140,6 +1216,14 @@ export function pipeUpstreamStream(
|
|
|
1140
1216
|
// inject a terminal nor end res — the replacement stream owns the turn now.
|
|
1141
1217
|
if (refirePending) return;
|
|
1142
1218
|
try {
|
|
1219
|
+
// Same obligation as the clean-end flush: the client already saw the call
|
|
1220
|
+
// announced, so its buffered arguments must still arrive before the turn is
|
|
1221
|
+
// terminated — and before the re-fire offer, since a rescue regenerates the
|
|
1222
|
+
// turn and would double the calls.
|
|
1223
|
+
if (cloakState && cloak) {
|
|
1224
|
+
const flushed = flushBufferedToolArgs(cloakState, cloak);
|
|
1225
|
+
if (flushed !== null) forwardText(flushed);
|
|
1226
|
+
}
|
|
1143
1227
|
if (!hasTerminalEvent && totalChunks > 0) {
|
|
1144
1228
|
if (clientAborted || upstreamEnded) {
|
|
1145
1229
|
// Client disconnect teardown or post-end cleanup — the relay
|