sentinelayer-cli 0.31.4 → 0.32.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/package.json +1 -1
- package/src/ai/client.js +71 -0
- package/src/commands/session.js +121 -0
- package/src/session/daemon.js +23 -0
- package/src/session/sync.js +90 -0
- package/src/session/usage-report.js +382 -0
- package/src/session/usage.js +3 -1
package/package.json
CHANGED
package/src/ai/client.js
CHANGED
|
@@ -146,6 +146,70 @@ function extractTextFromResponse(provider, payload) {
|
|
|
146
146
|
return "";
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
function normalizeUsageNumber(value) {
|
|
150
|
+
const parsed = Number(value);
|
|
151
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
return Math.floor(parsed);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildUsagePayload({ provider, model, inputTokens = 0, outputTokens = 0, totalTokens = 0 } = {}) {
|
|
158
|
+
const input = normalizeUsageNumber(inputTokens);
|
|
159
|
+
const output = normalizeUsageNumber(outputTokens);
|
|
160
|
+
const total = normalizeUsageNumber(totalTokens) || input + output;
|
|
161
|
+
if (total <= 0 && input + output <= 0) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
inputTokens: input,
|
|
166
|
+
outputTokens: output,
|
|
167
|
+
totalTokens: total,
|
|
168
|
+
model,
|
|
169
|
+
provider,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function extractUsageFromResponse(provider, payload, model) {
|
|
174
|
+
if (!payload || typeof payload !== "object") {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (provider === "openai") {
|
|
179
|
+
const usage = payload.usage || {};
|
|
180
|
+
return buildUsagePayload({
|
|
181
|
+
provider,
|
|
182
|
+
model,
|
|
183
|
+
inputTokens: usage.prompt_tokens ?? usage.input_tokens,
|
|
184
|
+
outputTokens: usage.completion_tokens ?? usage.output_tokens,
|
|
185
|
+
totalTokens: usage.total_tokens,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (provider === "anthropic") {
|
|
190
|
+
const usage = payload.usage || {};
|
|
191
|
+
return buildUsagePayload({
|
|
192
|
+
provider,
|
|
193
|
+
model,
|
|
194
|
+
inputTokens: usage.input_tokens,
|
|
195
|
+
outputTokens: usage.output_tokens,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (provider === "google") {
|
|
200
|
+
const usage = payload.usageMetadata || payload.usage_metadata || {};
|
|
201
|
+
return buildUsagePayload({
|
|
202
|
+
provider,
|
|
203
|
+
model,
|
|
204
|
+
inputTokens: usage.promptTokenCount ?? usage.prompt_token_count,
|
|
205
|
+
outputTokens: usage.candidatesTokenCount ?? usage.candidates_token_count,
|
|
206
|
+
totalTokens: usage.totalTokenCount ?? usage.total_token_count,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
149
213
|
function buildProviderRequest({ provider, apiKey, model, prompt, stream }) {
|
|
150
214
|
const normalizedProvider = normalizeProvider(provider);
|
|
151
215
|
const normalizedPrompt = normalizePrompt(prompt);
|
|
@@ -527,6 +591,13 @@ export class MultiProviderApiClient {
|
|
|
527
591
|
} else {
|
|
528
592
|
const payload = await response.json();
|
|
529
593
|
text = extractTextFromResponse(resolvedProvider, payload);
|
|
594
|
+
const usage = extractUsageFromResponse(resolvedProvider, payload, resolvedModel);
|
|
595
|
+
return {
|
|
596
|
+
provider: resolvedProvider,
|
|
597
|
+
model: resolvedModel,
|
|
598
|
+
text,
|
|
599
|
+
usage: usage || undefined,
|
|
600
|
+
};
|
|
530
601
|
}
|
|
531
602
|
|
|
532
603
|
return {
|
package/src/commands/session.js
CHANGED
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
import { readSessionPreview } from "../session/preview.js";
|
|
88
88
|
import {
|
|
89
89
|
createSessionMessageAction,
|
|
90
|
+
fetchSessionUsageLedger,
|
|
90
91
|
fetchSessionPinnedMessages,
|
|
91
92
|
listSessionMessageActions,
|
|
92
93
|
listSessionsFromApi,
|
|
@@ -108,6 +109,12 @@ import {
|
|
|
108
109
|
} from "../session/rotating-log.js";
|
|
109
110
|
import { buildSessionRecap } from "../session/recap.js";
|
|
110
111
|
import { computeTranscriptStats } from "../session/transcript.js";
|
|
112
|
+
import {
|
|
113
|
+
buildSessionUsageReport,
|
|
114
|
+
buildSessionUsageReportFromLedgerPayload,
|
|
115
|
+
renderSessionUsageMarkdown,
|
|
116
|
+
renderSessionUsageSummary,
|
|
117
|
+
} from "../session/usage-report.js";
|
|
111
118
|
import { deriveSessionTitle } from "../session/senti-naming.js";
|
|
112
119
|
import { pushSessionTitleToApi } from "../session/title-sync.js";
|
|
113
120
|
import {
|
|
@@ -6487,6 +6494,120 @@ export function registerSessionCommand(program) {
|
|
|
6487
6494
|
}
|
|
6488
6495
|
});
|
|
6489
6496
|
|
|
6497
|
+
session
|
|
6498
|
+
.command("usage <sessionId>")
|
|
6499
|
+
.description(
|
|
6500
|
+
"Show a sanitized token/cost usage report for a session without raw prompts, responses, or config values",
|
|
6501
|
+
)
|
|
6502
|
+
.option(
|
|
6503
|
+
"--remote",
|
|
6504
|
+
"Hydrate from the SentinelLayer API before rendering (uses the authenticated account-scoped session events endpoint)",
|
|
6505
|
+
)
|
|
6506
|
+
.option("--recent <n>", "Recent ledger entries to include in the report (0-50)", "10")
|
|
6507
|
+
.option("--format <format>", "Output format: summary, markdown, or json", "summary")
|
|
6508
|
+
.option("--out <file>", "Write the selected report format to a file")
|
|
6509
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
6510
|
+
.option("--json", "Emit machine-readable JSON output")
|
|
6511
|
+
.action(async (sessionId, options, command) => {
|
|
6512
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
6513
|
+
if (!normalizedSessionId) {
|
|
6514
|
+
throw new Error("session id is required.");
|
|
6515
|
+
}
|
|
6516
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
6517
|
+
const emitJson = shouldEmitJson(options, command);
|
|
6518
|
+
const requestedFormat = emitJson
|
|
6519
|
+
? "json"
|
|
6520
|
+
: normalizeString(options.format || "summary").toLowerCase();
|
|
6521
|
+
if (!["summary", "markdown", "json"].includes(requestedFormat)) {
|
|
6522
|
+
throw new Error("--format must be one of: summary, markdown, json.");
|
|
6523
|
+
}
|
|
6524
|
+
|
|
6525
|
+
let hydration = null;
|
|
6526
|
+
let hostedUsage = null;
|
|
6527
|
+
let events = [];
|
|
6528
|
+
let dedupedEvents = [];
|
|
6529
|
+
let report = null;
|
|
6530
|
+
if (options.remote) {
|
|
6531
|
+
hostedUsage = await fetchSessionUsageLedger(normalizedSessionId, {
|
|
6532
|
+
targetPath,
|
|
6533
|
+
limit: 500,
|
|
6534
|
+
});
|
|
6535
|
+
if (hostedUsage.ok) {
|
|
6536
|
+
report = buildSessionUsageReportFromLedgerPayload({
|
|
6537
|
+
sessionId: normalizedSessionId,
|
|
6538
|
+
payload: hostedUsage.payload,
|
|
6539
|
+
recentLimit: options.recent,
|
|
6540
|
+
});
|
|
6541
|
+
} else {
|
|
6542
|
+
hydration = await hydrateSessionFromRemote({
|
|
6543
|
+
sessionId: normalizedSessionId,
|
|
6544
|
+
targetPath,
|
|
6545
|
+
}).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
|
|
6546
|
+
}
|
|
6547
|
+
}
|
|
6548
|
+
|
|
6549
|
+
if (!report) {
|
|
6550
|
+
const sessionPayload = await getSession(normalizedSessionId, { targetPath });
|
|
6551
|
+
if (!sessionPayload) {
|
|
6552
|
+
throw new Error(`Session '${normalizedSessionId}' was not found.`);
|
|
6553
|
+
}
|
|
6554
|
+
|
|
6555
|
+
events = await readStream(normalizedSessionId, { targetPath, tail: 0 });
|
|
6556
|
+
dedupedEvents = dedupeSessionEvents(events);
|
|
6557
|
+
report = buildSessionUsageReport({
|
|
6558
|
+
sessionId: normalizedSessionId,
|
|
6559
|
+
events: dedupedEvents,
|
|
6560
|
+
recentLimit: options.recent,
|
|
6561
|
+
});
|
|
6562
|
+
}
|
|
6563
|
+
|
|
6564
|
+
const payload = {
|
|
6565
|
+
command: "session usage",
|
|
6566
|
+
targetPath,
|
|
6567
|
+
source: hostedUsage?.ok ? "remote_usage" : "local_events",
|
|
6568
|
+
remote: {
|
|
6569
|
+
usage: hostedUsage
|
|
6570
|
+
? {
|
|
6571
|
+
ok: Boolean(hostedUsage.ok),
|
|
6572
|
+
reason: hostedUsage.reason || "",
|
|
6573
|
+
status: hostedUsage.status || 0,
|
|
6574
|
+
}
|
|
6575
|
+
: null,
|
|
6576
|
+
hydration,
|
|
6577
|
+
},
|
|
6578
|
+
rawEventCount: events.length,
|
|
6579
|
+
eventCount: dedupedEvents.length,
|
|
6580
|
+
...report,
|
|
6581
|
+
};
|
|
6582
|
+
|
|
6583
|
+
const output =
|
|
6584
|
+
requestedFormat === "json"
|
|
6585
|
+
? `${JSON.stringify(payload, null, 2)}\n`
|
|
6586
|
+
: requestedFormat === "markdown"
|
|
6587
|
+
? renderSessionUsageMarkdown(report)
|
|
6588
|
+
: renderSessionUsageSummary(report);
|
|
6589
|
+
|
|
6590
|
+
const outArg = normalizeString(options.out);
|
|
6591
|
+
if (outArg) {
|
|
6592
|
+
const outPath = path.resolve(process.cwd(), outArg);
|
|
6593
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true });
|
|
6594
|
+
await fsp.writeFile(outPath, output, "utf-8");
|
|
6595
|
+
if (emitJson || requestedFormat === "json") {
|
|
6596
|
+
console.log(JSON.stringify({ ...payload, outPath }, null, 2));
|
|
6597
|
+
return;
|
|
6598
|
+
}
|
|
6599
|
+
console.log(pc.bold(`Wrote session usage ${normalizedSessionId} -> ${outPath}`));
|
|
6600
|
+
console.log(
|
|
6601
|
+
pc.gray(
|
|
6602
|
+
`${report.totals.acceptedEntries} entries - ${report.totals.totalTokens} tokens - provider cost=$${report.totals.providerCostUsd.toFixed(4)}`,
|
|
6603
|
+
),
|
|
6604
|
+
);
|
|
6605
|
+
return;
|
|
6606
|
+
}
|
|
6607
|
+
|
|
6608
|
+
process.stdout.write(output);
|
|
6609
|
+
});
|
|
6610
|
+
|
|
6490
6611
|
session
|
|
6491
6612
|
.command("leave <sessionId>")
|
|
6492
6613
|
.description("Leave a session")
|
package/src/session/daemon.js
CHANGED
|
@@ -44,6 +44,7 @@ import { stopRuntimeRunsForSession } from "./runtime-bridge.js";
|
|
|
44
44
|
import { getSession, renewSession } from "./store.js";
|
|
45
45
|
import { appendToStream, readStream, tailStream } from "./stream.js";
|
|
46
46
|
import { handleTaskDirective } from "./tasks.js";
|
|
47
|
+
import { emitLLMInteraction } from "./usage.js";
|
|
47
48
|
|
|
48
49
|
const DAEMON_TICK_INTERVAL_MS = 30_000;
|
|
49
50
|
const HELP_REQUEST_TIMEOUT_MS = 1_200;
|
|
@@ -653,6 +654,28 @@ async function maybeRespondToHelpRequest(
|
|
|
653
654
|
nowIso,
|
|
654
655
|
}
|
|
655
656
|
);
|
|
657
|
+
if (normalizeUsageNumber(response.usage.inputTokens) + normalizeUsageNumber(response.usage.outputTokens) > 0) {
|
|
658
|
+
// The managed proxy already records the durable API billing/v1 row for
|
|
659
|
+
// this same idempotency key. Keep this copy local for immediate transcript
|
|
660
|
+
// and recap usage without double-counting Session.total_cost_usd remotely.
|
|
661
|
+
await emitLLMInteraction(daemonState.sessionId, {
|
|
662
|
+
agentId: SENTI_IDENTITY.id,
|
|
663
|
+
agentModel: response.usage.model || daemonState.model,
|
|
664
|
+
role: SENTI_IDENTITY.role,
|
|
665
|
+
inputTokens: response.usage.inputTokens,
|
|
666
|
+
outputTokens: response.usage.outputTokens,
|
|
667
|
+
costUsd: response.usage.costUsd,
|
|
668
|
+
durationMs: response.usage.latencyMs,
|
|
669
|
+
action: "proxy_llm",
|
|
670
|
+
provider: response.usage.provider || "sentinelayer",
|
|
671
|
+
billingTier: "internal",
|
|
672
|
+
prompt: normalizeString(requestEvent?.payload?.message) || normalizeString(requestEvent?.payload?.request),
|
|
673
|
+
response: response.message,
|
|
674
|
+
interactionId: `senti:${daemonState.sessionId}:help:${requestId}`,
|
|
675
|
+
targetPath,
|
|
676
|
+
syncRemote: false,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
656
679
|
return responseEvent;
|
|
657
680
|
}
|
|
658
681
|
|
package/src/session/sync.js
CHANGED
|
@@ -1816,6 +1816,96 @@ export async function listSessionMessageActions(
|
|
|
1816
1816
|
}
|
|
1817
1817
|
}
|
|
1818
1818
|
|
|
1819
|
+
export async function fetchSessionUsageLedger(
|
|
1820
|
+
sessionId,
|
|
1821
|
+
{
|
|
1822
|
+
targetPath = process.cwd(),
|
|
1823
|
+
limit = 500,
|
|
1824
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1825
|
+
forceCircuitProbe = false,
|
|
1826
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1827
|
+
fetchImpl = fetchWithTimeout,
|
|
1828
|
+
nowMs = Date.now,
|
|
1829
|
+
} = {}
|
|
1830
|
+
) {
|
|
1831
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1832
|
+
if (!normalizedSessionId) {
|
|
1833
|
+
return {
|
|
1834
|
+
ok: false,
|
|
1835
|
+
reason: "invalid_session_id",
|
|
1836
|
+
status: 0,
|
|
1837
|
+
payload: null,
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
const normalizedNowMs = Number(nowMs()) || Date.now();
|
|
1841
|
+
if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
|
|
1842
|
+
return {
|
|
1843
|
+
ok: false,
|
|
1844
|
+
reason: "circuit_breaker_open",
|
|
1845
|
+
status: 0,
|
|
1846
|
+
payload: null,
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
let session = null;
|
|
1851
|
+
try {
|
|
1852
|
+
session = await resolveAuthSession({
|
|
1853
|
+
cwd: targetPath,
|
|
1854
|
+
env: process.env,
|
|
1855
|
+
autoRotate: false,
|
|
1856
|
+
});
|
|
1857
|
+
} catch {
|
|
1858
|
+
return { ok: false, reason: "no_session", status: 0, payload: null };
|
|
1859
|
+
}
|
|
1860
|
+
if (!session || !session.token) {
|
|
1861
|
+
return { ok: false, reason: "not_authenticated", status: 0, payload: null };
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1865
|
+
const query = new URLSearchParams();
|
|
1866
|
+
query.set(
|
|
1867
|
+
"limit",
|
|
1868
|
+
String(Math.max(1, Math.min(500, normalizePositiveInteger(limit, 500))))
|
|
1869
|
+
);
|
|
1870
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/usage?${query.toString()}`;
|
|
1871
|
+
|
|
1872
|
+
try {
|
|
1873
|
+
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
1874
|
+
endpoint,
|
|
1875
|
+
{
|
|
1876
|
+
method: "GET",
|
|
1877
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
1878
|
+
},
|
|
1879
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
1880
|
+
fetchImpl,
|
|
1881
|
+
);
|
|
1882
|
+
if (!response || !response.ok) {
|
|
1883
|
+
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|
|
1884
|
+
return {
|
|
1885
|
+
ok: false,
|
|
1886
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
1887
|
+
status: response?.status || 0,
|
|
1888
|
+
payload: null,
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
recordCircuitSuccess(inboundCircuit);
|
|
1892
|
+
return {
|
|
1893
|
+
ok: true,
|
|
1894
|
+
reason: "",
|
|
1895
|
+
status: response.status || 200,
|
|
1896
|
+
payload: payload && typeof payload === "object" ? payload : {},
|
|
1897
|
+
};
|
|
1898
|
+
} catch (error) {
|
|
1899
|
+
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|
|
1900
|
+
return {
|
|
1901
|
+
ok: false,
|
|
1902
|
+
reason: normalizeString(error?.message) || "usage_read_failed",
|
|
1903
|
+
status: 0,
|
|
1904
|
+
payload: null,
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1819
1909
|
function pinnedEventContentText(event = {}) {
|
|
1820
1910
|
const payload = event && typeof event === "object" ? event.payload || {} : {};
|
|
1821
1911
|
return (
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { buildSessionUsageLedger } from "./pricing-ledger.js";
|
|
4
|
+
|
|
5
|
+
export const SESSION_USAGE_REPORT_SCHEMA = "session_usage_report/v1";
|
|
6
|
+
const DEFAULT_RECENT_LIMIT = 10;
|
|
7
|
+
const MAX_RECENT_LIMIT = 50;
|
|
8
|
+
|
|
9
|
+
function normalize(value) {
|
|
10
|
+
return String(value == null ? "" : value).trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function object(value) {
|
|
14
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function nonNegativeNumber(value) {
|
|
18
|
+
if (value == null || value === "") return null;
|
|
19
|
+
const parsed = Number(value);
|
|
20
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nonNegativeInt(value) {
|
|
24
|
+
const parsed = nonNegativeNumber(value);
|
|
25
|
+
return parsed == null ? 0 : Math.floor(parsed);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function money(value) {
|
|
29
|
+
const parsed = nonNegativeNumber(value);
|
|
30
|
+
return parsed == null ? null : Math.round(parsed * 1_000_000) / 1_000_000;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clampRecentLimit(value) {
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_RECENT_LIMIT;
|
|
36
|
+
return Math.min(MAX_RECENT_LIMIT, Math.floor(parsed));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function intCell(value) {
|
|
40
|
+
return Math.max(0, Math.floor(Number(value || 0))).toLocaleString("en-US");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function usdCell(value) {
|
|
44
|
+
return `$${Number(value || 0).toFixed(6)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tableText(value, { maxLength = 160 } = {}) {
|
|
48
|
+
const escaped = normalize(value)
|
|
49
|
+
.replace(/\r?\n/g, " ")
|
|
50
|
+
.replace(/\|/g, "\\|")
|
|
51
|
+
.replace(/`/g, "'");
|
|
52
|
+
if (!maxLength || escaped.length <= maxLength) return escaped;
|
|
53
|
+
const head = Math.max(24, Math.floor((maxLength - 3) * 0.7));
|
|
54
|
+
const tail = Math.max(12, maxLength - 3 - head);
|
|
55
|
+
return `${escaped.slice(0, head)}...${escaped.slice(-tail)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function codeCell(value) {
|
|
59
|
+
const text = tableText(value);
|
|
60
|
+
return text ? `\`${text}\`` : "-";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function optionalUsdCell(value, hasValue) {
|
|
64
|
+
return hasValue ? usdCell(value) : "-";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function timestampOnly(iso) {
|
|
68
|
+
if (!iso) return "";
|
|
69
|
+
const date = new Date(iso);
|
|
70
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
71
|
+
return date.toISOString().replace("T", " ").replace(/\..+/, " UTC");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hashOpaque(value) {
|
|
75
|
+
const text = normalize(value);
|
|
76
|
+
if (!text) return "";
|
|
77
|
+
return `sha256:${createHash("sha256").update(text).digest("hex").slice(0, 16)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sortRollups(map) {
|
|
81
|
+
return [...map.values()].sort((a, b) => (
|
|
82
|
+
b.providerCostUsd - a.providerCostUsd ||
|
|
83
|
+
b.totalTokens - a.totalTokens ||
|
|
84
|
+
b.entries - a.entries ||
|
|
85
|
+
tableText(a.label).localeCompare(tableText(b.label))
|
|
86
|
+
));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function recentUsageEntries(entries, limit) {
|
|
90
|
+
return entries
|
|
91
|
+
.map((entry, index) => ({ entry, index }))
|
|
92
|
+
.sort((a, b) => {
|
|
93
|
+
const aSequence = Number.isFinite(a.entry.sequenceId) ? a.entry.sequenceId : null;
|
|
94
|
+
const bSequence = Number.isFinite(b.entry.sequenceId) ? b.entry.sequenceId : null;
|
|
95
|
+
if (aSequence != null && bSequence != null && aSequence !== bSequence) {
|
|
96
|
+
return bSequence - aSequence;
|
|
97
|
+
}
|
|
98
|
+
const aTime = Date.parse(a.entry.timestamp);
|
|
99
|
+
const bTime = Date.parse(b.entry.timestamp);
|
|
100
|
+
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
|
|
101
|
+
return bTime - aTime;
|
|
102
|
+
}
|
|
103
|
+
return b.index - a.index;
|
|
104
|
+
})
|
|
105
|
+
.slice(0, limit)
|
|
106
|
+
.map(({ entry }) => entry);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function rollupToJson(rollup) {
|
|
110
|
+
return {
|
|
111
|
+
label: rollup.label,
|
|
112
|
+
entries: rollup.entries,
|
|
113
|
+
inputTokens: rollup.inputTokens,
|
|
114
|
+
outputTokens: rollup.outputTokens,
|
|
115
|
+
totalTokens: rollup.totalTokens,
|
|
116
|
+
providerCostUsd: rollup.providerCostUsd,
|
|
117
|
+
customerCostUsd: rollup.hasCustomerCost ? rollup.customerCostUsd : null,
|
|
118
|
+
unpriced: rollup.unpriced,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hostedRollupToJson(rollup, fallbackLabel = "") {
|
|
123
|
+
const bag = object(rollup);
|
|
124
|
+
const hasCustomerCost = Boolean(bag.hasCustomerCost ?? bag.has_customer_cost ?? bag.customerCostUsd != null ?? bag.customer_cost_usd != null);
|
|
125
|
+
return {
|
|
126
|
+
label: normalize(bag.label ?? bag.agentId ?? bag.agent_id ?? bag.action ?? fallbackLabel) || "unknown",
|
|
127
|
+
entries: nonNegativeInt(bag.entries ?? bag.count),
|
|
128
|
+
inputTokens: nonNegativeInt(bag.inputTokens ?? bag.input_tokens),
|
|
129
|
+
outputTokens: nonNegativeInt(bag.outputTokens ?? bag.output_tokens),
|
|
130
|
+
totalTokens: nonNegativeInt(bag.totalTokens ?? bag.total_tokens),
|
|
131
|
+
providerCostUsd: money(bag.providerCostUsd ?? bag.provider_cost_usd ?? bag.costUsd ?? bag.cost_usd) ?? 0,
|
|
132
|
+
customerCostUsd: hasCustomerCost
|
|
133
|
+
? money(bag.customerCostUsd ?? bag.customer_cost_usd ?? bag.billableCostUsd ?? bag.billable_cost_usd) ?? 0
|
|
134
|
+
: null,
|
|
135
|
+
unpriced: nonNegativeInt(bag.unpriced),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function entryToJson(entry) {
|
|
140
|
+
return {
|
|
141
|
+
timestamp: normalize(entry.timestamp) || null,
|
|
142
|
+
sequenceId: Number.isFinite(entry.sequenceId) ? entry.sequenceId : null,
|
|
143
|
+
ledgerEntryId: entry.ledgerEntryId,
|
|
144
|
+
idempotencyKeyHash: hashOpaque(entry.idempotencyKey),
|
|
145
|
+
schema: entry.schema,
|
|
146
|
+
agentId: entry.agentId,
|
|
147
|
+
action: entry.action,
|
|
148
|
+
model: entry.model,
|
|
149
|
+
provider: normalize(entry.provider) || null,
|
|
150
|
+
billingTier: entry.billingTier,
|
|
151
|
+
priceBookVersion: entry.priceBookVersion,
|
|
152
|
+
inputTokens: entry.inputTokens,
|
|
153
|
+
outputTokens: entry.outputTokens,
|
|
154
|
+
totalTokens: entry.totalTokens,
|
|
155
|
+
providerCostUsd: entry.providerCostUsd,
|
|
156
|
+
customerCostUsd: entry.customerCostUsd,
|
|
157
|
+
unpriced: entry.unpriced,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function hostedEntryToJson(entry) {
|
|
162
|
+
const bag = object(entry);
|
|
163
|
+
const idempotencyKey = normalize(bag.idempotencyKey ?? bag.idempotency_key ?? bag.runKey ?? bag.run_key);
|
|
164
|
+
return {
|
|
165
|
+
timestamp: normalize(bag.timestamp ?? bag.createdAt ?? bag.created_at ?? bag.occurredAt ?? bag.occurred_at) || null,
|
|
166
|
+
sequenceId: nonNegativeNumber(bag.sequenceId ?? bag.sequence_id ?? bag.sequence) == null
|
|
167
|
+
? null
|
|
168
|
+
: nonNegativeInt(bag.sequenceId ?? bag.sequence_id ?? bag.sequence),
|
|
169
|
+
ledgerEntryId: normalize(bag.ledgerEntryId ?? bag.ledger_entry_id ?? bag.billingEventId ?? bag.billing_event_id) || "",
|
|
170
|
+
idempotencyKeyHash: hashOpaque(idempotencyKey),
|
|
171
|
+
schema: normalize(bag.schema) || "billing/v1",
|
|
172
|
+
agentId: normalize(bag.agentId ?? bag.agent_id) || "unknown",
|
|
173
|
+
action: normalize(bag.action ?? bag.operation ?? bag.kind ?? bag.billingAction ?? bag.billing_action) || "agent_message",
|
|
174
|
+
model: normalize(bag.model ?? bag.modelId ?? bag.model_id) || "unknown",
|
|
175
|
+
provider: normalize(bag.provider ?? bag.providerName ?? bag.provider_name) || null,
|
|
176
|
+
billingTier: normalize(bag.billingTier ?? bag.billing_tier ?? bag.tier) || "unknown",
|
|
177
|
+
priceBookVersion: normalize(bag.priceBookVersion ?? bag.price_book_version ?? bag.pricingVersion ?? bag.pricing_version) || "unknown",
|
|
178
|
+
inputTokens: nonNegativeInt(bag.inputTokens ?? bag.input_tokens),
|
|
179
|
+
outputTokens: nonNegativeInt(bag.outputTokens ?? bag.output_tokens),
|
|
180
|
+
totalTokens: nonNegativeInt(bag.totalTokens ?? bag.total_tokens ?? bag.tokens),
|
|
181
|
+
providerCostUsd: money(bag.providerCostUsd ?? bag.provider_cost_usd ?? bag.costUsd ?? bag.cost_usd ?? bag.cost) ?? 0,
|
|
182
|
+
customerCostUsd: money(bag.customerCostUsd ?? bag.customer_cost_usd ?? bag.billableCostUsd ?? bag.billable_cost_usd),
|
|
183
|
+
unpriced: Boolean(bag.unpriced),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sourceArray(value) {
|
|
188
|
+
if (Array.isArray(value)) return value;
|
|
189
|
+
if (value && typeof value === "object") return Object.values(value);
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function recentReportEntries(entries, limit) {
|
|
194
|
+
return entries
|
|
195
|
+
.map((entry, index) => ({ entry, index }))
|
|
196
|
+
.sort((a, b) => {
|
|
197
|
+
const aSequence = Number.isFinite(a.entry.sequenceId) ? a.entry.sequenceId : null;
|
|
198
|
+
const bSequence = Number.isFinite(b.entry.sequenceId) ? b.entry.sequenceId : null;
|
|
199
|
+
if (aSequence != null && bSequence != null && aSequence !== bSequence) {
|
|
200
|
+
return bSequence - aSequence;
|
|
201
|
+
}
|
|
202
|
+
const aTime = Date.parse(a.entry.timestamp);
|
|
203
|
+
const bTime = Date.parse(b.entry.timestamp);
|
|
204
|
+
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
|
|
205
|
+
return bTime - aTime;
|
|
206
|
+
}
|
|
207
|
+
return b.index - a.index;
|
|
208
|
+
})
|
|
209
|
+
.slice(0, limit)
|
|
210
|
+
.map(({ entry }) => entry);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function buildSessionUsageReport({
|
|
214
|
+
sessionId = "",
|
|
215
|
+
events = [],
|
|
216
|
+
recentLimit = DEFAULT_RECENT_LIMIT,
|
|
217
|
+
} = {}) {
|
|
218
|
+
const ledger = buildSessionUsageLedger(events, { sessionId: normalize(sessionId) });
|
|
219
|
+
const limit = clampRecentLimit(recentLimit);
|
|
220
|
+
return {
|
|
221
|
+
schema: SESSION_USAGE_REPORT_SCHEMA,
|
|
222
|
+
sessionId: normalize(sessionId),
|
|
223
|
+
generatedAt: new Date().toISOString(),
|
|
224
|
+
totals: {
|
|
225
|
+
acceptedEntries: ledger.entries.length,
|
|
226
|
+
duplicatesSkipped: ledger.duplicatesSkipped,
|
|
227
|
+
inputTokens: ledger.totals.inputTokens,
|
|
228
|
+
outputTokens: ledger.totals.outputTokens,
|
|
229
|
+
totalTokens: ledger.totals.totalTokens,
|
|
230
|
+
providerCostUsd: ledger.totals.providerCostUsd,
|
|
231
|
+
customerCostUsd: ledger.totals.hasCustomerCost ? ledger.totals.customerCostUsd : null,
|
|
232
|
+
unpriced: ledger.totals.unpriced,
|
|
233
|
+
priceBookVersions: ledger.priceBookVersions,
|
|
234
|
+
},
|
|
235
|
+
perAgent: sortRollups(ledger.perAgent).map(rollupToJson),
|
|
236
|
+
perAction: sortRollups(ledger.perAction).map(rollupToJson),
|
|
237
|
+
recentEntries: recentUsageEntries(ledger.entries, limit).map(entryToJson),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function buildSessionUsageReportFromLedgerPayload({
|
|
242
|
+
sessionId = "",
|
|
243
|
+
payload = {},
|
|
244
|
+
recentLimit = DEFAULT_RECENT_LIMIT,
|
|
245
|
+
} = {}) {
|
|
246
|
+
const root = object(payload);
|
|
247
|
+
const embedded = root.usageLedger || root.usage_ledger || root.ledger;
|
|
248
|
+
const source = embedded && typeof embedded === "object" && !Array.isArray(embedded)
|
|
249
|
+
? embedded
|
|
250
|
+
: root;
|
|
251
|
+
const entries = sourceArray(source.entries).map(hostedEntryToJson);
|
|
252
|
+
const totals = hostedRollupToJson(object(source.totals), "session");
|
|
253
|
+
const priceBookVersions = sourceArray(source.priceBooks || source.price_books || source.priceBookVersions || source.price_book_versions)
|
|
254
|
+
.map(normalize)
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.sort();
|
|
257
|
+
const limit = clampRecentLimit(recentLimit);
|
|
258
|
+
return {
|
|
259
|
+
schema: SESSION_USAGE_REPORT_SCHEMA,
|
|
260
|
+
sessionId: normalize(root.sessionId ?? root.session_id ?? source.sessionId ?? source.session_id ?? sessionId),
|
|
261
|
+
generatedAt: new Date().toISOString(),
|
|
262
|
+
totals: {
|
|
263
|
+
acceptedEntries: totals.entries || entries.length,
|
|
264
|
+
duplicatesSkipped: nonNegativeInt(source.duplicatesSkipped ?? source.duplicates_skipped),
|
|
265
|
+
inputTokens: totals.inputTokens,
|
|
266
|
+
outputTokens: totals.outputTokens,
|
|
267
|
+
totalTokens: totals.totalTokens,
|
|
268
|
+
providerCostUsd: totals.providerCostUsd,
|
|
269
|
+
customerCostUsd: totals.customerCostUsd,
|
|
270
|
+
unpriced: totals.unpriced,
|
|
271
|
+
priceBookVersions,
|
|
272
|
+
},
|
|
273
|
+
perAgent: sourceArray(source.agents || source.perAgent || source.per_agent || source.agentsById || source.agents_by_id)
|
|
274
|
+
.map((rollup) => hostedRollupToJson(rollup))
|
|
275
|
+
.sort((a, b) => b.providerCostUsd - a.providerCostUsd || b.totalTokens - a.totalTokens || a.label.localeCompare(b.label)),
|
|
276
|
+
perAction: sourceArray(source.actions || source.perAction || source.per_action || source.actionsById || source.actions_by_id)
|
|
277
|
+
.map((rollup) => hostedRollupToJson(rollup))
|
|
278
|
+
.sort((a, b) => b.providerCostUsd - a.providerCostUsd || b.totalTokens - a.totalTokens || a.label.localeCompare(b.label)),
|
|
279
|
+
recentEntries: recentReportEntries(entries, limit),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function appendRollupTable(lines, { title, labelHeader, rollups }) {
|
|
284
|
+
lines.push(`### ${title}`);
|
|
285
|
+
lines.push("");
|
|
286
|
+
lines.push(`| ${labelHeader} | Entries | Input | Output | Total | Provider cost | Customer cost | Unpriced |`);
|
|
287
|
+
lines.push("|---|---:|---:|---:|---:|---:|---:|---:|");
|
|
288
|
+
if (!rollups.length) {
|
|
289
|
+
lines.push(`| - | 0 | 0 | 0 | 0 | ${usdCell(0)} | - | 0 |`);
|
|
290
|
+
} else {
|
|
291
|
+
for (const rollup of rollups) {
|
|
292
|
+
lines.push(
|
|
293
|
+
`| ${codeCell(rollup.label)} | ${intCell(rollup.entries)} | ${intCell(rollup.inputTokens)} | ${intCell(rollup.outputTokens)} | ${intCell(rollup.totalTokens)} | ${usdCell(rollup.providerCostUsd)} | ${optionalUsdCell(rollup.customerCostUsd, rollup.customerCostUsd != null)} | ${intCell(rollup.unpriced)} |`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
lines.push("");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function renderSessionUsageMarkdown(report = {}) {
|
|
301
|
+
const totals = report.totals || {};
|
|
302
|
+
const lines = [];
|
|
303
|
+
lines.push(`# Session Usage ${normalize(report.sessionId) || "unknown"}`);
|
|
304
|
+
lines.push("");
|
|
305
|
+
lines.push(`Generated: ${normalize(report.generatedAt) || new Date().toISOString()}`);
|
|
306
|
+
lines.push("");
|
|
307
|
+
lines.push("## Totals");
|
|
308
|
+
lines.push("");
|
|
309
|
+
lines.push(`Accepted entries: ${intCell(totals.acceptedEntries)}`);
|
|
310
|
+
lines.push(
|
|
311
|
+
`Tokens: ${intCell(totals.totalTokens)} (input ${intCell(totals.inputTokens)} / output ${intCell(totals.outputTokens)})`,
|
|
312
|
+
);
|
|
313
|
+
lines.push(
|
|
314
|
+
`Provider cost: ${usdCell(totals.providerCostUsd)} - Customer cost: ${optionalUsdCell(totals.customerCostUsd, totals.customerCostUsd != null)}`,
|
|
315
|
+
);
|
|
316
|
+
lines.push(`Price books: ${(totals.priceBookVersions || []).map(tableText).filter(Boolean).join(", ") || "-"}`);
|
|
317
|
+
lines.push(`Duplicates skipped: ${intCell(totals.duplicatesSkipped)} - Unpriced entries: ${intCell(totals.unpriced)}`);
|
|
318
|
+
lines.push("");
|
|
319
|
+
|
|
320
|
+
appendRollupTable(lines, {
|
|
321
|
+
title: "Per Agent",
|
|
322
|
+
labelHeader: "Agent",
|
|
323
|
+
rollups: Array.isArray(report.perAgent) ? report.perAgent : [],
|
|
324
|
+
});
|
|
325
|
+
appendRollupTable(lines, {
|
|
326
|
+
title: "Per Action",
|
|
327
|
+
labelHeader: "Action",
|
|
328
|
+
rollups: Array.isArray(report.perAction) ? report.perAction : [],
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
lines.push("### Recent Entries");
|
|
332
|
+
lines.push("");
|
|
333
|
+
lines.push("| Time | Agent | Action | Model | Tokens | Provider cost | Customer cost | Ledger | Idempotency hash |");
|
|
334
|
+
lines.push("|---|---|---|---|---:|---:|---:|---|---|");
|
|
335
|
+
const entries = Array.isArray(report.recentEntries) ? report.recentEntries : [];
|
|
336
|
+
if (!entries.length) {
|
|
337
|
+
lines.push(`| - | - | - | - | 0 | ${usdCell(0)} | - | - | - |`);
|
|
338
|
+
} else {
|
|
339
|
+
for (const entry of entries) {
|
|
340
|
+
lines.push(
|
|
341
|
+
`| ${tableText(timestampOnly(entry.timestamp)) || "-"} | ${codeCell(entry.agentId)} | ${codeCell(entry.action)} | ${codeCell(entry.model)} | ${intCell(entry.totalTokens)} | ${usdCell(entry.providerCostUsd)} | ${optionalUsdCell(entry.customerCostUsd, entry.customerCostUsd != null)} | ${codeCell(entry.ledgerEntryId)} | ${codeCell(entry.idempotencyKeyHash)} |`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
lines.push("");
|
|
346
|
+
return `${lines.join("\n")}\n`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function renderSessionUsageSummary(report = {}) {
|
|
350
|
+
const totals = report.totals || {};
|
|
351
|
+
const lines = [];
|
|
352
|
+
lines.push(`Session usage ${normalize(report.sessionId) || "unknown"}`);
|
|
353
|
+
lines.push(
|
|
354
|
+
`${intCell(totals.acceptedEntries)} entries - ${intCell(totals.totalTokens)} tokens (${intCell(totals.inputTokens)} in / ${intCell(totals.outputTokens)} out) - provider ${usdCell(totals.providerCostUsd)}${
|
|
355
|
+
totals.customerCostUsd == null ? "" : ` - customer ${usdCell(totals.customerCostUsd)}`
|
|
356
|
+
}`,
|
|
357
|
+
);
|
|
358
|
+
lines.push(
|
|
359
|
+
`priceBooks=${(totals.priceBookVersions || []).map(tableText).filter(Boolean).join(",") || "-"} duplicatesSkipped=${intCell(totals.duplicatesSkipped)} unpriced=${intCell(totals.unpriced)}`,
|
|
360
|
+
);
|
|
361
|
+
const agents = Array.isArray(report.perAgent) ? report.perAgent.slice(0, 8) : [];
|
|
362
|
+
if (agents.length) {
|
|
363
|
+
lines.push("");
|
|
364
|
+
lines.push("Per agent:");
|
|
365
|
+
for (const agent of agents) {
|
|
366
|
+
lines.push(
|
|
367
|
+
`- ${agent.label}: ${intCell(agent.totalTokens)} tokens, ${intCell(agent.entries)} entries, provider ${usdCell(agent.providerCostUsd)}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const entries = Array.isArray(report.recentEntries) ? report.recentEntries.slice(0, 5) : [];
|
|
372
|
+
if (entries.length) {
|
|
373
|
+
lines.push("");
|
|
374
|
+
lines.push("Recent:");
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
lines.push(
|
|
377
|
+
`- ${timestampOnly(entry.timestamp) || "-"} ${entry.agentId} ${entry.action} ${intCell(entry.totalTokens)} tokens ${usdCell(entry.providerCostUsd)} ledger=${entry.ledgerEntryId}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return `${lines.join("\n")}\n`;
|
|
382
|
+
}
|
package/src/session/usage.js
CHANGED
|
@@ -111,6 +111,7 @@ function computedCost({ model, inputTokens, outputTokens }) {
|
|
|
111
111
|
* @param {string} [params.response] full response text (clipped)
|
|
112
112
|
* @param {string} [params.interactionId] opaque id for cross-event correlation
|
|
113
113
|
* @param {string} [params.targetPath] workspace path (default cwd)
|
|
114
|
+
* @param {boolean} [params.syncRemote] sync local usage event to API (default true)
|
|
114
115
|
* @returns {Promise<{ event: string, interactionId: string, totalTokens: number, costUsd: number }>}
|
|
115
116
|
*/
|
|
116
117
|
export async function emitLLMInteraction(
|
|
@@ -132,6 +133,7 @@ export async function emitLLMInteraction(
|
|
|
132
133
|
response = "",
|
|
133
134
|
interactionId = "",
|
|
134
135
|
targetPath = process.cwd(),
|
|
136
|
+
syncRemote = true,
|
|
135
137
|
} = {},
|
|
136
138
|
) {
|
|
137
139
|
const sid = n(sessionId);
|
|
@@ -219,7 +221,7 @@ export async function emitLLMInteraction(
|
|
|
219
221
|
ts,
|
|
220
222
|
});
|
|
221
223
|
|
|
222
|
-
await appendToStream(paths.sessionId, envelope, { targetPath });
|
|
224
|
+
await appendToStream(paths.sessionId, envelope, { targetPath, syncRemote });
|
|
223
225
|
return {
|
|
224
226
|
event: SESSION_USAGE_EVENT,
|
|
225
227
|
interactionId: id,
|