pi-freeflow 1.40.0 → 1.41.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/src/proxy.ts CHANGED
@@ -82,6 +82,7 @@ import {
82
82
  isReasoningCallerMismatch,
83
83
  } from "./responses.ts";
84
84
  import { pipeUpstreamStream, type StreamCloakOptions } from "./stream-pipe.ts";
85
+ import { createToolcallRecorder, type ToolcallRecorder, type ToolcallWire } from "./toolcall-audit.ts";
85
86
  import {
86
87
  decideZenRoute,
87
88
  isFreeTierGate,
@@ -250,6 +251,113 @@ async function clineRefreshImpl(refreshToken: string): Promise<{ token: string;
250
251
  }
251
252
  }
252
253
 
254
+ /**
255
+ * Bound on the caller tool names the audit summary carries (the recorder
256
+ * applies the same caps itself; bounding here keeps a hostile body from
257
+ * building a large array first).
258
+ */
259
+ const AUDIT_MAX_DECLARED = 32;
260
+ const AUDIT_MAX_NAME = 64;
261
+
262
+ /**
263
+ * Caller-declared tool names from a request body, in all three wire shapes:
264
+ * chat `{type:"function",function:{name}}`, responses `{type:"function",name}`
265
+ * and messages `{name}`. Names only — the audit never records schemas.
266
+ */
267
+ function declaredToolNames(body: Record<string, unknown>): string[] {
268
+ const names: string[] = [];
269
+ const tools = body.tools;
270
+ if (!Array.isArray(tools)) return names;
271
+ for (const raw of tools) {
272
+ if (names.length >= AUDIT_MAX_DECLARED) break;
273
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
274
+ const rec = raw as Record<string, unknown>;
275
+ const fn = rec.function;
276
+ const fnRec = fn !== null && typeof fn === "object" && !Array.isArray(fn)
277
+ ? (fn as Record<string, unknown>)
278
+ : null;
279
+ const rawName = typeof fnRec?.name === "string" ? fnRec.name : rec.name;
280
+ if (typeof rawName !== "string" || rawName === "") continue;
281
+ const name = rawName.length > AUDIT_MAX_NAME ? rawName.slice(0, AUDIT_MAX_NAME) : rawName;
282
+ if (!names.includes(name)) names.push(name);
283
+ }
284
+ return names;
285
+ }
286
+
287
+ /** Audit wire of a request path: the body shape upstream is asked for. */
288
+ function auditWireFor(pathname: string): ToolcallWire {
289
+ if (pathname.endsWith("/responses")) return "responses";
290
+ if (pathname.endsWith("/messages")) return "messages";
291
+ return "chat";
292
+ }
293
+
294
+ /**
295
+ * Classify an already-assembled chat completion for the audit (non-stream
296
+ * path). Never throws: the audit is best-effort and must stay off the request
297
+ * path. A body without `choices` records a tool-less turn, which is exactly
298
+ * what the caller received.
299
+ */
300
+ function auditChatCompletion(audit: ToolcallRecorder | undefined, completion: unknown): void {
301
+ if (audit === undefined || !audit.active) return;
302
+ try {
303
+ const parsed = completion !== null && typeof completion === "object" && !Array.isArray(completion)
304
+ ? (completion as Record<string, unknown>)
305
+ : {};
306
+ const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
307
+ const first = choices.length > 0 && choices[0] !== null && typeof choices[0] === "object" && !Array.isArray(choices[0])
308
+ ? (choices[0] as Record<string, unknown>)
309
+ : undefined;
310
+ const message = first !== undefined && first.message !== null && typeof first.message === "object" && !Array.isArray(first.message)
311
+ ? (first.message as Record<string, unknown>)
312
+ : {};
313
+ const finishReason = typeof first?.finish_reason === "string" ? (first.finish_reason as string) : null;
314
+ audit.finishFromChat(message, finishReason);
315
+ } catch {
316
+ // Unreadable body: nothing to record.
317
+ }
318
+ }
319
+
320
+ /** `auditChatCompletion` over serialized JSON (the aggregated non-stream body). */
321
+ function auditChatBody(audit: ToolcallRecorder | undefined, json: string): void {
322
+ if (audit === undefined || !audit.active) return;
323
+ try {
324
+ auditChatCompletion(audit, JSON.parse(json));
325
+ } catch {
326
+ // Not JSON: nothing to record.
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Classify a non-stream turn of a Responses/Anthropic wire: the recorder reads
332
+ * the raw SSE text itself. A body that carried no SSE frames is left
333
+ * unrecorded rather than guessed as a tool-less turn — the recorder has no
334
+ * reader for a plain object of those wires.
335
+ */
336
+ function auditSseBody(audit: ToolcallRecorder | undefined, text: string): void {
337
+ if (audit === undefined || !audit.active) return;
338
+ if (!text.includes("data:")) return;
339
+ try {
340
+ audit.observe(text);
341
+ audit.finish();
342
+ } catch {
343
+ // Best-effort by contract.
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Non-stream final classification: a chat-wire body parses directly; the
349
+ * responses/messages wires are classified from the raw upstream SSE text.
350
+ */
351
+ function auditFinalBody(
352
+ audit: ToolcallRecorder | undefined,
353
+ pathname: string,
354
+ rawUpstream: string,
355
+ served: string,
356
+ ): void {
357
+ if (auditWireFor(pathname) === "chat") auditChatBody(audit, served);
358
+ else auditSseBody(audit, rawUpstream);
359
+ }
360
+
253
361
  /**
254
362
  * Serve a Cline-model request direct (never relay pool, no opencode
255
363
  * fingerprint). Cline serves chat completions only: responses-path bodies
@@ -264,8 +372,9 @@ async function handleClineRequest(opts: {
264
372
  res: http.ServerResponse;
265
373
  reqId: string;
266
374
  clientRequestedStream: boolean;
375
+ audit?: ToolcallRecorder;
267
376
  }): Promise<void> {
268
- const { parsedBody, pathname, req, res, reqId, clientRequestedStream } = opts;
377
+ const { parsedBody, pathname, req, res, reqId, clientRequestedStream, audit } = opts;
269
378
  const responsesRequest = pathname.endsWith("/responses");
270
379
  const model = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
271
380
  let chatBody: Record<string, unknown>;
@@ -332,6 +441,9 @@ async function handleClineRequest(opts: {
332
441
  }
333
442
  if (!upstreamRes.ok) {
334
443
  log("warn", `cline upstream ${upstreamRes.status} for model ${model}`, { status: upstreamRes.status, model }, reqId);
444
+ // A tools request rejected upstream (e.g. a tool-schema 400) is the verdict
445
+ // the host will see; record it so the audit names the cause.
446
+ audit?.attemptFailure({ attempt: 0, status: upstreamRes.status, reason: "final-upstream-status" });
335
447
  const raw = await upstreamRes.text().catch(() => null);
336
448
  if (raw === null) {
337
449
  log("error", "cline upstream body unreadable", { status: upstreamRes.status, model }, reqId);
@@ -368,7 +480,7 @@ async function handleClineRequest(opts: {
368
480
  findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
369
481
  ? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
370
482
  : undefined,
371
- { upstreamContentType: upstreamRes.headers.get("content-type") },
483
+ { upstreamContentType: upstreamRes.headers.get("content-type"), audit: audit?.active ? audit : undefined },
372
484
  );
373
485
  return;
374
486
  }
@@ -385,6 +497,7 @@ async function handleClineRequest(opts: {
385
497
  }
386
498
  if (!responsesRequest) {
387
499
  const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
500
+ auditChatBody(audit, data);
388
501
  if (!res.headersSent) {
389
502
  res.writeHead(upstreamRes.status, { "content-type": "application/json" });
390
503
  res.end(data);
@@ -394,6 +507,9 @@ async function handleClineRequest(opts: {
394
507
  return;
395
508
  }
396
509
  const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
510
+ // Classify the chat object before it is mapped: finishFromChat reads the
511
+ // assistant message the model actually produced.
512
+ auditChatCompletion(audit, chat);
397
513
  const resp = chatResponsesJsonFromChatCompletion(chat, model);
398
514
  if (clientRequestedStream) {
399
515
  if (!res.headersSent) {
@@ -417,6 +533,133 @@ async function handleClineRequest(opts: {
417
533
  }
418
534
  }
419
535
 
536
+ // ── OVH lane quirks ───────────────────────────────────────────────────
537
+ // Two catalog models have upstream-side quirks this lane must absorb. Both
538
+ // checks key off the canonical catalog id, never the wire id: the wire id has
539
+ // already lost its `ovh-free/` prefix, and a short alias (`qwen3.8-27b`,
540
+ // `mistral-small-3.2`) must resolve to the same model.
541
+
542
+ /** Qwen3.8-27B on OVH: `reasoning_effort` starves the model into an empty turn. */
543
+ const OVH_NO_EFFORT_MODEL = "ovh-free/Qwen3.8-27B";
544
+ /** Mistral Small 3.2 on OVH: replayed tool-call ids must be 9 alphanumerics. */
545
+ const OVH_STRICT_TOOL_CALL_ID_MODEL = "ovh-free/Mistral-Small-3.2-24B-Instruct-2506";
546
+ const OVH_TOOL_CALL_ID_RE = /^[A-Za-z0-9]{9}$/;
547
+
548
+ /**
549
+ * Rewrite the non-conforming tool-call ids in a chat body's messages.
550
+ *
551
+ * Upstream validates every replayed id against "a-z, A-Z, 0-9, length 9" and
552
+ * drops the whole turn otherwise: a 400 `Tool call id was ll_loop_1 but must be
553
+ * a-z, A-Z, 0-9, with a length of 9` for a non-streamed call, and the same
554
+ * rejection as a silent `data: [DONE]`-only 200 when streaming (measured
555
+ * 2026-10-22). Upstream mints conforming ids itself (observed `hCCedplH4`), so
556
+ * only host-supplied history needs fixing. Replacements are deterministic
557
+ * (`t` + 8-character zero-padded base36 of the request-local sequence) and the
558
+ * same map is applied to the matching `role:"tool"` message, so the pair a host
559
+ * replays next turn stays equal. Conforming ids pass through untouched.
560
+ */
561
+ function normalizeOvhToolCallIds(messages: unknown): void {
562
+ if (!Array.isArray(messages)) return;
563
+ const taken = new Set<string>();
564
+ const broken: string[] = [];
565
+ const note = (id: unknown): void => {
566
+ if (typeof id !== "string" || id.length === 0) return;
567
+ if (OVH_TOOL_CALL_ID_RE.test(id)) taken.add(id);
568
+ else if (!broken.includes(id)) broken.push(id);
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)) note((call as Record<string, unknown>).id);
576
+ }
577
+ }
578
+ if (rec.role === "tool") note(rec.tool_call_id);
579
+ }
580
+ if (broken.length === 0) return;
581
+ const remap = new Map<string, string>();
582
+ let seq = 0;
583
+ for (const id of broken) {
584
+ let next = `t${seq.toString(36).padStart(8, "0")}`;
585
+ while (taken.has(next)) {
586
+ seq += 1;
587
+ next = `t${seq.toString(36).padStart(8, "0")}`;
588
+ }
589
+ seq += 1;
590
+ taken.add(next);
591
+ remap.set(id, next);
592
+ }
593
+ for (const message of messages) {
594
+ if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
595
+ const rec = message as Record<string, unknown>;
596
+ if (Array.isArray(rec.tool_calls)) {
597
+ for (const call of rec.tool_calls) {
598
+ if (call === null || typeof call !== "object" || Array.isArray(call)) continue;
599
+ const callRec = call as Record<string, unknown>;
600
+ const next = remap.get(callRec.id as string);
601
+ if (next !== undefined) callRec.id = next;
602
+ }
603
+ }
604
+ if (rec.role === "tool") {
605
+ const next = remap.get(rec.tool_call_id as string);
606
+ if (next !== undefined) rec.tool_call_id = next;
607
+ }
608
+ }
609
+ }
610
+
611
+ /**
612
+ * True when an aggregated chat answer carries neither assistant text nor a
613
+ * tool call — the exact shape OVH produces when it starves a request (Qwen
614
+ * with a `reasoning_effort` field, Mistral with a malformed replayed tool-call
615
+ * id) and closes the stream after a bare `data: [DONE]`. Aggregating that
616
+ * yields `content: null, finish_reason: "stop"`, which the host reads as a
617
+ * finished empty turn; the lane must surface it instead of fabricating it.
618
+ */
619
+ function ovhAnswerIsEmpty(chat: Record<string, unknown>): boolean {
620
+ const choices = Array.isArray(chat.choices) ? chat.choices : [];
621
+ const first = choices.length > 0 && choices[0] !== null && typeof choices[0] === "object" && !Array.isArray(choices[0])
622
+ ? choices[0] as Record<string, unknown>
623
+ : undefined;
624
+ const message = first !== undefined && first.message !== null && typeof first.message === "object" && !Array.isArray(first.message)
625
+ ? first.message as Record<string, unknown>
626
+ : undefined;
627
+ if (message === undefined) return true;
628
+ if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return false;
629
+ const content = message.content;
630
+ if (typeof content === "string") return content.length === 0;
631
+ if (Array.isArray(content)) return content.length === 0;
632
+ return true;
633
+ }
634
+
635
+ /** `ovhAnswerIsEmpty` over the JSON text `convertSseToJson` serialized. */
636
+ function ovhAggregateIsEmpty(data: string): boolean {
637
+ let parsed: unknown;
638
+ try {
639
+ parsed = JSON.parse(data);
640
+ } catch {
641
+ return false;
642
+ }
643
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
644
+ return ovhAnswerIsEmpty(parsed as Record<string, unknown>);
645
+ }
646
+
647
+ /**
648
+ * Answer an empty upstream stream as an error. Never emit the aggregated
649
+ * `chatcmpl-freeflow` shell for it: that is a fabricated 200 completion with
650
+ * `content: null` and `finish_reason: "stop"`, the very thing that hid these
651
+ * upstream failures from the host.
652
+ */
653
+ function respondOvhEmptyStream(res: http.ServerResponse, reqId: string, model: string): void {
654
+ log("warn", `ovh upstream returned an empty stream for model ${model}`, { model }, reqId);
655
+ if (!res.headersSent) {
656
+ res.writeHead(502, { "content-type": "application/json" });
657
+ res.end(JSON.stringify({ error: "upstream error", detail: "upstream returned an empty stream" }));
658
+ } else if (!res.writableEnded) {
659
+ res.end();
660
+ }
661
+ }
662
+
420
663
  /**
421
664
  * Serve an OVH-model request direct (never relay pool, no opencode
422
665
  * fingerprint). Chat-only upstream: OVH's responses endpoint rejects every
@@ -436,8 +679,9 @@ async function handleOvhRequest(opts: {
436
679
  res: http.ServerResponse;
437
680
  reqId: string;
438
681
  clientRequestedStream: boolean;
682
+ audit?: ToolcallRecorder;
439
683
  }): Promise<void> {
440
- const { parsedBody, pathname, req, res, reqId, clientRequestedStream } = opts;
684
+ const { parsedBody, pathname, req, res, reqId, clientRequestedStream, audit } = opts;
441
685
  const responsesRequest = pathname.endsWith("/responses");
442
686
  const requestedModel = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
443
687
  let chatBody: Record<string, unknown>;
@@ -452,6 +696,25 @@ async function handleOvhRequest(opts: {
452
696
  // (`llama-3.3-70b`) must resolve to `ovh-free/Meta-Llama-3_3-70B-Instruct`
453
697
  // first, or it reaches OVH unrecognised and answers 404 model_not_found.
454
698
  chatBody.model = ovhUpstreamModelId(requestedModel);
699
+ // Lane quirks, keyed by canonical catalog id so an alias resolves to the same
700
+ // model (the wire id above has already lost its `ovh-free/` prefix).
701
+ const canonicalModelId = resolveCanonicalModelId(requestedModel);
702
+ if (canonicalModelId === OVH_NO_EFFORT_MODEL) {
703
+ // Measured 2026-10-22: with `reasoning_effort` present this model answers
704
+ // HTTP 200 whose body is a bare `data: [DONE]` — no deltas, no error — at
705
+ // max_tokens 4000 and 32000 alike; without the field it streams its own
706
+ // reasoning plus a proper tool call. Every other OVH model tolerates the
707
+ // field, so the strip is this model only. The responses-path translation
708
+ // cannot reintroduce it: chatBodyFromResponsesBody copies a fixed scalar
709
+ // list that carries no reasoning/effort key.
710
+ delete chatBody.reasoning_effort;
711
+ }
712
+ if (canonicalModelId === OVH_STRICT_TOOL_CALL_ID_MODEL) {
713
+ // Quirk documented on normalizeOvhToolCallIds: a host-supplied history id
714
+ // that is not 9 alphanumerics kills the turn upstream (400 non-streamed,
715
+ // silent `[DONE]`-only 200 streamed).
716
+ normalizeOvhToolCallIds(chatBody.messages);
717
+ }
455
718
  // Bypassing the fingerprint does not exempt OVH from malformed tool
456
719
  // arguments: capture the caller's own schemas before translation for repair.
457
720
  const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
@@ -503,6 +766,7 @@ async function handleOvhRequest(opts: {
503
766
  }
504
767
  if (!upstreamRes.ok) {
505
768
  log("warn", `ovh upstream ${upstreamRes.status} for model ${requestedModel}`, { status: upstreamRes.status, model: requestedModel }, reqId);
769
+ audit?.attemptFailure({ attempt: 0, status: upstreamRes.status, reason: "final-upstream-status" });
506
770
  const raw = await upstreamRes.text().catch(() => null);
507
771
  if (raw === null) {
508
772
  log("error", "ovh upstream body unreadable", { status: upstreamRes.status, model: requestedModel }, reqId);
@@ -540,7 +804,7 @@ async function handleOvhRequest(opts: {
540
804
  findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
541
805
  ? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
542
806
  : undefined,
543
- { upstreamContentType: upstreamRes.headers.get("content-type") },
807
+ { upstreamContentType: upstreamRes.headers.get("content-type"), audit: audit?.active ? audit : undefined },
544
808
  );
545
809
  return;
546
810
  }
@@ -555,8 +819,22 @@ async function handleOvhRequest(opts: {
555
819
  }
556
820
  return;
557
821
  }
822
+ // The emptiness guard below is about a real upstream *stream*: a 200 body
823
+ // that is not SSE at all is passed through by `convertSseToJson` by
824
+ // contract, and says nothing about starvation.
825
+ const upstreamSpokeSse = looksLikeSse(sseText.trim());
558
826
  if (!responsesRequest) {
559
827
  const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
828
+ // Classified before the starvation guard: a starved turn is exactly the
829
+ // no_tool_calls record worth keeping.
830
+ auditChatBody(audit, data);
831
+ // An upstream that starved the request closes with a bare `data: [DONE]`:
832
+ // the aggregate is an empty assistant turn, which must surface as an error
833
+ // rather than as a finished (and silently blank) completion.
834
+ if (upstreamSpokeSse && ovhAggregateIsEmpty(data)) {
835
+ respondOvhEmptyStream(res, reqId, requestedModel);
836
+ return;
837
+ }
560
838
  if (!res.headersSent) {
561
839
  res.writeHead(upstreamRes.status, { "content-type": "application/json" });
562
840
  res.end(data);
@@ -566,6 +844,11 @@ async function handleOvhRequest(opts: {
566
844
  return;
567
845
  }
568
846
  const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
847
+ auditChatCompletion(audit, chat);
848
+ if (upstreamSpokeSse && ovhAnswerIsEmpty(chat)) {
849
+ respondOvhEmptyStream(res, reqId, requestedModel);
850
+ return;
851
+ }
569
852
  const resp = chatResponsesJsonFromChatCompletion(chat, requestedModel);
570
853
  if (clientRequestedStream) {
571
854
  if (!res.headersSent) {
@@ -602,8 +885,9 @@ async function handleKiloRequest(opts: {
602
885
  reqId: string;
603
886
  clientRequestedStream: boolean;
604
887
  sessionKey?: string | null;
888
+ audit?: ToolcallRecorder;
605
889
  }): Promise<void> {
606
- const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey } = opts;
890
+ const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey, audit } = opts;
607
891
  const isStream = clientRequestedStream;
608
892
  const kiloController = new AbortController();
609
893
  const kiloArgSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
@@ -630,6 +914,8 @@ async function handleKiloRequest(opts: {
630
914
  signal: kiloController.signal,
631
915
  },
632
916
  reqId,
917
+ undefined,
918
+ (info) => audit?.attemptFailure(info),
633
919
  );
634
920
  if (response.status === 400 && !res.writableEnded) {
635
921
  // Token-clamp retry only. The model is NEVER reassigned: a 400 must
@@ -660,6 +946,8 @@ async function handleKiloRequest(opts: {
660
946
  signal: kiloController.signal,
661
947
  },
662
948
  reqId,
949
+ undefined,
950
+ (info) => audit?.attemptFailure(info),
663
951
  );
664
952
  if (retryRes.ok) {
665
953
  log("info", `kilo 400 recovery succeeded with clamped tokens for model ${String(retryBody.model)}`, { model: retryBody.model }, reqId);
@@ -674,6 +962,7 @@ async function handleKiloRequest(opts: {
674
962
  req.off("error", abortKiloOnClientGone);
675
963
  }
676
964
  if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
965
+ else audit?.attemptFailure({ attempt: 0, status: response.status, reason: "final-upstream-status" });
677
966
 
678
967
  if (isStream && response.ok && response.body) {
679
968
  const ct = response.headers.get("content-type") || "text/event-stream";
@@ -698,14 +987,16 @@ async function handleKiloRequest(opts: {
698
987
  pathname: pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
699
988
  }
700
989
  : undefined,
701
- { upstreamContentType: response.headers.get("content-type") },
990
+ { upstreamContentType: response.headers.get("content-type"), audit: audit?.active ? audit : undefined },
702
991
  );
703
992
  } else {
704
- let rawText = await response.text();
993
+ const rawUpstreamText = await response.text();
994
+ let rawText = rawUpstreamText;
705
995
  if (response.ok && kiloArgSchemas.size > 0) {
706
996
  rawText = convertSseToJson(rawText, pathname, true, undefined, undefined, undefined, kiloArgSchemas);
707
997
  rawText = repairJsonToolArguments(rawText, pathname, kiloArgSchemas);
708
998
  }
999
+ if (response.ok) auditFinalBody(audit, pathname, rawUpstreamText, rawText);
709
1000
  const data = withRateLimitHint(response.status, rawText);
710
1001
  const ct = response.headers.get("content-type") || "application/json";
711
1002
  res.writeHead(response.status, { "content-type": ct });
@@ -1502,6 +1793,17 @@ export function startProxy(
1502
1793
  }
1503
1794
  } catch { }
1504
1795
 
1796
+ // Tool-call audit capture, BEFORE any body mutation (zen failover swaps the
1797
+ // model, the fingerprint injects placeholder tools): `declaredNames` must be
1798
+ // the caller's own surface, and a host re-send of the same conversation has
1799
+ // to hash to the same retry key. Both are bounded names/lists — never
1800
+ // message text or argument values.
1801
+ const auditModel = parsedBody !== null && typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
1802
+ const auditDeclaredNames = parsedBody !== null ? declaredToolNames(parsedBody) : [];
1803
+ const auditConversationSeed = parsedBody !== null
1804
+ ? { model: auditModel, messages: parsedBody.messages ?? parsedBody.input, tools: parsedBody.tools }
1805
+ : undefined;
1806
+
1505
1807
  const sessionKey = sessionKeyOf(parsedBody);
1506
1808
  // Zen free-tier gate failover: refused or fresh Zen sessions ride a
1507
1809
  // healthy Kilo model on the same wire API via the existing Kilo branch.
@@ -1523,6 +1825,20 @@ export function startProxy(
1523
1825
  log("info", `zen gated — session failed over ${from} -> ${zenFailover.model}`, { model: from, failover: zenFailover.model }, reqId);
1524
1826
  }
1525
1827
 
1828
+ // One recorder per turn, active only when the caller declared tools (an
1829
+ // empty list is a no-op recorder that writes nothing). Created after the
1830
+ // lane flags settle — a zen failover is served by the Kilo lane — while the
1831
+ // model and tool names above stay the caller's, pre-mutation.
1832
+ const audit = createToolcallRecorder({
1833
+ reqId,
1834
+ model: auditModel,
1835
+ lane: isCline ? "cline" : isOvh ? "ovh" : isKilo ? "kilo" : "opencode",
1836
+ wire: auditWireFor(target.pathname),
1837
+ stream: clientRequestedStream,
1838
+ declaredNames: auditDeclaredNames,
1839
+ conversationSeed: auditConversationSeed,
1840
+ });
1841
+
1526
1842
  // OpenCode Zen free-tier fingerprint: upstream mandates stream: true and the
1527
1843
  // placeholder sextet {bash, glob, grep, read, edit, write}. Missing tools (such as on
1528
1844
  // subagent or advisor watchdog turns) trigger 403 FreeTierError.
@@ -1571,7 +1887,7 @@ export function startProxy(
1571
1887
  // Cline serves chat completions only, direct (never relay pool, no
1572
1888
  // opencode fingerprint). The pool module owns the per-user Bearer
1573
1889
  // token: the proxy only passes the body and maps the answer.
1574
- await handleClineRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream });
1890
+ await handleClineRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream, audit });
1575
1891
  } else if (isKilo && parsedBody) {
1576
1892
  await handleKiloRequest({
1577
1893
  parsedBody,
@@ -1581,11 +1897,12 @@ export function startProxy(
1581
1897
  reqId,
1582
1898
  clientRequestedStream,
1583
1899
  sessionKey,
1900
+ audit,
1584
1901
  });
1585
1902
  } else if (isOvh && parsedBody) {
1586
1903
  // OVH serves chat upstream only (see handleOvhRequest): direct, keyless,
1587
1904
  // no fingerprint, and a responses-path body translated to chat and back.
1588
- await handleOvhRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream });
1905
+ await handleOvhRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream, audit });
1589
1906
  } else {
1590
1907
  // OpenCode routing — relay egress is for free-catalog models
1591
1908
  // only; unknown/paid models go direct upstream.
@@ -1709,6 +2026,7 @@ export function startProxy(
1709
2026
  issuerReported = true;
1710
2027
  },
1711
2028
  },
2029
+ (info) => audit?.attemptFailure(info),
1712
2030
  );
1713
2031
  } finally {
1714
2032
  clearTimeout(relayTimeoutId);
@@ -1794,6 +2112,7 @@ export function startProxy(
1794
2112
  streamCloak,
1795
2113
  {
1796
2114
  upstreamContentType: response.headers.get("content-type"),
2115
+ audit: audit.active ? audit : undefined,
1797
2116
  refire: async (reason) => {
1798
2117
  if (midStreamRefires >= 1) return null;
1799
2118
  midStreamRefires += 1;
@@ -1866,8 +2185,12 @@ export function startProxy(
1866
2185
  // different host entirely (logged the wrong relay before this).
1867
2186
  const servedRelay = servedIssuer ?? "direct";
1868
2187
  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);
2188
+ // The host sees this status as the turn's verdict (e.g. a 400
2189
+ // rejecting the caller's tool schema); record it as such.
2190
+ audit?.attemptFailure({ attempt: 0, status: response.status, reason: "final-upstream-status" });
1869
2191
  }
1870
- let rawText = await response.text();
2192
+ const rawUpstreamText = await response.text();
2193
+ let rawText = rawUpstreamText;
1871
2194
  let ct =
1872
2195
  response.headers.get("content-type") ||
1873
2196
  "application/json";
@@ -1878,6 +2201,7 @@ export function startProxy(
1878
2201
  const data = isRelayDisabledError(response.status, rawText)
1879
2202
  ? rawText
1880
2203
  : withFreeTierHint(response.status, withRateLimitHint(response.status, rawText));
2204
+ if (response.ok) auditFinalBody(audit, target.pathname, rawUpstreamText, data);
1881
2205
  res.writeHead(response.status, { "content-type": ct });
1882
2206
  res.end(data);
1883
2207
  }
@@ -2000,6 +2324,7 @@ export function startProxy(
2000
2324
  }
2001
2325
  if (upstreamRes.status >= 400) {
2002
2326
  log("warn", `direct upstream ${upstreamRes.status} for model ${String(parsedBody?.model ?? "?")} ${target.pathname}`, { status: upstreamRes.status, model: parsedBody?.model, path: target.pathname }, reqId);
2327
+ audit?.attemptFailure({ attempt: 0, status: upstreamRes.status, reason: "final-upstream-status" });
2003
2328
  }
2004
2329
  // Upstream degradation tracking, mirroring the relay path: successes
2005
2330
  // prove Zen reachable, 403 free-tier gates feed the health machine.
@@ -2057,14 +2382,16 @@ export function startProxy(
2057
2382
  reqId,
2058
2383
  "direct",
2059
2384
  streamCloak,
2060
- { upstreamContentType: upstreamRes.headers.get("content-type") },
2385
+ { upstreamContentType: upstreamRes.headers.get("content-type"), audit: audit?.active ? audit : undefined },
2061
2386
  );
2062
2387
  } else if (upstreamRes.body) {
2063
- let rawText = await upstreamRes.text();
2388
+ const rawUpstreamText = await upstreamRes.text();
2389
+ let rawText = rawUpstreamText;
2064
2390
  if (upstreamRes.ok && (outHeaders["content-type"]?.includes("text/event-stream") || looksLikeSse(rawText))) {
2065
2391
  rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected, callerArgSchemas);
2066
2392
  outHeaders["content-type"] = "application/json";
2067
2393
  }
2394
+ if (upstreamRes.ok) auditFinalBody(audit, target.pathname, rawUpstreamText, rawText);
2068
2395
  res.writeHead(upstreamRes.status, outHeaders);
2069
2396
  res.end(rawText);
2070
2397
  } else {