@prism-d1/cli 1.1.2 → 1.2.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.
Files changed (160) hide show
  1. package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
  2. package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +11 -1
  3. package/dist/assets/gitlab-workflows/prism-eval-gate.yml +8 -0
  4. package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +96 -0
  5. package/dist/assets/infra/lib/dashboard-stack.ts +27 -0
  6. package/dist/assets/infra/lib/lambda/otel-receiver.ts +278 -8
  7. package/dist/assets/infra/lib/lambda/pricing/litellm-snapshot.json +3769 -0
  8. package/dist/assets/infra/lib/lambda/pricing/price-source.ts +56 -0
  9. package/dist/assets/infra/lib/lambda/pricing/price-table.ts +240 -0
  10. package/dist/assets/infra/lib/lambda/pricing/prices-key.ts +6 -0
  11. package/dist/assets/infra/lib/lambda/pricing/reprice.ts +108 -0
  12. package/dist/assets/infra/lib/lambda/pricing-refresh.ts +352 -0
  13. package/dist/assets/infra/lib/lambda/velocity-widget.ts +124 -1
  14. package/dist/assets/infra/package.json +4 -1
  15. package/dist/assets/metric-hooks/prepare-commit-msg +3 -1
  16. package/dist/src/codewatch/config.d.ts +17 -0
  17. package/dist/src/codewatch/config.d.ts.map +1 -0
  18. package/dist/src/codewatch/config.js +40 -0
  19. package/dist/src/codewatch/config.js.map +1 -0
  20. package/dist/src/codewatch/gitleaks.d.ts +49 -0
  21. package/dist/src/codewatch/gitleaks.d.ts.map +1 -0
  22. package/dist/src/codewatch/gitleaks.js +160 -0
  23. package/dist/src/codewatch/gitleaks.js.map +1 -0
  24. package/dist/src/codewatch/globalHooks.d.ts +52 -0
  25. package/dist/src/codewatch/globalHooks.d.ts.map +1 -0
  26. package/dist/src/codewatch/globalHooks.js +235 -0
  27. package/dist/src/codewatch/globalHooks.js.map +1 -0
  28. package/dist/src/codewatch/hooks.d.ts +38 -0
  29. package/dist/src/codewatch/hooks.d.ts.map +1 -0
  30. package/dist/src/codewatch/hooks.js +176 -0
  31. package/dist/src/codewatch/hooks.js.map +1 -0
  32. package/dist/src/codewatch/prepush.d.ts +37 -0
  33. package/dist/src/codewatch/prepush.d.ts.map +1 -0
  34. package/dist/src/codewatch/prepush.js +231 -0
  35. package/dist/src/codewatch/prepush.js.map +1 -0
  36. package/dist/src/codewatch/results.d.ts +90 -0
  37. package/dist/src/codewatch/results.d.ts.map +1 -0
  38. package/dist/src/codewatch/results.js +121 -0
  39. package/dist/src/codewatch/results.js.map +1 -0
  40. package/dist/src/codewatch/reviewers/claude.d.ts +9 -0
  41. package/dist/src/codewatch/reviewers/claude.d.ts.map +1 -0
  42. package/dist/src/codewatch/reviewers/claude.js +48 -0
  43. package/dist/src/codewatch/reviewers/claude.js.map +1 -0
  44. package/dist/src/codewatch/reviewers/index.d.ts +39 -0
  45. package/dist/src/codewatch/reviewers/index.d.ts.map +1 -0
  46. package/dist/src/codewatch/reviewers/index.js +97 -0
  47. package/dist/src/codewatch/reviewers/index.js.map +1 -0
  48. package/dist/src/codewatch/reviewers/kiro.d.ts +14 -0
  49. package/dist/src/codewatch/reviewers/kiro.d.ts.map +1 -0
  50. package/dist/src/codewatch/reviewers/kiro.js +32 -0
  51. package/dist/src/codewatch/reviewers/kiro.js.map +1 -0
  52. package/dist/src/codewatch/reviewers/types.d.ts +56 -0
  53. package/dist/src/codewatch/reviewers/types.d.ts.map +1 -0
  54. package/dist/src/codewatch/reviewers/types.js +91 -0
  55. package/dist/src/codewatch/reviewers/types.js.map +1 -0
  56. package/dist/src/codewatch/rules.d.ts +12 -0
  57. package/dist/src/codewatch/rules.d.ts.map +1 -0
  58. package/dist/src/codewatch/rules.js +27 -0
  59. package/dist/src/codewatch/rules.js.map +1 -0
  60. package/dist/src/commands/_top/dashboard.d.ts +17 -0
  61. package/dist/src/commands/_top/dashboard.d.ts.map +1 -0
  62. package/dist/src/commands/_top/dashboard.js +76 -0
  63. package/dist/src/commands/_top/dashboard.js.map +1 -0
  64. package/dist/src/commands/bedrock-protection/scan-account.d.ts.map +1 -1
  65. package/dist/src/commands/bedrock-protection/scan-account.js +2 -1
  66. package/dist/src/commands/bedrock-protection/scan-account.js.map +1 -1
  67. package/dist/src/commands/bedrock-protection/scan-org.d.ts.map +1 -1
  68. package/dist/src/commands/bedrock-protection/scan-org.js +16 -4
  69. package/dist/src/commands/bedrock-protection/scan-org.js.map +1 -1
  70. package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
  71. package/dist/src/commands/bootstrapper/install-eval-harness.js +15 -48
  72. package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
  73. package/dist/src/commands/bootstrapper/install-git-hooks.d.ts +13 -0
  74. package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -1
  75. package/dist/src/commands/bootstrapper/install-git-hooks.js +28 -147
  76. package/dist/src/commands/bootstrapper/install-git-hooks.js.map +1 -1
  77. package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
  78. package/dist/src/commands/bootstrapper/setup-otel-sync.js +5 -39
  79. package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
  80. package/dist/src/commands/codewatch/pre-commit.d.ts +7 -0
  81. package/dist/src/commands/codewatch/pre-commit.d.ts.map +1 -0
  82. package/dist/src/commands/codewatch/pre-commit.js +30 -0
  83. package/dist/src/commands/codewatch/pre-commit.js.map +1 -0
  84. package/dist/src/commands/codewatch/pre-push.d.ts +16 -0
  85. package/dist/src/commands/codewatch/pre-push.d.ts.map +1 -0
  86. package/dist/src/commands/codewatch/pre-push.js +32 -0
  87. package/dist/src/commands/codewatch/pre-push.js.map +1 -0
  88. package/dist/src/commands/codewatch/report.d.ts +22 -0
  89. package/dist/src/commands/codewatch/report.d.ts.map +1 -0
  90. package/dist/src/commands/codewatch/report.js +64 -0
  91. package/dist/src/commands/codewatch/report.js.map +1 -0
  92. package/dist/src/commands/enable/codewatch.d.ts +15 -0
  93. package/dist/src/commands/enable/codewatch.d.ts.map +1 -0
  94. package/dist/src/commands/enable/codewatch.js +186 -0
  95. package/dist/src/commands/enable/codewatch.js.map +1 -0
  96. package/dist/src/commands/git/claude-session-context.d.ts.map +1 -1
  97. package/dist/src/commands/git/claude-session-context.js +2 -1
  98. package/dist/src/commands/git/claude-session-context.js.map +1 -1
  99. package/dist/src/commands/git/commit-trailers.js +1 -1
  100. package/dist/src/commands/git/commit-trailers.js.map +1 -1
  101. package/dist/src/commands/workshop/verify-setup.d.ts.map +1 -1
  102. package/dist/src/commands/workshop/verify-setup.js +5 -3
  103. package/dist/src/commands/workshop/verify-setup.js.map +1 -1
  104. package/dist/src/dashboard/codewatch.d.ts +106 -0
  105. package/dist/src/dashboard/codewatch.d.ts.map +1 -0
  106. package/dist/src/dashboard/codewatch.js +119 -0
  107. package/dist/src/dashboard/codewatch.js.map +1 -0
  108. package/dist/src/dashboard/commits.d.ts +96 -0
  109. package/dist/src/dashboard/commits.d.ts.map +1 -0
  110. package/dist/src/dashboard/commits.js +207 -0
  111. package/dist/src/dashboard/commits.js.map +1 -0
  112. package/dist/src/dashboard/page.d.ts +10 -0
  113. package/dist/src/dashboard/page.d.ts.map +1 -0
  114. package/dist/src/dashboard/page.js +315 -0
  115. package/dist/src/dashboard/page.js.map +1 -0
  116. package/dist/src/dashboard/payload.d.ts +111 -0
  117. package/dist/src/dashboard/payload.d.ts.map +1 -0
  118. package/dist/src/dashboard/payload.js +93 -0
  119. package/dist/src/dashboard/payload.js.map +1 -0
  120. package/dist/src/dashboard/report.d.ts +99 -0
  121. package/dist/src/dashboard/report.d.ts.map +1 -0
  122. package/dist/src/dashboard/report.js +300 -0
  123. package/dist/src/dashboard/report.js.map +1 -0
  124. package/dist/src/dashboard/repos.d.ts +58 -0
  125. package/dist/src/dashboard/repos.d.ts.map +1 -0
  126. package/dist/src/dashboard/repos.js +111 -0
  127. package/dist/src/dashboard/repos.js.map +1 -0
  128. package/dist/src/dashboard/server.d.ts +52 -0
  129. package/dist/src/dashboard/server.d.ts.map +1 -0
  130. package/dist/src/dashboard/server.js +163 -0
  131. package/dist/src/dashboard/server.js.map +1 -0
  132. package/dist/src/dashboard/setup.d.ts +52 -0
  133. package/dist/src/dashboard/setup.d.ts.map +1 -0
  134. package/dist/src/dashboard/setup.js +129 -0
  135. package/dist/src/dashboard/setup.js.map +1 -0
  136. package/dist/src/dashboard/usage.d.ts +43 -0
  137. package/dist/src/dashboard/usage.d.ts.map +1 -0
  138. package/dist/src/dashboard/usage.js +88 -0
  139. package/dist/src/dashboard/usage.js.map +1 -0
  140. package/dist/src/index.d.ts +1 -0
  141. package/dist/src/index.d.ts.map +1 -1
  142. package/dist/src/index.js +40 -1
  143. package/dist/src/index.js.map +1 -1
  144. package/dist/src/utils/audit-bedrock.d.ts +1 -0
  145. package/dist/src/utils/audit-bedrock.d.ts.map +1 -1
  146. package/dist/src/utils/audit-bedrock.js +133 -0
  147. package/dist/src/utils/audit-bedrock.js.map +1 -1
  148. package/dist/src/utils/audit-org.d.ts +1 -0
  149. package/dist/src/utils/audit-org.d.ts.map +1 -1
  150. package/dist/src/utils/audit-org.js +155 -0
  151. package/dist/src/utils/audit-org.js.map +1 -1
  152. package/dist/src/utils/otel-sync-schedule.d.ts +22 -0
  153. package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -0
  154. package/dist/src/utils/otel-sync-schedule.js +68 -0
  155. package/dist/src/utils/otel-sync-schedule.js.map +1 -0
  156. package/dist/src/utils/tool-check.d.ts +14 -0
  157. package/dist/src/utils/tool-check.d.ts.map +1 -0
  158. package/dist/src/utils/tool-check.js +42 -0
  159. package/dist/src/utils/tool-check.js.map +1 -0
  160. package/package.json +3 -2
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Where the receiver gets its price table at runtime.
3
+ *
4
+ * The refresh Lambda writes a validated, trimmed LiteLLM table to S3 once a
5
+ * day. The receiver reads it at most once an hour per warm container. When the
6
+ * object is missing (fresh deploy, refresh not yet run) or unreadable, it
7
+ * prices with the snapshot bundled at build time, so ingestion never fails or
8
+ * waits on pricing.
9
+ */
10
+ import { S3Client, GetObjectCommand, NoSuchKey } from '@aws-sdk/client-s3';
11
+ import { parseDocument, PriceDocument } from './price-table';
12
+ import bundled from './litellm-snapshot.json';
13
+ import { PRICES_KEY } from './prices-key';
14
+
15
+ export { PRICES_KEY };
16
+
17
+ const CACHE_MS = 60 * 60 * 1000;
18
+ /** After a failed read, retry no sooner than this, so S3 errors do not slow every batch. */
19
+ const RETRY_MS = 5 * 60 * 1000;
20
+
21
+ export const BUNDLED_PRICES: PriceDocument = parseDocument(bundled) ?? {
22
+ version: 1, source: 'empty', fetchedAt: '', entryCount: 0, entries: {},
23
+ };
24
+
25
+ let cached: { doc: PriceDocument; origin: 's3' | 'bundled'; until: number } | null = null;
26
+
27
+ export async function loadPrices(
28
+ s3: S3Client,
29
+ bucket: string,
30
+ now = Date.now(),
31
+ ): Promise<{ doc: PriceDocument; origin: 's3' | 'bundled' }> {
32
+ if (cached && now < cached.until) return cached;
33
+ if (!bucket) {
34
+ cached = { doc: BUNDLED_PRICES, origin: 'bundled', until: now + CACHE_MS };
35
+ return cached;
36
+ }
37
+ try {
38
+ const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: PRICES_KEY }));
39
+ const doc = parseDocument(JSON.parse(await res.Body!.transformToString()));
40
+ if (!doc) throw new Error('price document failed shape check');
41
+ cached = { doc, origin: 's3', until: now + CACHE_MS };
42
+ } catch (e) {
43
+ if (!(e instanceof NoSuchKey)) {
44
+ console.warn(`[pricing] using bundled snapshot (${BUNDLED_PRICES.fetchedAt}): ${(e as Error).message}`);
45
+ }
46
+ // Keep a previously loaded S3 table over the older bundled one.
47
+ const doc = cached?.origin === 's3' ? cached.doc : BUNDLED_PRICES;
48
+ cached = { doc, origin: cached?.origin === 's3' ? 's3' : 'bundled', until: now + RETRY_MS };
49
+ }
50
+ return cached;
51
+ }
52
+
53
+ /** Test hook: forget the cached table. */
54
+ export function resetPriceCache(): void {
55
+ cached = null;
56
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * LiteLLM-derived price table: trimming, validation, model-id lookup, costing.
3
+ *
4
+ * Pure functions only -- no AWS calls -- so the receiver, the refresh Lambda,
5
+ * the snapshot script and the check script all share one implementation.
6
+ *
7
+ * Why the receiver prices anything at all: codeburn prices each call on the
8
+ * client and sends `ai.cost_usd`. When its bundled table has no entry for the
9
+ * id a tool reports, it sends 0 without flagging the span estimated. Codex on
10
+ * Bedrock reports `openai.gpt-6-astra`; neither codeburn 0.9.24 nor LiteLLM
11
+ * lists that exact key (LiteLLM has `bedrock_mantle/openai.gpt-6-astra`,
12
+ * `us.openai.gpt-6-astra` and `global.openai.gpt-6-astra`), so 1,597 spans and
13
+ * 214M tokens reached the dashboards as $0. A fresher table alone would not
14
+ * have fixed it; the lookup chain below is the other half.
15
+ */
16
+
17
+ /** [input, output, cacheWrite, cacheRead] in USD per token. null = not listed. */
18
+ export type PriceEntry = [number | null, number | null, number | null, number | null];
19
+ export type PriceTable = Record<string, PriceEntry>;
20
+
21
+ export interface TokenCounts {
22
+ inputTokens: number;
23
+ outputTokens: number;
24
+ cacheReadTokens: number;
25
+ cacheWriteTokens: number;
26
+ }
27
+
28
+ /** Above this a per-token rate is a unit error ($10,000 per million tokens). */
29
+ const MAX_RATE = 0.01;
30
+
31
+ function rate(v: unknown): number | null {
32
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= MAX_RATE ? v : null;
33
+ }
34
+
35
+ /**
36
+ * Reduce LiteLLM's model_prices_and_context_window.json (about 3 MB) to the
37
+ * four rates the receiver uses (about 450 KB). Entries with neither an input
38
+ * nor an output rate (image, audio, rerank models) are dropped.
39
+ */
40
+ export function slimLiteLLM(raw: unknown): PriceTable {
41
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
42
+ throw new Error('LiteLLM table is not a JSON object');
43
+ }
44
+ const out: PriceTable = {};
45
+ for (const [key, v] of Object.entries(raw as Record<string, unknown>)) {
46
+ if (key === 'sample_spec' || !v || typeof v !== 'object') continue;
47
+ const e = v as Record<string, unknown>;
48
+ const entry: PriceEntry = [
49
+ rate(e.input_cost_per_token),
50
+ rate(e.output_cost_per_token),
51
+ rate(e.cache_creation_input_token_cost),
52
+ rate(e.cache_read_input_token_cost),
53
+ ];
54
+ if (entry[0] === null && entry[1] === null) continue;
55
+ out[key] = entry;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ export interface ValidationResult {
61
+ ok: boolean;
62
+ reasons: string[];
63
+ /** Keys whose input or output rate moved by more than RATE_JUMP. */
64
+ jumps: string[];
65
+ }
66
+
67
+ /** A table smaller than this is a truncated or wrong file, not LiteLLM. */
68
+ export const MIN_ENTRIES = 1000;
69
+ /** A refresh may lose at most this share of the previous table's entries. */
70
+ const MAX_SHRINK = 0.1;
71
+ /** Factor that counts as a suspicious rate change. */
72
+ const RATE_JUMP = 10;
73
+ /** Share of shared keys allowed to jump before the whole refresh is refused. */
74
+ const MAX_JUMP_SHARE = 0.01;
75
+
76
+ /**
77
+ * Decide whether a freshly fetched table may replace the current one.
78
+ *
79
+ * The table comes from a third-party repository and directly changes the cost
80
+ * figures on every dashboard, so a bad upstream commit must not land silently.
81
+ * A single corrected price is normal; many prices moving 10x at once, or a
82
+ * table that suddenly lost entries, is a broken file. Refusing keeps the
83
+ * previous table in service, which is always safe.
84
+ */
85
+ export function validateTable(next: PriceTable, prev?: PriceTable | null): ValidationResult {
86
+ const reasons: string[] = [];
87
+ const jumps: string[] = [];
88
+ const n = Object.keys(next).length;
89
+ if (n < MIN_ENTRIES) reasons.push(`only ${n} priced entries (minimum ${MIN_ENTRIES})`);
90
+
91
+ if (prev) {
92
+ const p = Object.keys(prev).length;
93
+ if (p > 0 && n < p * (1 - MAX_SHRINK)) {
94
+ reasons.push(`entry count fell from ${p} to ${n} (more than ${MAX_SHRINK * 100}%)`);
95
+ }
96
+ let shared = 0;
97
+ for (const [key, was] of Object.entries(prev)) {
98
+ const now = next[key];
99
+ if (!now) continue;
100
+ shared++;
101
+ for (const i of [0, 1] as const) {
102
+ const a = was[i];
103
+ const b = now[i];
104
+ if (a && b && (b / a > RATE_JUMP || a / b > RATE_JUMP)) {
105
+ jumps.push(key);
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ if (shared > 0 && jumps.length / shared > MAX_JUMP_SHARE) {
111
+ reasons.push(`${jumps.length} of ${shared} shared entries changed price by more than ${RATE_JUMP}x`);
112
+ }
113
+ }
114
+ return { ok: reasons.length === 0, reasons, jumps };
115
+ }
116
+
117
+ const REGION_PREFIX = /^(us|eu|apac|ap|jp|au|ca|us-gov|global)\./i;
118
+ /** Bedrock model ids carry the vendor as a dotted prefix: openai.gpt-6-astra. */
119
+ const BEDROCK_VENDOR = /^(openai|anthropic|amazon|meta|mistral|cohere|deepseek|qwen|writer|ai21|moonshot|google|minimax)\./i;
120
+
121
+ export interface PriceMatch {
122
+ entry: PriceEntry;
123
+ /** The table key that matched, stored on repriced spans for audit. */
124
+ key: string;
125
+ /**
126
+ * True when the match came from a different id than the one reported
127
+ * (another region, endpoint or the plain model name), so the rate may not
128
+ * be the one actually billed.
129
+ */
130
+ approximate: boolean;
131
+ }
132
+
133
+ /**
134
+ * Ordered candidate keys for a reported model id, most specific first.
135
+ *
136
+ * 1. The id as reported, then lowercased, then without a date suffix.
137
+ * 2. For a bare Bedrock id (`openai.x`, no region): the `global.`
138
+ * cross-region key, then the OpenAI-compatible ("mantle") endpoint key.
139
+ * 3. For a region-prefixed id (`eu.openai.x`): the same id under `global.`
140
+ * and mantle, and without the prefix.
141
+ * 4. The plain model name (`gpt-6-astra`), the direct-API rate. Last because
142
+ * Bedrock rates differ from direct rates.
143
+ */
144
+ export function candidateKeys(model: string): Array<{ key: string; approximate: boolean }> {
145
+ const out: Array<{ key: string; approximate: boolean }> = [];
146
+ const seen = new Set<string>();
147
+ const push = (key: string, approximate: boolean) => {
148
+ if (!key || seen.has(key)) return;
149
+ seen.add(key);
150
+ out.push({ key, approximate });
151
+ };
152
+
153
+ const reported = model.trim().replace(/@.*$/, '');
154
+ const lower = reported.toLowerCase();
155
+ const undated = lower.replace(/-\d{8}$/, '');
156
+ push(reported, false);
157
+ push(lower, false);
158
+ push(undated, false);
159
+
160
+ const region = undated.match(REGION_PREFIX);
161
+ const unregioned = undated.replace(REGION_PREFIX, '');
162
+ if (BEDROCK_VENDOR.test(unregioned)) {
163
+ if (!region) {
164
+ // LiteLLM prices its own unprefixed Bedrock keys (openai.gpt-6-sol) at
165
+ // the global cross-region rate; regional and mantle keys are 1.1x.
166
+ // Follow that convention for an unprefixed id it happens not to list.
167
+ push(`global.${unregioned}`, true);
168
+ push(`bedrock_mantle/${unregioned}`, true);
169
+ } else {
170
+ push(`global.${unregioned}`, true);
171
+ push(`bedrock_mantle/${unregioned}`, true);
172
+ push(unregioned, true);
173
+ }
174
+ // Bedrock Anthropic keys carry a version suffix the tool may omit.
175
+ if (!/-v\d+(:\d+)?$/.test(unregioned)) {
176
+ if (region) push(`${undated}-v1:0`, true);
177
+ push(`${unregioned}-v1:0`, true);
178
+ }
179
+ push(unregioned.replace(BEDROCK_VENDOR, ''), true);
180
+ } else if (region) {
181
+ push(unregioned, true);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ export function lookupPrice(table: PriceTable, model: string): PriceMatch | null {
187
+ if (!model) return null;
188
+ for (const c of candidateKeys(model)) {
189
+ const entry = table[c.key];
190
+ if (entry) return { entry, key: c.key, approximate: c.approximate };
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * Cost of one call, the way codeburn's calculateCost computes it: input
197
+ * excludes cache reads, and each token class is billed at its own rate. A
198
+ * missing cache-write rate falls back to 1.25x input and a missing cache-read
199
+ * rate to 0.1x input, the ratios LiteLLM lists for most Bedrock models.
200
+ */
201
+ export function computeCost(entry: PriceEntry, t: TokenCounts): number {
202
+ const [input, output, cacheWrite, cacheRead] = entry;
203
+ const inRate = input ?? 0;
204
+ const cost =
205
+ t.inputTokens * inRate +
206
+ t.outputTokens * (output ?? 0) +
207
+ t.cacheWriteTokens * (cacheWrite ?? inRate * 1.25) +
208
+ t.cacheReadTokens * (cacheRead ?? inRate * 0.1);
209
+ // 1e-6 USD resolution keeps the stored N value short; per-call costs are
210
+ // fractions of a cent and daily aggregates sum thousands of them.
211
+ return Math.round(cost * 1_000_000) / 1_000_000;
212
+ }
213
+
214
+ export function hasTokens(t: TokenCounts): boolean {
215
+ return t.inputTokens + t.outputTokens + t.cacheReadTokens + t.cacheWriteTokens > 0;
216
+ }
217
+
218
+ /** Upstream file. Pinned here rather than configurable: it is a trust anchor. */
219
+ export const LITELLM_PRICES_URL =
220
+ 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
221
+
222
+ /** Shape of both the bundled snapshot and the S3 copy the refresh writes. */
223
+ export interface PriceDocument {
224
+ version: 1;
225
+ source: string;
226
+ fetchedAt: string;
227
+ entryCount: number;
228
+ entries: PriceTable;
229
+ }
230
+
231
+ export function toDocument(entries: PriceTable, fetchedAt: string): PriceDocument {
232
+ return { version: 1, source: LITELLM_PRICES_URL, fetchedAt, entryCount: Object.keys(entries).length, entries };
233
+ }
234
+
235
+ export function parseDocument(raw: unknown): PriceDocument | null {
236
+ if (!raw || typeof raw !== 'object') return null;
237
+ const d = raw as Partial<PriceDocument>;
238
+ if (d.version !== 1 || !d.entries || typeof d.entries !== 'object') return null;
239
+ return d as PriceDocument;
240
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * S3 key of the LiteLLM price table, shared by the receiver, the refresh
3
+ * Lambda and the CDK grants. Its own module so the CDK app can import it
4
+ * without pulling in the AWS SDK or the bundled snapshot.
5
+ */
6
+ export const PRICES_KEY = 'pricing/litellm-prices.json';
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Decide the corrected cost for a span stored at $0.
3
+ *
4
+ * Pure: takes a DynamoDB item and a price table, returns the writes to make.
5
+ * The refresh Lambda performs them; the check script tests this directly.
6
+ */
7
+ import { PriceTable, lookupPrice, computeCost, hasTokens, TokenCounts } from './price-table';
8
+
9
+ type Attr = { S?: string; N?: string; BOOL?: boolean };
10
+ export type SpanItem = Record<string, Attr | undefined>;
11
+
12
+ export interface RepricePlan {
13
+ pk: string;
14
+ sk: string;
15
+ /** sk of the OTEL#DAY aggregate the cost must be added to. */
16
+ aggregateSk: string;
17
+ model: string;
18
+ costUsd: number;
19
+ pricedKey: string;
20
+ cacheReadTokens: number;
21
+ cacheWriteTokens: number;
22
+ /** Where the cache-token counts came from. */
23
+ cacheSource: 'span' | 'archive' | 'absent';
24
+ }
25
+
26
+ /** The aggregate sk the receiver's bumpDailyAggregate writes for this span. */
27
+ export function aggregateSkFor(timestamp: string, tool: string, model: string): string {
28
+ const m = (model || 'unknown').replace(/#/g, '');
29
+ return `OTEL#DAY#${timestamp.slice(0, 10)}#${tool}#${m}`;
30
+ }
31
+
32
+ function n(a: Attr | undefined): number {
33
+ const v = Number(a?.N ?? 0);
34
+ return Number.isFinite(v) && v > 0 ? v : 0;
35
+ }
36
+
37
+ /**
38
+ * Spans written before the receiver stored cache-token counts have no
39
+ * `cache_read_tokens` attribute at all (newer spans store 0 explicitly). Their
40
+ * real counts are only in the raw OTLP archive.
41
+ */
42
+ export function lacksCacheCounts(item: SpanItem): boolean {
43
+ return item.cache_read_tokens === undefined;
44
+ }
45
+
46
+ export function planReprice(
47
+ item: SpanItem,
48
+ table: PriceTable,
49
+ /** Cache counts recovered from the OTLP archive, keyed by span id. */
50
+ archived?: Map<string, { cacheReadTokens: number; cacheWriteTokens: number }>,
51
+ ): RepricePlan | null {
52
+ if (item.record_type?.S !== 'OTEL_SPAN') return null;
53
+ if (Number(item.cost_usd?.N ?? 'NaN') !== 0) return null;
54
+ const pk = item.pk?.S;
55
+ const sk = item.sk?.S;
56
+ const timestamp = item.timestamp?.S;
57
+ if (!pk || !sk || !timestamp) return null;
58
+
59
+ let cacheSource: RepricePlan['cacheSource'] = 'span';
60
+ let cacheReadTokens = n(item.cache_read_tokens);
61
+ let cacheWriteTokens = n(item.cache_write_tokens);
62
+ if (lacksCacheCounts(item)) {
63
+ // sk is SPAN#<timestamp>#<spanId>; the timestamp contains no '#'.
64
+ const spanId = sk.slice(sk.lastIndexOf('#') + 1);
65
+ const hit = archived?.get(spanId);
66
+ cacheSource = hit ? 'archive' : 'absent';
67
+ cacheReadTokens = hit?.cacheReadTokens ?? 0;
68
+ cacheWriteTokens = hit?.cacheWriteTokens ?? 0;
69
+ }
70
+
71
+ const tokens: TokenCounts = {
72
+ inputTokens: n(item.input_tokens),
73
+ outputTokens: n(item.output_tokens),
74
+ cacheReadTokens,
75
+ cacheWriteTokens,
76
+ };
77
+ if (!hasTokens(tokens)) return null;
78
+
79
+ const model = item.model?.S ?? '';
80
+ const match = lookupPrice(table, model);
81
+ if (!match) return null;
82
+ const costUsd = computeCost(match.entry, tokens);
83
+ if (costUsd <= 0) return null;
84
+
85
+ return {
86
+ pk,
87
+ sk,
88
+ aggregateSk: aggregateSkFor(timestamp, item.tool?.S ?? '', model),
89
+ model,
90
+ costUsd,
91
+ pricedKey: match.key,
92
+ cacheReadTokens,
93
+ cacheWriteTokens,
94
+ cacheSource,
95
+ };
96
+ }
97
+
98
+ /** Plans keyed by the aggregate item they add to, in their original order. */
99
+ export function groupByAggregate(plans: RepricePlan[]): Map<string, RepricePlan[]> {
100
+ const groups = new Map<string, RepricePlan[]>();
101
+ for (const p of plans) {
102
+ const k = `${p.pk}\u0000${p.aggregateSk}`;
103
+ const g = groups.get(k);
104
+ if (g) g.push(p);
105
+ else groups.set(k, [p]);
106
+ }
107
+ return groups;
108
+ }