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/src/proxy.ts CHANGED
@@ -35,11 +35,12 @@ import {
35
35
  } from "./config.ts";
36
36
 
37
37
  import {
38
- convertSseToJson,
39
- enforceOpencodeFingerprint,
40
- sseToChatCompletionJson,
41
- type CaseRestoreMap,
42
- type FindGlobRestore,
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
- chatResponsesJsonFromChatCompletion,
51
- chatResponsesSseFromChatCompletion,
52
- chatBodyFromResponsesBody,
53
- translateToolsForPath,
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
- const data = convertSseToJson(sseText, pathname, true, undefined, undefined, undefined, argSchemas);
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, undefined, undefined, argSchemas);
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
- const data = convertSseToJson(sseText, pathname, true, undefined, undefined, undefined, argSchemas);
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, undefined, undefined, argSchemas);
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(1500),
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
- // Header-wait timeout + client-disconnect abort: once headers
1270
- // arrive the timer is cleared so a long stream is not killed at
1271
- // the timeout ceiling; the stream phase is owned by
1272
- // pipeUpstreamStream and its close handling.
1273
- const kiloController = new AbortController();
1274
- // Kilo bodies ride verbatim, but its models malform arguments the same
1275
- // way: capture the caller's schemas so the reply can be repaired too.
1276
- const kiloArgSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
1277
- const kiloTimeoutId = setTimeout(
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 data = withFreeTierHint(403, await upstreamRes.text());
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);
@@ -15,7 +15,7 @@ import {
15
15
  WATCHDOG_SSE_MIN_SAMPLES,
16
16
  WATCHDOG_SSE_WINDOW,
17
17
  } from "./config.ts";
18
- import type { CaseRestoreMap, FindGlobRestore } from "./opencode-fingerprint.ts";
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