@skaile/transform-proxy 0.4.0 → 0.5.1

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,10 +1,10 @@
1
1
  import { createRequire } from "node:module";
2
- import { timingSafeEqual } from "node:crypto";
3
- import { createServer } from "node:http";
4
- import { existsSync, readFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
5
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
- import { pathToFileURL } from "node:url";
7
4
  import { parse } from "yaml";
5
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
6
+ import { createServer } from "node:http";
7
+ import { pathToFileURL } from "node:url";
8
8
  // src/log.ts
9
9
  /** Default logger: drops everything. Hosts pass their own `Logger`. */
10
10
  const silentLogger = {
@@ -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.
@@ -69,6 +92,112 @@ function composeStreams(streams) {
69
92
  }
70
93
  };
71
94
  }
95
+ // src/config.ts
96
+ const CONFIG_FILE_NAME = "transform-proxy.yaml";
97
+ /** The implicit lookup locations, in order: `./.skaile/` then `~/.config/skaile/`. */
98
+ function configCandidates(cwd, home) {
99
+ return [join(cwd, ".skaile", CONFIG_FILE_NAME), join(home, ".config", "skaile", CONFIG_FILE_NAME)];
100
+ }
101
+ /**
102
+ * Finds the config file: the explicit path when given (it must exist), otherwise the first
103
+ * existing implicit candidate. Returns `undefined` when no implicit candidate exists.
104
+ */
105
+ function findProxyConfig(lookup) {
106
+ if (lookup.explicit !== void 0) {
107
+ const path = resolve(lookup.cwd, lookup.explicit);
108
+ if (!existsSync(path)) throw new Error(`transform proxy config not found: ${path}`);
109
+ return path;
110
+ }
111
+ return configCandidates(lookup.cwd, lookup.home).find((candidate) => existsSync(candidate));
112
+ }
113
+ /** Finds and loads the config. Throws, naming the searched paths, when none is found. */
114
+ function resolveProxyConfig(lookup) {
115
+ const path = findProxyConfig(lookup);
116
+ if (path === void 0) {
117
+ const searched = configCandidates(lookup.cwd, lookup.home).join(", ");
118
+ throw new Error(`no transform proxy config found (pass --config, or create one at ${searched})`);
119
+ }
120
+ return {
121
+ path,
122
+ options: loadProxyConfig(path)
123
+ };
124
+ }
125
+ const TOP_LEVEL_KEYS = {
126
+ transforms: true,
127
+ pipelines: true,
128
+ defaultPipeline: true,
129
+ allowUpstreams: true,
130
+ trace: true
131
+ };
132
+ const TRACE_KEYS = {
133
+ dir: true,
134
+ maxChars: true
135
+ };
136
+ /** Reads and validates a YAML config file. Unknown keys are rejected so that typos fail loudly. */
137
+ function loadProxyConfig(path) {
138
+ const source = parse(readFileSync(path, "utf8"));
139
+ try {
140
+ return parseProxyConfig(source ?? {}, dirname(path));
141
+ } catch (err) {
142
+ throw new Error(`invalid transform proxy config ${path}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
143
+ }
144
+ }
145
+ /** Validates an already-parsed config object. Exported for hosts that load YAML themselves. */
146
+ function parseProxyConfig(source, baseDir) {
147
+ if (!isRecord$1(source)) throw new Error("the document must be a mapping");
148
+ for (const key of Object.keys(source)) if (!Object.hasOwn(TOP_LEVEL_KEYS, key)) throw new Error(`unknown key "${key}"`);
149
+ return {
150
+ baseDir,
151
+ transforms: parseTransforms(source.transforms),
152
+ pipelines: parsePipelines(source.pipelines),
153
+ ...source.defaultPipeline === void 0 ? {} : { defaultPipeline: requireString(source.defaultPipeline, "defaultPipeline") },
154
+ ...source.allowUpstreams === void 0 ? {} : { allowUpstreams: requireStringList(source.allowUpstreams, "allowUpstreams") },
155
+ ...source.trace === void 0 ? {} : { trace: parseTrace(source.trace, baseDir) }
156
+ };
157
+ }
158
+ function parseTransforms(value) {
159
+ if (value === void 0) return {};
160
+ if (!isRecord$1(value)) throw new Error("transforms must be a mapping of transform id to { module, config }");
161
+ const out = {};
162
+ for (const [id, spec] of Object.entries(value)) {
163
+ if (!isRecord$1(spec)) throw new Error(`transforms.${id} must be a mapping with a module`);
164
+ const module = requireString(spec.module, `transforms.${id}.module`);
165
+ out[id] = spec.config === void 0 ? { module } : {
166
+ module,
167
+ config: spec.config
168
+ };
169
+ }
170
+ return out;
171
+ }
172
+ function parsePipelines(value) {
173
+ if (value === void 0) return {};
174
+ if (!isRecord$1(value)) throw new Error("pipelines must be a mapping of pipeline id to a list of transform ids");
175
+ const out = {};
176
+ for (const [id, stages] of Object.entries(value)) out[id] = requireStringList(stages, `pipelines.${id}`);
177
+ return out;
178
+ }
179
+ function parseTrace(value, baseDir) {
180
+ if (!isRecord$1(value)) throw new Error("trace must be a mapping with a dir");
181
+ for (const key of Object.keys(value)) if (!Object.hasOwn(TRACE_KEYS, key)) throw new Error(`unknown key "trace.${key}"`);
182
+ const dir = resolve(baseDir, requireString(value.dir, "trace.dir"));
183
+ if (value.maxChars === void 0) return { dir };
184
+ if (typeof value.maxChars !== "number" || !Number.isInteger(value.maxChars) || value.maxChars <= 0) throw new Error("trace.maxChars must be a positive integer");
185
+ return {
186
+ dir,
187
+ maxChars: value.maxChars
188
+ };
189
+ }
190
+ function requireString(value, field) {
191
+ if (typeof value !== "string" || value === "") throw new Error(`${field} must be a non-empty string`);
192
+ return value;
193
+ }
194
+ function requireStringList(value, field) {
195
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item === "")) throw new Error(`${field} must be a list of non-empty strings`);
196
+ return value;
197
+ }
198
+ function isRecord$1(value) {
199
+ return typeof value === "object" && value !== null && !Array.isArray(value);
200
+ }
72
201
  // src/facts.ts
73
202
  function createSessionFacts() {
74
203
  const bySource = /* @__PURE__ */ new Map();
@@ -102,6 +231,74 @@ function createSessionFacts() {
102
231
  }
103
232
  };
104
233
  }
234
+ // src/leaks.ts
235
+ /** Shorter values match by chance too often to be worth a warning. */
236
+ const MIN_LEAK_LENGTH = 3;
237
+ const PLAIN_KEY = /^[A-Za-z_$][\w$]*$/;
238
+ /**
239
+ * The values the leak check compares against: the session's facts, each with its publisher as source, plus what the
240
+ * pipeline instance reports via `knownOriginals()` as source `pipeline`. If that throws, only the facts are returned.
241
+ */
242
+ function knownOriginals(facts, instance) {
243
+ const out = facts.list().map(({ kind, source, value }) => ({
244
+ kind,
245
+ source,
246
+ value
247
+ }));
248
+ if (instance?.knownOriginals === void 0) return out;
249
+ try {
250
+ const fromPipeline = [];
251
+ for (const { kind, value } of instance.knownOriginals()) fromPipeline.push({
252
+ kind,
253
+ source: "pipeline",
254
+ value
255
+ });
256
+ out.push(...fromPipeline);
257
+ } catch {}
258
+ return out;
259
+ }
260
+ /**
261
+ * Every place in `body` (the rewritten request) where a string value contains a known original, case-sensitively.
262
+ * Only string values are scanned, not object keys. The result holds kind, source and JSON path, never the value.
263
+ */
264
+ function findLeaks(body, originals) {
265
+ const candidates = [];
266
+ for (const original of originals) if (original.value.length >= MIN_LEAK_LENGTH) candidates.push(original);
267
+ if (candidates.length === 0) return [];
268
+ const leaks = [];
269
+ const seen = /* @__PURE__ */ new Set();
270
+ const visit = (node, path) => {
271
+ if (typeof node === "string") {
272
+ for (const { kind, source, value } of candidates) {
273
+ if (!node.includes(value)) continue;
274
+ const key = JSON.stringify([
275
+ kind,
276
+ source,
277
+ path
278
+ ]);
279
+ if (seen.has(key)) continue;
280
+ seen.add(key);
281
+ leaks.push({
282
+ kind,
283
+ source,
284
+ at: path
285
+ });
286
+ }
287
+ return;
288
+ }
289
+ if (Array.isArray(node)) {
290
+ node.forEach((item, i) => visit(item, `${path}[${i}]`));
291
+ return;
292
+ }
293
+ if (node !== null && typeof node === "object") for (const [key, value] of Object.entries(node)) visit(value, joinKey(path, key));
294
+ };
295
+ visit(body, "");
296
+ return leaks;
297
+ }
298
+ function joinKey(path, key) {
299
+ if (!PLAIN_KEY.test(key)) return `${path}[${JSON.stringify(key)}]`;
300
+ return path === "" ? key : `${path}.${key}`;
301
+ }
105
302
  // src/modules.ts
106
303
  /** Loads and opens every configured transform once. Any failure rejects with the transform id. */
107
304
  async function openTransforms(specs, baseDir, log) {
@@ -165,17 +362,17 @@ function resolveImportCondition(specifier, root) {
165
362
  }
166
363
  }
167
364
  function exportTarget(exports, subpath) {
168
- return conditionTarget((isRecord$1(exports) && Object.keys(exports).some((key) => key.startsWith(".")) ? exports : { ".": exports })[subpath]);
365
+ return conditionTarget((isRecord(exports) && Object.keys(exports).some((key) => key.startsWith(".")) ? exports : { ".": exports })[subpath]);
169
366
  }
170
367
  function conditionTarget(entry) {
171
368
  if (typeof entry === "string") return entry;
172
- if (!isRecord$1(entry)) return void 0;
369
+ if (!isRecord(entry)) return void 0;
173
370
  return conditionTarget(entry.import) ?? conditionTarget(entry.default);
174
371
  }
175
372
  function isTransformModule(value) {
176
- return isRecord$1(value) && typeof value.id === "string" && typeof value.open === "function";
373
+ return isRecord(value) && typeof value.id === "string" && typeof value.open === "function";
177
374
  }
178
- function isRecord$1(value) {
375
+ function isRecord(value) {
179
376
  return typeof value === "object" && value !== null && !Array.isArray(value);
180
377
  }
181
378
  // src/wire/json-args.ts
@@ -497,8 +694,81 @@ function tailDelta(kind, tail) {
497
694
  partial_json: tail
498
695
  };
499
696
  }
697
+ /**
698
+ * Rebuilds the Anthropic message from the raw upstream events (before restore) for `ctx.observe`.
699
+ * Errors are swallowed: recording the reply must never disturb the stream.
700
+ */
701
+ function createStreamObserver(observe) {
702
+ let message;
703
+ const content = [];
704
+ const toolJson = /* @__PURE__ */ new Map();
705
+ const settleTool = (index) => {
706
+ const json = toolJson.get(index);
707
+ const block = content[index];
708
+ if (json === void 0 || block === void 0) return;
709
+ toolJson.delete(index);
710
+ if (json === "") return;
711
+ try {
712
+ block.input = JSON.parse(json);
713
+ } catch {
714
+ block.input = json;
715
+ }
716
+ };
717
+ return {
718
+ event(payload) {
719
+ try {
720
+ const index = typeof payload.index === "number" ? payload.index : void 0;
721
+ switch (payload.type) {
722
+ case "message_start":
723
+ if (isObj$2(payload.message)) message = { ...payload.message };
724
+ return;
725
+ case "content_block_start":
726
+ if (index === void 0 || !isObj$2(payload.content_block)) return;
727
+ content[index] = { ...payload.content_block };
728
+ if (payload.content_block.type === "tool_use") toolJson.set(index, "");
729
+ return;
730
+ case "content_block_delta": {
731
+ const block = index === void 0 ? void 0 : content[index];
732
+ const delta = payload.delta;
733
+ if (block === void 0 || index === void 0 || !isObj$2(delta)) return;
734
+ if (delta.type === "text_delta" && typeof delta.text === "string") block.text = String(block.text ?? "") + delta.text;
735
+ else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") block.thinking = String(block.thinking ?? "") + delta.thinking;
736
+ else if (delta.type === "signature_delta" && typeof delta.signature === "string") block.signature = String(block.signature ?? "") + delta.signature;
737
+ else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") toolJson.set(index, (toolJson.get(index) ?? "") + delta.partial_json);
738
+ return;
739
+ }
740
+ case "content_block_stop":
741
+ if (index !== void 0) settleTool(index);
742
+ return;
743
+ case "message_delta":
744
+ if (message === void 0) return;
745
+ if (isObj$2(payload.delta)) {
746
+ if ("stop_reason" in payload.delta) message.stop_reason = payload.delta.stop_reason;
747
+ if ("stop_sequence" in payload.delta) message.stop_sequence = payload.delta.stop_sequence;
748
+ }
749
+ if (isObj$2(payload.usage)) message.usage = {
750
+ ...isObj$2(message.usage) ? message.usage : {},
751
+ ...payload.usage
752
+ };
753
+ return;
754
+ }
755
+ } catch {}
756
+ },
757
+ finish() {
758
+ try {
759
+ for (const index of [...toolJson.keys()]) settleTool(index);
760
+ if (message !== void 0) observe({
761
+ ...message,
762
+ content: content.filter((block) => block !== void 0)
763
+ });
764
+ } catch {}
765
+ message = void 0;
766
+ }
767
+ };
768
+ }
500
769
  function createStreamTransformer$2(ctx) {
501
770
  const blocks = /* @__PURE__ */ new Map();
771
+ const observer = ctx.observe === void 0 ? void 0 : createStreamObserver(ctx.observe);
502
772
  const handle = (block) => {
503
773
  let payload;
504
774
  try {
@@ -507,6 +777,7 @@ function createStreamTransformer$2(ctx) {
507
777
  return block.raw;
508
778
  }
509
779
  if (!isObj$2(payload)) return block.raw;
780
+ observer?.event(payload);
510
781
  const index = typeof payload.index === "number" ? payload.index : void 0;
511
782
  switch (payload.type) {
512
783
  case "content_block_start": {
@@ -568,6 +839,7 @@ function createStreamTransformer$2(ctx) {
568
839
  }
569
840
  };
570
841
  const finish = () => {
842
+ observer?.finish();
571
843
  let out = "";
572
844
  for (const [index, state] of blocks) {
573
845
  const tail = state.restore.flush();
@@ -807,10 +1079,93 @@ function flushChoice(state) {
807
1079
  state.tools.clear();
808
1080
  return Object.keys(delta).length === 0 ? void 0 : delta;
809
1081
  }
1082
+ /** Rebuilds the non-streamed `chat.completion` from raw (pre-restore) chunks for `ctx.observe`. */
1083
+ function createChatAssembler() {
1084
+ let seen = false;
1085
+ let envelope = {};
1086
+ let usage;
1087
+ const choices = /* @__PURE__ */ new Map();
1088
+ return {
1089
+ add(chunk) {
1090
+ seen = true;
1091
+ envelope = chunk;
1092
+ if (chunk.usage !== void 0 && chunk.usage !== null) usage = chunk.usage;
1093
+ for (const choice of chunk.choices) {
1094
+ if (!isObj$1(choice)) continue;
1095
+ const index = typeof choice.index === "number" ? choice.index : 0;
1096
+ let state = choices.get(index);
1097
+ if (state === void 0) {
1098
+ state = {
1099
+ content: null,
1100
+ tools: /* @__PURE__ */ new Map(),
1101
+ finish: null
1102
+ };
1103
+ choices.set(index, state);
1104
+ }
1105
+ if (choice.finish_reason !== null && choice.finish_reason !== void 0) state.finish = choice.finish_reason;
1106
+ const delta = choice.delta;
1107
+ if (!isObj$1(delta)) continue;
1108
+ if (typeof delta.content === "string") state.content = (state.content ?? "") + delta.content;
1109
+ if (typeof delta.reasoning_content === "string") state.reasoning = (state.reasoning ?? "") + delta.reasoning_content;
1110
+ if (!Array.isArray(delta.tool_calls)) continue;
1111
+ for (const call of delta.tool_calls) {
1112
+ if (!isObj$1(call)) continue;
1113
+ const callIndex = typeof call.index === "number" ? call.index : 0;
1114
+ let tool = state.tools.get(callIndex);
1115
+ if (tool === void 0) {
1116
+ tool = {
1117
+ name: "",
1118
+ arguments: ""
1119
+ };
1120
+ state.tools.set(callIndex, tool);
1121
+ }
1122
+ if (call.id !== void 0 && call.id !== null) tool.id = call.id;
1123
+ const fn = call.function;
1124
+ if (isObj$1(fn)) {
1125
+ if (typeof fn.name === "string") tool.name += fn.name;
1126
+ if (typeof fn.arguments === "string") tool.arguments += fn.arguments;
1127
+ }
1128
+ }
1129
+ }
1130
+ },
1131
+ take() {
1132
+ if (!seen) return void 0;
1133
+ seen = false;
1134
+ const out = {
1135
+ id: envelope.id,
1136
+ object: "chat.completion",
1137
+ model: envelope.model,
1138
+ choices: [...choices].sort(([a], [b]) => a - b).map(([index, state]) => {
1139
+ const message = {
1140
+ role: "assistant",
1141
+ content: state.content
1142
+ };
1143
+ if (state.reasoning !== void 0) message.reasoning_content = state.reasoning;
1144
+ if (state.tools.size > 0) message.tool_calls = [...state.tools].sort(([a], [b]) => a - b).map(([, t]) => ({
1145
+ id: t.id,
1146
+ type: "function",
1147
+ function: {
1148
+ name: t.name,
1149
+ arguments: t.arguments
1150
+ }
1151
+ }));
1152
+ return {
1153
+ index,
1154
+ message,
1155
+ finish_reason: state.finish
1156
+ };
1157
+ })
1158
+ };
1159
+ if (usage !== void 0) out.usage = usage;
1160
+ return out;
1161
+ }
1162
+ };
1163
+ }
810
1164
  function createStreamTransformer$1(ctx) {
811
1165
  const choices = /* @__PURE__ */ new Map();
812
1166
  /** The latest chunk's envelope (id, model, ...), reused for tail chunks. */
813
1167
  let envelope = { object: "chat.completion.chunk" };
1168
+ const assembler = ctx.observe === void 0 ? void 0 : createChatAssembler();
814
1169
  const choiceState = (index) => {
815
1170
  let state = choices.get(index);
816
1171
  if (state === void 0) {
@@ -889,6 +1244,7 @@ function createStreamTransformer$1(ctx) {
889
1244
  return block.raw;
890
1245
  }
891
1246
  if (!isObj$1(payload) || !Array.isArray(payload.choices)) return block.raw;
1247
+ assembler?.add(payload);
892
1248
  const { choices: _choices, ...rest } = payload;
893
1249
  envelope = rest;
894
1250
  let changed = false;
@@ -920,7 +1276,20 @@ function createStreamTransformer$1(ctx) {
920
1276
  choices: rewritten
921
1277
  }) : block.raw);
922
1278
  };
923
- return createSseFramer(handle, () => flushAll(void 0));
1279
+ const framer = createSseFramer(handle, () => flushAll(void 0));
1280
+ if (assembler === void 0) return framer;
1281
+ const observe = ctx.observe;
1282
+ return {
1283
+ push: (chunk) => framer.push(chunk),
1284
+ flush() {
1285
+ const out = framer.flush();
1286
+ const message = assembler.take();
1287
+ if (message !== void 0) try {
1288
+ observe(message);
1289
+ } catch {}
1290
+ return out;
1291
+ }
1292
+ };
924
1293
  }
925
1294
  const openaiChatWalker = {
926
1295
  async request(body, ctx) {
@@ -1165,6 +1534,8 @@ function restoreResponse(body, ctx) {
1165
1534
  }
1166
1535
  function createStreamTransformer(ctx) {
1167
1536
  const streams = /* @__PURE__ */ new Map();
1537
+ /** Raw (pre-restore) terminal `response` object, kept only for `ctx.observe`. */
1538
+ let final;
1168
1539
  const open = (key, factory) => {
1169
1540
  let state = streams.get(key);
1170
1541
  if (state === void 0) {
@@ -1256,10 +1627,16 @@ function createStreamTransformer(ctx) {
1256
1627
  item: restoreItem(payload.item, ctx)
1257
1628
  }) : block.raw;
1258
1629
  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;
1630
+ case "response.incomplete":
1631
+ if (!isObj(payload.response)) return block.raw;
1632
+ if (ctx.observe !== void 0) final = payload.response;
1633
+ return formatSseEvent(block.event, {
1634
+ ...payload,
1635
+ response: restoreResponse(payload.response, ctx)
1636
+ });
1637
+ case "response.failed":
1638
+ if (ctx.observe !== void 0 && isObj(payload.response)) final = payload.response;
1639
+ return block.raw;
1263
1640
  default: return block.raw;
1264
1641
  }
1265
1642
  };
@@ -1279,7 +1656,21 @@ function createStreamTransformer(ctx) {
1279
1656
  streams.clear();
1280
1657
  return out;
1281
1658
  };
1282
- return createSseFramer(handle, finish);
1659
+ const framer = createSseFramer(handle, finish);
1660
+ const observe = ctx.observe;
1661
+ if (observe === void 0) return framer;
1662
+ return {
1663
+ push: (chunk) => framer.push(chunk),
1664
+ flush() {
1665
+ const out = framer.flush();
1666
+ const response = final;
1667
+ final = void 0;
1668
+ if (response !== void 0) try {
1669
+ observe(response);
1670
+ } catch {}
1671
+ return out;
1672
+ }
1673
+ };
1283
1674
  }
1284
1675
  const openaiResponsesWalker = {
1285
1676
  async request(body, ctx) {
@@ -1315,21 +1706,25 @@ const openaiResponsesWalker = {
1315
1706
  */
1316
1707
  const WIRE_ROUTES = [
1317
1708
  {
1709
+ name: "anthropic.count_tokens",
1318
1710
  suffix: "/messages/count_tokens",
1319
1711
  wire: anthropicWalker,
1320
1712
  rewriteResponse: false
1321
1713
  },
1322
1714
  {
1715
+ name: "anthropic.messages",
1323
1716
  suffix: "/messages",
1324
1717
  wire: anthropicWalker,
1325
1718
  rewriteResponse: true
1326
1719
  },
1327
1720
  {
1721
+ name: "openai.chat",
1328
1722
  suffix: "/chat/completions",
1329
1723
  wire: openaiChatWalker,
1330
1724
  rewriteResponse: true
1331
1725
  },
1332
1726
  {
1727
+ name: "openai.responses",
1333
1728
  suffix: "/responses",
1334
1729
  wire: openaiResponsesWalker,
1335
1730
  rewriteResponse: true
@@ -1350,7 +1745,8 @@ function createSessionState() {
1350
1745
  return {
1351
1746
  facts: createSessionFacts(),
1352
1747
  signatures: /* @__PURE__ */ new Map(),
1353
- instances: /* @__PURE__ */ new Map()
1748
+ instances: /* @__PURE__ */ new Map(),
1749
+ traced: /* @__PURE__ */ new Set()
1354
1750
  };
1355
1751
  }
1356
1752
  /**
@@ -1380,6 +1776,65 @@ async function disposeInstances(state, log) {
1380
1776
  }
1381
1777
  }
1382
1778
  }
1779
+ // src/trace.ts
1780
+ const DEFAULT_TRACE_MAX_CHARS = 262144;
1781
+ const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
1782
+ /** Top-level body keys whose array elements are traced one by one: the conversation history the harness resends each round. */
1783
+ const PER_ELEMENT_KEYS = {
1784
+ messages: true,
1785
+ input: true
1786
+ };
1787
+ /**
1788
+ * One JSON line (no trailing newline). When the JSON of `content` is longer than `maxChars`,
1789
+ * `content` becomes that JSON text cut to `maxChars` and `truncated` is set.
1790
+ */
1791
+ function serializeTraceEvent(event, maxChars = DEFAULT_TRACE_MAX_CHARS) {
1792
+ const content = JSON.stringify(event.content);
1793
+ if (content === void 0 || content.length <= maxChars) return JSON.stringify(event);
1794
+ return JSON.stringify({
1795
+ ...event,
1796
+ content: content.slice(0, maxChars),
1797
+ truncated: true
1798
+ });
1799
+ }
1800
+ /** Appends each event to `<dir>/<session>.jsonl`. The files hold whatever leaked, so they are owner-only. */
1801
+ function createFileTraceSink(options) {
1802
+ const maxChars = options.maxChars ?? 262144;
1803
+ let created = false;
1804
+ return { record(event) {
1805
+ if (!created) {
1806
+ mkdirSync(options.dir, {
1807
+ recursive: true,
1808
+ mode: 448
1809
+ });
1810
+ created = true;
1811
+ }
1812
+ const file = join(options.dir, `${safeSessionId(event.sessionId)}.jsonl`);
1813
+ appendFileSync(file, `${serializeTraceEvent(event, maxChars)}\n`, { mode: 384 });
1814
+ } };
1815
+ }
1816
+ /** The id itself when it is a safe file name, otherwise a stable hash of it. */
1817
+ function safeSessionId(id) {
1818
+ if (SAFE_SESSION_ID.test(id)) return id;
1819
+ return `sha256-${createHash("sha256").update(id).digest("hex").slice(0, 32)}`;
1820
+ }
1821
+ /**
1822
+ * The request entries not yet traced in this session, keyed by JSON path; adds their hashes to `traced`.
1823
+ * `messages`/`input` arrays count per element, every other top-level key as a whole.
1824
+ */
1825
+ function requestDelta(body, traced) {
1826
+ const delta = {};
1827
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return delta;
1828
+ const add = (key, path, value) => {
1829
+ const hash = createHash("sha256").update(`${key}\u0000${JSON.stringify(value)}`).digest("hex");
1830
+ if (traced.has(hash)) return;
1831
+ traced.add(hash);
1832
+ delta[path] = value;
1833
+ };
1834
+ 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));
1835
+ else add(key, key, value);
1836
+ return delta;
1837
+ }
1383
1838
  // src/server.ts
1384
1839
  const UPSTREAM_HEADER = "x-skaile-transform-upstream";
1385
1840
  const SESSION_HEADER = "x-skaile-transform-session";
@@ -1415,6 +1870,8 @@ const RESPONSE_SKIP = {
1415
1870
  "content-encoding": true,
1416
1871
  "set-cookie": true
1417
1872
  };
1873
+ /** Upstream error bodies in the trace are cut to this many characters. */
1874
+ const ERROR_TRACE_CHARS = 4096;
1418
1875
  async function createTransformProxy(options) {
1419
1876
  const log = options.log ?? silentLogger;
1420
1877
  const host = options.host ?? "127.0.0.1";
@@ -1433,6 +1890,28 @@ async function createTransformProxy(options) {
1433
1890
  sessions.set(id, created);
1434
1891
  return created;
1435
1892
  }
1893
+ /** A throwing sink is logged and otherwise ignored: the trace must never change what the client gets. */
1894
+ function recordTrace(event) {
1895
+ if (options.trace === void 0) return;
1896
+ try {
1897
+ options.trace.record(event);
1898
+ } catch (err) {
1899
+ log.error(`transform proxy: trace sink failed: ${errorMessage(err)}`);
1900
+ }
1901
+ }
1902
+ /** Records this request's single response or error line; a no-op without a trace. */
1903
+ function recordReply(trace, phase, status, content) {
1904
+ if (trace === void 0) return;
1905
+ recordTrace({
1906
+ ...trace.base,
1907
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1908
+ phase,
1909
+ status,
1910
+ durationMs: Date.now() - trace.startedAt,
1911
+ content,
1912
+ leaks: []
1913
+ });
1914
+ }
1436
1915
  async function buildInstance(state, sessionId, pipelineId) {
1437
1916
  const stages = [];
1438
1917
  const ids = pipelines[pipelineId] ?? [];
@@ -1481,7 +1960,8 @@ async function createTransformProxy(options) {
1481
1960
  const method = req.method ?? "GET";
1482
1961
  const classified = classify(method, url.pathname);
1483
1962
  if (classified === void 0) return sendError(res, 404, "unsupported_route", `${method} ${url.pathname} is not proxied`);
1484
- const forwardTo = `${upstream.origin}${upstream.pathname.replace(/\/+$/, "")}${url.pathname}${url.search}`;
1963
+ const forwardPath = `${upstream.origin}${upstream.pathname.replace(/\/+$/, "")}${url.pathname}`;
1964
+ const forwardTo = `${forwardPath}${url.search}`;
1485
1965
  const headers = forwardRequestHeaders(req.headers);
1486
1966
  if (classified.kind === "passthrough") return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, void 0), void 0);
1487
1967
  const { route } = classified;
@@ -1512,12 +1992,36 @@ async function createTransformProxy(options) {
1512
1992
  if (err instanceof UnsupportedContentError) return sendError(res, 400, "unsupported_content", err.message);
1513
1993
  return sendError(res, 502, "transform_failed", errorMessage(err));
1514
1994
  }
1515
- return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, JSON.stringify(rewritten)), route.rewriteResponse ? {
1995
+ const leaks = findLeaks(rewritten, knownOriginals(state.facts.facts, instance));
1996
+ if (leaks.length > 0) log.warn("transform proxy: known original sent upstream", {
1997
+ sessionId,
1998
+ route: route.name,
1999
+ leaks
2000
+ });
2001
+ const trace = options.trace === void 0 ? void 0 : {
2002
+ startedAt: Date.now(),
2003
+ base: {
2004
+ requestId: randomUUID(),
2005
+ sessionId,
2006
+ pipeline: pipelineId,
2007
+ route: route.name,
2008
+ upstream: forwardPath,
2009
+ ...modelOf(rewritten)
2010
+ }
2011
+ };
2012
+ if (trace !== void 0) recordTrace({
2013
+ ...trace.base,
2014
+ at: new Date(trace.startedAt).toISOString(),
2015
+ phase: "request",
2016
+ content: requestDelta(rewritten, state.traced),
2017
+ leaks
2018
+ });
2019
+ return relayUpstream(res, await fetchUpstream(res, forwardTo, method, headers, JSON.stringify(rewritten), trace), route.rewriteResponse ? {
1516
2020
  wire: route.wire,
1517
2021
  ctx
1518
- } : void 0);
2022
+ } : void 0, trace);
1519
2023
  }
1520
- async function fetchUpstream(res, target, method, headers, body) {
2024
+ async function fetchUpstream(res, target, method, headers, body, trace) {
1521
2025
  try {
1522
2026
  return await fetch(target, {
1523
2027
  method,
@@ -1526,21 +2030,61 @@ async function createTransformProxy(options) {
1526
2030
  redirect: "manual"
1527
2031
  });
1528
2032
  } catch (err) {
2033
+ recordReply(trace, "error", 502, errorMessage(err));
1529
2034
  sendError(res, 502, "upstream_unreachable", errorMessage(err));
1530
2035
  return;
1531
2036
  }
1532
2037
  }
1533
- /** Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. */
1534
- async function relayUpstream(res, upstreamRes, rewrite) {
2038
+ /**
2039
+ * Relays an upstream response. With `rewrite`, a 2xx JSON or SSE body is restored on the way back. With `trace`
2040
+ * (wire routes only), the reply before restore is recorded as exactly one response or error line.
2041
+ */
2042
+ async function relayUpstream(res, upstreamRes, rewrite, trace) {
1535
2043
  if (upstreamRes === void 0) return;
1536
2044
  const contentType = (upstreamRes.headers.get("content-type") ?? "").toLowerCase();
1537
2045
  const headers = relayHeaders(upstreamRes.headers);
1538
- if (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
1539
- if (rewrite !== void 0 && upstreamRes.ok && contentType.includes("application/json")) {
1540
- const text = await upstreamRes.text();
2046
+ if (trace !== void 0 && !upstreamRes.ok) {
2047
+ const bytes = Buffer.from(await upstreamRes.arrayBuffer());
2048
+ recordReply(trace, "error", upstreamRes.status, bytes.toString("utf8").slice(0, ERROR_TRACE_CHARS));
2049
+ res.writeHead(upstreamRes.status, headers);
2050
+ res.end(bytes);
2051
+ return;
2052
+ }
2053
+ if (rewrite !== void 0 && upstreamRes.ok && contentType.startsWith("text/event-stream")) {
2054
+ if (trace === void 0) return pipeStream(res, upstreamRes, headers, rewrite.wire.stream(rewrite.ctx), log);
2055
+ let observed = false;
2056
+ const observe = (message) => {
2057
+ observed = true;
2058
+ recordReply(trace, "response", upstreamRes.status, message);
2059
+ };
2060
+ await pipeStream(res, upstreamRes, headers, rewrite.wire.stream({
2061
+ ...rewrite.ctx,
2062
+ observe
2063
+ }), log);
2064
+ if (!observed) recordReply(trace, "response", upstreamRes.status, void 0);
2065
+ return;
2066
+ }
2067
+ if (upstreamRes.ok && contentType.includes("application/json") && (rewrite !== void 0 || trace !== void 0)) {
2068
+ const bytes = Buffer.from(await upstreamRes.arrayBuffer());
2069
+ let body;
2070
+ try {
2071
+ body = JSON.parse(new TextDecoder().decode(bytes));
2072
+ } catch (err) {
2073
+ recordReply(trace, "error", 502, errorMessage(err));
2074
+ if (rewrite !== void 0) return sendError(res, 502, "transform_failed", errorMessage(err));
2075
+ res.writeHead(upstreamRes.status, headers);
2076
+ res.end(bytes);
2077
+ return;
2078
+ }
2079
+ recordReply(trace, "response", upstreamRes.status, body);
2080
+ if (rewrite === void 0) {
2081
+ res.writeHead(upstreamRes.status, headers);
2082
+ res.end(bytes);
2083
+ return;
2084
+ }
1541
2085
  let restored;
1542
2086
  try {
1543
- restored = JSON.stringify(rewrite.wire.response(JSON.parse(text), rewrite.ctx));
2087
+ restored = JSON.stringify(rewrite.wire.response(body, rewrite.ctx));
1544
2088
  } catch (err) {
1545
2089
  return sendError(res, 502, "transform_failed", errorMessage(err));
1546
2090
  }
@@ -1548,8 +2092,10 @@ async function createTransformProxy(options) {
1548
2092
  res.end(restored);
1549
2093
  return;
1550
2094
  }
2095
+ const bytes = Buffer.from(await upstreamRes.arrayBuffer());
2096
+ recordReply(trace, "response", upstreamRes.status, void 0);
1551
2097
  res.writeHead(upstreamRes.status, headers);
1552
- res.end(Buffer.from(await upstreamRes.arrayBuffer()));
2098
+ res.end(bytes);
1553
2099
  }
1554
2100
  const server = createServer((req, res) => {
1555
2101
  handle(req, res).catch((err) => {
@@ -1613,6 +2159,12 @@ function validatePipelines(options) {
1613
2159
  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}"`);
1614
2160
  if (options.defaultPipeline !== void 0 && !Object.hasOwn(options.pipelines, options.defaultPipeline)) throw new Error(`defaultPipeline "${options.defaultPipeline}" is not a configured pipeline`);
1615
2161
  }
2162
+ /** `{ model }` when the request body names its model as a string, otherwise `{}`. */
2163
+ function modelOf(body) {
2164
+ if (typeof body !== "object" || body === null) return {};
2165
+ const model = body.model;
2166
+ return typeof model === "string" ? { model } : {};
2167
+ }
1616
2168
  function originOf(value) {
1617
2169
  const url = parseHttpUrl(value);
1618
2170
  if (url === void 0) throw new Error(`allowUpstreams entry "${value}" is not an http(s) URL`);
@@ -1714,93 +2266,4 @@ function sendError(res, status, type, message) {
1714
2266
  message
1715
2267
  } }));
