@bakers-build/addon-claude 0.0.0-stage → 0.1.0-next.42
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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/src/footprint.d.ts +22 -0
- package/dist/src/footprint.js +152 -0
- package/dist/src/index.d.ts +44 -0
- package/dist/src/index.js +217 -0
- package/dist/src/preset.d.ts +4 -0
- package/dist/src/preset.js +33 -0
- package/measure/agent-proxy.mjs +686 -0
- package/measure/claude-measure.mjs +113 -0
- package/measure/keytec-coding.json +36 -0
- package/measure/keytec-ops.json +30 -0
- package/measure/keytec-plan.json +32 -0
- package/measure/keytec-review.json +32 -0
- package/package.json +27 -4
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-proxy — see what Claude Code actually sends the model.
|
|
3
|
+
*
|
|
4
|
+
* Based on Matt Pocock, https://gist.github.com/mattpocock/5b3d76ea21f5f698aefded47a9cea3b1
|
|
5
|
+
* revision e142f08fd2d1de0adc914355112d6f2b56386959 (updated 2026-09-23), fetched 2026-09-28.
|
|
6
|
+
*
|
|
7
|
+
* A zero-dependency logging proxy for Claude Code. It sits between the CLI and
|
|
8
|
+
* the Anthropic API, forwards every request untouched (auth header and all),
|
|
9
|
+
* streams the response straight back so the CLI is unaffected, and for each
|
|
10
|
+
* request writes a readable Markdown document — led by a ranked table of what
|
|
11
|
+
* is eating your context.
|
|
12
|
+
*
|
|
13
|
+
* Run: BB_TICKET_IDENTIFIER=BB-569 node measure/agent-proxy.mjs
|
|
14
|
+
* Point Claude Code at it:
|
|
15
|
+
* ANTHROPIC_BASE_URL=http://127.0.0.1:8787 claude
|
|
16
|
+
*
|
|
17
|
+
* Measurement only. Zero runtime dependencies — Node built-ins only. Requires Node 18+.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import http from 'node:http';
|
|
24
|
+
import https from 'node:https';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PORT = 8787;
|
|
29
|
+
const LISTEN_HOST = '127.0.0.1';
|
|
30
|
+
const UPSTREAM = {
|
|
31
|
+
hostname: 'api.anthropic.com',
|
|
32
|
+
port: 443,
|
|
33
|
+
protocol: 'https',
|
|
34
|
+
};
|
|
35
|
+
const TICKET_ID = /^[A-Z][A-Z0-9]*-\d+$/;
|
|
36
|
+
const USAGE_KEYS = [
|
|
37
|
+
'input_tokens',
|
|
38
|
+
'cache_read_input_tokens',
|
|
39
|
+
'cache_creation_input_tokens',
|
|
40
|
+
];
|
|
41
|
+
const REDACT = new Set(['authorization', 'x-api-key', 'api-key']);
|
|
42
|
+
|
|
43
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
|
|
45
|
+
/** Rough token estimate for display. Real input tokens come from the response
|
|
46
|
+
* usage; this is only for ranking the request before the reply arrives. */
|
|
47
|
+
const estTokens = (bytes) => Math.round(bytes / 4);
|
|
48
|
+
|
|
49
|
+
/** count_tokens calls send content but get back only a number, never a reply.
|
|
50
|
+
* A single turn fires many as housekeeping — pure noise here, so skip them. */
|
|
51
|
+
const isTokenCount = (reqPath) => reqPath.includes('count_tokens');
|
|
52
|
+
|
|
53
|
+
/** Strip hop-by-hop and encoding headers so the captured response is readable,
|
|
54
|
+
* recompute content-length, and pass auth through untouched so the real request
|
|
55
|
+
* still authenticates. */
|
|
56
|
+
function forwardHeaders(headers, body) {
|
|
57
|
+
const out = { ...headers };
|
|
58
|
+
delete out.host;
|
|
59
|
+
delete out.connection;
|
|
60
|
+
delete out['accept-encoding']; // force identity so we can read the stream
|
|
61
|
+
delete out['transfer-encoding'];
|
|
62
|
+
delete out['content-length'];
|
|
63
|
+
if (body.length > 0) out['content-length'] = String(body.length);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createLogBasename() {
|
|
68
|
+
const stamp = new Date()
|
|
69
|
+
.toISOString()
|
|
70
|
+
.replace(/:/g, '-')
|
|
71
|
+
.replace('.', '-')
|
|
72
|
+
.replace('Z', '');
|
|
73
|
+
return `${stamp}_${randomUUID()}_anthropic`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveLogDirectory({
|
|
77
|
+
cwd = process.cwd(),
|
|
78
|
+
ticketIdentifier,
|
|
79
|
+
subdir,
|
|
80
|
+
} = {}) {
|
|
81
|
+
const id = ticketIdentifier ?? process.env.BB_TICKET_IDENTIFIER;
|
|
82
|
+
if (typeof id !== 'string' || !TICKET_ID.test(id)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
'[agent-proxy] BB_TICKET_IDENTIFIER is required (ticket identifier such as BB-569, not a UUID)',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const root = path.resolve(cwd, 'assets-ai', id, 'node');
|
|
88
|
+
if (subdir == null || subdir === '') return root;
|
|
89
|
+
if (path.isAbsolute(subdir)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
'[agent-proxy] log subdir must be a relative path within the ticket node directory',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const resolved = path.resolve(root, subdir);
|
|
95
|
+
const rel = path.relative(root, resolved);
|
|
96
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
'[agent-proxy] log subdir must stay within the ticket node directory',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return resolved;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parsePort(value, { allowEphemeral = false } = {}) {
|
|
105
|
+
const port = typeof value === 'number' ? value : Number(value);
|
|
106
|
+
if (allowEphemeral && port === 0) return 0;
|
|
107
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
108
|
+
throw new Error(`[agent-proxy] invalid port: ${value}`);
|
|
109
|
+
}
|
|
110
|
+
return port;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function firstUserMessage(reqJson) {
|
|
114
|
+
if (!Array.isArray(reqJson?.messages)) return undefined;
|
|
115
|
+
return reqJson.messages.find((m) => m?.role === 'user');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function threeUsage(usage) {
|
|
119
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
120
|
+
const present = USAGE_KEYS.some(
|
|
121
|
+
(key) => Object.hasOwn(usage, key) && usage[key] != null,
|
|
122
|
+
);
|
|
123
|
+
if (!present) return null;
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const key of USAGE_KEYS) {
|
|
126
|
+
out[key] =
|
|
127
|
+
Object.hasOwn(usage, key) && usage[key] != null ? usage[key] : null;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mergeUsage(current, incoming) {
|
|
133
|
+
if (!incoming) return current;
|
|
134
|
+
const next = { ...(current ?? {}) };
|
|
135
|
+
for (const key of USAGE_KEYS) {
|
|
136
|
+
if (Object.hasOwn(incoming, key) && incoming[key] != null) {
|
|
137
|
+
next[key] = incoming[key];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return next;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// The audit: rank what's in the request
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/** Measure every removable region of the request and rank the tools by size.
|
|
148
|
+
* This is the whole point of the proxy — the numbers you cut against. */
|
|
149
|
+
export function auditRequest(reqJson, usage = null) {
|
|
150
|
+
const tools = Array.isArray(reqJson?.tools) ? reqJson.tools : [];
|
|
151
|
+
const toolRows = tools
|
|
152
|
+
.map((t) => {
|
|
153
|
+
const bytes = Buffer.byteLength(JSON.stringify(t));
|
|
154
|
+
return { name: t?.name ?? '(unnamed)', bytes, tokens: estTokens(bytes) };
|
|
155
|
+
})
|
|
156
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
157
|
+
|
|
158
|
+
const toolsBytes = toolRows.reduce((n, r) => n + r.bytes, 0);
|
|
159
|
+
const systemBytes = reqJson?.system
|
|
160
|
+
? Buffer.byteLength(JSON.stringify(reqJson.system))
|
|
161
|
+
: 0;
|
|
162
|
+
const firstUser = firstUserMessage(reqJson);
|
|
163
|
+
const firstUserBytes = firstUser
|
|
164
|
+
? Buffer.byteLength(JSON.stringify(firstUser))
|
|
165
|
+
: 0;
|
|
166
|
+
const totalBytes = Buffer.byteLength(JSON.stringify(reqJson ?? {}));
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
toolRows,
|
|
170
|
+
toolCount: toolRows.length,
|
|
171
|
+
toolsBytes,
|
|
172
|
+
systemBytes,
|
|
173
|
+
firstUserBytes,
|
|
174
|
+
totalBytes,
|
|
175
|
+
usage: threeUsage(usage),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The ranked table, as Markdown. The hero of the whole document. */
|
|
180
|
+
function renderAudit(a) {
|
|
181
|
+
const pct = (b) =>
|
|
182
|
+
a.totalBytes ? ((b / a.totalBytes) * 100).toFixed(1) : '0.0';
|
|
183
|
+
const rows = a.toolRows
|
|
184
|
+
.map(
|
|
185
|
+
(r) =>
|
|
186
|
+
`| ${r.name} | ${r.bytes.toLocaleString()} | ~${r.tokens.toLocaleString()} | ${pct(r.bytes)}% |`,
|
|
187
|
+
)
|
|
188
|
+
.join('\n');
|
|
189
|
+
const usageLines = a.usage
|
|
190
|
+
? [
|
|
191
|
+
`- **input_tokens**: ${a.usage.input_tokens}`,
|
|
192
|
+
`- **cache_read_input_tokens**: ${a.usage.cache_read_input_tokens}`,
|
|
193
|
+
`- **cache_creation_input_tokens**: ${a.usage.cache_creation_input_tokens}`,
|
|
194
|
+
]
|
|
195
|
+
: ['- **usage**: unknown'];
|
|
196
|
+
|
|
197
|
+
return [
|
|
198
|
+
'<audit>',
|
|
199
|
+
'',
|
|
200
|
+
...usageLines,
|
|
201
|
+
'',
|
|
202
|
+
`- **tools**: ${a.toolCount} definitions, ${a.toolsBytes.toLocaleString()} bytes (~${estTokens(a.toolsBytes).toLocaleString()} tokens)`,
|
|
203
|
+
`- **system prompt**: ${a.systemBytes.toLocaleString()} bytes (~${estTokens(a.systemBytes).toLocaleString()} tokens)`,
|
|
204
|
+
`- **first user message**: ${a.firstUserBytes.toLocaleString()} bytes (~${estTokens(a.firstUserBytes).toLocaleString()} tokens)`,
|
|
205
|
+
`- **total request**: ${a.totalBytes.toLocaleString()} bytes`,
|
|
206
|
+
'',
|
|
207
|
+
'**Tools, ranked by size — this is your cut list:**',
|
|
208
|
+
'',
|
|
209
|
+
'| tool | bytes | ~tokens | % of request |',
|
|
210
|
+
'| --- | --: | --: | --: |',
|
|
211
|
+
rows,
|
|
212
|
+
'',
|
|
213
|
+
'</audit>',
|
|
214
|
+
].join('\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The same ranking, compact, for the terminal — so you see the bloat live. */
|
|
218
|
+
function printAudit(a, base, logDir) {
|
|
219
|
+
const top = a.toolRows.slice(0, 12);
|
|
220
|
+
const w = Math.max(4, ...top.map((r) => r.name.length));
|
|
221
|
+
const usage = a.usage
|
|
222
|
+
? ` · in ${a.usage.input_tokens} / cache-read ${a.usage.cache_read_input_tokens} / cache-create ${a.usage.cache_creation_input_tokens}`
|
|
223
|
+
: '';
|
|
224
|
+
console.log(
|
|
225
|
+
`\n[agent-proxy] ${a.toolCount} tools · ${a.toolsBytes.toLocaleString()} tool bytes · first user ${a.firstUserBytes.toLocaleString()} B${usage}`,
|
|
226
|
+
);
|
|
227
|
+
for (const r of top) {
|
|
228
|
+
console.log(
|
|
229
|
+
` ${r.name.padEnd(w)} ${String(r.bytes).padStart(7)} B ~${r.tokens} tok`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (a.toolRows.length > top.length)
|
|
233
|
+
console.log(` … ${a.toolRows.length - top.length} more`);
|
|
234
|
+
console.log(` ${path.join(logDir, `${base}.md`)}\n`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Readable Markdown render (Anthropic /messages only)
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
const fenceJson = (v) => `\`\`\`json\n${JSON.stringify(v, null, 2)}\n\`\`\``;
|
|
242
|
+
const fence = (t, lang = '') => `\`\`\`${lang}\n${t}\n\`\`\``;
|
|
243
|
+
|
|
244
|
+
function blockText(b) {
|
|
245
|
+
if (typeof b === 'string') return b;
|
|
246
|
+
if (b?.type === 'text' && typeof b.text === 'string') return b.text;
|
|
247
|
+
return '';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function renderSystem(system) {
|
|
251
|
+
if (typeof system === 'string') return system;
|
|
252
|
+
if (Array.isArray(system)) {
|
|
253
|
+
return system
|
|
254
|
+
.map(
|
|
255
|
+
(b) =>
|
|
256
|
+
blockText(b) +
|
|
257
|
+
(b?.cache_control ? '\n\n<!-- cache_control breakpoint -->' : ''),
|
|
258
|
+
)
|
|
259
|
+
.join('\n\n');
|
|
260
|
+
}
|
|
261
|
+
return fenceJson(system);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function renderTools(tools) {
|
|
265
|
+
const rendered = tools.map((t) => {
|
|
266
|
+
const lines = [`### ${t.name ?? '(unnamed tool)'}`, ''];
|
|
267
|
+
if (t.description) lines.push(t.description, '');
|
|
268
|
+
if (t.input_schema) lines.push(fenceJson(t.input_schema));
|
|
269
|
+
return lines.join('\n');
|
|
270
|
+
});
|
|
271
|
+
return ['<tools>', '', rendered.join('\n\n'), '', '</tools>'].join('\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function imagePlaceholder(b) {
|
|
275
|
+
const src = b.source ?? {};
|
|
276
|
+
const bytes = typeof src.data === 'string' ? src.data.length : 0;
|
|
277
|
+
return `\`[image: ${src.media_type ?? 'unknown'}, ${bytes} base64 chars — full data in .request.txt]\``;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderContent(content) {
|
|
281
|
+
if (typeof content === 'string') return content;
|
|
282
|
+
if (!Array.isArray(content)) return fenceJson(content);
|
|
283
|
+
return content
|
|
284
|
+
.map((b) => {
|
|
285
|
+
switch (b?.type) {
|
|
286
|
+
case 'text':
|
|
287
|
+
return b.text ?? '';
|
|
288
|
+
case 'tool_use':
|
|
289
|
+
return [
|
|
290
|
+
`<tool-use name="${b.name}" id="${b.id ?? ''}">`,
|
|
291
|
+
'',
|
|
292
|
+
fenceJson(b.input ?? {}),
|
|
293
|
+
'',
|
|
294
|
+
'</tool-use>',
|
|
295
|
+
].join('\n');
|
|
296
|
+
case 'tool_result': {
|
|
297
|
+
const inner =
|
|
298
|
+
typeof b.content === 'string'
|
|
299
|
+
? b.content
|
|
300
|
+
: Array.isArray(b.content)
|
|
301
|
+
? b.content
|
|
302
|
+
.map((x) =>
|
|
303
|
+
x?.type === 'image'
|
|
304
|
+
? imagePlaceholder(x)
|
|
305
|
+
: blockText(x) || fenceJson(x),
|
|
306
|
+
)
|
|
307
|
+
.join('\n\n')
|
|
308
|
+
: fenceJson(b.content);
|
|
309
|
+
return [
|
|
310
|
+
`<tool-result tool-use-id="${b.tool_use_id ?? ''}" is-error="${!!b.is_error}">`,
|
|
311
|
+
'',
|
|
312
|
+
inner,
|
|
313
|
+
'',
|
|
314
|
+
'</tool-result>',
|
|
315
|
+
].join('\n');
|
|
316
|
+
}
|
|
317
|
+
case 'image':
|
|
318
|
+
return imagePlaceholder(b);
|
|
319
|
+
case 'thinking':
|
|
320
|
+
return ['<thinking>', '', b.thinking ?? '', '', '</thinking>'].join(
|
|
321
|
+
'\n',
|
|
322
|
+
);
|
|
323
|
+
default:
|
|
324
|
+
return fenceJson(b);
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
.join('\n\n');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function renderMessages(messages) {
|
|
331
|
+
if (!Array.isArray(messages)) return '<messages></messages>';
|
|
332
|
+
const rendered = messages.map((m, i) =>
|
|
333
|
+
[
|
|
334
|
+
`<message index="${i + 1}" role="${m.role ?? 'unknown'}">`,
|
|
335
|
+
'',
|
|
336
|
+
renderContent(m.content),
|
|
337
|
+
'',
|
|
338
|
+
'</message>',
|
|
339
|
+
].join('\n'),
|
|
340
|
+
);
|
|
341
|
+
return ['<messages>', '', rendered.join('\n\n'), '', '</messages>'].join(
|
|
342
|
+
'\n',
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function decodeSse(raw) {
|
|
347
|
+
const events = [];
|
|
348
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
349
|
+
const m = line.match(/^data:\s?(.*)$/);
|
|
350
|
+
if (!m || m[1] === '[DONE]' || m[1].trim() === '') continue;
|
|
351
|
+
try {
|
|
352
|
+
events.push(JSON.parse(m[1]));
|
|
353
|
+
} catch {
|
|
354
|
+
/* skip */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const blocks = {};
|
|
358
|
+
let stopReason;
|
|
359
|
+
let usage;
|
|
360
|
+
for (const ev of events) {
|
|
361
|
+
if (ev.type === 'content_block_start') {
|
|
362
|
+
blocks[ev.index] = {
|
|
363
|
+
type: ev.content_block?.type ?? 'text',
|
|
364
|
+
text: '',
|
|
365
|
+
name: ev.content_block?.name,
|
|
366
|
+
id: ev.content_block?.id,
|
|
367
|
+
};
|
|
368
|
+
} else if (ev.type === 'content_block_delta' && blocks[ev.index]) {
|
|
369
|
+
const d = ev.delta ?? {};
|
|
370
|
+
blocks[ev.index].text += d.text ?? d.partial_json ?? d.thinking ?? '';
|
|
371
|
+
} else if (ev.type === 'message_start' && ev.message?.usage) {
|
|
372
|
+
usage = mergeUsage(usage, ev.message.usage);
|
|
373
|
+
} else if (ev.type === 'message_delta') {
|
|
374
|
+
if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
|
|
375
|
+
if (ev.usage) usage = mergeUsage(usage, ev.usage);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const parts = [];
|
|
379
|
+
if (stopReason) parts.push(`- **stop reason**: ${stopReason}`);
|
|
380
|
+
if (usage) {
|
|
381
|
+
const fields = threeUsage(usage);
|
|
382
|
+
if (fields) parts.push(`- **usage**: ${JSON.stringify(fields)}`, '');
|
|
383
|
+
}
|
|
384
|
+
for (const i of Object.keys(blocks)
|
|
385
|
+
.map(Number)
|
|
386
|
+
.sort((a, b) => a - b)) {
|
|
387
|
+
const b = blocks[i];
|
|
388
|
+
if (b.type === 'text')
|
|
389
|
+
parts.push(
|
|
390
|
+
['<assistant-text>', '', b.text, '', '</assistant-text>'].join('\n'),
|
|
391
|
+
);
|
|
392
|
+
else if (b.type === 'thinking')
|
|
393
|
+
parts.push(['<thinking>', '', b.text, '', '</thinking>'].join('\n'));
|
|
394
|
+
else if (b.type === 'tool_use')
|
|
395
|
+
parts.push(
|
|
396
|
+
[
|
|
397
|
+
`<tool-use name="${b.name}" id="${b.id ?? ''}">`,
|
|
398
|
+
'',
|
|
399
|
+
fence(b.text || '{}', 'json'),
|
|
400
|
+
'',
|
|
401
|
+
'</tool-use>',
|
|
402
|
+
].join('\n'),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
markdown: parts.length ? parts.join('\n\n') : fence(raw),
|
|
407
|
+
usage: threeUsage(usage),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function decodeJsonMessage(raw) {
|
|
412
|
+
const json = JSON.parse(raw.trim());
|
|
413
|
+
const text = Array.isArray(json.content)
|
|
414
|
+
? json.content
|
|
415
|
+
.map((b) => (b?.type === 'text' ? (b.text ?? '') : ''))
|
|
416
|
+
.join('')
|
|
417
|
+
: '';
|
|
418
|
+
const markdown = text
|
|
419
|
+
? ['<assistant-text>', '', text, '', '</assistant-text>'].join('\n')
|
|
420
|
+
: fenceJson(json);
|
|
421
|
+
return { markdown, usage: threeUsage(json.usage) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Reassemble the streamed SSE response, or a non-streaming JSON body, and
|
|
425
|
+
* report the three usage fields independently. Missing usage stays unknown. */
|
|
426
|
+
export function decodeResponse(raw) {
|
|
427
|
+
const trimmed = String(raw ?? '').trim();
|
|
428
|
+
if (trimmed.startsWith('{')) {
|
|
429
|
+
try {
|
|
430
|
+
return decodeJsonMessage(trimmed);
|
|
431
|
+
} catch {
|
|
432
|
+
// fall through to SSE
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return decodeSse(raw);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function renderMarkdown(c, audit, responseMd) {
|
|
439
|
+
const headers = Object.entries(c.headers).map(
|
|
440
|
+
([k, v]) =>
|
|
441
|
+
`${k}: ${REDACT.has(k.toLowerCase()) ? '[REDACTED]' : Array.isArray(v) ? v.join(', ') : (v ?? '')}`,
|
|
442
|
+
);
|
|
443
|
+
const req = c.reqJson;
|
|
444
|
+
const parts = [
|
|
445
|
+
[
|
|
446
|
+
'<meta>',
|
|
447
|
+
'',
|
|
448
|
+
`- **timestamp**: ${c.timestamp}`,
|
|
449
|
+
`- **model**: ${req?.model ?? 'unknown'}`,
|
|
450
|
+
`- **endpoint**: ${c.method} ${c.path}`,
|
|
451
|
+
`- **upstream status**: ${c.statusCode}`,
|
|
452
|
+
'',
|
|
453
|
+
'</meta>',
|
|
454
|
+
].join('\n'),
|
|
455
|
+
renderAudit(audit),
|
|
456
|
+
['<headers>', '', '```', ...headers, '```', '', '</headers>'].join('\n'),
|
|
457
|
+
];
|
|
458
|
+
if (req?.system != null)
|
|
459
|
+
parts.push(
|
|
460
|
+
[
|
|
461
|
+
'<system-prompt>',
|
|
462
|
+
'',
|
|
463
|
+
renderSystem(req.system),
|
|
464
|
+
'',
|
|
465
|
+
'</system-prompt>',
|
|
466
|
+
].join('\n'),
|
|
467
|
+
);
|
|
468
|
+
if (Array.isArray(req?.tools) && req.tools.length)
|
|
469
|
+
parts.push(renderTools(req.tools));
|
|
470
|
+
parts.push(renderMessages(req?.messages));
|
|
471
|
+
parts.push(`<response>\n\n${responseMd}\n\n</response>`);
|
|
472
|
+
return `${parts.join('\n\n')}\n`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function writeExclusiveLogs(logDir, requestText, markdown) {
|
|
476
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
477
|
+
// A UUID basename opened with `wx`: a collision throws instead of overwriting.
|
|
478
|
+
const base = createLogBasename();
|
|
479
|
+
const mdPath = path.join(logDir, `${base}.md`);
|
|
480
|
+
const mdFd = fs.openSync(mdPath, 'wx');
|
|
481
|
+
try {
|
|
482
|
+
fs.writeFileSync(path.join(logDir, `${base}.request.txt`), requestText, {
|
|
483
|
+
flag: 'wx',
|
|
484
|
+
});
|
|
485
|
+
fs.writeFileSync(mdFd, markdown);
|
|
486
|
+
} catch (err) {
|
|
487
|
+
fs.unlinkSync(mdPath);
|
|
488
|
+
throw err;
|
|
489
|
+
} finally {
|
|
490
|
+
fs.closeSync(mdFd);
|
|
491
|
+
}
|
|
492
|
+
return base;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Errors go to stderr, or — quiet, inside a terminal UI — to a file beside the logs. */
|
|
496
|
+
function report(options, logDir, message) {
|
|
497
|
+
if (!options.quiet) {
|
|
498
|
+
console.error(message);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
503
|
+
fs.appendFileSync(
|
|
504
|
+
path.join(logDir, 'agent-proxy.errors.log'),
|
|
505
|
+
`${new Date().toISOString()} ${message}\n`,
|
|
506
|
+
);
|
|
507
|
+
} catch {
|
|
508
|
+
/* nowhere left to report */
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Full conversations must never be committable: refuse a log root git would track. */
|
|
513
|
+
function assertIgnored(logDirectory, cwd) {
|
|
514
|
+
const probe = spawnSync(
|
|
515
|
+
'git',
|
|
516
|
+
['check-ignore', '-q', path.join(logDirectory, 'probe.md')],
|
|
517
|
+
{ cwd },
|
|
518
|
+
);
|
|
519
|
+
if (probe.status !== 0) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`[agent-proxy] log directory is not ignored by git: ${logDirectory}`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function handle(req, res, options) {
|
|
527
|
+
const reqPath = req.url ?? '/';
|
|
528
|
+
const chunks = [];
|
|
529
|
+
req.on('data', (c) => chunks.push(c));
|
|
530
|
+
req.on('end', () => {
|
|
531
|
+
const body = Buffer.concat(chunks);
|
|
532
|
+
const timestamp = new Date().toISOString();
|
|
533
|
+
const logDir = options.logDirectory;
|
|
534
|
+
const upstream = options.upstream ?? UPSTREAM;
|
|
535
|
+
const transport = upstream.protocol === 'http' ? http : https;
|
|
536
|
+
|
|
537
|
+
const upReq = transport.request(
|
|
538
|
+
{
|
|
539
|
+
hostname: upstream.hostname,
|
|
540
|
+
port: upstream.port,
|
|
541
|
+
path: reqPath,
|
|
542
|
+
method: req.method,
|
|
543
|
+
headers: forwardHeaders(req.headers, body),
|
|
544
|
+
},
|
|
545
|
+
(up) => {
|
|
546
|
+
res.writeHead(up.statusCode ?? 502, up.headers);
|
|
547
|
+
const respChunks = [];
|
|
548
|
+
let failed = false;
|
|
549
|
+
up.on('error', () => {
|
|
550
|
+
failed = true;
|
|
551
|
+
});
|
|
552
|
+
up.on('aborted', () => {
|
|
553
|
+
failed = true;
|
|
554
|
+
});
|
|
555
|
+
up.on('data', (c) => {
|
|
556
|
+
respChunks.push(c);
|
|
557
|
+
res.write(c);
|
|
558
|
+
});
|
|
559
|
+
// `close` fires after `end` and after an aborted stream, so the client
|
|
560
|
+
// response always ends; only a completed stream is logged.
|
|
561
|
+
let completed = false;
|
|
562
|
+
up.on('end', () => {
|
|
563
|
+
completed = !failed;
|
|
564
|
+
});
|
|
565
|
+
up.on('close', () => {
|
|
566
|
+
if (!res.writableEnded) res.end();
|
|
567
|
+
// Body-less requests (model lists, GETs) carry no payload to measure.
|
|
568
|
+
if (!completed || isTokenCount(reqPath) || body.length === 0) return;
|
|
569
|
+
try {
|
|
570
|
+
const reqJson = JSON.parse(body.toString('utf8'));
|
|
571
|
+
const decoded = decodeResponse(
|
|
572
|
+
Buffer.concat(respChunks).toString('utf8'),
|
|
573
|
+
);
|
|
574
|
+
const audit = auditRequest(reqJson, decoded.usage);
|
|
575
|
+
const base = writeExclusiveLogs(
|
|
576
|
+
logDir,
|
|
577
|
+
body.toString('utf8'),
|
|
578
|
+
renderMarkdown(
|
|
579
|
+
{
|
|
580
|
+
reqJson,
|
|
581
|
+
timestamp,
|
|
582
|
+
method: req.method ?? 'POST',
|
|
583
|
+
path: reqPath,
|
|
584
|
+
statusCode: up.statusCode ?? 0,
|
|
585
|
+
headers: req.headers,
|
|
586
|
+
},
|
|
587
|
+
audit,
|
|
588
|
+
decoded.markdown,
|
|
589
|
+
),
|
|
590
|
+
);
|
|
591
|
+
if (!options.quiet) printAudit(audit, base, logDir);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
report(
|
|
594
|
+
options,
|
|
595
|
+
logDir,
|
|
596
|
+
`[agent-proxy] log write failed: ${err.message}`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
);
|
|
602
|
+
res.on('close', () => {
|
|
603
|
+
if (!res.writableFinished) upReq.destroy();
|
|
604
|
+
});
|
|
605
|
+
upReq.on('error', (err) => {
|
|
606
|
+
report(options, logDir, `[agent-proxy] upstream error: ${err.message}`);
|
|
607
|
+
if (!res.headersSent)
|
|
608
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
609
|
+
if (!res.writableEnded)
|
|
610
|
+
res.end(
|
|
611
|
+
JSON.stringify({
|
|
612
|
+
error: `agent-proxy upstream error: ${err.message}`,
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
if (body.length > 0) upReq.write(body);
|
|
617
|
+
upReq.end();
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function startServer(options = {}) {
|
|
622
|
+
return new Promise((resolve, reject) => {
|
|
623
|
+
let port;
|
|
624
|
+
let logDirectory;
|
|
625
|
+
try {
|
|
626
|
+
port = parsePort(options.port ?? process.env.PORT ?? DEFAULT_PORT, {
|
|
627
|
+
allowEphemeral: options.port === 0,
|
|
628
|
+
});
|
|
629
|
+
logDirectory =
|
|
630
|
+
options.logDirectory ??
|
|
631
|
+
resolveLogDirectory({
|
|
632
|
+
cwd: options.cwd,
|
|
633
|
+
ticketIdentifier: options.ticketIdentifier,
|
|
634
|
+
subdir: options.logSubdir ?? process.env.BB_PROXY_LOG_SUBDIR,
|
|
635
|
+
});
|
|
636
|
+
if (!options.logDirectory)
|
|
637
|
+
assertIgnored(logDirectory, options.cwd ?? process.cwd());
|
|
638
|
+
if (
|
|
639
|
+
path.resolve(logDirectory).startsWith(path.resolve(HERE) + path.sep) ||
|
|
640
|
+
path.resolve(logDirectory) === path.resolve(HERE)
|
|
641
|
+
) {
|
|
642
|
+
throw new Error(
|
|
643
|
+
'[agent-proxy] refusing to log beside the installed script',
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
} catch (err) {
|
|
647
|
+
reject(err);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const server = http.createServer((req, res) =>
|
|
651
|
+
handle(req, res, { ...options, logDirectory }),
|
|
652
|
+
);
|
|
653
|
+
server.on('error', reject);
|
|
654
|
+
server.listen(port, LISTEN_HOST, () => {
|
|
655
|
+
resolve({
|
|
656
|
+
server,
|
|
657
|
+
address: server.address(),
|
|
658
|
+
logDirectory,
|
|
659
|
+
close: () =>
|
|
660
|
+
new Promise((done, fail) => {
|
|
661
|
+
server.close((err) => (err ? fail(err) : done()));
|
|
662
|
+
server.closeAllConnections();
|
|
663
|
+
}),
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const isMain =
|
|
670
|
+
process.argv[1] &&
|
|
671
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
672
|
+
if (isMain) {
|
|
673
|
+
startServer()
|
|
674
|
+
.then((started) => {
|
|
675
|
+
const { port } = started.address;
|
|
676
|
+
console.log(`[agent-proxy] listening on http://${LISTEN_HOST}:${port}`);
|
|
677
|
+
console.log(`[agent-proxy] logs: ${started.logDirectory}`);
|
|
678
|
+
console.log(
|
|
679
|
+
`[agent-proxy] point Claude Code at it: ANTHROPIC_BASE_URL=http://${LISTEN_HOST}:${port} claude`,
|
|
680
|
+
);
|
|
681
|
+
})
|
|
682
|
+
.catch((err) => {
|
|
683
|
+
console.error(err.message);
|
|
684
|
+
process.exit(1);
|
|
685
|
+
});
|
|
686
|
+
}
|