@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.
- package/CHANGELOG.md +21 -0
- package/README.md +74 -5
- package/dist/cli.d.mts +8 -1
- package/dist/cli.mjs +41 -28
- package/dist/index.d.mts +34 -10
- package/dist/index.mjs +1 -1
- package/dist/omp/index.mjs +81 -8
- package/dist/{config-Ceb5AF_a.mjs → server-BR1dDA8S.mjs} +580 -117
- package/dist/{server-CicvlQ7q.d.mts → server-gwJGHF82.d.mts} +57 -2
- package/package.json +9 -8
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
373
|
+
return isRecord(value) && typeof value.id === "string" && typeof value.open === "function";
|
|
177
374
|
}
|
|
178
|
-
function isRecord
|
|
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
|
-
|
|
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":
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
1534
|
-
|
|
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 (
|
|
1539
|
-
|
|
1540
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 };
|