1716
2268
  }
1717
- // src/config.ts
1718
- const CONFIG_FILE_NAME = "transform-proxy.yaml";
1719
- /** The implicit lookup locations, in order: `./.skaile/` then `~/.config/skaile/`. */
1720
- function configCandidates(cwd, home) {
1721
- return [join(cwd, ".skaile", CONFIG_FILE_NAME), join(home, ".config", "skaile", CONFIG_FILE_NAME)];
1722
- }
1723
- /**
1724
- * Finds the config file: the explicit path when given (it must exist), otherwise the first
1725
- * existing implicit candidate. Returns `undefined` when no implicit candidate exists.
1726
- */
1727
- function findProxyConfig(lookup) {
1728
- if (lookup.explicit !== void 0) {
1729
- const path = resolve(lookup.cwd, lookup.explicit);
1730
- if (!existsSync(path)) throw new Error(`transform proxy config not found: ${path}`);
1731
- return path;
1732
- }
1733
- return configCandidates(lookup.cwd, lookup.home).find((candidate) => existsSync(candidate));
1734
- }
1735
- /** Finds and loads the config. Throws, naming the searched paths, when none is found. */
1736
- function resolveProxyConfig(lookup) {
1737
- const path = findProxyConfig(lookup);
1738
- if (path === void 0) {
1739
- const searched = configCandidates(lookup.cwd, lookup.home).join(", ");
1740
- throw new Error(`no transform proxy config found (pass --config, or create one at ${searched})`);
1741
- }
1742
- return {
1743
- path,
1744
- options: loadProxyConfig(path)
1745
- };
1746
- }
1747
- const TOP_LEVEL_KEYS = {
1748
- transforms: true,
1749
- pipelines: true,
1750
- defaultPipeline: true,
1751
- allowUpstreams: true
1752
- };
1753
- /** Reads and validates a YAML config file. Unknown keys are rejected so that typos fail loudly. */
1754
- function loadProxyConfig(path) {
1755
- const source = parse(readFileSync(path, "utf8"));
1756
- try {
1757
- return parseProxyConfig(source ?? {}, dirname(path));
1758
- } catch (err) {
1759
- throw new Error(`invalid transform proxy config ${path}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1760
- }
1761
- }
1762
- /** Validates an already-parsed config object. Exported for hosts that load YAML themselves. */
1763
- function parseProxyConfig(source, baseDir) {
1764
- if (!isRecord(source)) throw new Error("the document must be a mapping");
1765
- for (const key of Object.keys(source)) if (!Object.hasOwn(TOP_LEVEL_KEYS, key)) throw new Error(`unknown key "${key}"`);
1766
- return {
1767
- baseDir,
1768
- transforms: parseTransforms(source.transforms),
1769
- pipelines: parsePipelines(source.pipelines),
1770
- ...source.defaultPipeline === void 0 ? {} : { defaultPipeline: requireString(source.defaultPipeline, "defaultPipeline") },
1771
- ...source.allowUpstreams === void 0 ? {} : { allowUpstreams: requireStringList(source.allowUpstreams, "allowUpstreams") }
1772
- };
1773
- }
1774
- function parseTransforms(value) {
1775
- if (value === void 0) return {};
1776
- if (!isRecord(value)) throw new Error("transforms must be a mapping of transform id to { module, config }");
1777
- const out = {};
1778
- for (const [id, spec] of Object.entries(value)) {
1779
- if (!isRecord(spec)) throw new Error(`transforms.${id} must be a mapping with a module`);
1780
- const module = requireString(spec.module, `transforms.${id}.module`);
1781
- out[id] = spec.config === void 0 ? { module } : {
1782
- module,
1783
- config: spec.config
1784
- };
1785
- }
1786
- return out;
1787
- }
1788
- function parsePipelines(value) {
1789
- if (value === void 0) return {};
1790
- if (!isRecord(value)) throw new Error("pipelines must be a mapping of pipeline id to a list of transform ids");
1791
- const out = {};
1792
- for (const [id, stages] of Object.entries(value)) out[id] = requireStringList(stages, `pipelines.${id}`);
1793
- return out;
1794
- }
1795
- function requireString(value, field) {
1796
- if (typeof value !== "string" || value === "") throw new Error(`${field} must be a non-empty string`);
1797
- return value;
1798
- }
1799
- function requireStringList(value, field) {
1800
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item === "")) throw new Error(`${field} must be a list of non-empty strings`);
1801
- return value;
1802
- }
1803
- function isRecord(value) {
1804
- return typeof value === "object" && value !== null && !Array.isArray(value);
1805
- }
1806
- 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, loadProxyConfig as _, createTransformProxy as a, composeTransforms as b, serializeTraceEvent as c, anthropicWalker as d, UnsupportedContentError as f, findProxyConfig as g, configCandidates as h, UPSTREAM_HEADER as i, openaiResponsesWalker as l, CONFIG_FILE_NAME as m, SESSION_HEADER as n, DEFAULT_TRACE_MAX_CHARS as o, createSessionFacts as p, TOKEN_HEADER as r, createFileTraceSink as s, PIPELINE_HEADER as t, openaiChatWalker as u, parseProxyConfig as v, errorMessage as x, resolveProxyConfig as y };