sentinelayer-cli 0.9.6 → 0.9.8
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/auth/session-store.js +55 -30
- package/src/billing/ledger-entry.js +162 -0
- package/src/billing/price-book.js +123 -0
- package/src/billing/session-usage.js +80 -0
- package/src/commands/session.js +8 -0
- package/src/cost/tracker.js +26 -2
- package/src/review/ai-review.js +70 -16
package/package.json
CHANGED
|
@@ -17,6 +17,8 @@ const SESSION_WARNING_ALLOWED_FIELDS = new Set([
|
|
|
17
17
|
"codeHint",
|
|
18
18
|
"requestIdHash",
|
|
19
19
|
]);
|
|
20
|
+
const emittedSessionWarningKeys = new Set();
|
|
21
|
+
let keytarClientOverrideForTests;
|
|
20
22
|
|
|
21
23
|
function nowIso() {
|
|
22
24
|
return new Date().toISOString();
|
|
@@ -69,26 +71,45 @@ function sanitizeSessionWarningDetails(details) {
|
|
|
69
71
|
|
|
70
72
|
function emitSessionWarning(code, details = {}) {
|
|
71
73
|
const sanitizedDetails = sanitizeSessionWarningDetails(details);
|
|
74
|
+
const normalizedCode = String(code || "SESSION_WARNING").toUpperCase();
|
|
75
|
+
const allowedDetails = {};
|
|
76
|
+
for (const [key, value] of Object.entries(sanitizedDetails)) {
|
|
77
|
+
allowedDetails[key] = SESSION_WARNING_ALLOWED_FIELDS.has(key) ? value : "[OMITTED]";
|
|
78
|
+
}
|
|
79
|
+
const warningKey = `${normalizedCode}:${JSON.stringify(allowedDetails)}`;
|
|
80
|
+
if (emittedSessionWarningKeys.has(warningKey)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
emittedSessionWarningKeys.add(warningKey);
|
|
84
|
+
|
|
72
85
|
const payload = {
|
|
73
86
|
level: "warn",
|
|
74
|
-
code:
|
|
87
|
+
code: normalizedCode,
|
|
75
88
|
warningId: createSessionWarningId(),
|
|
76
89
|
timestamp: nowIso(),
|
|
77
90
|
};
|
|
78
|
-
for (const [key, value] of Object.entries(
|
|
79
|
-
|
|
80
|
-
payload[key] = value;
|
|
81
|
-
} else {
|
|
82
|
-
payload[key] = "[OMITTED]";
|
|
83
|
-
}
|
|
91
|
+
for (const [key, value] of Object.entries(allowedDetails)) {
|
|
92
|
+
payload[key] = value;
|
|
84
93
|
}
|
|
85
94
|
try {
|
|
86
|
-
|
|
95
|
+
process.stderr.write(`${SESSION_WARNING_PREFIX} ${JSON.stringify(payload)}\n`);
|
|
87
96
|
} catch {
|
|
88
|
-
console.
|
|
97
|
+
console.error(`${SESSION_WARNING_PREFIX} ${payload.code}`);
|
|
89
98
|
}
|
|
90
99
|
}
|
|
91
100
|
|
|
101
|
+
export function resetSessionWarningsForTests() {
|
|
102
|
+
emittedSessionWarningKeys.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function setKeytarClientForTests(client) {
|
|
106
|
+
const previous = keytarClientOverrideForTests;
|
|
107
|
+
keytarClientOverrideForTests = client || null;
|
|
108
|
+
return () => {
|
|
109
|
+
keytarClientOverrideForTests = previous;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
function resolveHomeDir(homeDir) {
|
|
93
114
|
return path.resolve(String(homeDir || os.homedir()));
|
|
94
115
|
}
|
|
@@ -356,7 +377,10 @@ async function replaceWithBackup(tmpPath, filePath) {
|
|
|
356
377
|
}
|
|
357
378
|
}
|
|
358
379
|
|
|
359
|
-
async function loadKeytarClient() {
|
|
380
|
+
async function loadKeytarClient({ allowImplicit = false } = {}) {
|
|
381
|
+
if (keytarClientOverrideForTests !== undefined) {
|
|
382
|
+
return keytarClientOverrideForTests;
|
|
383
|
+
}
|
|
360
384
|
const disableKeyring = String(process.env.SENTINELAYER_DISABLE_KEYRING || "")
|
|
361
385
|
.trim()
|
|
362
386
|
.toLowerCase();
|
|
@@ -372,7 +396,7 @@ async function loadKeytarClient() {
|
|
|
372
396
|
keyringMode === "on" ||
|
|
373
397
|
keyringMode === "true" ||
|
|
374
398
|
keyringMode === "1";
|
|
375
|
-
if (!enableKeyring) {
|
|
399
|
+
if (!enableKeyring && !allowImplicit) {
|
|
376
400
|
return null;
|
|
377
401
|
}
|
|
378
402
|
try {
|
|
@@ -394,6 +418,20 @@ async function loadKeytarClient() {
|
|
|
394
418
|
}
|
|
395
419
|
}
|
|
396
420
|
|
|
421
|
+
async function encryptTokenForFileFallback(token, { homeDir } = {}) {
|
|
422
|
+
const key = await loadOrCreateFileKey({ homeDir });
|
|
423
|
+
return encryptToken(token, key);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function attachEncryptedTokenFallback(metadata, token, { homeDir } = {}) {
|
|
427
|
+
const encrypted = await encryptTokenForFileFallback(token, { homeDir });
|
|
428
|
+
metadata.tokenEncrypted = encrypted.tokenEncrypted;
|
|
429
|
+
metadata.tokenIv = encrypted.tokenIv;
|
|
430
|
+
metadata.tokenTag = encrypted.tokenTag;
|
|
431
|
+
metadata.token = null;
|
|
432
|
+
return metadata;
|
|
433
|
+
}
|
|
434
|
+
|
|
397
435
|
async function readMetadata({ homeDir } = {}) {
|
|
398
436
|
const filePath = resolveCredentialsFilePath({ homeDir });
|
|
399
437
|
try {
|
|
@@ -480,20 +518,12 @@ async function migratePlaintextTokenIfNeeded({ metadata, filePath, homeDir } = {
|
|
|
480
518
|
nextMetadata.storage = "keyring";
|
|
481
519
|
nextMetadata.keyringService = KEYRING_SERVICE;
|
|
482
520
|
nextMetadata.keyringAccount = keyringAccount;
|
|
483
|
-
|
|
484
|
-
const encrypted = encryptToken(plaintextToken, key);
|
|
485
|
-
nextMetadata.tokenEncrypted = encrypted.tokenEncrypted;
|
|
486
|
-
nextMetadata.tokenIv = encrypted.tokenIv;
|
|
487
|
-
nextMetadata.tokenTag = encrypted.tokenTag;
|
|
521
|
+
await attachEncryptedTokenFallback(nextMetadata, plaintextToken, { homeDir });
|
|
488
522
|
} else {
|
|
489
|
-
const key = await loadOrCreateFileKey({ homeDir });
|
|
490
|
-
const encrypted = encryptToken(plaintextToken, key);
|
|
491
523
|
nextMetadata.storage = "file";
|
|
492
524
|
nextMetadata.keyringService = KEYRING_SERVICE;
|
|
493
525
|
nextMetadata.keyringAccount = "";
|
|
494
|
-
nextMetadata
|
|
495
|
-
nextMetadata.tokenIv = encrypted.tokenIv;
|
|
496
|
-
nextMetadata.tokenTag = encrypted.tokenTag;
|
|
526
|
+
await attachEncryptedTokenFallback(nextMetadata, plaintextToken, { homeDir });
|
|
497
527
|
}
|
|
498
528
|
|
|
499
529
|
await writeMetadata(filePath, nextMetadata);
|
|
@@ -570,7 +600,7 @@ export async function readStoredSession({ homeDir } = {}) {
|
|
|
570
600
|
}
|
|
571
601
|
|
|
572
602
|
if (metadata.storage === "keyring") {
|
|
573
|
-
const keytar = await loadKeytarClient();
|
|
603
|
+
const keytar = await loadKeytarClient({ allowImplicit: true });
|
|
574
604
|
let keyringError = null;
|
|
575
605
|
if (keytar && metadata.keyringAccount) {
|
|
576
606
|
try {
|
|
@@ -763,17 +793,12 @@ export async function writeStoredSession(
|
|
|
763
793
|
nextMetadata.storage = "keyring";
|
|
764
794
|
nextMetadata.keyringService = KEYRING_SERVICE;
|
|
765
795
|
nextMetadata.keyringAccount = keyringAccount;
|
|
766
|
-
nextMetadata
|
|
796
|
+
await attachEncryptedTokenFallback(nextMetadata, normalizedToken, { homeDir });
|
|
767
797
|
} else {
|
|
768
798
|
nextMetadata.storage = "file";
|
|
769
799
|
nextMetadata.keyringService = KEYRING_SERVICE;
|
|
770
800
|
nextMetadata.keyringAccount = "";
|
|
771
|
-
|
|
772
|
-
const encrypted = encryptToken(normalizedToken, key);
|
|
773
|
-
nextMetadata.token = null;
|
|
774
|
-
nextMetadata.tokenEncrypted = encrypted.tokenEncrypted;
|
|
775
|
-
nextMetadata.tokenIv = encrypted.tokenIv;
|
|
776
|
-
nextMetadata.tokenTag = encrypted.tokenTag;
|
|
801
|
+
await attachEncryptedTokenFallback(nextMetadata, normalizedToken, { homeDir });
|
|
777
802
|
}
|
|
778
803
|
|
|
779
804
|
await writeMetadata(filePath, nextMetadata);
|
|
@@ -794,7 +819,7 @@ export async function writeStoredSession(
|
|
|
794
819
|
export async function clearStoredSession({ homeDir } = {}) {
|
|
795
820
|
const { filePath, metadata } = await readMetadata({ homeDir });
|
|
796
821
|
if (metadata && metadata.storage === "keyring") {
|
|
797
|
-
const keytar = await loadKeytarClient();
|
|
822
|
+
const keytar = await loadKeytarClient({ allowImplicit: true });
|
|
798
823
|
if (keytar && metadata.keyringAccount) {
|
|
799
824
|
await keytar.deletePassword(metadata.keyringService || KEYRING_SERVICE, metadata.keyringAccount);
|
|
800
825
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { redactEventPayload } from "../session/redact.js";
|
|
4
|
+
import { computeProviderCost, PRICE_BOOK_VERSION } from "./price-book.js";
|
|
5
|
+
|
|
6
|
+
export const PRICED_ACTIONS = Object.freeze([
|
|
7
|
+
"audit_run",
|
|
8
|
+
"audit_security",
|
|
9
|
+
"audit_frontend",
|
|
10
|
+
"omargate_deep",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const OMIT_METADATA_KEYS = new Set([
|
|
14
|
+
"content",
|
|
15
|
+
"input",
|
|
16
|
+
"message",
|
|
17
|
+
"messages",
|
|
18
|
+
"output",
|
|
19
|
+
"prompt",
|
|
20
|
+
"prompts",
|
|
21
|
+
"raw",
|
|
22
|
+
"rawtext",
|
|
23
|
+
"response",
|
|
24
|
+
"responses",
|
|
25
|
+
"text",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function normalizeString(value) {
|
|
29
|
+
return String(value || "").trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeTokenCount(value, field) {
|
|
33
|
+
const parsed = Number(value || 0);
|
|
34
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
35
|
+
throw new Error(`${field} must be a non-negative number.`);
|
|
36
|
+
}
|
|
37
|
+
return Math.floor(parsed);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeIsoTimestamp(value, fallbackIso = new Date().toISOString()) {
|
|
41
|
+
const normalized = normalizeString(value);
|
|
42
|
+
if (!normalized) return fallbackIso;
|
|
43
|
+
const epoch = Date.parse(normalized);
|
|
44
|
+
if (!Number.isFinite(epoch)) {
|
|
45
|
+
throw new Error("createdAt must be an ISO timestamp.");
|
|
46
|
+
}
|
|
47
|
+
return new Date(epoch).toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function stableHash(value) {
|
|
51
|
+
return crypto.createHash("sha256").update(String(value || "")).digest("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sanitizeMetadataValue(value, depth = 0) {
|
|
55
|
+
if (depth > 8) return "[REDACTED]";
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
return value
|
|
58
|
+
.map((item) => sanitizeMetadataValue(item, depth + 1))
|
|
59
|
+
.filter((item) => item !== undefined);
|
|
60
|
+
}
|
|
61
|
+
if (value && typeof value === "object") {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
64
|
+
const normalizedKey = normalizeString(key).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
65
|
+
if (!normalizedKey || OMIT_METADATA_KEYS.has(normalizedKey)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const sanitized = sanitizeMetadataValue(inner, depth + 1);
|
|
69
|
+
if (sanitized !== undefined) {
|
|
70
|
+
out[key] = sanitized;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function sanitizeBillingMetadata(metadata = {}) {
|
|
79
|
+
const sanitized = sanitizeMetadataValue(metadata);
|
|
80
|
+
const redacted = redactEventPayload({ payload: sanitized });
|
|
81
|
+
return redacted?.payload && typeof redacted.payload === "object" ? redacted.payload : {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildBillingRunId({
|
|
85
|
+
sessionId,
|
|
86
|
+
invocationTimestamp,
|
|
87
|
+
configHash,
|
|
88
|
+
} = {}) {
|
|
89
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
90
|
+
const normalizedTimestamp = normalizeString(invocationTimestamp);
|
|
91
|
+
const normalizedConfigHash = normalizeString(configHash);
|
|
92
|
+
if (!normalizedSessionId) throw new Error("sessionId is required.");
|
|
93
|
+
if (!normalizedTimestamp) throw new Error("invocationTimestamp is required.");
|
|
94
|
+
if (!normalizedConfigHash) throw new Error("configHash is required.");
|
|
95
|
+
return stableHash(`${normalizedSessionId}|${normalizedTimestamp}|${normalizedConfigHash}`).slice(0, 16);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildCallIdempotencyKey({ runId, callIndex = 0 } = {}) {
|
|
99
|
+
const normalizedRunId = normalizeString(runId);
|
|
100
|
+
if (!normalizedRunId) throw new Error("runId is required.");
|
|
101
|
+
const normalizedCallIndex = normalizeTokenCount(callIndex, "callIndex");
|
|
102
|
+
return `${normalizedRunId}:${normalizedCallIndex}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function buildLedgerEntry({
|
|
106
|
+
sessionId,
|
|
107
|
+
agentId,
|
|
108
|
+
action,
|
|
109
|
+
model,
|
|
110
|
+
inputTokens = 0,
|
|
111
|
+
outputTokens = 0,
|
|
112
|
+
idempotencyKey,
|
|
113
|
+
billingTier = "internal",
|
|
114
|
+
metadata = {},
|
|
115
|
+
createdAt = "",
|
|
116
|
+
} = {}) {
|
|
117
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
118
|
+
const normalizedAgentId = normalizeString(agentId);
|
|
119
|
+
const normalizedAction = normalizeString(action);
|
|
120
|
+
const normalizedModel = normalizeString(model);
|
|
121
|
+
const normalizedIdempotencyKey = normalizeString(idempotencyKey);
|
|
122
|
+
if (!normalizedSessionId) throw new Error("sessionId is required.");
|
|
123
|
+
if (!normalizedAgentId) throw new Error("agentId is required.");
|
|
124
|
+
if (!normalizedAction) throw new Error("action is required.");
|
|
125
|
+
if (!normalizedModel) throw new Error("model is required.");
|
|
126
|
+
if (!normalizedIdempotencyKey) throw new Error("idempotencyKey is required.");
|
|
127
|
+
|
|
128
|
+
const priced = computeProviderCost({
|
|
129
|
+
model: normalizedModel,
|
|
130
|
+
inputTokens,
|
|
131
|
+
outputTokens,
|
|
132
|
+
});
|
|
133
|
+
const createdIso = normalizeIsoTimestamp(createdAt);
|
|
134
|
+
return {
|
|
135
|
+
ledgerEntryId: `bill_${stableHash(normalizedIdempotencyKey).slice(0, 16)}`,
|
|
136
|
+
schema: "billing/v1",
|
|
137
|
+
sessionId: normalizedSessionId,
|
|
138
|
+
agentId: normalizedAgentId,
|
|
139
|
+
action: normalizedAction,
|
|
140
|
+
model: normalizedModel,
|
|
141
|
+
canonicalModel: priced.canonicalModel,
|
|
142
|
+
priceBookVersion: PRICE_BOOK_VERSION,
|
|
143
|
+
inputTokens: priced.inputTokens,
|
|
144
|
+
outputTokens: priced.outputTokens,
|
|
145
|
+
totalTokens: priced.totalTokens,
|
|
146
|
+
providerCostUsd: priced.providerCostUsd,
|
|
147
|
+
customerCostUsd: null,
|
|
148
|
+
billingTier: normalizeString(billingTier) || "internal",
|
|
149
|
+
idempotencyKey: normalizedIdempotencyKey,
|
|
150
|
+
unpriced: Boolean(priced.unpriced),
|
|
151
|
+
createdAt: createdIso,
|
|
152
|
+
metadata: sanitizeBillingMetadata(metadata),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function countPricedUsageEvents(events = [], pricedActions = PRICED_ACTIONS) {
|
|
157
|
+
const actions = new Set((Array.isArray(pricedActions) ? pricedActions : []).map(normalizeString));
|
|
158
|
+
return (Array.isArray(events) ? events : []).filter((event) => {
|
|
159
|
+
const payload = event?.payload || {};
|
|
160
|
+
return event?.event === "session_usage" && payload.schema === "billing/v1" && actions.has(normalizeString(payload.action));
|
|
161
|
+
}).length;
|
|
162
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export const PRICE_BOOK_VERSION = "2026-05-19";
|
|
2
|
+
|
|
3
|
+
export const PRICE_BOOK_SOURCES = Object.freeze({
|
|
4
|
+
openai: "https://platform.openai.com/docs/pricing",
|
|
5
|
+
openaiCodex: "https://developers.openai.com/api/docs/models/gpt-5.3-codex",
|
|
6
|
+
anthropic: "https://docs.anthropic.com/en/docs/about-claude/pricing",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const RATE = (inputPerMTok, outputPerMTok, source, aliases = []) =>
|
|
10
|
+
Object.freeze({
|
|
11
|
+
inputPerMTok,
|
|
12
|
+
outputPerMTok,
|
|
13
|
+
currency: "USD",
|
|
14
|
+
source,
|
|
15
|
+
aliases,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const PRICE_BOOK = Object.freeze({
|
|
19
|
+
"gpt-5.4-mini": RATE(0.75, 4.5, "openai"),
|
|
20
|
+
"gpt-5.3-codex": RATE(1.75, 14, "openaiCodex"),
|
|
21
|
+
"gpt-5.2-codex": RATE(1.75, 14, "openai"),
|
|
22
|
+
"gpt-5.1-codex": RATE(1.25, 10, "openai"),
|
|
23
|
+
"gpt-5-codex": RATE(1.25, 10, "openai"),
|
|
24
|
+
"gpt-4.1-mini": RATE(0.4, 1.6, "openai"),
|
|
25
|
+
"claude-opus-4.1": RATE(15, 75, "anthropic", ["claude-opus-4-7"]),
|
|
26
|
+
"claude-opus-4": RATE(15, 75, "anthropic", ["claude-opus-4-6"]),
|
|
27
|
+
"claude-sonnet-4": RATE(3, 15, "anthropic", ["claude-sonnet-4-6"]),
|
|
28
|
+
"claude-sonnet-3.7": RATE(3, 15, "anthropic"),
|
|
29
|
+
"claude-sonnet-3.5": RATE(3, 15, "anthropic"),
|
|
30
|
+
"claude-haiku-3.5": RATE(0.8, 4, "anthropic"),
|
|
31
|
+
"claude-haiku-3": RATE(0.25, 1.25, "anthropic"),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function normalizeString(value) {
|
|
35
|
+
return String(value || "").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeModel(value) {
|
|
39
|
+
return normalizeString(value).toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeTokenCount(value, field) {
|
|
43
|
+
const parsed = Number(value || 0);
|
|
44
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
45
|
+
throw new Error(`${field} must be a non-negative number.`);
|
|
46
|
+
}
|
|
47
|
+
return Math.floor(parsed);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function round6(value) {
|
|
51
|
+
return Math.round((Number(value || 0) + Number.EPSILON) * 1_000_000) / 1_000_000;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ALIAS_TO_MODEL = Object.freeze(
|
|
55
|
+
Object.entries(PRICE_BOOK).reduce((acc, [model, rate]) => {
|
|
56
|
+
acc[model] = model;
|
|
57
|
+
for (const alias of rate.aliases || []) {
|
|
58
|
+
acc[normalizeModel(alias)] = model;
|
|
59
|
+
}
|
|
60
|
+
return acc;
|
|
61
|
+
}, {}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export function resolvePriceBookRate(model) {
|
|
65
|
+
const requestedModel = normalizeString(model);
|
|
66
|
+
const normalized = normalizeModel(requestedModel);
|
|
67
|
+
const canonicalModel = ALIAS_TO_MODEL[normalized] || normalized;
|
|
68
|
+
const rate = PRICE_BOOK[canonicalModel] || null;
|
|
69
|
+
if (!rate) {
|
|
70
|
+
return {
|
|
71
|
+
model: requestedModel,
|
|
72
|
+
canonicalModel: normalized || requestedModel,
|
|
73
|
+
rate: null,
|
|
74
|
+
unpriced: true,
|
|
75
|
+
priceBookVersion: PRICE_BOOK_VERSION,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
model: requestedModel || canonicalModel,
|
|
80
|
+
canonicalModel,
|
|
81
|
+
rate,
|
|
82
|
+
unpriced: false,
|
|
83
|
+
priceBookVersion: PRICE_BOOK_VERSION,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function computeProviderCost({
|
|
88
|
+
model,
|
|
89
|
+
inputTokens = 0,
|
|
90
|
+
outputTokens = 0,
|
|
91
|
+
} = {}) {
|
|
92
|
+
const input = normalizeTokenCount(inputTokens, "inputTokens");
|
|
93
|
+
const output = normalizeTokenCount(outputTokens, "outputTokens");
|
|
94
|
+
const resolved = resolvePriceBookRate(model);
|
|
95
|
+
if (resolved.unpriced) {
|
|
96
|
+
return {
|
|
97
|
+
model: resolved.model,
|
|
98
|
+
canonicalModel: resolved.canonicalModel,
|
|
99
|
+
priceBookVersion: PRICE_BOOK_VERSION,
|
|
100
|
+
providerCostUsd: null,
|
|
101
|
+
unpriced: true,
|
|
102
|
+
inputTokens: input,
|
|
103
|
+
outputTokens: output,
|
|
104
|
+
totalTokens: input + output,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const providerCostUsd = round6(
|
|
108
|
+
(input / 1_000_000) * resolved.rate.inputPerMTok +
|
|
109
|
+
(output / 1_000_000) * resolved.rate.outputPerMTok,
|
|
110
|
+
);
|
|
111
|
+
return {
|
|
112
|
+
model: resolved.model,
|
|
113
|
+
canonicalModel: resolved.canonicalModel,
|
|
114
|
+
priceBookVersion: PRICE_BOOK_VERSION,
|
|
115
|
+
providerCostUsd,
|
|
116
|
+
unpriced: false,
|
|
117
|
+
inputTokens: input,
|
|
118
|
+
outputTokens: output,
|
|
119
|
+
totalTokens: input + output,
|
|
120
|
+
currency: resolved.rate.currency,
|
|
121
|
+
source: resolved.rate.source,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
import { createAgentEvent } from "../events/schema.js";
|
|
4
|
+
import { createSession } from "../session/store.js";
|
|
5
|
+
import { appendToStream } from "../session/stream.js";
|
|
6
|
+
import { buildLedgerEntry } from "./ledger-entry.js";
|
|
7
|
+
|
|
8
|
+
const SESSION_USAGE_EVENT = "session_usage";
|
|
9
|
+
|
|
10
|
+
function normalizeString(value) {
|
|
11
|
+
return String(value || "").trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function appendUsageEvent(sessionId, envelope, { targetPath, syncRemote }) {
|
|
15
|
+
return appendToStream(sessionId, envelope, {
|
|
16
|
+
targetPath,
|
|
17
|
+
syncRemote,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function recordSessionUsage(
|
|
22
|
+
sessionId,
|
|
23
|
+
params = {},
|
|
24
|
+
{
|
|
25
|
+
targetPath = process.cwd(),
|
|
26
|
+
// Billing events originate in the CLI and must reach the API for server-side
|
|
27
|
+
// ledger/quota projection; this intentionally defaults to remote sync.
|
|
28
|
+
syncRemote = true,
|
|
29
|
+
ensureLocalSession = true,
|
|
30
|
+
append = appendUsageEvent,
|
|
31
|
+
} = {},
|
|
32
|
+
) {
|
|
33
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
34
|
+
if (!normalizedSessionId) throw new Error("sessionId is required.");
|
|
35
|
+
const entry = buildLedgerEntry({
|
|
36
|
+
...params,
|
|
37
|
+
sessionId: normalizedSessionId,
|
|
38
|
+
});
|
|
39
|
+
const envelope = createAgentEvent({
|
|
40
|
+
event: SESSION_USAGE_EVENT,
|
|
41
|
+
sessionId: normalizedSessionId,
|
|
42
|
+
agentId: entry.agentId,
|
|
43
|
+
agentModel: entry.model,
|
|
44
|
+
payload: {
|
|
45
|
+
...entry,
|
|
46
|
+
costUsd: entry.providerCostUsd ?? 0,
|
|
47
|
+
usage: {
|
|
48
|
+
totalTokens: entry.totalTokens,
|
|
49
|
+
inputTokens: entry.inputTokens,
|
|
50
|
+
outputTokens: entry.outputTokens,
|
|
51
|
+
costUsd: entry.providerCostUsd ?? 0,
|
|
52
|
+
providerCostUsd: entry.providerCostUsd,
|
|
53
|
+
customerCostUsd: entry.customerCostUsd,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
ts: entry.createdAt,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await append(normalizedSessionId, envelope, { targetPath, syncRemote });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!ensureLocalSession || !/was not found/i.test(error?.message || "")) {
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
await createSession({
|
|
66
|
+
sessionId: normalizedSessionId,
|
|
67
|
+
targetPath,
|
|
68
|
+
title: `Billing usage ${normalizedSessionId}`,
|
|
69
|
+
createdAt: entry.createdAt,
|
|
70
|
+
lastInteractionAt: entry.createdAt,
|
|
71
|
+
});
|
|
72
|
+
await append(normalizedSessionId, envelope, { targetPath, syncRemote });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
event: SESSION_USAGE_EVENT,
|
|
78
|
+
ledgerEntry: entry,
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/commands/session.js
CHANGED
|
@@ -1690,6 +1690,14 @@ export function registerSessionCommand(program) {
|
|
|
1690
1690
|
let hydration = null;
|
|
1691
1691
|
let remoteTail = null;
|
|
1692
1692
|
if (options.remote) {
|
|
1693
|
+
const authSession = await resolveActiveAuthSession({
|
|
1694
|
+
cwd: targetPath,
|
|
1695
|
+
env: process.env,
|
|
1696
|
+
autoRotate: false,
|
|
1697
|
+
});
|
|
1698
|
+
if (!authSession || !authSession.token) {
|
|
1699
|
+
throw new Error(`Remote session read requires authentication. Run \`${authLoginHint()}\` first.`);
|
|
1700
|
+
}
|
|
1693
1701
|
hydration = await hydrateSessionFromRemote({
|
|
1694
1702
|
sessionId: normalizedSessionId,
|
|
1695
1703
|
targetPath,
|
package/src/cost/tracker.js
CHANGED
|
@@ -6,8 +6,16 @@ const DEFAULT_MODEL_PRICING = Object.freeze({
|
|
|
6
6
|
outputPerMillionUsd: 10.0,
|
|
7
7
|
}),
|
|
8
8
|
"gpt-5.3-codex": Object.freeze({
|
|
9
|
-
inputPerMillionUsd: 1.
|
|
10
|
-
outputPerMillionUsd:
|
|
9
|
+
inputPerMillionUsd: 1.75,
|
|
10
|
+
outputPerMillionUsd: 14.0,
|
|
11
|
+
}),
|
|
12
|
+
"gpt-5.4-mini": Object.freeze({
|
|
13
|
+
inputPerMillionUsd: 0.75,
|
|
14
|
+
outputPerMillionUsd: 4.5,
|
|
15
|
+
}),
|
|
16
|
+
"gpt-4.1-mini": Object.freeze({
|
|
17
|
+
inputPerMillionUsd: 0.4,
|
|
18
|
+
outputPerMillionUsd: 1.6,
|
|
11
19
|
}),
|
|
12
20
|
"claude-sonnet-4": Object.freeze({
|
|
13
21
|
inputPerMillionUsd: 3.0,
|
|
@@ -21,6 +29,14 @@ const DEFAULT_MODEL_PRICING = Object.freeze({
|
|
|
21
29
|
inputPerMillionUsd: 3.0,
|
|
22
30
|
outputPerMillionUsd: 15.0,
|
|
23
31
|
}),
|
|
32
|
+
"claude-opus-4": Object.freeze({
|
|
33
|
+
inputPerMillionUsd: 15.0,
|
|
34
|
+
outputPerMillionUsd: 75.0,
|
|
35
|
+
}),
|
|
36
|
+
"claude-opus-4.1": Object.freeze({
|
|
37
|
+
inputPerMillionUsd: 15.0,
|
|
38
|
+
outputPerMillionUsd: 75.0,
|
|
39
|
+
}),
|
|
24
40
|
"claude-opus-4-6": Object.freeze({
|
|
25
41
|
inputPerMillionUsd: 15.0,
|
|
26
42
|
outputPerMillionUsd: 75.0,
|
|
@@ -29,6 +45,14 @@ const DEFAULT_MODEL_PRICING = Object.freeze({
|
|
|
29
45
|
inputPerMillionUsd: 15.0,
|
|
30
46
|
outputPerMillionUsd: 75.0,
|
|
31
47
|
}),
|
|
48
|
+
"claude-haiku-3.5": Object.freeze({
|
|
49
|
+
inputPerMillionUsd: 0.8,
|
|
50
|
+
outputPerMillionUsd: 4.0,
|
|
51
|
+
}),
|
|
52
|
+
"claude-haiku-3": Object.freeze({
|
|
53
|
+
inputPerMillionUsd: 0.25,
|
|
54
|
+
outputPerMillionUsd: 1.25,
|
|
55
|
+
}),
|
|
32
56
|
"gemini-2.5-pro": Object.freeze({
|
|
33
57
|
inputPerMillionUsd: 2.5,
|
|
34
58
|
outputPerMillionUsd: 10.0,
|
package/src/review/ai-review.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import fsp from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
|
|
4
5
|
import {
|
|
5
6
|
createMultiProviderApiClient,
|
|
6
7
|
resolveModel,
|
|
7
8
|
resolveProvider,
|
|
8
9
|
} from "../ai/client.js";
|
|
10
|
+
import { computeProviderCost } from "../billing/price-book.js";
|
|
11
|
+
import { buildBillingRunId, buildCallIdempotencyKey } from "../billing/ledger-entry.js";
|
|
12
|
+
import { recordSessionUsage } from "../billing/session-usage.js";
|
|
9
13
|
import { loadConfig } from "../config/service.js";
|
|
10
14
|
import { evaluateBudget } from "../cost/budget.js";
|
|
11
15
|
import { appendCostEntry, summarizeCostHistory } from "../cost/history.js";
|
|
12
|
-
import { estimateModelCost } from "../cost/tracker.js";
|
|
13
16
|
import { estimateTokens } from "../cost/tokenizer.js";
|
|
14
17
|
import { appendRunEvent, deriveStopClassFromBudget } from "../telemetry/ledger.js";
|
|
15
18
|
|
|
@@ -329,21 +332,22 @@ export function buildAiReviewPrompt({
|
|
|
329
332
|
}
|
|
330
333
|
|
|
331
334
|
function maybeEstimateModelCost({ modelId, inputTokens, outputTokens }) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
335
|
+
const priced = computeProviderCost({
|
|
336
|
+
model: modelId,
|
|
337
|
+
inputTokens,
|
|
338
|
+
outputTokens,
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
costUsd: priced.providerCostUsd ?? 0,
|
|
342
|
+
providerCostUsd: priced.providerCostUsd,
|
|
343
|
+
pricingFound: !priced.unpriced,
|
|
344
|
+
priceBookVersion: priced.priceBookVersion,
|
|
345
|
+
canonicalModel: priced.canonicalModel,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function stableConfigHash(value = {}) {
|
|
350
|
+
return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
347
351
|
}
|
|
348
352
|
|
|
349
353
|
function composeAiReviewMarkdown({
|
|
@@ -529,6 +533,7 @@ export async function runAiReviewLayer({
|
|
|
529
533
|
});
|
|
530
534
|
|
|
531
535
|
const startedAt = Date.now();
|
|
536
|
+
const startedAtIso = new Date(startedAt).toISOString();
|
|
532
537
|
const responseText = dryRun
|
|
533
538
|
? buildDryRunResponse({
|
|
534
539
|
deterministicSummary: deterministic?.summary || {},
|
|
@@ -643,6 +648,53 @@ export async function runAiReviewLayer({
|
|
|
643
648
|
}
|
|
644
649
|
);
|
|
645
650
|
|
|
651
|
+
let sessionUsageLedger = null;
|
|
652
|
+
try {
|
|
653
|
+
const billingRunId = buildBillingRunId({
|
|
654
|
+
sessionId: normalizedSessionId,
|
|
655
|
+
invocationTimestamp: startedAtIso,
|
|
656
|
+
configHash: stableConfigHash({
|
|
657
|
+
sourceCommand: "review",
|
|
658
|
+
layer: "ai_reasoning",
|
|
659
|
+
provider: resolvedProvider,
|
|
660
|
+
model: resolvedModel,
|
|
661
|
+
mode: normalizedMode,
|
|
662
|
+
runId: normalizedRunId,
|
|
663
|
+
dryRun: Boolean(dryRun),
|
|
664
|
+
}),
|
|
665
|
+
});
|
|
666
|
+
sessionUsageLedger = await recordSessionUsage(
|
|
667
|
+
normalizedSessionId,
|
|
668
|
+
{
|
|
669
|
+
agentId: "audit-orchestrator",
|
|
670
|
+
action: "audit_run",
|
|
671
|
+
model: resolvedModel,
|
|
672
|
+
inputTokens,
|
|
673
|
+
outputTokens,
|
|
674
|
+
idempotencyKey: buildCallIdempotencyKey({ runId: billingRunId, callIndex: 0 }),
|
|
675
|
+
billingTier: "internal",
|
|
676
|
+
createdAt: startedAtIso,
|
|
677
|
+
metadata: {
|
|
678
|
+
sourceCommand: "review",
|
|
679
|
+
layer: "ai_reasoning",
|
|
680
|
+
provider: resolvedProvider,
|
|
681
|
+
mode: normalizedMode,
|
|
682
|
+
runId: normalizedRunId,
|
|
683
|
+
dryRun: Boolean(dryRun),
|
|
684
|
+
pricingFound: modelCost.pricingFound,
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
targetPath: normalizedTargetPath,
|
|
689
|
+
},
|
|
690
|
+
);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
sessionUsageLedger = {
|
|
693
|
+
ok: false,
|
|
694
|
+
reason: error instanceof Error ? error.message : String(error || "session_usage_failed"),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
646
698
|
let stopTelemetry = null;
|
|
647
699
|
if (budget.blocking) {
|
|
648
700
|
stopTelemetry = await appendRunEvent(
|
|
@@ -704,6 +756,7 @@ export async function runAiReviewLayer({
|
|
|
704
756
|
dryRun: Boolean(dryRun),
|
|
705
757
|
usage,
|
|
706
758
|
pricingFound: modelCost.pricingFound,
|
|
759
|
+
sessionUsageLedger,
|
|
707
760
|
budget,
|
|
708
761
|
deterministicSummary,
|
|
709
762
|
aiSummary,
|
|
@@ -756,6 +809,7 @@ export async function runAiReviewLayer({
|
|
|
756
809
|
usageEventId: usageTelemetry.event.eventId,
|
|
757
810
|
stopEventId: stopTelemetry?.event?.eventId || null,
|
|
758
811
|
},
|
|
812
|
+
billing: sessionUsageLedger,
|
|
759
813
|
};
|
|
760
814
|
}
|
|
761
815
|
|