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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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: String(code || "SESSION_WARNING").toUpperCase(),
87
+ code: normalizedCode,
75
88
  warningId: createSessionWarningId(),
76
89
  timestamp: nowIso(),
77
90
  };
78
- for (const [key, value] of Object.entries(sanitizedDetails)) {
79
- if (SESSION_WARNING_ALLOWED_FIELDS.has(key)) {
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
- console.warn(`${SESSION_WARNING_PREFIX} ${JSON.stringify(payload)}`);
95
+ process.stderr.write(`${SESSION_WARNING_PREFIX} ${JSON.stringify(payload)}\n`);
87
96
  } catch {
88
- console.warn(`${SESSION_WARNING_PREFIX} ${payload.code}`);
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
- const key = await loadOrCreateFileKey({ homeDir });
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.tokenEncrypted = encrypted.tokenEncrypted;
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.token = null;
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
- const key = await loadOrCreateFileKey({ homeDir });
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
+ }
@@ -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,
@@ -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.5,
10
- outputPerMillionUsd: 6.0,
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,
@@ -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
- try {
333
- return {
334
- costUsd: estimateModelCost({
335
- modelId,
336
- inputTokens,
337
- outputTokens,
338
- }),
339
- pricingFound: true,
340
- };
341
- } catch {
342
- return {
343
- costUsd: 0,
344
- pricingFound: false,
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