sandoichi 0.3.0 → 0.4.0

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 CHANGED
@@ -24,7 +24,9 @@ Project-specific detectors can be declared in `.sando/redaction.json`:
24
24
 
25
25
  Built-ins stay enabled. Profiles are declarative and local to the current project; invalid profiles fail visibly.
26
26
 
27
- The library requires Node.js `>=22.22.0 <23` and has no runtime dependencies. Installing it does not install or enable the plugin.
27
+ The library requires Node.js `>=22.22.0 <23` and has no runtime dependencies. Installing it does not install or enable the plugin. The plugin is the supported host surface; this package exports the context/history runtime, provider usage report, paired accounting, and explicit proxy API only.
28
+
29
+ `computeWeightedUsage` and `summarizePairedSessions` keep mechanical reduction, weighted estimates, provider-reported cost, and paired-session evidence distinct. The benchmark report adds explicit replay counterfactuals. The library does not install hooks, register MCP servers, or make routing/backoff decisions for a host.
28
30
 
29
31
  For plugin installation, see the [main project README](https://github.com/yuzushi-dev/Sando#readme).
30
32
 
package/index.mjs CHANGED
@@ -8,6 +8,16 @@ export {
8
8
  export { createRedactionProfile } from './src/redaction-profile.mjs';
9
9
  export { loadProjectRedactionProfile } from './src/redaction-config.mjs';
10
10
  export { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './src/context-transform.mjs';
11
+ export {
12
+ DEFAULT_ACCOUNTING_WEIGHTS,
13
+ PAIRED_ARMS,
14
+ computeWeightedUsage,
15
+ pairedArmFromEnv,
16
+ pairedExperimentFromEnv,
17
+ pairedWorkloadFromEnv,
18
+ summarizePairedSessions,
19
+ } from './src/paired-accounting.mjs';
20
+ export { formatAccountingReport, runAccountingCli } from './src/accounting-cli.mjs';
11
21
  export {
12
22
  buildSemanticPrompt,
13
23
  createSemanticCompactor,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.3.0",
4
- "description": "Reduce repeated tool-output context in Claude Code and Codex.",
3
+ "version": "0.4.0",
4
+ "description": "Bound repeated tool-output context in Claude Code and Codex with deterministic local routing and provider accounting.",
5
5
  "license": "MIT",
6
6
  "author": "yuzushi",
7
7
  "repository": {
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ import { buildProviderUsageReport, defaultProviderUsagePath, readProviderUsage } from './provider-usage.mjs';
7
+
8
+ function option(argv, name) {
9
+ const index = argv.indexOf(`--${name}`);
10
+ return index === -1 ? undefined : argv[index + 1];
11
+ }
12
+
13
+ export function formatAccountingReport(report) {
14
+ const lines = [
15
+ 'provider accounting',
16
+ `input: ${report.inputTokens}`,
17
+ `fresh input: ${report.freshInputTokens}`,
18
+ `cache read: ${report.cachedInputTokens}`,
19
+ `cache write: ${report.cacheWriteInputTokens}`,
20
+ `output: ${report.outputTokens}`,
21
+ `reasoning: ${report.reasoningOutputTokens}`,
22
+ `turns: ${report.turnCount}`,
23
+ `weighted estimate: ${report.weightedCost.costUnits} cost units`,
24
+ `provider cost: ${report.cost.status === 'provider-reported' ? `$${report.cost.totalCostUsd.toFixed(6)}` : report.cost.status}`,
25
+ ];
26
+ if (report.cost.effectiveRateUsdPerMillionTokens !== null) {
27
+ lines.push(`blended effective rate: $${report.cost.effectiveRateUsdPerMillionTokens.toFixed(2)}/M tokens`);
28
+ }
29
+ return `${lines.join('\n')}\n`;
30
+ }
31
+
32
+ export function runAccountingCli({ argv = process.argv.slice(2), env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
33
+ if (argv.includes('--help')) {
34
+ stdout.write('Usage: node accounting-cli.mjs [--json] [--path ABSOLUTE_PATH] [--session SESSION_ID]\n');
35
+ return null;
36
+ }
37
+ try {
38
+ const storagePath = option(argv, 'path') || defaultProviderUsagePath(env);
39
+ const report = buildProviderUsageReport(readProviderUsage(storagePath), { sessionId: option(argv, 'session') });
40
+ stdout.write(argv.includes('--json') ? `${JSON.stringify(report, null, 2)}\n` : formatAccountingReport(report));
41
+ return report;
42
+ } catch (error) {
43
+ stderr.write(`sando accounting: ${error instanceof Error ? error.message : String(error)}\n`);
44
+ process.exitCode = 1;
45
+ return null;
46
+ }
47
+ }
48
+
49
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) runAccountingCli();
@@ -0,0 +1,10 @@
1
+ // Compatibility aliases for older direct imports. Routing no longer reads this module.
2
+ export {
3
+ DEFAULT_ACCOUNTING_WEIGHTS as DEFAULT_ADAPTIVE_WEIGHTS,
4
+ PAIRED_ARMS as ADAPTIVE_ARMS,
5
+ computeWeightedUsage as computeUsageCost,
6
+ pairedArmFromEnv as adaptiveArmFromEnv,
7
+ pairedExperimentFromEnv as adaptiveExperimentFromEnv,
8
+ pairedWorkloadFromEnv as adaptiveWorkloadFromEnv,
9
+ summarizePairedSessions as summarizeAdaptiveSessions,
10
+ } from './paired-accounting.mjs';
@@ -0,0 +1,94 @@
1
+ export const MAX_EXEC_CAPTURE_BYTES = 16_777_216;
2
+
3
+ function terminate(child, signal) {
4
+ try {
5
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
6
+ else child.kill(signal);
7
+ } catch {}
8
+ try { child.kill(signal); } catch {}
9
+ }
10
+
11
+ function incompleteUtf8Suffix(buffer) {
12
+ let index = buffer.length - 1;
13
+ let continuation = 0;
14
+ while (index >= 0 && (buffer[index] & 0xc0) === 0x80) {
15
+ continuation += 1;
16
+ index -= 1;
17
+ }
18
+ if (index < 0) return 0;
19
+ const lead = buffer[index];
20
+ const expected = lead >= 0xc2 && lead <= 0xdf ? 2
21
+ : lead >= 0xe0 && lead <= 0xef ? 3
22
+ : lead >= 0xf0 && lead <= 0xf4 ? 4 : 0;
23
+ return expected > continuation + 1 ? continuation + 1 : 0;
24
+ }
25
+
26
+ export function textOrBinary(buffer, { truncated = false } = {}) {
27
+ if (!Buffer.isBuffer(buffer)) throw new TypeError('output must be a Buffer');
28
+ if (buffer.includes(0)) return { binary: true, text: '', utf8Truncated: false };
29
+ const decoder = new TextDecoder('utf-8', { fatal: true });
30
+ try { return { binary: false, text: decoder.decode(buffer), utf8Truncated: false }; }
31
+ catch {
32
+ if (!truncated) return { binary: true, text: '', utf8Truncated: false };
33
+ const suffix = incompleteUtf8Suffix(buffer);
34
+ if (!suffix) return { binary: true, text: '', utf8Truncated: false };
35
+ try {
36
+ return { binary: false, text: decoder.decode(buffer.subarray(0, -suffix)), utf8Truncated: true };
37
+ } catch {
38
+ return { binary: true, text: '', utf8Truncated: false };
39
+ }
40
+ }
41
+ }
42
+
43
+ export function captureProcess(child, { maxBytes, timeoutMs, signal } = {}) {
44
+ if (!child || typeof child.once !== 'function' || !Number.isSafeInteger(maxBytes) || maxBytes < 1
45
+ || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('capture options are invalid');
46
+ return new Promise((resolve, reject) => {
47
+ const buffers = { stdout: [], stderr: [] };
48
+ const captured = { stdout: 0, stderr: 0 };
49
+ const truncated = { stdout: false, stderr: false };
50
+ let timedOut = false;
51
+ let cancelled = false;
52
+ let settled = false;
53
+ let forceTimer;
54
+ let timer;
55
+ const collect = (name, chunk) => {
56
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
57
+ const remaining = Math.max(0, maxBytes - captured[name]);
58
+ if (remaining) {
59
+ const part = buffer.subarray(0, remaining);
60
+ buffers[name].push(part);
61
+ captured[name] += part.length;
62
+ }
63
+ if (buffer.length > remaining) truncated[name] = true;
64
+ };
65
+ const stop = (signalName) => {
66
+ terminate(child, signalName);
67
+ if (signalName === 'SIGTERM' && forceTimer === undefined) {
68
+ forceTimer = setTimeout(() => terminate(child, 'SIGKILL'), 250);
69
+ }
70
+ };
71
+ const finish = (result, error) => {
72
+ if (settled) return;
73
+ settled = true;
74
+ clearTimeout(timer);
75
+ clearTimeout(forceTimer);
76
+ signal?.removeEventListener('abort', onAbort);
77
+ if (error) reject(error);
78
+ else resolve({
79
+ stdout: Buffer.concat(buffers.stdout), stderr: Buffer.concat(buffers.stderr),
80
+ stdoutBytes: captured.stdout, stderrBytes: captured.stderr,
81
+ stdoutTruncated: truncated.stdout, stderrTruncated: truncated.stderr,
82
+ truncated: truncated.stdout || truncated.stderr, timedOut, cancelled, ...result,
83
+ });
84
+ };
85
+ const onAbort = () => { cancelled = true; stop('SIGTERM'); };
86
+ timer = setTimeout(() => { timedOut = true; stop('SIGTERM'); }, timeoutMs);
87
+ signal?.addEventListener('abort', onAbort, { once: true });
88
+ child.stdout?.on('data', (chunk) => collect('stdout', chunk));
89
+ child.stderr?.on('data', (chunk) => collect('stderr', chunk));
90
+ child.once('error', (error) => finish(null, error));
91
+ child.once('close', (exitCode, exitSignal) => finish({ exitCode, exitSignal }));
92
+ if (signal?.aborted) onAbort();
93
+ });
94
+ }
package/src/metrics.mjs CHANGED
@@ -375,15 +375,15 @@ function tokenLine(value) {
375
375
 
376
376
  export function formatMetricsReport(report) {
377
377
  const lines = [
378
- `Sando savings (timezone: ${report.timezone})`,
378
+ `Sando mechanical measurement (timezone: ${report.timezone})`,
379
379
  `Current session: ${report.currentSession ? report.currentSession.id ?? 'unknown' : 'none'}`,
380
- `Current session estimated transform savings: ${tokenLine(report.currentSession?.estimatedTransformSavingsTokens ?? 0)}`,
381
- `Average estimated transform savings per session: ${tokenLine(report.averagePerSession.estimatedTransformSavingsTokens)} (${report.averagePerSession.sessionCount} sessions)`,
382
- `Cumulative estimated transform savings: ${tokenLine(report.cumulative.estimatedTransformSavingsTokens)}`,
383
- `Cumulative provider-reported savings: ${tokenLine(report.cumulative.providerReportedSavingsTokens)}`,
384
- `Daily (${report.periods.daily.current.period}) estimated transform savings: ${tokenLine(report.periods.daily.current.estimatedTransformSavingsTokens)}`,
385
- `ISO week (${report.periods.weekly.current.period}) estimated transform savings: ${tokenLine(report.periods.weekly.current.estimatedTransformSavingsTokens)}`,
386
- `Monthly (${report.periods.monthly.current.period}) estimated transform savings: ${tokenLine(report.periods.monthly.current.estimatedTransformSavingsTokens)}`,
380
+ `Current session mechanical reduction: ${tokenLine(report.currentSession?.estimatedTransformSavingsTokens ?? 0)}`,
381
+ `Average mechanical reduction per session: ${tokenLine(report.averagePerSession.estimatedTransformSavingsTokens)} (${report.averagePerSession.sessionCount} sessions)`,
382
+ `Cumulative mechanical reduction: ${tokenLine(report.cumulative.estimatedTransformSavingsTokens)}`,
383
+ `Provider cost: unavailable here; use paired control/apply ledger evidence`,
384
+ `Daily (${report.periods.daily.current.period}) mechanical reduction: ${tokenLine(report.periods.daily.current.estimatedTransformSavingsTokens)}`,
385
+ `ISO week (${report.periods.weekly.current.period}) mechanical reduction: ${tokenLine(report.periods.weekly.current.estimatedTransformSavingsTokens)}`,
386
+ `Monthly (${report.periods.monthly.current.period}) mechanical reduction: ${tokenLine(report.periods.monthly.current.estimatedTransformSavingsTokens)}`,
387
387
  ];
388
388
  if (!report.cumulative.eventCount) lines.push('No Sando events recorded.');
389
389
  return `${lines.join('\n')}\n`;
@@ -0,0 +1,142 @@
1
+ const ARMS = new Set(['apply', 'control']);
2
+
3
+ export const PAIRED_ARMS = Object.freeze(['apply', 'control']);
4
+ export const DEFAULT_ACCOUNTING_WEIGHTS = Object.freeze({
5
+ freshInput: 1,
6
+ cacheRead: 0.1,
7
+ cacheWrite: 1.25,
8
+ output: 1,
9
+ reasoningOutput: 1,
10
+ });
11
+
12
+ function text(value) {
13
+ return typeof value === 'string' && value.length > 0;
14
+ }
15
+
16
+ function counter(value) {
17
+ return Number.isSafeInteger(value) && value >= 0;
18
+ }
19
+
20
+ function weights(value) {
21
+ const result = { ...DEFAULT_ACCOUNTING_WEIGHTS, ...(value ?? {}) };
22
+ if (Object.keys(result).some((key) => !Object.hasOwn(DEFAULT_ACCOUNTING_WEIGHTS, key))
23
+ || Object.values(result).some((item) => typeof item !== 'number' || !Number.isFinite(item) || item < 0)) {
24
+ throw new TypeError('accounting weights are invalid');
25
+ }
26
+ return result;
27
+ }
28
+
29
+ function add(left, right, message = 'accounting aggregate overflow') {
30
+ const total = left + right;
31
+ if (!Number.isSafeInteger(total)) throw new RangeError(message);
32
+ return total;
33
+ }
34
+
35
+ function addCostUnits(left, right) {
36
+ const total = left + right;
37
+ if (!Number.isFinite(total)) throw new RangeError('accounting cost overflow');
38
+ return total;
39
+ }
40
+
41
+ function armValue(env) {
42
+ return env.SANDO_EXPERIMENT_ARM ?? env.SANDO_ADAPTIVE_ARM ?? 'apply';
43
+ }
44
+
45
+ export function pairedArmFromEnv(env = process.env) {
46
+ const value = armValue(env);
47
+ return ARMS.has(value) ? value : null;
48
+ }
49
+
50
+ export function pairedExperimentFromEnv(env = process.env) {
51
+ const value = env.SANDO_EXPERIMENT ?? env.SANDO_ADAPTIVE_EXPERIMENT ?? 'default';
52
+ return text(value) ? value : 'default';
53
+ }
54
+
55
+ export function pairedWorkloadFromEnv(env = process.env) {
56
+ const value = env.SANDO_EXPERIMENT_WORKLOAD ?? env.SANDO_ADAPTIVE_WORKLOAD;
57
+ return text(value) ? value : undefined;
58
+ }
59
+
60
+ export function computeWeightedUsage(record, pricing) {
61
+ if (!record || typeof record !== 'object' || Array.isArray(record)) throw new TypeError('usage record is invalid');
62
+ const inputTokens = record.inputTokens;
63
+ const cachedInputTokens = record.cachedInputTokens ?? 0;
64
+ const cacheWriteInputTokens = record.cacheWriteInputTokens ?? 0;
65
+ const outputTokens = record.outputTokens;
66
+ const reasoningOutputTokens = record.reasoningOutputTokens ?? 0;
67
+ if (![inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens, reasoningOutputTokens].every(counter)
68
+ || cachedInputTokens + cacheWriteInputTokens > inputTokens
69
+ || reasoningOutputTokens > outputTokens) throw new TypeError('usage counters are invalid');
70
+ const nonReasoningOutputTokens = outputTokens - reasoningOutputTokens;
71
+ const parts = {
72
+ inputTokens,
73
+ freshInputTokens: inputTokens - cachedInputTokens - cacheWriteInputTokens,
74
+ cachedInputTokens,
75
+ cacheWriteInputTokens,
76
+ outputTokens,
77
+ nonReasoningOutputTokens,
78
+ reasoningOutputTokens,
79
+ };
80
+ const prices = weights(pricing);
81
+ const costUnits = parts.freshInputTokens * prices.freshInput
82
+ + parts.cachedInputTokens * prices.cacheRead
83
+ + parts.cacheWriteInputTokens * prices.cacheWrite
84
+ + parts.nonReasoningOutputTokens * prices.output
85
+ + parts.reasoningOutputTokens * prices.reasoningOutput;
86
+ if (!Number.isFinite(costUnits)) throw new RangeError('accounting cost overflow');
87
+ return { ...parts, costUnits };
88
+ }
89
+
90
+ function sessionKey(item) {
91
+ return `${item.arm}\0${item.sessionId}`;
92
+ }
93
+
94
+ function optionalCounter(item, field) {
95
+ return item[field] === undefined ? undefined : counter(item[field]) ? item[field] : null;
96
+ }
97
+
98
+ export function summarizePairedSessions(records, { host, experimentId, workloadId, pricing } = {}) {
99
+ if (!Array.isArray(records)) throw new TypeError('usage records must be an array');
100
+ const groups = new Map();
101
+ records.forEach((item, index) => {
102
+ if (!item || typeof item !== 'object' || item.host !== host || !ARMS.has(item.arm)
103
+ || !text(item.sessionId) || (experimentId !== undefined && item.experimentId !== experimentId)
104
+ || (workloadId !== undefined && item.workloadId !== workloadId)) return;
105
+ const usage = computeWeightedUsage(item, pricing);
106
+ const fields = {
107
+ inputTokens: usage.inputTokens,
108
+ freshInputTokens: usage.freshInputTokens,
109
+ cachedInputTokens: usage.cachedInputTokens,
110
+ cacheWriteInputTokens: usage.cacheWriteInputTokens,
111
+ outputTokens: usage.outputTokens,
112
+ reasoningOutputTokens: usage.reasoningOutputTokens,
113
+ totalTokens: add(item.inputTokens, item.outputTokens),
114
+ costUnits: usage.costUnits,
115
+ };
116
+ const current = groups.get(sessionKey(item));
117
+ if (!current) {
118
+ groups.set(sessionKey(item), {
119
+ sessionId: item.sessionId, arm: item.arm, ...fields,
120
+ turnIds: new Set([text(item.turnId) ? item.turnId : `record:${index}`]),
121
+ totalToolCalls: optionalCounter(item, 'totalToolCalls') ?? null,
122
+ nativeToolCalls: optionalCounter(item, 'nativeToolCalls') ?? null,
123
+ sandoMcpCalls: optionalCounter(item, 'sandoMcpCalls') ?? null,
124
+ mechanicalContextTrimmedBytes: optionalCounter(item, 'mechanicalContextTrimmedBytes') ?? null,
125
+ });
126
+ return;
127
+ }
128
+ for (const field of Object.keys(fields)) current[field] = field === 'costUnits'
129
+ ? addCostUnits(current[field], fields[field]) : add(current[field], fields[field]);
130
+ current.turnIds.add(text(item.turnId) ? item.turnId : `record:${index}`);
131
+ for (const field of ['totalToolCalls', 'nativeToolCalls', 'sandoMcpCalls', 'mechanicalContextTrimmedBytes']) {
132
+ if (current[field] !== null) {
133
+ const value = optionalCounter(item, field);
134
+ current[field] = value === undefined || value === null || current[field] === null
135
+ ? null : add(current[field], value);
136
+ }
137
+ }
138
+ });
139
+ return [...groups.values()]
140
+ .map(({ turnIds, ...group }) => ({ ...group, turns: turnIds.size }))
141
+ .sort((left, right) => left.arm.localeCompare(right.arm) || left.sessionId.localeCompare(right.sessionId));
142
+ }
@@ -3,11 +3,14 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
+ import { computeWeightedUsage } from './paired-accounting.mjs';
7
+
6
8
  const SCHEMA = 'sando-provider-usage/v1';
7
9
  const VERSION = 1;
8
10
  const LOCK_WAIT_MS = 10;
9
11
  const LOCK_ATTEMPTS = 250;
10
12
  const STALE_LOCK_MS = 30_000;
13
+ const COST_SCOPES = new Set(['session', 'event']);
11
14
 
12
15
  export const PROVIDER_USAGE_SCHEMA = SCHEMA;
13
16
  export const PROVIDER_USAGE_VERSION = VERSION;
@@ -15,10 +18,19 @@ export const PROVIDER_USAGE_VERSION = VERSION;
15
18
  function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
16
19
  function text(value) { return typeof value === 'string' && value.length > 0; }
17
20
  function counter(value) { return Number.isSafeInteger(value) && value >= 0; }
21
+ function usd(value) { return typeof value === 'number' && Number.isFinite(value) && value >= 0; }
22
+ function cacheFits(inputTokens, cachedInputTokens, cacheWriteInputTokens) {
23
+ return cacheWriteInputTokens <= inputTokens
24
+ && cachedInputTokens <= inputTokens - cacheWriteInputTokens;
25
+ }
18
26
  function safeSum(...values) {
19
27
  const total = values.reduce((sum, value) => sum + value, 0);
20
28
  return Number.isSafeInteger(total) ? total : null;
21
29
  }
30
+ function sumUsd(values) {
31
+ const total = values.reduce((sum, value) => sum + value, 0);
32
+ return Number.isFinite(total) ? Number(total.toFixed(12)) : null;
33
+ }
22
34
  function sha256(value) { return `sha256:${createHash('sha256').update(value).digest('hex')}`; }
23
35
  function isoDate(value, fallback = new Date()) {
24
36
  const date = value === undefined ? new Date(fallback) : new Date(value);
@@ -39,26 +51,55 @@ function jsonLines(textValue) {
39
51
  });
40
52
  }
41
53
 
54
+ function reportedCost(value) {
55
+ const candidates = [
56
+ value?.total_cost_usd,
57
+ value?.totalCostUsd,
58
+ value?.cost_usd,
59
+ value?.costUsd,
60
+ value?.usage?.total_cost_usd,
61
+ value?.usage?.totalCostUsd,
62
+ value?.message?.usage?.total_cost_usd,
63
+ value?.message?.usage?.totalCostUsd,
64
+ value?.cost?.total_cost_usd,
65
+ value?.cost?.totalCostUsd,
66
+ value?.cost?.usd,
67
+ ];
68
+ return candidates.find(usd);
69
+ }
70
+
71
+ function attachReportedCost(records, totalCostUsd) {
72
+ if (!records.length || !usd(totalCostUsd)) return records;
73
+ return records.map((item, index) => index === records.length - 1
74
+ ? { ...item, totalCostUsd, costScope: 'session' } : item);
75
+ }
76
+
42
77
  function usageRecord({ host, source, sourceKey, sessionId, turnId, at, inputTokens, cachedInputTokens = 0,
43
- cacheWriteInputTokens = 0, outputTokens, reasoningOutputTokens = 0 }) {
78
+ cacheWriteInputTokens = 0, outputTokens, reasoningOutputTokens = 0, totalCostUsd, arm, experimentId, workloadId }) {
44
79
  if (!text(host) || !text(source) || !text(sourceKey)
45
80
  || (sessionId !== null && !text(sessionId)) || (turnId !== null && !text(turnId))
46
81
  || !text(at) || !counter(inputTokens) || !counter(cachedInputTokens)
47
- || !counter(cacheWriteInputTokens) || !counter(outputTokens) || !counter(reasoningOutputTokens)) return null;
82
+ || !counter(cacheWriteInputTokens) || !cacheFits(inputTokens, cachedInputTokens, cacheWriteInputTokens)
83
+ || !counter(outputTokens) || !counter(reasoningOutputTokens) || reasoningOutputTokens > outputTokens) return null;
48
84
  const totalTokens = safeSum(inputTokens, outputTokens);
49
85
  if (totalTokens === null) return null;
50
86
  const identity = JSON.stringify({ host, source, sourceKey, at, inputTokens, cachedInputTokens,
51
- cacheWriteInputTokens, outputTokens, reasoningOutputTokens, totalTokens });
52
- return {
87
+ cacheWriteInputTokens, outputTokens, reasoningOutputTokens, totalTokens, arm, experimentId, workloadId });
88
+ const result = {
53
89
  eventKey: `usage:${host}:${sha256(identity)}`,
54
90
  schema: SCHEMA, version: VERSION, host, source,
55
91
  sessionId: sessionId ?? null, turnId: turnId ?? null, at,
56
92
  inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens,
57
93
  reasoningOutputTokens, totalTokens,
58
94
  };
95
+ if (totalCostUsd !== undefined && usd(totalCostUsd)) result.totalCostUsd = totalCostUsd;
96
+ if (arm !== undefined) result.arm = arm;
97
+ if (experimentId !== undefined) result.experimentId = experimentId;
98
+ if (workloadId !== undefined) result.workloadId = workloadId;
99
+ return result;
59
100
  }
60
101
 
61
- function claudeRecord(value, index, { sessionId = null, turnId = null, now } = {}) {
102
+ function claudeRecord(value, index, { sessionId = null, turnId = null, now, arm, experimentId, workloadId } = {}) {
62
103
  if (value.type !== 'assistant' || !record(value.message?.usage)) return null;
63
104
  const usage = value.message.usage;
64
105
  const inputTokens = usage.input_tokens;
@@ -74,10 +115,11 @@ function claudeRecord(value, index, { sessionId = null, turnId = null, now } = {
74
115
  host: 'claude', source: 'claude-transcript', sourceKey: value.uuid ?? value.request_id ?? value.timestamp ?? String(index),
75
116
  sessionId, turnId: value.turn_id ?? turnId, at: isoDate(value.timestamp, now),
76
117
  inputTokens: totalInputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens,
118
+ arm, experimentId, workloadId,
77
119
  });
78
120
  }
79
121
 
80
- function codexRecord(value, index, { sessionId = null, turnId = null, now } = {}) {
122
+ function codexRecord(value, index, { sessionId = null, turnId = null, now, arm, experimentId, workloadId } = {}) {
81
123
  const usage = value.type === 'turn.completed'
82
124
  ? value.usage
83
125
  : value.type === 'event_msg' && value.payload?.type === 'token_count'
@@ -95,17 +137,24 @@ function codexRecord(value, index, { sessionId = null, turnId = null, now } = {}
95
137
  if (!counter(totalTokens) || totalTokens !== inputTokens + outputTokens) return null;
96
138
  return usageRecord({
97
139
  host: 'codex', source: 'codex-transcript', sourceKey: value.turn_id ?? value.id ?? value.timestamp ?? String(index),
98
- sessionId, turnId: value.turn_id ?? turnId, at: isoDate(value.timestamp, now),
140
+ sessionId, turnId: value.turn_id ?? value.id ?? (value.timestamp ? `at:${value.timestamp}` : turnId), at: isoDate(value.timestamp, now),
99
141
  inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens, reasoningOutputTokens,
142
+ arm, experimentId, workloadId,
100
143
  });
101
144
  }
102
145
 
103
146
  export function parseClaudeTranscript(textValue, options = {}) {
104
- return jsonLines(textValue).map(({ value }, index) => claudeRecord(value, index, options)).filter(Boolean);
147
+ const entries = jsonLines(textValue);
148
+ const records = entries.map(({ value }, index) => claudeRecord(value, index, options)).filter(Boolean);
149
+ const totalCostUsd = options.totalCostUsd ?? entries.slice().reverse().map(({ value }) => reportedCost(value)).find(usd);
150
+ return attachReportedCost(records, totalCostUsd);
105
151
  }
106
152
 
107
153
  export function parseCodexTranscript(textValue, options = {}) {
108
- return jsonLines(textValue).map(({ value }, index) => codexRecord(value, index, options)).filter(Boolean);
154
+ const entries = jsonLines(textValue);
155
+ const records = entries.map(({ value }, index) => codexRecord(value, index, options)).filter(Boolean);
156
+ const totalCostUsd = options.totalCostUsd ?? entries.slice().reverse().map(({ value }) => reportedCost(value)).find(usd);
157
+ return attachReportedCost(records, totalCostUsd);
109
158
  }
110
159
 
111
160
  export function defaultProviderUsagePath(env = process.env) {
@@ -131,8 +180,17 @@ function validateUsage(value) {
131
180
  || !text(value.host) || !text(value.source) || !text(value.at)
132
181
  || (value.sessionId !== null && !text(value.sessionId)) || (value.turnId !== null && !text(value.turnId))
133
182
  || !counter(value.inputTokens) || !counter(value.cachedInputTokens) || !counter(value.cacheWriteInputTokens)
134
- || !counter(value.outputTokens) || !counter(value.reasoningOutputTokens) || !counter(value.totalTokens)
135
- || value.totalTokens !== value.inputTokens + value.outputTokens) throw new Error('provider usage record is invalid');
183
+ || !cacheFits(value.inputTokens, value.cachedInputTokens, value.cacheWriteInputTokens)
184
+ || !counter(value.outputTokens) || !counter(value.reasoningOutputTokens) || value.reasoningOutputTokens > value.outputTokens
185
+ || !counter(value.totalTokens)
186
+ || value.totalTokens !== value.inputTokens + value.outputTokens
187
+ || (value.arm !== undefined && !['apply', 'control'].includes(value.arm))
188
+ || (value.experimentId !== undefined && !text(value.experimentId))
189
+ || (value.workloadId !== undefined && !text(value.workloadId))
190
+ || (value.totalCostUsd !== undefined && !usd(value.totalCostUsd))
191
+ || (value.costScope !== undefined && (!COST_SCOPES.has(value.costScope) || value.totalCostUsd === undefined))) {
192
+ throw new Error('provider usage record is invalid');
193
+ }
136
194
  }
137
195
 
138
196
  function validateState(value) {
@@ -191,34 +249,92 @@ export function appendProviderUsage({ storagePath = defaultProviderUsagePath(),
191
249
  ensureDirectory(path.dirname(filePath));
192
250
  return withLock(`${filePath}.lock`, () => {
193
251
  const state = readProviderUsage(filePath);
194
- const existing = new Set(state.records.map((item) => item.eventKey));
195
- for (const item of records) if (!existing.has(item.eventKey)) { state.records.push(item); existing.add(item.eventKey); }
252
+ const existing = new Map(state.records.map((item, index) => [item.eventKey, index]));
253
+ for (const item of records) {
254
+ const index = existing.get(item.eventKey);
255
+ if (index === undefined) {
256
+ existing.set(item.eventKey, state.records.length);
257
+ state.records.push(item);
258
+ } else if (item.costScope === 'session') {
259
+ state.records[index] = item;
260
+ }
261
+ }
196
262
  atomicWrite(filePath, state);
197
263
  return state;
198
264
  });
199
265
  }
200
266
 
201
267
  export function collectProviderUsage({ host, transcriptPath, sessionId = null, turnId = null,
202
- storagePath = defaultProviderUsagePath(), now } = {}) {
268
+ storagePath = defaultProviderUsagePath(), now, totalCostUsd, arm, experimentId, workloadId } = {}) {
203
269
  if (!['claude', 'codex'].includes(host) || typeof transcriptPath !== 'string' || !transcriptPath) return { records: [], state: readProviderUsage(storagePath) };
204
270
  try {
205
271
  const textValue = fs.readFileSync(transcriptPath, 'utf8');
206
272
  const parse = host === 'claude' ? parseClaudeTranscript : parseCodexTranscript;
207
- const records = parse(textValue, { sessionId, turnId, now });
273
+ const records = parse(textValue, { sessionId, turnId, now, totalCostUsd, arm, experimentId, workloadId });
208
274
  return { records, state: appendProviderUsage({ storagePath, records }) };
209
275
  } catch {
210
276
  return { records: [], state: readProviderUsage(storagePath) };
211
277
  }
212
278
  }
213
279
 
214
- export function buildProviderUsageReport(state, { sessionId } = {}) {
280
+ export function buildProviderUsageReport(state, { sessionId, pricing } = {}) {
215
281
  const records = validateState(state).records.filter((item) => sessionId === undefined || item.sessionId === sessionId);
216
282
  const sessions = new Set(records.map((item) => `${item.host}\0${item.sessionId ?? '<unknown>'}`));
283
+ const turns = new Set(records.map((item, index) => `${item.host}\0${item.sessionId ?? '<unknown>'}\0${item.turnId ?? `record:${index}`}`));
217
284
  const sum = (field) => records.reduce((total, item) => total + item[field], 0);
285
+ const weightedCostUnits = records.reduce((total, item) => total + computeWeightedUsage(item, pricing).costUnits, 0);
286
+ const freshInputTokens = records.reduce((total, item) => total + item.inputTokens - item.cachedInputTokens - item.cacheWriteInputTokens, 0);
287
+ const billing = reportedCostSummary(records);
288
+ const totalCostUsd = billing.complete ? billing.totalCostUsd : null;
289
+ const effectiveRate = totalCostUsd !== null && totalTokens(records) > 0
290
+ ? totalCostUsd / totalTokens(records) * 1_000_000 : null;
291
+ const cost = {
292
+ status: billing.complete ? 'provider-reported' : 'unavailable',
293
+ coverage: billing.complete ? 'complete' : billing.partial ? 'partial' : 'none',
294
+ totalCostUsd,
295
+ effectiveRateUsdPerMillionTokens: effectiveRate,
296
+ };
218
297
  return {
219
298
  eventCount: records.length, sessionCount: sessions.size,
220
299
  inputTokens: sum('inputTokens'), cachedInputTokens: sum('cachedInputTokens'),
221
- cacheWriteInputTokens: sum('cacheWriteInputTokens'), outputTokens: sum('outputTokens'),
222
- reasoningOutputTokens: sum('reasoningOutputTokens'), totalTokens: sum('totalTokens'),
300
+ cacheWriteInputTokens: sum('cacheWriteInputTokens'), freshInputTokens,
301
+ outputTokens: sum('outputTokens'), reasoningOutputTokens: sum('reasoningOutputTokens'), totalTokens: sum('totalTokens'),
302
+ turnCount: turns.size, weightedCostUnits,
303
+ weightedCost: { source: 'weighted-estimate', costUnits: weightedCostUnits },
304
+ cost,
305
+ totalCostUsd,
306
+ providerReportedCostUsd: totalCostUsd,
307
+ sessionBlendedEffectiveRateUsdPerMillionTokens: effectiveRate,
308
+ costSource: cost.status,
223
309
  };
224
310
  }
311
+
312
+ function billingKey(item) {
313
+ return `${item.host}\0${item.sessionId ?? '<unknown>'}`;
314
+ }
315
+
316
+ function reportedCostSummary(records) {
317
+ const groups = new Map();
318
+ records.forEach((item) => {
319
+ const entries = groups.get(billingKey(item)) ?? [];
320
+ entries.push(item);
321
+ groups.set(billingKey(item), entries);
322
+ });
323
+ const totals = [];
324
+ for (const entries of groups.values()) {
325
+ const sessionCosts = entries.filter((item) => item.costScope === 'session' && usd(item.totalCostUsd));
326
+ if (sessionCosts.length) {
327
+ const latest = sessionCosts.reduce((left, right) => right.at >= left.at ? right : left);
328
+ totals.push(latest.totalCostUsd);
329
+ continue;
330
+ }
331
+ if (entries.every((item) => usd(item.totalCostUsd))) totals.push(sumUsd(entries.map((item) => item.totalCostUsd)));
332
+ }
333
+ const complete = groups.size > 0 && totals.length === groups.size && !totals.includes(null);
334
+ const totalCostUsd = complete ? sumUsd(totals) : null;
335
+ return { complete: complete && totalCostUsd !== null, partial: totals.length > 0, totalCostUsd };
336
+ }
337
+
338
+ function totalTokens(records) {
339
+ return records.reduce((total, item) => total + item.totalTokens, 0);
340
+ }
package/src/proxy.mjs CHANGED
@@ -225,14 +225,15 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
225
225
  lastRequestAt = now;
226
226
  const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
227
227
  if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
228
+ const mechanicalContextTrimmedBytes = Math.max(0, rawBody.length - body.length);
228
229
  recordProxyTelemetry({
229
230
  env, provider, transformed,
230
231
  beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
231
232
  });
232
- lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
233
+ lastStats = { provider, ...transformed.stats, mechanicalContextTrimmedBytes, changed: transformed.changed, reasons: transformed.reasons };
233
234
  recordProvider = provider;
234
235
  recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
235
- recordStats = transformed.stats;
236
+ recordStats = { ...transformed.stats, mechanicalContextTrimmedBytes };
236
237
  if (typeof semanticCompactor === 'function') {
237
238
  const candidates = listSemanticCandidates({ provider, body: transformed.body });
238
239
  const stats = createSemanticStats(candidates);
@@ -80,21 +80,23 @@ function compactTokens(value) {
80
80
  return `${Number((value / 1_000_000).toFixed(2))}M`;
81
81
  }
82
82
 
83
- // The rate is the session's own blended $/token (totalCostUsd / totalTokens),
84
- // so cache reads (0.1x) and cache writes (1.25x/2x) are already folded in:
85
- // it's the harness's real billed rate, not a reconstructed list price.
86
- function compactCost(tokens, effectiveRate) {
87
- return `$${(tokens * effectiveRate).toFixed(2)}`;
83
+ function compactTurns(value) {
84
+ return `${value} ${value === 1 ? 'turn' : 'turns'}`;
88
85
  }
89
86
 
90
87
  export function renderStatusLine({ metrics, providerUsage, totalCostUsd } = {}, _now = Date.now()) {
91
- if (!metrics || !['estimate', 'provider-reported'].includes(metrics.source)
92
- || !Number.isSafeInteger(metrics.savedTokens) || metrics.savedTokens <= 0) return '🥪 —';
93
- const estimated = metrics.source === 'estimate';
94
- const savings = `${estimated ? '~' : ''}${compactTokens(metrics.savedTokens)} token saved`;
95
- const effectiveRate = Number.isFinite(totalCostUsd) && totalCostUsd > 0
96
- && Number.isSafeInteger(providerUsage?.totalTokens) && providerUsage.totalTokens > 0
97
- ? totalCostUsd / providerUsage.totalTokens : undefined;
98
- void effectiveRate; // computed for downstream cost tracking, intentionally not shown in the status bar
99
- return `🥪 ${savings}`;
88
+ if (!Number.isSafeInteger(providerUsage?.totalTokens) || providerUsage.totalTokens <= 0
89
+ || !Number.isSafeInteger(providerUsage?.turnCount) || providerUsage.turnCount <= 0) return '🥪 —';
90
+ const parts = [
91
+ `${compactTokens(providerUsage.totalTokens)} provider tokens`,
92
+ compactTurns(providerUsage.turnCount),
93
+ ];
94
+ if (Number.isFinite(providerUsage.weightedCostUnits) && providerUsage.weightedCostUnits >= 0) {
95
+ parts.push(`${compactTokens(Math.round(providerUsage.weightedCostUnits))} cost units`);
96
+ }
97
+ if (Number.isFinite(totalCostUsd) && totalCostUsd >= 0) {
98
+ const effectiveRate = totalCostUsd / providerUsage.totalTokens;
99
+ parts.push(`$${totalCostUsd.toFixed(2)}`, `$${(effectiveRate * 1_000_000).toFixed(2)}/M`);
100
+ }
101
+ return `🥪 ${parts.join(' · ')}`;
100
102
  }