@intx/inference-discovery 0.1.2
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/README.md +110 -0
- package/media/sample.jpg +0 -0
- package/media/sample.mp4 +0 -0
- package/media/sample.pdf +0 -0
- package/media/sample.wav +0 -0
- package/package.json +19 -0
- package/src/catalog/capability.test.ts +43 -0
- package/src/catalog/capability.ts +38 -0
- package/src/catalog/index.ts +10 -0
- package/src/catalog/intent.test.ts +94 -0
- package/src/catalog/intent.ts +297 -0
- package/src/catalog/manifest.test.ts +72 -0
- package/src/catalog/manifest.ts +12 -0
- package/src/catalog/support-matrix.test.ts +79 -0
- package/src/catalog/support-matrix.ts +344 -0
- package/src/ci-guard.test.ts +36 -0
- package/src/ci-guard.ts +9 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +133 -0
- package/src/content-type.test.ts +45 -0
- package/src/content-type.ts +20 -0
- package/src/env.test.ts +31 -0
- package/src/env.ts +36 -0
- package/src/index.ts +24 -0
- package/src/manifest.test.ts +56 -0
- package/src/manifest.ts +29 -0
- package/src/plugin.ts +70 -0
- package/src/runner.test.ts +508 -0
- package/src/runner.ts +242 -0
- package/src/write-capture.test.ts +168 -0
- package/src/write-capture.ts +83 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import type { Capability, CapabilityIntent } from "./catalog";
|
|
4
|
+
import { detectResponseKind } from "./content-type";
|
|
5
|
+
import { buildManifest } from "./manifest";
|
|
6
|
+
import type { CaptureStep, CapturedResponse, ProviderPlugin } from "./plugin";
|
|
7
|
+
import {
|
|
8
|
+
writeCapture,
|
|
9
|
+
type RequestBody,
|
|
10
|
+
type ResponseBody,
|
|
11
|
+
type WriteCaptureInput,
|
|
12
|
+
} from "./write-capture";
|
|
13
|
+
|
|
14
|
+
export type FetchLike = (
|
|
15
|
+
input: string,
|
|
16
|
+
init: {
|
|
17
|
+
method: string;
|
|
18
|
+
headers: Record<string, string>;
|
|
19
|
+
body: string | Uint8Array;
|
|
20
|
+
},
|
|
21
|
+
) => Promise<Response>;
|
|
22
|
+
|
|
23
|
+
export interface RunCaptureOpts {
|
|
24
|
+
plugin: ProviderPlugin;
|
|
25
|
+
model: string;
|
|
26
|
+
capability: Capability;
|
|
27
|
+
intent: CapabilityIntent;
|
|
28
|
+
outDir: string;
|
|
29
|
+
now?: () => Date;
|
|
30
|
+
fetch?: FetchLike;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const REASONING_TRACE_CAPABILITY_PREFIXES = [
|
|
34
|
+
"reasoning-content",
|
|
35
|
+
"redacted-thinking",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
function shouldEmitReasoningTrace(capability: Capability): boolean {
|
|
39
|
+
for (const prefix of REASONING_TRACE_CAPABILITY_PREFIXES) {
|
|
40
|
+
if (capability.startsWith(prefix)) return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function headersToObject(headers: Headers): Record<string, string> {
|
|
46
|
+
const out: Record<string, string> = {};
|
|
47
|
+
headers.forEach((value, key) => {
|
|
48
|
+
out[key] = value;
|
|
49
|
+
});
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const defaultFetch: FetchLike = (input, init) =>
|
|
54
|
+
fetch(input, {
|
|
55
|
+
method: init.method,
|
|
56
|
+
headers: init.headers,
|
|
57
|
+
body: init.body,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function mergeHeaders(
|
|
61
|
+
defaults: Record<string, string>,
|
|
62
|
+
stepHeaders: Record<string, string>,
|
|
63
|
+
authHeaders: Record<string, string>,
|
|
64
|
+
): Record<string, string> {
|
|
65
|
+
const authKeys = new Set(
|
|
66
|
+
Object.keys(authHeaders).map((k) => k.toLowerCase()),
|
|
67
|
+
);
|
|
68
|
+
for (const key of Object.keys(stepHeaders)) {
|
|
69
|
+
if (authKeys.has(key.toLowerCase())) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`capture step attempted to override plug-in auth header '${key}'; ` +
|
|
72
|
+
`auth headers are plug-in-wide and cannot be overridden per step`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Default → step overrides default → auth wins over everything.
|
|
77
|
+
return { ...defaults, ...stepHeaders, ...authHeaders };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildRequestForStep(
|
|
81
|
+
step: CaptureStep,
|
|
82
|
+
authHeaders: Record<string, string>,
|
|
83
|
+
): {
|
|
84
|
+
method: string;
|
|
85
|
+
headers: Record<string, string>;
|
|
86
|
+
body: string | Uint8Array;
|
|
87
|
+
request: RequestBody;
|
|
88
|
+
} {
|
|
89
|
+
const method = step.method ?? "POST";
|
|
90
|
+
const stepHeaders = step.headers ?? {};
|
|
91
|
+
if (step.kind === "raw") {
|
|
92
|
+
const headers = mergeHeaders(
|
|
93
|
+
{ "Content-Type": step.contentType },
|
|
94
|
+
stepHeaders,
|
|
95
|
+
authHeaders,
|
|
96
|
+
);
|
|
97
|
+
return {
|
|
98
|
+
method,
|
|
99
|
+
headers,
|
|
100
|
+
body: step.body,
|
|
101
|
+
request: { kind: "raw", bytes: step.body, contentType: step.contentType },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const headers = mergeHeaders(
|
|
105
|
+
{ "Content-Type": "application/json" },
|
|
106
|
+
stepHeaders,
|
|
107
|
+
authHeaders,
|
|
108
|
+
);
|
|
109
|
+
return {
|
|
110
|
+
method,
|
|
111
|
+
headers,
|
|
112
|
+
body: JSON.stringify(step.body),
|
|
113
|
+
request: { kind: "json", body: step.body },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function captureStep(args: {
|
|
118
|
+
step: CaptureStep;
|
|
119
|
+
outDir: string;
|
|
120
|
+
plugin: ProviderPlugin;
|
|
121
|
+
capability: Capability;
|
|
122
|
+
doFetch: FetchLike;
|
|
123
|
+
}): Promise<CapturedResponse> {
|
|
124
|
+
const { step, outDir, plugin, capability, doFetch } = args;
|
|
125
|
+
|
|
126
|
+
const stepDir =
|
|
127
|
+
step.subdir === null ? outDir : path.join(outDir, step.subdir);
|
|
128
|
+
|
|
129
|
+
const authHeaders = plugin.buildAuthHeaders();
|
|
130
|
+
const {
|
|
131
|
+
method,
|
|
132
|
+
headers: requestHeaders,
|
|
133
|
+
body,
|
|
134
|
+
request,
|
|
135
|
+
} = buildRequestForStep(step, authHeaders);
|
|
136
|
+
|
|
137
|
+
const response = await doFetch(step.url, {
|
|
138
|
+
method,
|
|
139
|
+
headers: requestHeaders,
|
|
140
|
+
body,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const responseHeaders = headersToObject(response.headers);
|
|
144
|
+
const kind = detectResponseKind(response.headers);
|
|
145
|
+
|
|
146
|
+
let captured: ResponseBody;
|
|
147
|
+
let parsedForGenerator: unknown | null;
|
|
148
|
+
let bytesForGenerator: Uint8Array | null;
|
|
149
|
+
// Read the body as raw bytes regardless of kind so the capture
|
|
150
|
+
// can write them verbatim. For JSON responses we also parse for
|
|
151
|
+
// the generator's reasoning-trace extraction path, but the bytes
|
|
152
|
+
// we write to disk are the original network bytes (not a
|
|
153
|
+
// re-serialised pretty-print of the parsed value).
|
|
154
|
+
const buf = await response.arrayBuffer();
|
|
155
|
+
const bytes = new Uint8Array(buf);
|
|
156
|
+
if (kind === "sse") {
|
|
157
|
+
captured = { kind: "sse", bytes };
|
|
158
|
+
parsedForGenerator = null;
|
|
159
|
+
bytesForGenerator = bytes;
|
|
160
|
+
} else {
|
|
161
|
+
const text = new TextDecoder().decode(bytes);
|
|
162
|
+
const parsed: unknown = JSON.parse(text);
|
|
163
|
+
captured = { kind: "json", bytes, parsed };
|
|
164
|
+
parsedForGenerator = parsed;
|
|
165
|
+
bytesForGenerator = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const captureInput: WriteCaptureInput = {
|
|
169
|
+
request,
|
|
170
|
+
requestHeaders,
|
|
171
|
+
redactRequestHeaders: plugin.redactRequestHeaders,
|
|
172
|
+
response: captured,
|
|
173
|
+
responseHeaders,
|
|
174
|
+
redactResponseHeaders: plugin.redactResponseHeaders,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
await writeCapture(stepDir, captureInput);
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
plugin.extractReasoningTrace !== undefined &&
|
|
181
|
+
shouldEmitReasoningTrace(capability) &&
|
|
182
|
+
parsedForGenerator !== null
|
|
183
|
+
) {
|
|
184
|
+
const trace = plugin.extractReasoningTrace(parsedForGenerator);
|
|
185
|
+
if (trace !== null) {
|
|
186
|
+
await fs.writeFile(
|
|
187
|
+
path.join(stepDir, "reasoning-trace.json"),
|
|
188
|
+
`${JSON.stringify(trace, null, 2)}\n`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
status: response.status,
|
|
195
|
+
headers: responseHeaders,
|
|
196
|
+
parsed: parsedForGenerator,
|
|
197
|
+
bytes: bytesForGenerator,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function runCapture(opts: RunCaptureOpts): Promise<void> {
|
|
202
|
+
const { plugin, model, capability, intent, outDir } = opts;
|
|
203
|
+
const doFetch = opts.fetch ?? defaultFetch;
|
|
204
|
+
|
|
205
|
+
const iterator = plugin.iterateCaptureSteps({ model, capability, intent });
|
|
206
|
+
|
|
207
|
+
let stepsExecuted = 0;
|
|
208
|
+
let iterResult = iterator.next();
|
|
209
|
+
while (!iterResult.done) {
|
|
210
|
+
const captured = await captureStep({
|
|
211
|
+
step: iterResult.value,
|
|
212
|
+
outDir,
|
|
213
|
+
plugin,
|
|
214
|
+
capability,
|
|
215
|
+
doFetch,
|
|
216
|
+
});
|
|
217
|
+
stepsExecuted += 1;
|
|
218
|
+
iterResult = iterator.next(captured);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (stepsExecuted === 0) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`plug-in ${plugin.name} produced no capture steps for ${model}/${capability}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const manifestOpts: Parameters<typeof buildManifest>[0] = {
|
|
228
|
+
provider: plugin.name,
|
|
229
|
+
model,
|
|
230
|
+
capability,
|
|
231
|
+
};
|
|
232
|
+
if (opts.now !== undefined) {
|
|
233
|
+
manifestOpts.now = opts.now;
|
|
234
|
+
}
|
|
235
|
+
const manifest = buildManifest(manifestOpts);
|
|
236
|
+
|
|
237
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
238
|
+
await fs.writeFile(
|
|
239
|
+
path.join(outDir, "manifest.json"),
|
|
240
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { writeCapture } from "./write-capture";
|
|
6
|
+
|
|
7
|
+
async function makeTempDir(): Promise<string> {
|
|
8
|
+
return await fs.mkdtemp(path.join(os.tmpdir(), "write-capture-test-"));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("writeCapture", () => {
|
|
12
|
+
let dir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
dir = await makeTempDir();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("writes JSON request body and response/metadata files", async () => {
|
|
23
|
+
// Use a non-default key order and trailing whitespace in the
|
|
24
|
+
// response bytes to prove writeCapture preserves the body
|
|
25
|
+
// byte-identical rather than re-formatting it via JSON.stringify.
|
|
26
|
+
const originalResponseText = `{"text":"hello","seq":1} \n`;
|
|
27
|
+
const originalResponseBytes = new TextEncoder().encode(
|
|
28
|
+
originalResponseText,
|
|
29
|
+
);
|
|
30
|
+
await writeCapture(dir, {
|
|
31
|
+
request: {
|
|
32
|
+
kind: "json",
|
|
33
|
+
body: { messages: [{ role: "user", content: "hi" }] },
|
|
34
|
+
},
|
|
35
|
+
requestHeaders: {
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
"X-Goog-Api-Key": "secret",
|
|
38
|
+
},
|
|
39
|
+
redactRequestHeaders: ["x-goog-api-key"],
|
|
40
|
+
response: {
|
|
41
|
+
kind: "json",
|
|
42
|
+
bytes: originalResponseBytes,
|
|
43
|
+
parsed: { text: "hello", seq: 1 },
|
|
44
|
+
},
|
|
45
|
+
responseHeaders: { "Content-Type": "application/json" },
|
|
46
|
+
redactResponseHeaders: [],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const entries = (await fs.readdir(dir)).sort();
|
|
50
|
+
expect(entries).toEqual([
|
|
51
|
+
"request-headers.json",
|
|
52
|
+
"request.json",
|
|
53
|
+
"response-headers.json",
|
|
54
|
+
"response.json",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const requestHeaders = JSON.parse(
|
|
58
|
+
await fs.readFile(path.join(dir, "request-headers.json"), "utf8"),
|
|
59
|
+
);
|
|
60
|
+
expect(requestHeaders["X-Goog-Api-Key"]).toBe("<REDACTED>");
|
|
61
|
+
expect(requestHeaders["Content-Type"]).toBe("application/json");
|
|
62
|
+
|
|
63
|
+
const writtenResponse = await fs.readFile(path.join(dir, "response.json"));
|
|
64
|
+
expect(Array.from(writtenResponse)).toEqual(
|
|
65
|
+
Array.from(originalResponseBytes),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("writes a raw request body to request.bin verbatim", async () => {
|
|
70
|
+
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e]);
|
|
71
|
+
await writeCapture(dir, {
|
|
72
|
+
request: {
|
|
73
|
+
kind: "raw",
|
|
74
|
+
bytes,
|
|
75
|
+
contentType: "application/pdf",
|
|
76
|
+
},
|
|
77
|
+
requestHeaders: {
|
|
78
|
+
"Content-Type": "application/pdf",
|
|
79
|
+
"X-Goog-Api-Key": "secret",
|
|
80
|
+
},
|
|
81
|
+
redactRequestHeaders: ["x-goog-api-key"],
|
|
82
|
+
response: {
|
|
83
|
+
kind: "json",
|
|
84
|
+
bytes: new TextEncoder().encode('{"fileId":"abc"}'),
|
|
85
|
+
parsed: { fileId: "abc" },
|
|
86
|
+
},
|
|
87
|
+
responseHeaders: { "Content-Type": "application/json" },
|
|
88
|
+
redactResponseHeaders: [],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const entries = (await fs.readdir(dir)).sort();
|
|
92
|
+
expect(entries).toEqual([
|
|
93
|
+
"request-headers.json",
|
|
94
|
+
"request.bin",
|
|
95
|
+
"response-headers.json",
|
|
96
|
+
"response.json",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const written = await fs.readFile(path.join(dir, "request.bin"));
|
|
100
|
+
expect(Array.from(written)).toEqual(Array.from(bytes));
|
|
101
|
+
|
|
102
|
+
const requestHeaders = JSON.parse(
|
|
103
|
+
await fs.readFile(path.join(dir, "request-headers.json"), "utf8"),
|
|
104
|
+
);
|
|
105
|
+
expect(requestHeaders["X-Goog-Api-Key"]).toBe("<REDACTED>");
|
|
106
|
+
expect(requestHeaders["Content-Type"]).toBe("application/pdf");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("writes SSE response as response.sse and not response.json", async () => {
|
|
110
|
+
const bytes = new TextEncoder().encode("data: hello\n\n");
|
|
111
|
+
await writeCapture(dir, {
|
|
112
|
+
request: { kind: "json", body: {} },
|
|
113
|
+
requestHeaders: {},
|
|
114
|
+
redactRequestHeaders: [],
|
|
115
|
+
response: { kind: "sse", bytes },
|
|
116
|
+
responseHeaders: { "Content-Type": "text/event-stream" },
|
|
117
|
+
redactResponseHeaders: [],
|
|
118
|
+
});
|
|
119
|
+
const entries = (await fs.readdir(dir)).sort();
|
|
120
|
+
expect(entries).toContain("response.sse");
|
|
121
|
+
expect(entries).not.toContain("response.json");
|
|
122
|
+
const written = await fs.readFile(path.join(dir, "response.sse"));
|
|
123
|
+
expect(new TextDecoder().decode(written)).toBe("data: hello\n\n");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("redaction is case-insensitive on header names", async () => {
|
|
127
|
+
await writeCapture(dir, {
|
|
128
|
+
request: { kind: "json", body: {} },
|
|
129
|
+
requestHeaders: { Authorization: "Bearer xyz" },
|
|
130
|
+
redactRequestHeaders: ["AUTHORIZATION"],
|
|
131
|
+
response: {
|
|
132
|
+
kind: "json",
|
|
133
|
+
bytes: new TextEncoder().encode("{}"),
|
|
134
|
+
parsed: {},
|
|
135
|
+
},
|
|
136
|
+
responseHeaders: { "Set-Cookie": "abc=1" },
|
|
137
|
+
redactResponseHeaders: ["set-cookie"],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const requestHeaders = JSON.parse(
|
|
141
|
+
await fs.readFile(path.join(dir, "request-headers.json"), "utf8"),
|
|
142
|
+
);
|
|
143
|
+
expect(requestHeaders.Authorization).toBe("<REDACTED>");
|
|
144
|
+
|
|
145
|
+
const responseHeaders = JSON.parse(
|
|
146
|
+
await fs.readFile(path.join(dir, "response-headers.json"), "utf8"),
|
|
147
|
+
);
|
|
148
|
+
expect(responseHeaders["Set-Cookie"]).toBe("<REDACTED>");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("creates target directory if it does not exist", async () => {
|
|
152
|
+
const nested = path.join(dir, "a", "b", "c");
|
|
153
|
+
await writeCapture(nested, {
|
|
154
|
+
request: { kind: "json", body: {} },
|
|
155
|
+
requestHeaders: {},
|
|
156
|
+
redactRequestHeaders: [],
|
|
157
|
+
response: {
|
|
158
|
+
kind: "json",
|
|
159
|
+
bytes: new TextEncoder().encode("{}"),
|
|
160
|
+
parsed: {},
|
|
161
|
+
},
|
|
162
|
+
responseHeaders: {},
|
|
163
|
+
redactResponseHeaders: [],
|
|
164
|
+
});
|
|
165
|
+
const entries = await fs.readdir(nested);
|
|
166
|
+
expect(entries).toContain("request.json");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Captured response body. The on-disk file (response.json or
|
|
6
|
+
* response.sse) is written from `bytes` verbatim so the recording
|
|
7
|
+
* is byte-identical to what the server sent — pretty-printing
|
|
8
|
+
* a parsed JSON body would lose original key order, trailing
|
|
9
|
+
* whitespace, and any content-length / signature semantics. The
|
|
10
|
+
* optional `parsed` field is kept for the discovery rig's
|
|
11
|
+
* extractReasoningTrace path, which needs the JSON-decoded value
|
|
12
|
+
* to pull provider-specific metadata; writeCapture itself never
|
|
13
|
+
* reads it.
|
|
14
|
+
*/
|
|
15
|
+
export type ResponseBody =
|
|
16
|
+
| { kind: "json"; bytes: Uint8Array; parsed: unknown }
|
|
17
|
+
| { kind: "sse"; bytes: Uint8Array };
|
|
18
|
+
|
|
19
|
+
export type RequestBody =
|
|
20
|
+
| { kind: "json"; body: unknown }
|
|
21
|
+
| { kind: "raw"; bytes: Uint8Array; contentType: string };
|
|
22
|
+
|
|
23
|
+
export interface WriteCaptureInput {
|
|
24
|
+
request: RequestBody;
|
|
25
|
+
requestHeaders: Record<string, string>;
|
|
26
|
+
redactRequestHeaders: readonly string[];
|
|
27
|
+
response: ResponseBody;
|
|
28
|
+
responseHeaders: Record<string, string>;
|
|
29
|
+
redactResponseHeaders: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const REDACTED = "<REDACTED>";
|
|
33
|
+
|
|
34
|
+
function applyRedaction(
|
|
35
|
+
headers: Record<string, string>,
|
|
36
|
+
redactNames: readonly string[],
|
|
37
|
+
): Record<string, string> {
|
|
38
|
+
const lowered = new Set(redactNames.map((name) => name.toLowerCase()));
|
|
39
|
+
const result: Record<string, string> = {};
|
|
40
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
41
|
+
result[key] = lowered.has(key.toLowerCase()) ? REDACTED : value;
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function writeCapture(
|
|
47
|
+
dir: string,
|
|
48
|
+
input: WriteCaptureInput,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
await fs.mkdir(dir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
const redactedRequest = applyRedaction(
|
|
53
|
+
input.requestHeaders,
|
|
54
|
+
input.redactRequestHeaders,
|
|
55
|
+
);
|
|
56
|
+
const redactedResponse = applyRedaction(
|
|
57
|
+
input.responseHeaders,
|
|
58
|
+
input.redactResponseHeaders,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (input.request.kind === "json") {
|
|
62
|
+
await fs.writeFile(
|
|
63
|
+
path.join(dir, "request.json"),
|
|
64
|
+
`${JSON.stringify(input.request.body, null, 2)}\n`,
|
|
65
|
+
);
|
|
66
|
+
} else {
|
|
67
|
+
await fs.writeFile(path.join(dir, "request.bin"), input.request.bytes);
|
|
68
|
+
}
|
|
69
|
+
await fs.writeFile(
|
|
70
|
+
path.join(dir, "request-headers.json"),
|
|
71
|
+
`${JSON.stringify(redactedRequest, null, 2)}\n`,
|
|
72
|
+
);
|
|
73
|
+
await fs.writeFile(
|
|
74
|
+
path.join(dir, "response-headers.json"),
|
|
75
|
+
`${JSON.stringify(redactedResponse, null, 2)}\n`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (input.response.kind === "json") {
|
|
79
|
+
await fs.writeFile(path.join(dir, "response.json"), input.response.bytes);
|
|
80
|
+
} else {
|
|
81
|
+
await fs.writeFile(path.join(dir, "response.sse"), input.response.bytes);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/tsconfig.json
ADDED