@skaile/transform-proxy 0.3.1 → 0.5.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 +23 -0
- package/README.md +77 -5
- package/dist/cli.d.mts +3 -1
- package/dist/cli.mjs +18 -6
- package/dist/{config-BZsthgP-.mjs → config-DHxYF3Tv.mjs} +511 -25
- package/dist/index.d.mts +26 -2
- package/dist/index.mjs +1 -1
- package/dist/omp/index.mjs +80 -7
- package/dist/{server-C_eKyCTV.d.mts → server-DdXMr4yF.d.mts} +64 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { parse } from "yaml";
|
|
@@ -46,9 +46,32 @@ function composeTransforms(stages, log = silentLogger) {
|
|
|
46
46
|
} catch (err) {
|
|
47
47
|
log.error(`transform dispose failed: ${errorMessage(err)}`);
|
|
48
48
|
}
|
|
49
|
-
}
|
|
49
|
+
},
|
|
50
|
+
...stages.some((stage) => stage.knownOriginals !== void 0) ? { knownOriginals: () => unionKnownOriginals(stages, log) } : {}
|
|
50
51
|
};
|
|
51
52
|
}
|
|
53
|
+
/** The originals of every stage, deduped by kind and value; a stage that throws is logged and skipped. */
|
|
54
|
+
function unionKnownOriginals(stages, log) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const seen = /* @__PURE__ */ new Set();
|
|
57
|
+
for (const stage of stages) {
|
|
58
|
+
if (stage.knownOriginals === void 0) continue;
|
|
59
|
+
try {
|
|
60
|
+
for (const { kind, value } of stage.knownOriginals()) {
|
|
61
|
+
const key = JSON.stringify([kind, value]);
|
|
62
|
+
if (seen.has(key)) continue;
|
|
63
|
+
seen.add(key);
|
|
64
|
+
out.push({
|
|
65
|
+
kind,
|
|
66
|
+
value
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
log.error(`transform knownOriginals failed: ${errorMessage(err)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
52
75
|
/**
|
|
53
76
|
* Streams stage by stage. `streams` is in pipeline order (stage 1 first).
|
|
54
77
|
* push feeds stage n first and passes each result down to stage 1.
|
|
@@ -102,6 +125,74 @@ function createSessionFacts() {
|
|
|
102
125
|
}
|
|
103
126
|
};
|
|
104
127
|
}
|
|
128
|
+
// src/leaks.ts
|
|
129
|
+
/** Shorter values match by chance too often to be worth a warning. */
|
|
130
|
+
const MIN_LEAK_LENGTH = 3;
|
|
131
|
+
const PLAIN_KEY = /^[A-Za-z_$][\w$]*$/;
|
|
132
|
+
/**
|
|
133
|
+
* The values the leak check compares against: the session's facts, each with its publisher as source, plus what the
|
|
134
|
+
* pipeline instance reports via `knownOriginals()` as source `pipeline`. If that throws, only the facts are returned.
|
|
135
|
+
*/
|
|
136
|
+
function knownOriginals(facts, instance) {
|
|
137
|
+
const out = facts.list().map(({ kind, source, value }) => ({
|
|
138
|
+
kind,
|
|
139
|
+
source,
|
|
140
|
+
value
|
|
141
|
+
}));
|
|
142
|
+
if (instance?.knownOriginals === void 0) return out;
|
|
143
|
+
try {
|
|
144
|
+
const fromPipeline = [];
|
|
145
|
+
for (const { kind, value } of instance.knownOriginals()) fromPipeline.push({
|
|
146
|
+
kind,
|
|
147
|
+
source: "pipeline",
|
|
148
|
+
value
|
|
149
|
+
});
|
|
150
|
+
out.push(...fromPipeline);
|
|
151
|
+
} catch {}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Every place in `body` (the rewritten request) where a string value contains a known original, case-sensitively.
|
|
156
|
+
* Only string values are scanned, not object keys. The result holds kind, source and JSON path, never the value.
|
|
157
|
+
*/
|
|
158
|
+
function findLeaks(body, originals) {
|
|
159
|
+
const candidates = [];
|
|
160
|
+
for (const original of originals) if (original.value.length >= MIN_LEAK_LENGTH) candidates.push(original);
|
|
161
|
+
if (candidates.length === 0) return [];
|
|
162
|
+
const leaks = [];
|
|
163
|
+
const seen = /* @__PURE__ */ new Set();
|
|
164
|
+
const visit = (node, path) => {
|
|
165
|
+
if (typeof node === "string") {
|
|
166
|
+
for (const { kind, source, value } of candidates) {
|
|
167
|
+
if (!node.includes(value)) continue;
|
|
168
|
+
const key = JSON.stringify([
|
|
169
|
+
kind,
|
|
170
|
+
source,
|
|
171
|
+
path
|
|
172
|
+
]);
|
|
173
|
+
if (seen.has(key)) continue;
|
|
174
|
+
seen.add(key);
|
|
175
|
+
leaks.push({
|
|
176
|
+
kind,
|
|
177
|
+
source,
|
|
178
|
+
at: path
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (Array.isArray(node)) {
|
|
184
|
+
node.forEach((item, i) => visit(item, `${path}[${i}]`));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (node !== null && typeof node === "object") for (const [key, value] of Object.entries(node)) visit(value, joinKey(path, key));
|
|
188
|
+
};
|
|
189
|
+
visit(body, "");
|
|
190
|
+
return leaks;
|
|
191
|
+
}
|
|
192
|
+
function joinKey(path, key) {
|
|
193
|
+
if (!PLAIN_KEY.test(key)) return `${path}[${JSON.stringify(key)}]`;
|
|
194
|
+
return path === "" ? key : `${path}.${key}`;
|
|
195
|
+
}
|
|
105
196
|
// src/modules.ts
|
|
106
197
|
/** Loads and opens every configured transform once. Any failure rejects with the transform id. */
|
|
107
198
|
async function openTransforms(specs, baseDir, log) {
|
|
@@ -497,8 +588,81 @@ function tailDelta(kind, tail) {
|
|
|
497
588
|
partial_json: tail
|
|
498
589
|
};
|
|
499
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* Rebuilds the Anthropic message from the raw upstream events (before restore) for `ctx.observe`.
|
|
593
|
+
* Errors are swallowed: recording the reply must never disturb the stream.
|
|
594
|
+
*/
|
|
595
|
+
function createStreamObserver(observe) {
|
|
596
|
+
let message;
|
|
597
|
+
const content = [];
|
|
598
|
+
const toolJson = /* @__PURE__ */ new Map();
|
|
599
|
+
const settleTool = (index) => {
|
|
600
|
+
const json = toolJson.get(index);
|
|
601
|
+
const block = content[index];
|
|
602
|
+
if (json === void 0 || block === void 0) return;
|
|
603
|
+
toolJson.delete(index);
|
|
604
|
+
if (json === "") return;
|
|
605
|
+
try {
|
|
606
|
+
block.input = JSON.parse(json);
|
|
607
|
+
} catch {
|
|
608
|
+
block.input = json;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
return {
|
|
612
|
+
event(payload) {
|
|
613
|
+
try {
|
|
614
|
+
const index = typeof payload.index === "number" ? payload.index : void 0;
|
|
615
|
+
switch (payload.type) {
|
|
616
|
+
case "message_start":
|
|
617
|
+
if (isObj$2(payload.message)) message = { ...payload.message };
|
|
618
|
+
return;
|
|
619
|
+
case "content_block_start":
|
|
620
|
+
if (index === void 0 || !isObj$2(payload.content_block)) return;
|
|
621
|
+
content[index] = { ...payload.content_block };
|
|
622
|
+
if (payload.content_block.type === "tool_use") toolJson.set(index, "");
|
|
623
|
+
return;
|
|
624
|
+
case "content_block_delta": {
|
|
625
|
+
const block = index === void 0 ? void 0 : content[index];
|
|
626
|
+
const delta = payload.delta;
|
|
627
|
+
if (block === void 0 || index === void 0 || !isObj$2(delta)) return;
|
|
628
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") block.text = String(block.text ?? "") + delta.text;
|
|
629
|
+
else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") block.thinking = String(block.thinking ?? "") + delta.thinking;
|
|
630
|
+
else if (delta.type === "signature_delta" && typeof delta.signature === "string") block.signature = String(block.signature ?? "") + delta.signature;
|
|
631
|
+
else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") toolJson.set(index, (toolJson.get(index) ?? "") + delta.partial_json);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
case "content_block_stop":
|
|
635
|
+
if (index !== void 0) settleTool(index);
|
|
636
|
+
return;
|
|
637
|
+
case "message_delta":
|
|
638
|
+
if (message === void 0) return;
|
|
639
|
+
if (isObj$2(payload.delta)) {
|
|
640
|
+
if ("stop_reason" in payload.delta) message.stop_reason = payload.delta.stop_reason;
|
|
641
|
+
if ("stop_sequence" in payload.delta) message.stop_sequence = payload.delta.stop_sequence;
|
|
642
|
+
}
|
|
643
|
+
if (isObj$2(payload.usage)) message.usage = {
|
|
644
|
+
...isObj$2(message.usage) ? message.usage : {},
|
|
645
|
+
...payload.usage
|
|
646
|
+
};
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
} catch {}
|
|
650
|
+
},
|
|
651
|
+
finish() {
|
|
652
|
+
try {
|
|
653
|
+
for (const index of [...toolJson.keys()]) settleTool(index);
|
|
654
|
+
if (message !== void 0) observe({
|
|
655
|
+
...message,
|
|
656
|
+
content: content.filter((block) => block !== void 0)
|
|
657
|
+
});
|
|
658
|
+
} catch {}
|
|
659
|
+
message = void 0;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
}
|
|
500
663
|
function createStreamTransformer$2(ctx) {
|
|
501
664
|
const blocks = /* @__PURE__ */ new Map();
|
|
665
|
+
const observer = ctx.observe === void 0 ? void 0 : createStreamObserver(ctx.observe);
|
|
502
666
|
const handle = (block) => {
|
|
503
667
|
let payload;
|
|
504
668
|
try {
|
|
@@ -507,6 +671,7 @@ function createStreamTransformer$2(ctx) {
|
|
|
507
671
|
return block.raw;
|
|
508
672
|
}
|
|
509
673
|
if (!isObj$2(payload)) return block.raw;
|
|
674
|
+
observer?.event(payload);
|
|
510
675
|
const index = typeof payload.index === "number" ? payload.index : void 0;
|
|
511
676
|
switch (payload.type) {
|
|
512
677
|
case "content_block_start": {
|
|
@@ -568,6 +733,7 @@ function createStreamTransformer$2(ctx) {
|
|
|
568
733
|
}
|
|
569
734
|
};
|
|
570
735
|
const finish = () => {
|
|
736
|
+
observer?.finish();
|
|
571
737
|
let out = "";
|
|
572
738
|
for (const [index, state] of blocks) {
|
|
573
739
|
const tail = state.restore.flush();
|
|
@@ -807,10 +973,93 @@ function flushChoice(state) {
|
|
|
807
973
|
state.tools.clear();
|
|
808
974
|
return Object.keys(delta).length === 0 ? void 0 : delta;
|
|
809
975
|
}
|
|
976
|
+
/** Rebuilds the non-streamed `chat.completion` from raw (pre-restore) chunks for `ctx.observe`. */
|
|
977
|
+
function createChatAssembler() {
|
|
978
|
+
let seen = false;
|
|
979
|
+
let envelope = {};
|
|
980
|
+
let usage;
|
|
981
|
+
const choices = /* @__PURE__ */ new Map();
|
|
982
|
+
return {
|
|
983
|
+
add(chunk) {
|
|
984
|
+
seen = true;
|
|
985
|
+
envelope = chunk;
|
|
986
|
+
if (chunk.usage !== void 0 && chunk.usage !== null) usage = chunk.usage;
|
|
987
|
+
for (const choice of chunk.choices) {
|
|
988
|
+
if (!isObj$1(choice)) continue;
|
|
989
|
+
const index = typeof choice.index === "number" ? choice.index : 0;
|
|
990
|
+
let state = choices.get(index);
|
|
991
|
+
if (state === void 0) {
|
|
992
|
+
state = {
|
|
993
|
+
content: null,
|
|
994
|
+
tools: /* @__PURE__ */ new Map(),
|
|
995
|
+
finish: null
|
|
996
|
+
};
|
|
997
|
+
choices.set(index, state);
|
|
998
|
+
}
|
|
999
|
+
if (choice.finish_reason !== null && choice.finish_reason !== void 0) state.finish = choice.finish_reason;
|
|
1000
|
+
const delta = choice.delta;
|
|
1001
|
+
if (!isObj$1(delta)) continue;
|
|
1002
|
+
if (typeof delta.content === "string") state.content = (state.content ?? "") + delta.content;
|
|
1003
|
+
if (typeof delta.reasoning_content === "string") state.reasoning = (state.reasoning ?? "") + delta.reasoning_content;
|
|
1004
|
+
if (!Array.isArray(delta.tool_calls)) continue;
|
|
1005
|
+
for (const call of delta.tool_calls) {
|
|
1006
|
+
if (!isObj$1(call)) continue;
|
|
1007
|
+
const callIndex = typeof call.index === "number" ? call.index : 0;
|
|
1008
|
+
let tool = state.tools.get(callIndex);
|
|
1009
|
+
if (tool === void 0) {
|
|
1010
|
+
tool = {
|
|
1011
|
+
name: "",
|
|
1012
|
+
arguments: ""
|
|
1013
|
+
};
|
|
1014
|
+
state.tools.set(callIndex, tool);
|
|
1015
|
+
}
|
|
1016
|
+
if (call.id !== void 0 && call.id !== null) tool.id = call.id;
|
|
1017
|
+
const fn = call.function;
|
|
1018
|
+
if (isObj$1(fn)) {
|
|
1019
|
+
if (typeof fn.name === "string") tool.name += fn.name;
|
|
1020
|
+
if (typeof fn.arguments === "string") tool.arguments += fn.arguments;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
take() {
|
|
1026
|
+
if (!seen) return void 0;
|
|
1027
|
+
seen = false;
|
|
1028
|
+
const out = {
|
|
1029
|
+
id: envelope.id,
|
|
1030
|
+
object: "chat.completion",
|
|
1031
|
+
model: envelope.model,
|
|
1032
|
+
choices: [...choices].sort(([a], [b]) => a - b).map(([index, state]) => {
|
|
1033
|
+
const message = {
|
|
1034
|
+
role: "assistant",
|
|
1035
|
+
content: state.content
|
|
1036
|
+
};
|
|
1037
|
+
if (state.reasoning !== void 0) message.reasoning_content = state.reasoning;
|
|
1038
|
+
if (state.tools.size > 0) message.tool_calls = [...state.tools].sort(([a], [b]) => a - b).map(([, t]) => ({
|
|
1039
|
+
id: t.id,
|
|
1040
|
+
type: "function",
|
|
1041
|
+
function: {
|
|
1042
|
+
name: t.name,
|
|
1043
|
+
arguments: t.arguments
|
|
1044
|
+
}
|
|
1045
|
+
}));
|
|
1046
|
+
return {
|
|
1047
|
+
index,
|
|
1048
|
+
message,
|
|
1049
|
+
finish_reason: state.finish
|
|
1050
|
+
};
|
|
1051
|
+
})
|
|
1052
|
+
};
|
|
1053
|
+
if (usage !== void 0) out.usage = usage;
|
|
1054
|
+
return out;
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
810
1058
|
function createStreamTransformer$1(ctx) {
|
|
811
1059
|
const choices = /* @__PURE__ */ new Map();
|
|
812
1060
|
/** The latest chunk's envelope (id, model, ...), reused for tail chunks. */
|
|
813
1061
|
let envelope = { object: "chat.completion.chunk" };
|
|
1062
|
+
const assembler = ctx.observe === void 0 ? void 0 : createChatAssembler();
|
|
814
1063
|
const choiceState = (index) => {
|
|
815
1064
|
let state = choices.get(index);
|
|
816
1065
|
if (state === void 0) {
|
|
@@ -889,6 +1138,7 @@ function createStreamTransformer$1(ctx) {
|
|
|
889
1138
|
return block.raw;
|
|
890
1139
|
}
|
|
891
1140
|
if (!isObj$1(payload) || !Array.isArray(payload.choices)) return block.raw;
|
|
1141
|
+
assembler?.add(payload);
|
|
892
1142
|
const { choices: _choices, ...rest } = payload;
|
|
893
1143
|
envelope = rest;
|
|
894
1144
|
let changed = false;
|
|
@@ -920,7 +1170,20 @@ function createStreamTransformer$1(ctx) {
|
|
|
920
1170
|
choices: rewritten
|
|
921
1171
|
}) : block.raw);
|
|
922
1172
|
};
|
|
923
|
-
|
|
1173
|
+
const framer = createSseFramer(handle, () => flushAll(void 0));
|
|
1174
|
+
if (assembler === void 0) return framer;
|
|
1175
|
+
const observe = ctx.observe;
|
|
1176
|
+
return {
|
|
1177
|
+
push: (chunk) => framer.push(chunk),
|
|
1178
|
+
flush() {
|
|
1179
|
+
const out = framer.flush();
|
|
1180
|
+
const message = assembler.take();
|
|
1181
|
+
if (message !== void 0) try {
|
|
1182
|
+
observe(message);
|
|
1183
|
+
} catch {}
|
|
1184
|
+
return out;
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
924
1187
|
}
|
|
925
1188
|
const openaiChatWalker = {
|
|
926
1189
|
async request(body, ctx) {
|
|
@@ -1165,6 +1428,8 @@ function restoreResponse(body, ctx) {
|
|
|
1165
1428
|
}
|
|
1166
1429
|
function createStreamTransformer(ctx) {
|
|
1167
1430
|
const streams = /* @__PURE__ */ new Map();
|
|
1431
|
+
/** Raw (pre-restore) terminal `response` object, kept only for `ctx.observe`. */
|
|
1432
|
+
let final;
|
|
1168
1433
|
const open = (key, factory) => {
|
|
1169
1434
|
let state = streams.get(key);
|
|
1170
1435
|
if (state === void 0) {
|
|
@@ -1256,10 +1521,16 @@ function createStreamTransformer(ctx) {
|
|
|
1256
1521
|
item: restoreItem(payload.item, ctx)
|
|
1257
1522
|
}) : block.raw;
|
|
1258
1523
|
case "response.completed":
|
|
1259
|
-
case "response.incomplete":
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1524
|
+
case "response.incomplete":
|
|
1525
|
+
if (!isObj(payload.response)) return block.raw;
|
|
1526
|
+
if (ctx.observe !== void 0) final = payload.response;
|
|
1527
|
+
return formatSseEvent(block.event, {
|
|
1528
|
+
...payload,
|
|
1529
|
+
response: restoreResponse(payload.response, ctx)
|
|
1530
|
+
});
|
|
1531
|
+
case "response.failed":
|
|
1532
|
+
if (ctx.observe !== void 0 && isObj(payload.response)) final = payload.response;
|
|
1533
|
+
return block.raw;
|
|
1263
1534
|
default: return block.raw;
|
|
1264
1535
|
}
|
|
1265
1536
|
};
|
|
@@ -1279,7 +1550,21 @@ function createStreamTransformer(ctx) {
|
|
|
1279
1550
|
streams.clear();
|
|
1280
1551
|
return out;
|
|
1281
1552
|
};
|
|
1282
|
-
|
|
1553
|
+
const framer = createSseFramer(handle, finish);
|
|
1554
|
+
const observe = ctx.observe;
|
|
1555
|
+
if (observe === void 0) return framer;
|
|
1556
|
+
return {
|
|
1557
|
+
push: (chunk) => framer.push(chunk),
|
|
1558
|
+
flush() {
|
|
1559
|
+
const out = framer.flush();
|
|
1560
|
+
const response = final;
|
|
1561
|
+
final = void 0;
|
|
1562
|
+
if (response !== void 0) try {
|
|
1563
|
+
observe(response);
|
|
1564
|
+
} catch {}
|
|
1565
|
+
return out;
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1283
1568
|
}
|
|
1284
1569
|
const openaiResponsesWalker = {
|
|
1285
1570
|
async request(body, ctx) {
|
|
@@ -1315,21 +1600,25 @@ const openaiResponsesWalker = {
|
|
|
1315
1600
|
*/
|
|
1316
1601
|
const WIRE_ROUTES = [
|
|
1317
1602
|
{
|
|
1603
|
+
name: "anthropic.count_tokens",
|
|
1318
1604
|
suffix: "/messages/count_tokens",
|
|
1319
1605
|
wire: anthropicWalker,
|
|
1320
1606
|
rewriteResponse: false
|
|
1321
1607
|
},
|
|
1322
1608
|
{
|
|
1609
|
+
name: "anthropic.messages",
|
|
1323
1610
|
suffix: "/messages",
|
|
1324
1611
|
wire: anthropicWalker,
|
|
1325
1612
|
rewriteResponse: true
|
|
1326
1613
|
},
|
|
1327
1614
|
{
|
|
1615
|
+
name: "openai.chat",
|
|
1328
1616
|
suffix: "/chat/completions",
|
|
1329
1617
|
wire: openaiChatWalker,
|
|
1330
1618
|
rewriteResponse: true
|
|
1331
1619
|
},
|
|
1332
1620
|
{
|
|
1621
|
+
name: "openai.responses",
|
|
1333
1622
|
suffix: "/responses",
|
|
1334
1623
|
wire: openaiResponsesWalker,
|
|
1335
1624
|
rewriteResponse: true
|
|
@@ -1350,7 +1639,8 @@ function createSessionState() {
|
|
|
1350
1639
|
return {
|
|
1351
1640
|
facts: createSessionFacts(),
|
|
1352
1641
|
signatures: /* @__PURE__ */ new Map(),
|
|
1353
|
-
instances: /* @__PURE__ */ new Map()
|
|
1642
|
+
instances: /* @__PURE__ */ new Map(),
|
|
1643
|
+
traced: /* @__PURE__ */ new Set()
|
|
1354
1644
|
};
|
|
1355
1645
|
}
|
|
1356
1646
|
/**
|
|
@@ -1380,6 +1670,65 @@ async function disposeInstances(state, log) {
|
|
|
1380
1670
|
}
|
|
1381
1671
|
}
|
|
1382
1672
|
}
|
|
1673
|
+
// src/trace.ts
|
|
1674
|
+
const DEFAULT_TRACE_MAX_CHARS = 262144;
|
|
1675
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
1676
|
+
/** Top-level body keys whose array elements are traced one by one: the conversation history the harness resends each round. */
|
|
1677
|
+
const PER_ELEMENT_KEYS = {
|
|
1678
|
+
messages: true,
|
|
1679
|
+
input: true
|
|
1680
|
+
};
|
|
1681
|
+
/**
|
|
1682
|
+
* One JSON line (no trailing newline). When the JSON of `content` is longer than `maxChars`,
|
|
1683
|
+
* `content` becomes that JSON text cut to `maxChars` and `truncated` is set.
|
|
1684
|
+
*/
|
|
1685
|
+
function serializeTraceEvent(event, maxChars = DEFAULT_TRACE_MAX_CHARS) {
|
|
1686
|
+
const content = JSON.stringify(event.content);
|
|
1687
|
+
if (content === void 0 || content.length <= maxChars) return JSON.stringify(event);
|
|
1688
|
+
return JSON.stringify({
|
|
1689
|
+
...event,
|
|
1690
|
+
content: content.slice(0, maxChars),
|
|
1691
|
+
truncated: true
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
/** Appends each event to `<dir>/<session>.jsonl`. The files hold whatever leaked, so they are owner-only. */
|
|
1695
|
+
function createFileTraceSink(options) {
|
|
1696
|
+
const maxChars = options.maxChars ?? 262144;
|
|
1697
|
+
let created = false;
|
|
1698
|
+
return { record(event) {
|
|
1699
|
+
if (!created) {
|
|
1700
|
+
mkdirSync(options.dir, {
|
|
1701
|
+
recursive: true,
|
|
1702
|
+
mode: 448
|
|
1703
|
+
});
|
|
1704
|
+
created = true;
|
|
1705
|
+
}
|
|
1706
|
+
const file = join(options.dir, `${safeSessionId(event.sessionId)}.jsonl`);
|
|
1707
|
+
appendFileSync(file, `${serializeTraceEvent(event, maxChars)}\n`, { mode: 384 });
|
|
1708
|
+
} };
|
|
1709
|
+
}
|
|
1710
|
+
/** The id itself when it is a safe file name, otherwise a stable hash of it. */
|
|
1711
|
+
function safeSessionId(id) {
|
|
1712
|
+
if (SAFE_SESSION_ID.test(id)) return id;
|
|
1713
|
+
return `sha256-${createHash("sha256").update(id).digest("hex").slice(0, 32)}`;
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* The request entries not yet traced in this session, keyed by JSON path; adds their hashes to `traced`.
|
|
1717
|
+
* `messages`/`input` arrays count per element, every other top-level key as a whole.
|
|
1718
|
+
*/
|
|
1719
|
+
function requestDelta(body, traced) {
|
|
1720
|
+
const delta = {};
|
|
1721
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return delta;
|
|
1722
|
+
const add = (key, path, value) => {
|
|
1723
|
+
const hash = createHash("sha256").update(`${key}\u0000${JSON.stringify(value)}`).digest("hex");
|
|
1724
|
+
if (traced.has(hash)) return;
|
|
1725
|
+
traced.add(hash);
|
|
1726
|
+
delta[path] = value;
|
|
1727
|
+
};
|
|
1728
|
+
for (const [key, value] of Object.entries(body)) if (Object.hasOwn(PER_ELEMENT_KEYS, key) && Array.isArray(value)) value.forEach((element, i) => add(key, `${key}[${i}]`, element));
|
|
1729
|
+
else add(key, key, value);
|
|
1730
|
+
return delta;
|
|
1731
|
+
}
|
|
1383
1732
|
// src/server.ts
|
|
1384
1733
|
const UPSTREAM_HEADER = "x-skaile-transform-upstream";
|
|
1385
1734
|
const SESSION_HEADER = "x-skaile-transform-session";
|
|
@@ -1415,6 +1764,8 @@ const RESPONSE_SKIP = {
|
|
|
1415
1764
|
"content-encoding": true,
|
|
1416
1765
|
"set-cookie": true
|
|
1417
1766
|
};
|
|
1767
|
+
/** Upstream error bodies in the trace are cut to this many characters. */
|
|
1768
|
+
const ERROR_TRACE_CHARS = 4096;
|
|
1418
1769
|
async function createTransformProxy(options) {
|
|
1419
1770
|
const log = options.log ?? silentLogger;
|
|
1420
1771
|
const host = options.host ?? "127.0.0.1";
|
|
@@ -1424,6 +1775,8 @@ async function createTransformProxy(options) {
|
|
|
1424
1775
|
const factories = await openTransforms(options.transforms, options.baseDir, log);
|
|
1425
1776
|
const pipelines = options.pipelines;
|
|
1426
1777
|
const sessions = /* @__PURE__ */ new Map();
|
|
1778
|
+
/** Composed instance -> its stages with their transform ids, for `inspect`. */
|
|
1779
|
+
const stagesOf = /* @__PURE__ */ new WeakMap();
|
|
1427
1780
|
function stateFor(id) {
|
|
1428
1781
|
const existing = sessions.get(id);
|
|
1429
1782
|
if (existing) return existing;
|
|
@@ -1431,6 +1784,28 @@ async function createTransformProxy(options) {
|
|
|
1431
1784
|
sessions.set(id, created);
|
|
1432
1785
|
return created;
|
|
1433
1786
|
}
|
|
1787
|
+
/** A throwing sink is logged and otherwise ignored: the trace must never change what the client gets. */
|
|
1788
|
+
function recordTrace(event) {
|
|
1789
|
+
if (options.trace === void 0) return;
|
|
1790
|
+
try {
|
|
1791
|
+
options.trace.record(event);
|
|
1792
|
+
} catch (err) {
|
|
1793
|
+
log.error(`transform proxy: trace sink failed: ${errorMessage(err)}`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
/** Records this request's single response or error line; a no-op without a trace. */
|
|
1797
|
+
function recordReply(trace, phase, status, content) {
|
|
1798
|
+
if (trace === void 0) return;
|
|
1799
|
+
recordTrace({
|
|
1800
|
+
...trace.base,
|
|
1801
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1802
|
+
phase,
|
|
1803
|
+
status,
|
|
1804
|
+
durationMs: Date.now() - trace.startedAt,
|
|
1805
|
+
content,
|
|
1806
|
+
leaks: []
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1434
1809
|
async function buildInstance(state, sessionId, pipelineId) {
|
|
1435
1810
|
const stages = [];
|
|
1436
1811
|
const ids = pipelines[pipelineId] ?? [];
|
|
@@ -1456,7 +1831,12 @@ async function createTransformProxy(options) {
|
|
|
1456
1831
|
}
|
|
1457
1832
|
throw err;
|
|
1458
1833
|
}
|
|
1459
|
-
|
|
1834
|
+
const composed = composeTransforms(stages, log);
|
|
1835
|
+
stagesOf.set(composed, ids.map((id, i) => ({
|
|
1836
|
+
id,
|
|
1837
|
+
transform: stages[i]
|
|
1838
|
+
})));
|
|
1839
|
+
return composed;
|
|
1460
1840
|
}
|
|
1461
1841
|
async function handle(req, res) {
|
|
1462
1842
|
const url = new URL(req.url ?? "/", "http://proxy.invalid");
|
|
@@ -1474,7 +1854,8 @@ async function createTransformProxy(options) {
|
|
|
1474
1854
|
const method = req.method ?? "GET";
|
|
1475
1855
|
const classified = classify(method, url.pathname);
|
|
1476
1856
|
if (classified === void 0) return sendError(res, 404, "unsupported_route", `${method} ${url.pathname} is not proxied`);
|
|
1477
|
-
const
|
|
1857
|
+
const forwardPath = `${upstream.origin}${upstream.pathname.replace(/\/+$/, "")}${url.pathname}`;
|
|
1858
|
+
const forwardTo = `${forwardPath}${url.search}`;
|
|
1478
1859
|
const headers = forwardRequestHeaders(req.headers);
|
|
1479
1860
|
if (classified.kind === "passthrough") return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, void 0), void 0);
|
|
1480
1861
|
const { route } = classified;
|
|
@@ -1505,12 +1886,36 @@ async function createTransformProxy(options) {
|
|
|
1505
1886
|
if (err instanceof UnsupportedContentError) return sendError(res, 400, "unsupported_content", err.message);
|
|
1506
1887
|
return sendError(res, 502, "transform_failed", errorMessage(err));
|
|
1507
1888
|
}
|
|
1508
|
-
|
|
1889
|
+
const leaks = findLeaks(rewritten, knownOriginals(state.facts.facts, instance));
|
|
1890
|
+
if (leaks.length > 0) log.warn("transform proxy: known original sent upstream", {
|
|
1891
|
+
sessionId,
|
|
1892
|
+
route: route.name,
|
|
1893
|
+
leaks
|
|
1894
|
+
});
|
|
1895
|
+
const trace = options.trace === void 0 ? void 0 : {
|
|
1896
|
+
startedAt: Date.now(),
|
|
1897
|
+
base: {
|
|
1898
|
+
requestId: randomUUID(),
|
|
1899
|
+
sessionId,
|
|
1900
|
+
pipeline: pipelineId,
|
|
1901
|
+
route: route.name,
|
|
1902
|
+
upstream: forwardPath,
|
|
1903
|
+
...modelOf(rewritten)
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
if (trace !== void 0) recordTrace({
|
|
1907
|
+
...trace.base,
|
|
1908
|
+
at: new Date(trace.startedAt).toISOString(),
|
|
1909
|
+
phase: "request",
|
|
1910
|
+
content: requestDelta(rewritten, state.traced),
|
|
1911
|
+
leaks
|
|
1912
|
+
});
|
|
1913
|
+
return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, JSON.stringify(rewritten), trace), route.rewriteResponse ? {
|
|
1509
1914
|
wire: route.wire,
|
|
1510
1915
|
ctx
|
|
1511
|
-
} : void 0);
|
|
1916
|
+
} : void 0, trace);
|
|
1512
1917
|
}
|
|
1513
|
-
async function fetchUpstream(res, target, method, headers, body) {
|
|
1918
|
+
async function fetchUpstream(res, target, method, headers, body, trace) {
|
|
1514
1919
|
try {
|
|
1515
1920
|
return await fetch(target, {
|
|
1516
1921
|
method,
|
|
@@ -1519,21 +1924,61 @@ async function createTransformProxy(options) {
|
|
|
1519
1924
|
redirect: "manual"
|
|
1520
1925
|
});
|
|
1521
1926
|
} catch (err) {
|
|
1927
|
+
recordReply(trace, "error", 502, errorMessage(err));
|
|
1522
1928
|
sendError(res, 502, "upstream_unreachable", errorMessage(err));
|
|
1523
1929
|
return;
|
|
1524
1930
|
}
|
|
1525
1931
|
}
|
|
1526
|
-
/**
|
|
1527
|
-
|
|
1932
|
+
/**
|
|
1933
|
+
* Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. With `trace`
|
|
1934
|
+
* (wire routes only), the reply before restore is recorded as exactly one response or error line.
|
|
1935
|
+
*/
|
|
1936
|
+
async function relayUpstream(res, upstreamRes, rewrite, trace) {
|
|
1528
1937
|
if (upstreamRes === void 0) return;
|
|
1529
1938
|
const contentType = (upstreamRes.headers.get("content-type") ?? "").toLowerCase();
|
|
1530
1939
|
const headers = relayHeaders(upstreamRes.headers);
|
|
1531
|
-
if (
|
|
1532
|
-
|
|
1533
|
-
|
|
1940
|
+
if (trace !== void 0 && !upstreamRes.ok) {
|
|
1941
|
+
const bytes = Buffer.from(await upstreamRes.arrayBuffer());
|
|
1942
|
+
recordReply(trace, "error", upstreamRes.status, bytes.toString("utf8").slice(0, ERROR_TRACE_CHARS));
|
|
1943
|
+
res.writeHead(upstreamRes.status, headers);
|
|
1944
|
+
res.end(bytes);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
if (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) {
|
|
1948
|
+
if (trace === void 0) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
|
|
1949
|
+
let observed = false;
|
|
1950
|
+
const observe = (message) => {
|
|
1951
|
+
observed = true;
|
|
1952
|
+
recordReply(trace, "response", upstreamRes.status, message);
|
|
1953
|
+
};
|
|
1954
|
+
await pipeStream(res, upstreamRes, headers, rewrite.wire.stream({
|
|
1955
|
+
...rewrite.ctx,
|
|
1956
|
+
observe
|
|
1957
|
+
}), log);
|
|
1958
|
+
if (!observed) recordReply(trace, "response", upstreamRes.status, void 0);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
if (upstreamRes.ok && contentType.includes("application/json") && (rewrite !== void 0 || trace !== void 0)) {
|
|
1962
|
+
const bytes = Buffer.from(await upstreamRes.arrayBuffer());
|
|
1963
|
+
let body;
|
|
1964
|
+
try {
|
|
1965
|
+
body = JSON.parse(new TextDecoder().decode(bytes));
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
recordReply(trace, "error", 502, errorMessage(err));
|
|
1968
|
+
if (rewrite !== void 0) return sendError(res, 502, "transform_failed", errorMessage(err));
|
|
1969
|
+
res.writeHead(upstreamRes.status, headers);
|
|
1970
|
+
res.end(bytes);
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
recordReply(trace, "response", upstreamRes.status, body);
|
|
1974
|
+
if (rewrite === void 0) {
|
|
1975
|
+
res.writeHead(upstreamRes.status, headers);
|
|
1976
|
+
res.end(bytes);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1534
1979
|
let restored;
|
|
1535
1980
|
try {
|
|
1536
|
-
restored = JSON.stringify(rewrite.wire.response(
|
|
1981
|
+
restored = JSON.stringify(rewrite.wire.response(body, rewrite.ctx));
|
|
1537
1982
|
} catch (err) {
|
|
1538
1983
|
return sendError(res, 502, "transform_failed", errorMessage(err));
|
|
1539
1984
|
}
|
|
@@ -1541,8 +1986,10 @@ async function createTransformProxy(options) {
|
|
|
1541
1986
|
res.end(restored);
|
|
1542
1987
|
return;
|
|
1543
1988
|
}
|
|
1989
|
+
const bytes = Buffer.from(await upstreamRes.arrayBuffer());
|
|
1990
|
+
recordReply(trace, "response", upstreamRes.status, void 0);
|
|
1544
1991
|
res.writeHead(upstreamRes.status, headers);
|
|
1545
|
-
res.end(
|
|
1992
|
+
res.end(bytes);
|
|
1546
1993
|
}
|
|
1547
1994
|
const server = createServer((req, res) => {
|
|
1548
1995
|
handle(req, res).catch((err) => {
|
|
@@ -1573,6 +2020,22 @@ async function createTransformProxy(options) {
|
|
|
1573
2020
|
sessions.delete(id);
|
|
1574
2021
|
await disposeInstances(state, log);
|
|
1575
2022
|
},
|
|
2023
|
+
async inspect(sessionId, pipelineId) {
|
|
2024
|
+
const state = sessions.get(sessionId);
|
|
2025
|
+
const pipeline = pipelineId ?? options.defaultPipeline;
|
|
2026
|
+
if (state === void 0 || pipeline === void 0) return {};
|
|
2027
|
+
const pending = state.instances.get(pipeline);
|
|
2028
|
+
if (pending === void 0) return {};
|
|
2029
|
+
let instance;
|
|
2030
|
+
try {
|
|
2031
|
+
instance = await pending;
|
|
2032
|
+
} catch {
|
|
2033
|
+
return {};
|
|
2034
|
+
}
|
|
2035
|
+
const result = {};
|
|
2036
|
+
for (const stage of stagesOf.get(instance) ?? []) if (typeof stage.transform.inspect === "function") result[stage.id] = stage.transform.inspect();
|
|
2037
|
+
return result;
|
|
2038
|
+
},
|
|
1576
2039
|
async close() {
|
|
1577
2040
|
const closed = Promise.withResolvers();
|
|
1578
2041
|
server.close((err) => err ? closed.reject(err) : closed.resolve());
|
|
@@ -1590,6 +2053,12 @@ function validatePipelines(options) {
|
|
|
1590
2053
|
for (const [pipelineId, ids] of Object.entries(options.pipelines)) for (const id of ids) if (!Object.hasOwn(options.transforms, id)) throw new Error(`pipeline "${pipelineId}" names unknown transform "${id}"`);
|
|
1591
2054
|
if (options.defaultPipeline !== void 0 && !Object.hasOwn(options.pipelines, options.defaultPipeline)) throw new Error(`defaultPipeline "${options.defaultPipeline}" is not a configured pipeline`);
|
|
1592
2055
|
}
|
|
2056
|
+
/** `{ model }` when the request body names its model as a string, otherwise `{}`. */
|
|
2057
|
+
function modelOf(body) {
|
|
2058
|
+
if (typeof body !== "object" || body === null) return {};
|
|
2059
|
+
const model = body.model;
|
|
2060
|
+
return typeof model === "string" ? { model } : {};
|
|
2061
|
+
}
|
|
1593
2062
|
function originOf(value) {
|
|
1594
2063
|
const url = parseHttpUrl(value);
|
|
1595
2064
|
if (url === void 0) throw new Error(`allowUpstreams entry "${value}" is not an http(s) URL`);
|
|
@@ -1725,7 +2194,12 @@ const TOP_LEVEL_KEYS = {
|
|
|
1725
2194
|
transforms: true,
|
|
1726
2195
|
pipelines: true,
|
|
1727
2196
|
defaultPipeline: true,
|
|
1728
|
-
allowUpstreams: true
|
|
2197
|
+
allowUpstreams: true,
|
|
2198
|
+
trace: true
|
|
2199
|
+
};
|
|
2200
|
+
const TRACE_KEYS = {
|
|
2201
|
+
dir: true,
|
|
2202
|
+
maxChars: true
|
|
1729
2203
|
};
|
|
1730
2204
|
/** Reads and validates a YAML config file. Unknown keys are rejected so that typos fail loudly. */
|
|
1731
2205
|
function loadProxyConfig(path) {
|
|
@@ -1745,7 +2219,8 @@ function parseProxyConfig(source, baseDir) {
|
|
|
1745
2219
|
transforms: parseTransforms(source.transforms),
|
|
1746
2220
|
pipelines: parsePipelines(source.pipelines),
|
|
1747
2221
|
...source.defaultPipeline === void 0 ? {} : { defaultPipeline: requireString(source.defaultPipeline, "defaultPipeline") },
|
|
1748
|
-
...source.allowUpstreams === void 0 ? {} : { allowUpstreams: requireStringList(source.allowUpstreams, "allowUpstreams") }
|
|
2222
|
+
...source.allowUpstreams === void 0 ? {} : { allowUpstreams: requireStringList(source.allowUpstreams, "allowUpstreams") },
|
|
2223
|
+
...source.trace === void 0 ? {} : { trace: parseTrace(source.trace, baseDir) }
|
|
1749
2224
|
};
|
|
1750
2225
|
}
|
|
1751
2226
|
function parseTransforms(value) {
|
|
@@ -1769,6 +2244,17 @@ function parsePipelines(value) {
|
|
|
1769
2244
|
for (const [id, stages] of Object.entries(value)) out[id] = requireStringList(stages, `pipelines.${id}`);
|
|
1770
2245
|
return out;
|
|
1771
2246
|
}
|
|
2247
|
+
function parseTrace(value, baseDir) {
|
|
2248
|
+
if (!isRecord(value)) throw new Error("trace must be a mapping with a dir");
|
|
2249
|
+
for (const key of Object.keys(value)) if (!Object.hasOwn(TRACE_KEYS, key)) throw new Error(`unknown key "trace.${key}"`);
|
|
2250
|
+
const dir = resolve(baseDir, requireString(value.dir, "trace.dir"));
|
|
2251
|
+
if (value.maxChars === void 0) return { dir };
|
|
2252
|
+
if (typeof value.maxChars !== "number" || !Number.isInteger(value.maxChars) || value.maxChars <= 0) throw new Error("trace.maxChars must be a positive integer");
|
|
2253
|
+
return {
|
|
2254
|
+
dir,
|
|
2255
|
+
maxChars: value.maxChars
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
1772
2258
|
function requireString(value, field) {
|
|
1773
2259
|
if (typeof value !== "string" || value === "") throw new Error(`${field} must be a non-empty string`);
|
|
1774
2260
|
return value;
|
|
@@ -1780,4 +2266,4 @@ function requireStringList(value, field) {
|
|
|
1780
2266
|
function isRecord(value) {
|
|
1781
2267
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1782
2268
|
}
|
|
1783
|
-
export {
|
|
2269
|
+
export { silentLogger as S, anthropicWalker as _, parseProxyConfig as a, composeTransforms as b, SESSION_HEADER as c, createTransformProxy as d, DEFAULT_TRACE_MAX_CHARS as f, openaiChatWalker as g, openaiResponsesWalker as h, loadProxyConfig as i, TOKEN_HEADER as l, serializeTraceEvent as m, configCandidates as n, resolveProxyConfig as o, createFileTraceSink as p, findProxyConfig as r, PIPELINE_HEADER as s, CONFIG_FILE_NAME as t, UPSTREAM_HEADER as u, UnsupportedContentError as v, errorMessage as x, createSessionFacts as y };
|