@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.
@@ -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
- return createSseFramer(handle, () => flushAll(void 0));
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": return isObj(payload.response) ? formatSseEvent(block.event, {
1260
- ...payload,
1261
- response: restoreResponse(payload.response, ctx)
1262
- }) : block.raw;
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
- return createSseFramer(handle, finish);
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
- return composeTransforms(stages, log);
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 forwardTo = `${upstream.origin}${upstream.pathname.replace(/\/+$/, "")}${url.pathname}${url.search}`;
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
- return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, JSON.stringify(rewritten)), route.rewriteResponse ? {
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
- /** Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. */
1527
- async function relayUpstream(res, upstreamRes, rewrite) {
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 (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
1532
- if (rewrite !== void 0 && upstreamRes.ok && contentType.includes("application/json")) {
1533
- const text = await upstreamRes.text();
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(JSON.parse(text), rewrite.ctx));
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(Buffer.from(await upstreamRes.arrayBuffer()));
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 { composeTransforms as _, parseProxyConfig as a, SESSION_HEADER as c, createTransformProxy as d, openaiResponsesWalker as f, createSessionFacts as g, UnsupportedContentError as h, loadProxyConfig as i, TOKEN_HEADER as l, anthropicWalker as m, configCandidates as n, resolveProxyConfig as o, openaiChatWalker as p, findProxyConfig as r, PIPELINE_HEADER as s, CONFIG_FILE_NAME as t, UPSTREAM_HEADER as u, errorMessage as v, silentLogger as y };
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 };