@animalabs/connectome-host 0.7.0 → 0.7.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/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/src/logging-adapter.ts +14 -0
- package/test/logging-adapter.test.ts +33 -0
- package/web/src/Health.tsx +85 -110
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.2 — 2026-07-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`LLM_CALLS_FULL_PAYLOADS` env flag** — retain the raw request on every
|
|
10
|
+
llm-call log entry, not only on refusal/error. Debugging aid; off by
|
|
11
|
+
default (the logs grow gigabytes fast with it on).
|
|
12
|
+
|
|
13
|
+
## 0.7.1 — 2026-07-26
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Health call stats are now per-call, not cumulative.** The previous version
|
|
18
|
+
rolled everything into two totals (main / compression), which hid exactly what
|
|
19
|
+
you want to see — how an individual turn behaved. Now one row per call, newest
|
|
20
|
+
first: time, origin, messages, fresh input, cached tokens, cached share, cache
|
|
21
|
+
write, output, breakpoints, duration and verdict, with refusals and errors
|
|
22
|
+
highlighted. Cumulative totals for the session remain in the Usage panel.
|
|
23
|
+
|
|
24
|
+
## Unreleased
|
|
25
|
+
|
|
5
26
|
## 0.7.0 — 2026-07-26
|
|
6
27
|
|
|
7
28
|
### Added
|
package/package.json
CHANGED
package/src/logging-adapter.ts
CHANGED
|
@@ -43,6 +43,11 @@ export type ProviderCallObserver = (record: ProviderCallRecord) => void;
|
|
|
43
43
|
* system-prompt-append uses). */
|
|
44
44
|
const OAUTH_SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
45
45
|
|
|
46
|
+
/** Truthy env-flag parse: unset/''/'0'/'false' (any case) are off. */
|
|
47
|
+
function envFlag(value: string | undefined): boolean {
|
|
48
|
+
return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false';
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
export class LoggingAnthropicAdapter extends AnthropicAdapter {
|
|
47
52
|
private readonly logPath: string;
|
|
48
53
|
private readonly getReasoning?: ReasoningGetter;
|
|
@@ -50,6 +55,14 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
|
|
|
50
55
|
/** True when authenticated with an OAuth/Bearer token instead of an API
|
|
51
56
|
* key; requests then need the identity block prepended (see above). */
|
|
52
57
|
private readonly oauthMode: boolean;
|
|
58
|
+
/** LLM_CALLS_FULL_PAYLOADS=1: retain the full raw request on EVERY call,
|
|
59
|
+
* not just refusals/errors. Purpose: pass/refusal contrast corpora — a
|
|
60
|
+
* refusal's payload is only interpretable next to the passing payloads
|
|
61
|
+
* around it (classifier-forensics finding, 2026-07-27: verdicts are a
|
|
62
|
+
* near-deterministic function of the payload, so adjacent pairs isolate
|
|
63
|
+
* the differentiator). Costs disk, not memory (the raw request is already
|
|
64
|
+
* captured per-call for the summary); pair with llm-calls rotation. */
|
|
65
|
+
private readonly fullPayloads: boolean = envFlag(process.env.LLM_CALLS_FULL_PAYLOADS);
|
|
53
66
|
|
|
54
67
|
constructor(
|
|
55
68
|
config: ConstructorParameters<typeof AnthropicAdapter>[0],
|
|
@@ -206,6 +219,7 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
|
|
|
206
219
|
}
|
|
207
220
|
|
|
208
221
|
private refusalRawRequest(response: ProviderResponse, rawRequest: unknown): unknown {
|
|
222
|
+
if (this.fullPayloads) return rawRequest;
|
|
209
223
|
const raw = (response as { raw?: { stop_reason?: string } }).raw;
|
|
210
224
|
return raw?.stop_reason === 'refusal' ? rawRequest : undefined;
|
|
211
225
|
}
|
|
@@ -100,6 +100,39 @@ describe('LoggingAnthropicAdapter request logging', () => {
|
|
|
100
100
|
expect(internals.refusalRawRequest(refusal, rawRequest)).toBe(rawRequest);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test('LLM_CALLS_FULL_PAYLOADS=1 retains the raw request on every call', () => {
|
|
104
|
+
const prev = process.env.LLM_CALLS_FULL_PAYLOADS;
|
|
105
|
+
process.env.LLM_CALLS_FULL_PAYLOADS = '1';
|
|
106
|
+
try {
|
|
107
|
+
const full = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null');
|
|
108
|
+
const fi = full as unknown as typeof internals;
|
|
109
|
+
const rawRequest = { messages: ['forensic context'] };
|
|
110
|
+
const success = { raw: { stop_reason: 'end_turn' } } as unknown as ProviderResponse;
|
|
111
|
+
const refusal = { raw: { stop_reason: 'refusal' } } as unknown as ProviderResponse;
|
|
112
|
+
expect(fi.refusalRawRequest(success, rawRequest)).toBe(rawRequest);
|
|
113
|
+
expect(fi.refusalRawRequest(refusal, rawRequest)).toBe(rawRequest);
|
|
114
|
+
} finally {
|
|
115
|
+
if (prev === undefined) delete process.env.LLM_CALLS_FULL_PAYLOADS;
|
|
116
|
+
else process.env.LLM_CALLS_FULL_PAYLOADS = prev;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('LLM_CALLS_FULL_PAYLOADS off-values keep refusal-only behavior', () => {
|
|
121
|
+
const prev = process.env.LLM_CALLS_FULL_PAYLOADS;
|
|
122
|
+
try {
|
|
123
|
+
for (const off of ['', '0', 'false', 'FALSE']) {
|
|
124
|
+
process.env.LLM_CALLS_FULL_PAYLOADS = off;
|
|
125
|
+
const a = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null');
|
|
126
|
+
const ai = a as unknown as typeof internals;
|
|
127
|
+
const success = { raw: { stop_reason: 'end_turn' } } as unknown as ProviderResponse;
|
|
128
|
+
expect(ai.refusalRawRequest(success, { m: 1 })).toBeUndefined();
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
if (prev === undefined) delete process.env.LLM_CALLS_FULL_PAYLOADS;
|
|
132
|
+
else process.env.LLM_CALLS_FULL_PAYLOADS = prev;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
103
136
|
test('forwards authoritative billing buckets from the provider response', () => {
|
|
104
137
|
const calls: ProviderCallRecord[] = [];
|
|
105
138
|
const observed = new LoggingAnthropicAdapter(
|
package/web/src/Health.tsx
CHANGED
|
@@ -142,132 +142,107 @@ export function OpsAlertStrip(props: {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
|
|
145
|
-
/**
|
|
146
|
-
* Aggregate the call ledger by origin.
|
|
147
|
-
*
|
|
148
|
-
* `originEstimate` is the main-vs-compression split, and the `~` is honest: it
|
|
149
|
-
* is derived from stream-vs-complete (agent turns stream; compression and
|
|
150
|
-
* summarizer calls use complete()), NOT from a definitive origin tag. It is a
|
|
151
|
-
* reliable proxy in practice, but it is an inference.
|
|
152
|
-
*/
|
|
153
|
-
interface CallAgg {
|
|
154
|
-
calls: number;
|
|
155
|
-
input: number;
|
|
156
|
-
output: number;
|
|
157
|
-
cacheRead: number;
|
|
158
|
-
cacheWrite: number;
|
|
159
|
-
cost: number;
|
|
160
|
-
hits: number;
|
|
161
|
-
errors: number;
|
|
162
|
-
refusals: number;
|
|
163
|
-
breakpoints: number;
|
|
164
|
-
lastInputs: number[];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const EMPTY_AGG = (): CallAgg => ({
|
|
168
|
-
calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
|
|
169
|
-
cost: 0, hits: 0, errors: 0, refusals: 0, breakpoints: 0, lastInputs: [],
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
/** Verdicts that mean the prefix was actually reused. */
|
|
173
|
-
const HIT_VERDICTS = new Set(['HIT', 'hit+extend']);
|
|
174
|
-
|
|
175
|
-
function aggregate(rows: LedgerRow[], origin: 'turn~' | 'aux~'): CallAgg {
|
|
176
|
-
const a = EMPTY_AGG();
|
|
177
|
-
for (const r of rows) {
|
|
178
|
-
if (r.originEstimate !== origin) continue;
|
|
179
|
-
a.calls++;
|
|
180
|
-
a.input += r.tokens.input;
|
|
181
|
-
a.output += r.tokens.output;
|
|
182
|
-
a.cacheRead += r.tokens.cacheRead;
|
|
183
|
-
a.cacheWrite += r.tokens.cacheWrite;
|
|
184
|
-
a.cost += r.cost?.total ?? 0;
|
|
185
|
-
if (HIT_VERDICTS.has(r.verdict)) a.hits++;
|
|
186
|
-
if (r.error) a.errors++;
|
|
187
|
-
if (r.stopReason === 'refusal') a.refusals++;
|
|
188
|
-
a.breakpoints += r.cache.breakpoints ?? 0;
|
|
189
|
-
a.lastInputs.push(r.tokens.input);
|
|
190
|
-
}
|
|
191
|
-
a.lastInputs = a.lastInputs.slice(-8);
|
|
192
|
-
return a;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
145
|
const n0 = (v: number) => v.toLocaleString();
|
|
196
|
-
const pct = (num: number, den: number) => (den > 0 ? `${Math.round((100 * num) / den)}%` : '—');
|
|
197
|
-
|
|
198
|
-
/** Cached share of what was sent — the number that tells you whether the prefix
|
|
199
|
-
* is being reused or re-read. */
|
|
200
|
-
const cachedShare = (a: CallAgg) => {
|
|
201
|
-
const sent = a.input + a.cacheRead;
|
|
202
|
-
return sent > 0 ? `${Math.round((100 * a.cacheRead) / sent)}%` : '—';
|
|
203
|
-
};
|
|
204
146
|
|
|
205
147
|
function CallStats(props: { rows: LedgerRow[] }) {
|
|
206
|
-
|
|
207
|
-
const
|
|
148
|
+
/** Newest first — the interesting call is the one that just happened. */
|
|
149
|
+
const recent = () => [...props.rows].reverse().slice(0, 24);
|
|
208
150
|
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
['cache read', n0(a().cacheRead)],
|
|
219
|
-
['cache write', n0(a().cacheWrite)],
|
|
220
|
-
['cached share', cachedShare(a())],
|
|
221
|
-
['prefix reused', pct(a().hits, a().calls)],
|
|
222
|
-
['output', n0(a().output)],
|
|
223
|
-
['avg breakpoints', a().calls ? (a().breakpoints / a().calls).toFixed(1) : '—'],
|
|
224
|
-
['cost', a().cost ? `$${a().cost.toFixed(4)}` : '—'],
|
|
225
|
-
] as Array<[string, string]>}>
|
|
226
|
-
{([k, v]) => (
|
|
227
|
-
<tr>
|
|
228
|
-
<td class="text-neutral-500 pr-2">{k}</td>
|
|
229
|
-
<td class="text-neutral-200 text-right tabular-nums">{v}</td>
|
|
230
|
-
</tr>
|
|
231
|
-
)}
|
|
232
|
-
</For>
|
|
233
|
-
<Show when={a().errors > 0 || a().refusals > 0}>
|
|
234
|
-
<tr>
|
|
235
|
-
<td class="text-neutral-500 pr-2">errors / refusals</td>
|
|
236
|
-
<td class="text-right tabular-nums text-red-300">
|
|
237
|
-
{a().errors} / {a().refusals}
|
|
238
|
-
</td>
|
|
239
|
-
</tr>
|
|
240
|
-
</Show>
|
|
241
|
-
</tbody>
|
|
242
|
-
</table>
|
|
243
|
-
<Show when={a().lastInputs.length > 1}>
|
|
244
|
-
<div class="text-[9px] text-neutral-600 mt-1 font-mono break-all"
|
|
245
|
-
title="fresh input tokens, oldest → newest — a descent should trend down">
|
|
246
|
-
{a().lastInputs.map(n0).join(' → ')}
|
|
247
|
-
</div>
|
|
248
|
-
</Show>
|
|
249
|
-
</div>
|
|
250
|
-
);
|
|
151
|
+
const clock = (ts: string) => {
|
|
152
|
+
const d = new Date(ts);
|
|
153
|
+
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 19);
|
|
154
|
+
};
|
|
155
|
+
/** Fraction of the prompt that was reused rather than re-read. */
|
|
156
|
+
const share = (r: LedgerRow) => {
|
|
157
|
+
const sent = r.tokens.input + r.tokens.cacheRead;
|
|
158
|
+
return sent > 0 ? Math.round((100 * r.tokens.cacheRead) / sent) : null;
|
|
159
|
+
};
|
|
251
160
|
|
|
252
161
|
return (
|
|
253
162
|
<div>
|
|
254
163
|
<div class="text-[10px] uppercase tracking-wider text-neutral-600 mb-1">
|
|
255
|
-
|
|
164
|
+
llm calls — newest first ({props.rows.length} held)
|
|
256
165
|
</div>
|
|
257
|
-
<div class="
|
|
258
|
-
|
|
259
|
-
|
|
166
|
+
<div class="overflow-x-auto">
|
|
167
|
+
<table class="w-full font-mono text-[10px] whitespace-nowrap">
|
|
168
|
+
<thead>
|
|
169
|
+
<tr class="text-neutral-600 border-b border-neutral-800">
|
|
170
|
+
<th class="text-left pr-2 font-normal">time</th>
|
|
171
|
+
<th class="text-left pr-2 font-normal">origin</th>
|
|
172
|
+
<th class="text-right pr-2 font-normal">msgs</th>
|
|
173
|
+
<th class="text-right pr-2 font-normal">fresh</th>
|
|
174
|
+
<th class="text-right pr-2 font-normal">cached</th>
|
|
175
|
+
<th class="text-right pr-2 font-normal">%c</th>
|
|
176
|
+
<th class="text-right pr-2 font-normal">write</th>
|
|
177
|
+
<th class="text-right pr-2 font-normal">out</th>
|
|
178
|
+
<th class="text-right pr-2 font-normal">bp</th>
|
|
179
|
+
<th class="text-right pr-2 font-normal">ms</th>
|
|
180
|
+
<th class="text-left font-normal">verdict</th>
|
|
181
|
+
</tr>
|
|
182
|
+
</thead>
|
|
183
|
+
<tbody>
|
|
184
|
+
<For each={recent()}>
|
|
185
|
+
{(r) => {
|
|
186
|
+
const main = r.originEstimate === 'turn~';
|
|
187
|
+
return (
|
|
188
|
+
<tr class={`border-b border-neutral-900 ${
|
|
189
|
+
r.error || r.stopReason === 'refusal' ? 'bg-red-950/30' : ''
|
|
190
|
+
}`}>
|
|
191
|
+
<td class="pr-2 text-neutral-500">{clock(r.timestamp)}</td>
|
|
192
|
+
<td class={`pr-2 ${main ? 'text-cyan-400' : 'text-orange-400'}`}>
|
|
193
|
+
{main ? 'turn' : 'compr'}
|
|
194
|
+
</td>
|
|
195
|
+
<td class="pr-2 text-right text-neutral-400">{r.messages}</td>
|
|
196
|
+
<td class="pr-2 text-right text-neutral-100">{n0(r.tokens.input)}</td>
|
|
197
|
+
<td class="pr-2 text-right text-sky-300">{n0(r.tokens.cacheRead)}</td>
|
|
198
|
+
<td class="pr-2 text-right text-neutral-400">
|
|
199
|
+
{share(r) === null ? '—' : `${share(r)}%`}
|
|
200
|
+
</td>
|
|
201
|
+
<td class="pr-2 text-right text-violet-300">
|
|
202
|
+
{r.tokens.cacheWrite ? n0(r.tokens.cacheWrite) : '·'}
|
|
203
|
+
</td>
|
|
204
|
+
<td class="pr-2 text-right text-neutral-400">{n0(r.tokens.output)}</td>
|
|
205
|
+
<td class="pr-2 text-right text-neutral-600">{r.cache.breakpoints ?? '·'}</td>
|
|
206
|
+
<td class="pr-2 text-right text-neutral-600">{n0(r.durationMs)}</td>
|
|
207
|
+
<td class={verdictTone(r.verdict)} title={r.cause}>
|
|
208
|
+
{r.verdict}
|
|
209
|
+
<Show when={r.stopReason === 'refusal'}>
|
|
210
|
+
<span class="text-red-300"> refusal</span>
|
|
211
|
+
</Show>
|
|
212
|
+
<Show when={r.error}>
|
|
213
|
+
<span class="text-red-300"> err</span>
|
|
214
|
+
</Show>
|
|
215
|
+
</td>
|
|
216
|
+
</tr>
|
|
217
|
+
);
|
|
218
|
+
}}
|
|
219
|
+
</For>
|
|
220
|
+
</tbody>
|
|
221
|
+
</table>
|
|
260
222
|
</div>
|
|
261
223
|
<div class="text-[9px] text-neutral-600 mt-1 leading-relaxed">
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
224
|
+
<span class="text-cyan-400">turn</span> = main inference,
|
|
225
|
+
<span class="text-orange-400"> compr</span> = compression/summarizer — inferred from
|
|
226
|
+
stream-vs-complete, not a definitive tag (hence <span class="font-mono">~</span> upstream).
|
|
227
|
+
One turn may appear as a turn row plus several compr rows.
|
|
228
|
+
<span class="font-mono"> fresh</span> = tokens billed as new input;
|
|
229
|
+
<span class="font-mono"> cached</span> = read from cache;
|
|
230
|
+
<span class="font-mono"> %c</span> = cached ÷ (fresh+cached);
|
|
231
|
+
<span class="font-mono"> bp</span> = cache breakpoints.
|
|
266
232
|
</div>
|
|
267
233
|
</div>
|
|
268
234
|
);
|
|
269
235
|
}
|
|
270
236
|
|
|
237
|
+
/** Verdict colouring: reuse is good, rewrites cost a full re-read. */
|
|
238
|
+
function verdictTone(v: string): string {
|
|
239
|
+
if (v === 'HIT' || v === 'hit+extend') return 'text-emerald-300';
|
|
240
|
+
if (v === 'first-write') return 'text-violet-300';
|
|
241
|
+
if (v === 'uncached') return 'text-neutral-400';
|
|
242
|
+
if (v === 'ERROR' || v === 'empty') return 'text-red-300';
|
|
243
|
+
return 'text-amber-300';
|
|
244
|
+
}
|
|
245
|
+
|
|
271
246
|
function CompositionBlock(props: { c: ContextComposition }) {
|
|
272
247
|
const rows = () => {
|
|
273
248
|
const c = props.c;
|