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 +3 -1
- package/index.mjs +10 -0
- package/package.json +2 -2
- package/src/accounting-cli.mjs +49 -0
- package/src/adaptive-control.mjs +10 -0
- package/src/exec-capture.mjs +94 -0
- package/src/metrics.mjs +8 -8
- package/src/paired-accounting.mjs +142 -0
- package/src/provider-usage.mjs +134 -18
- package/src/proxy.mjs +3 -2
- package/src/statusline.mjs +16 -14
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.
|
|
4
|
-
"description": "
|
|
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
|
|
378
|
+
`Sando mechanical measurement (timezone: ${report.timezone})`,
|
|
379
379
|
`Current session: ${report.currentSession ? report.currentSession.id ?? 'unknown' : 'none'}`,
|
|
380
|
-
`Current session
|
|
381
|
-
`Average
|
|
382
|
-
`Cumulative
|
|
383
|
-
`
|
|
384
|
-
`Daily (${report.periods.daily.current.period})
|
|
385
|
-
`ISO week (${report.periods.weekly.current.period})
|
|
386
|
-
`Monthly (${report.periods.monthly.current.period})
|
|
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
|
+
}
|
package/src/provider-usage.mjs
CHANGED
|
@@ -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) || !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|| !
|
|
135
|
-
|| value.
|
|
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
|
|
195
|
-
for (const item of records)
|
|
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'),
|
|
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);
|
package/src/statusline.mjs
CHANGED
|
@@ -80,21 +80,23 @@ function compactTokens(value) {
|
|
|
80
80
|
return `${Number((value / 1_000_000).toFixed(2))}M`;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
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 (!
|
|
92
|
-
|| !Number.isSafeInteger(
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
}
|