sentinelayer-cli 0.9.6 → 0.9.7

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.7",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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
+ }
@@ -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