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/CHANGELOG.md +65 -0
- package/README.md +36 -19
- package/package.json +1 -1
- package/src/catalog.ts +33 -21
- package/src/cline-accounts.ts +4 -3
- package/src/config.ts +13 -0
- package/src/models.ts +240 -64
- package/src/opencode-fingerprint.ts +78 -17
- package/src/proxy.ts +339 -12
- package/src/relay.ts +92 -11
- package/src/responses.ts +6 -0
- package/src/stream-pipe.ts +448 -67
- package/src/tool-translation.ts +15 -12
- package/src/toolcall-audit.ts +580 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|