@prism-d1/cli 1.0.99 → 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.
- package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +11 -1
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +8 -0
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +312 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +74 -45
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +324 -12
- package/dist/assets/infra/lib/lambda/pre-token-generation.ts +79 -0
- package/dist/assets/infra/lib/lambda/pricing/litellm-snapshot.json +3769 -0
- package/dist/assets/infra/lib/lambda/pricing/price-source.ts +56 -0
- package/dist/assets/infra/lib/lambda/pricing/price-table.ts +240 -0
- package/dist/assets/infra/lib/lambda/pricing/prices-key.ts +6 -0
- package/dist/assets/infra/lib/lambda/pricing/reprice.ts +108 -0
- package/dist/assets/infra/lib/lambda/pricing-refresh.ts +352 -0
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +397 -1
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +13 -0
- package/dist/assets/infra/package.json +5 -2
- package/dist/assets/metric-hooks/prepare-commit-msg +3 -1
- package/dist/src/codewatch/config.d.ts +17 -0
- package/dist/src/codewatch/config.d.ts.map +1 -0
- package/dist/src/codewatch/config.js +40 -0
- package/dist/src/codewatch/config.js.map +1 -0
- package/dist/src/codewatch/gitleaks.d.ts +49 -0
- package/dist/src/codewatch/gitleaks.d.ts.map +1 -0
- package/dist/src/codewatch/gitleaks.js +160 -0
- package/dist/src/codewatch/gitleaks.js.map +1 -0
- package/dist/src/codewatch/globalHooks.d.ts +52 -0
- package/dist/src/codewatch/globalHooks.d.ts.map +1 -0
- package/dist/src/codewatch/globalHooks.js +235 -0
- package/dist/src/codewatch/globalHooks.js.map +1 -0
- package/dist/src/codewatch/hooks.d.ts +38 -0
- package/dist/src/codewatch/hooks.d.ts.map +1 -0
- package/dist/src/codewatch/hooks.js +176 -0
- package/dist/src/codewatch/hooks.js.map +1 -0
- package/dist/src/codewatch/prepush.d.ts +37 -0
- package/dist/src/codewatch/prepush.d.ts.map +1 -0
- package/dist/src/codewatch/prepush.js +231 -0
- package/dist/src/codewatch/prepush.js.map +1 -0
- package/dist/src/codewatch/results.d.ts +90 -0
- package/dist/src/codewatch/results.d.ts.map +1 -0
- package/dist/src/codewatch/results.js +121 -0
- package/dist/src/codewatch/results.js.map +1 -0
- package/dist/src/codewatch/reviewers/claude.d.ts +9 -0
- package/dist/src/codewatch/reviewers/claude.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/claude.js +48 -0
- package/dist/src/codewatch/reviewers/claude.js.map +1 -0
- package/dist/src/codewatch/reviewers/index.d.ts +39 -0
- package/dist/src/codewatch/reviewers/index.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/index.js +97 -0
- package/dist/src/codewatch/reviewers/index.js.map +1 -0
- package/dist/src/codewatch/reviewers/kiro.d.ts +14 -0
- package/dist/src/codewatch/reviewers/kiro.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/kiro.js +32 -0
- package/dist/src/codewatch/reviewers/kiro.js.map +1 -0
- package/dist/src/codewatch/reviewers/types.d.ts +56 -0
- package/dist/src/codewatch/reviewers/types.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/types.js +91 -0
- package/dist/src/codewatch/reviewers/types.js.map +1 -0
- package/dist/src/codewatch/rules.d.ts +12 -0
- package/dist/src/codewatch/rules.d.ts.map +1 -0
- package/dist/src/codewatch/rules.js +27 -0
- package/dist/src/codewatch/rules.js.map +1 -0
- package/dist/src/commands/_top/dashboard.d.ts +17 -0
- package/dist/src/commands/_top/dashboard.d.ts.map +1 -0
- package/dist/src/commands/_top/dashboard.js +76 -0
- package/dist/src/commands/_top/dashboard.js.map +1 -0
- package/dist/src/commands/bedrock-protection/scan-account.d.ts.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-account.js +2 -1
- package/dist/src/commands/bedrock-protection/scan-account.js.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-org.d.ts.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-org.js +16 -4
- package/dist/src/commands/bedrock-protection/scan-org.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.js +15 -48
- package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts +13 -0
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.js +28 -147
- package/dist/src/commands/bootstrapper/install-git-hooks.js.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.js +5 -39
- package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
- package/dist/src/commands/codewatch/pre-commit.d.ts +7 -0
- package/dist/src/commands/codewatch/pre-commit.d.ts.map +1 -0
- package/dist/src/commands/codewatch/pre-commit.js +30 -0
- package/dist/src/commands/codewatch/pre-commit.js.map +1 -0
- package/dist/src/commands/codewatch/pre-push.d.ts +16 -0
- package/dist/src/commands/codewatch/pre-push.d.ts.map +1 -0
- package/dist/src/commands/codewatch/pre-push.js +32 -0
- package/dist/src/commands/codewatch/pre-push.js.map +1 -0
- package/dist/src/commands/codewatch/report.d.ts +22 -0
- package/dist/src/commands/codewatch/report.d.ts.map +1 -0
- package/dist/src/commands/codewatch/report.js +64 -0
- package/dist/src/commands/codewatch/report.js.map +1 -0
- package/dist/src/commands/enable/codewatch.d.ts +15 -0
- package/dist/src/commands/enable/codewatch.d.ts.map +1 -0
- package/dist/src/commands/enable/codewatch.js +186 -0
- package/dist/src/commands/enable/codewatch.js.map +1 -0
- package/dist/src/commands/git/claude-session-context.d.ts.map +1 -1
- package/dist/src/commands/git/claude-session-context.js +2 -1
- package/dist/src/commands/git/claude-session-context.js.map +1 -1
- package/dist/src/commands/git/commit-trailers.js +1 -1
- package/dist/src/commands/git/commit-trailers.js.map +1 -1
- package/dist/src/commands/workshop/verify-setup.d.ts.map +1 -1
- package/dist/src/commands/workshop/verify-setup.js +5 -3
- package/dist/src/commands/workshop/verify-setup.js.map +1 -1
- package/dist/src/dashboard/codewatch.d.ts +106 -0
- package/dist/src/dashboard/codewatch.d.ts.map +1 -0
- package/dist/src/dashboard/codewatch.js +119 -0
- package/dist/src/dashboard/codewatch.js.map +1 -0
- package/dist/src/dashboard/commits.d.ts +96 -0
- package/dist/src/dashboard/commits.d.ts.map +1 -0
- package/dist/src/dashboard/commits.js +207 -0
- package/dist/src/dashboard/commits.js.map +1 -0
- package/dist/src/dashboard/page.d.ts +10 -0
- package/dist/src/dashboard/page.d.ts.map +1 -0
- package/dist/src/dashboard/page.js +315 -0
- package/dist/src/dashboard/page.js.map +1 -0
- package/dist/src/dashboard/payload.d.ts +111 -0
- package/dist/src/dashboard/payload.d.ts.map +1 -0
- package/dist/src/dashboard/payload.js +93 -0
- package/dist/src/dashboard/payload.js.map +1 -0
- package/dist/src/dashboard/report.d.ts +99 -0
- package/dist/src/dashboard/report.d.ts.map +1 -0
- package/dist/src/dashboard/report.js +300 -0
- package/dist/src/dashboard/report.js.map +1 -0
- package/dist/src/dashboard/repos.d.ts +58 -0
- package/dist/src/dashboard/repos.d.ts.map +1 -0
- package/dist/src/dashboard/repos.js +111 -0
- package/dist/src/dashboard/repos.js.map +1 -0
- package/dist/src/dashboard/server.d.ts +52 -0
- package/dist/src/dashboard/server.d.ts.map +1 -0
- package/dist/src/dashboard/server.js +163 -0
- package/dist/src/dashboard/server.js.map +1 -0
- package/dist/src/dashboard/setup.d.ts +52 -0
- package/dist/src/dashboard/setup.d.ts.map +1 -0
- package/dist/src/dashboard/setup.js +129 -0
- package/dist/src/dashboard/setup.js.map +1 -0
- package/dist/src/dashboard/usage.d.ts +43 -0
- package/dist/src/dashboard/usage.d.ts.map +1 -0
- package/dist/src/dashboard/usage.js +88 -0
- package/dist/src/dashboard/usage.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +40 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/utils/audit-bedrock.d.ts +1 -0
- package/dist/src/utils/audit-bedrock.d.ts.map +1 -1
- package/dist/src/utils/audit-bedrock.js +133 -0
- package/dist/src/utils/audit-bedrock.js.map +1 -1
- package/dist/src/utils/audit-org.d.ts +1 -0
- package/dist/src/utils/audit-org.d.ts.map +1 -1
- package/dist/src/utils/audit-org.js +155 -0
- package/dist/src/utils/audit-org.js.map +1 -1
- package/dist/src/utils/otel-sync-schedule.d.ts +22 -0
- package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -0
- package/dist/src/utils/otel-sync-schedule.js +68 -0
- package/dist/src/utils/otel-sync-schedule.js.map +1 -0
- package/dist/src/utils/tool-check.d.ts +14 -0
- package/dist/src/utils/tool-check.d.ts.map +1 -0
- package/dist/src/utils/tool-check.js +42 -0
- package/dist/src/utils/tool-check.js.map +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRISM D1 — daily price-table refresh and $0-span reprice.
|
|
3
|
+
*
|
|
4
|
+
* Runs once a day on an EventBridge schedule:
|
|
5
|
+
*
|
|
6
|
+
* 1. Refresh. Fetch LiteLLM's price file, trim it to four rates per model,
|
|
7
|
+
* validate it against the table currently in S3, and write it to
|
|
8
|
+
* s3://<archive>/pricing/litellm-prices.json. A refused or failed fetch
|
|
9
|
+
* keeps the previous table in service and fails the invocation at the end,
|
|
10
|
+
* so the Lambda Errors metric shows it.
|
|
11
|
+
*
|
|
12
|
+
* 2. Reprice. Scan for usage spans stored with cost 0 but tokens > 0. These
|
|
13
|
+
* are spans no price table matched when they arrived; a model is often
|
|
14
|
+
* added to LiteLLM days after people start using it. Each one that now
|
|
15
|
+
* matches is corrected in one transaction with its OTEL#DAY aggregate, so
|
|
16
|
+
* the dashboards and the CloudWatch metrics (via the table stream) pick up
|
|
17
|
+
* the difference.
|
|
18
|
+
*
|
|
19
|
+
* Spans written before the receiver stored cache-token counts lack them. For
|
|
20
|
+
* those, the cache counts are recovered from the raw OTLP archive; without
|
|
21
|
+
* them, cached input would go unpriced or be billed at the full input rate.
|
|
22
|
+
*
|
|
23
|
+
* Invoke manually with {"skipRefresh": true} to reprice without fetching, or
|
|
24
|
+
* {"dryRun": true} to report what would change without writing.
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
DynamoDBClient,
|
|
28
|
+
ScanCommand,
|
|
29
|
+
TransactWriteItemsCommand,
|
|
30
|
+
TransactionCanceledException,
|
|
31
|
+
type ScanCommandOutput,
|
|
32
|
+
} from '@aws-sdk/client-dynamodb';
|
|
33
|
+
import {
|
|
34
|
+
S3Client,
|
|
35
|
+
GetObjectCommand,
|
|
36
|
+
PutObjectCommand,
|
|
37
|
+
ListObjectsV2Command,
|
|
38
|
+
NoSuchKey,
|
|
39
|
+
} from '@aws-sdk/client-s3';
|
|
40
|
+
import {
|
|
41
|
+
LITELLM_PRICES_URL, slimLiteLLM, validateTable, toDocument, parseDocument, PriceDocument,
|
|
42
|
+
} from './pricing/price-table';
|
|
43
|
+
import { PRICES_KEY, BUNDLED_PRICES } from './pricing/price-source';
|
|
44
|
+
import { planReprice, lacksCacheCounts, groupByAggregate, RepricePlan, SpanItem } from './pricing/reprice';
|
|
45
|
+
|
|
46
|
+
const dynamo = new DynamoDBClient({});
|
|
47
|
+
const s3 = new S3Client({});
|
|
48
|
+
|
|
49
|
+
const TABLE = process.env.AI_USAGE_TABLE || 'prism-d1-ai-usage';
|
|
50
|
+
const BUCKET = process.env.ARCHIVE_BUCKET || '';
|
|
51
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
52
|
+
const WRITE_CONCURRENCY = 10;
|
|
53
|
+
/** Upper bound on the raw download; LiteLLM's file is about 3 MB today. */
|
|
54
|
+
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
|
|
55
|
+
|
|
56
|
+
interface RefreshEvent {
|
|
57
|
+
skipRefresh?: boolean;
|
|
58
|
+
dryRun?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readCurrentTable(): Promise<PriceDocument | null> {
|
|
62
|
+
try {
|
|
63
|
+
const res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: PRICES_KEY }));
|
|
64
|
+
return parseDocument(JSON.parse(await res.Body!.transformToString()));
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e instanceof NoSuchKey) return null;
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchLiteLLM(): Promise<unknown> {
|
|
72
|
+
const res = await fetch(LITELLM_PRICES_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
73
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} from ${LITELLM_PRICES_URL}`);
|
|
74
|
+
const len = Number(res.headers.get('content-length') ?? 0);
|
|
75
|
+
if (len > MAX_DOWNLOAD_BYTES) throw new Error(`response is ${len} bytes, over the ${MAX_DOWNLOAD_BYTES} limit`);
|
|
76
|
+
const text = await res.text();
|
|
77
|
+
if (text.length > MAX_DOWNLOAD_BYTES) throw new Error(`response over the ${MAX_DOWNLOAD_BYTES} byte limit`);
|
|
78
|
+
return JSON.parse(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Step 1. Returns the table to reprice with and, if the refresh failed, why. */
|
|
82
|
+
async function refresh(dryRun: boolean): Promise<{ doc: PriceDocument; failure: string | null }> {
|
|
83
|
+
const current = await readCurrentTable();
|
|
84
|
+
const fallback = current ?? BUNDLED_PRICES;
|
|
85
|
+
let next;
|
|
86
|
+
try {
|
|
87
|
+
next = slimLiteLLM(await fetchLiteLLM());
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { doc: fallback, failure: `fetch failed: ${(e as Error).message}` };
|
|
90
|
+
}
|
|
91
|
+
// Validate against the S3 table, or the bundled snapshot on first run, so
|
|
92
|
+
// even the first write is checked against something.
|
|
93
|
+
const v = validateTable(next, fallback.entries);
|
|
94
|
+
if (!v.ok) {
|
|
95
|
+
return { doc: fallback, failure: `refused new table: ${v.reasons.join('; ')}` };
|
|
96
|
+
}
|
|
97
|
+
const doc = toDocument(next, new Date().toISOString());
|
|
98
|
+
const added = Object.keys(next).filter((k) => !fallback.entries[k]);
|
|
99
|
+
console.log(JSON.stringify({
|
|
100
|
+
msg: 'price table refreshed',
|
|
101
|
+
entries: doc.entryCount,
|
|
102
|
+
previousEntries: fallback.entryCount,
|
|
103
|
+
previousFetchedAt: fallback.fetchedAt,
|
|
104
|
+
added: added.length,
|
|
105
|
+
addedSample: added.slice(0, 20),
|
|
106
|
+
rateJumps: v.jumps.slice(0, 20),
|
|
107
|
+
dryRun,
|
|
108
|
+
}));
|
|
109
|
+
if (!dryRun) {
|
|
110
|
+
await s3.send(new PutObjectCommand({
|
|
111
|
+
Bucket: BUCKET,
|
|
112
|
+
Key: PRICES_KEY,
|
|
113
|
+
Body: JSON.stringify(doc),
|
|
114
|
+
ContentType: 'application/json',
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
return { doc, failure: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every span item stored at cost 0. Usage spans only; tokens are checked in planReprice. */
|
|
121
|
+
async function scanZeroCostSpans(): Promise<SpanItem[]> {
|
|
122
|
+
const items: SpanItem[] = [];
|
|
123
|
+
let key: ScanCommandOutput['LastEvaluatedKey'];
|
|
124
|
+
do {
|
|
125
|
+
const res = await dynamo.send(new ScanCommand({
|
|
126
|
+
TableName: TABLE,
|
|
127
|
+
// Every attribute name is a placeholder: several (timestamp, model) are
|
|
128
|
+
// or may become DynamoDB reserved words, and a missed one fails the scan.
|
|
129
|
+
FilterExpression: '#rt = :span AND #cost = :zero',
|
|
130
|
+
ProjectionExpression: '#pk, #sk, #rt, #cost, #tool, #model, #ts, #in, #out, #cr, #cw',
|
|
131
|
+
ExpressionAttributeNames: {
|
|
132
|
+
'#pk': 'pk', '#sk': 'sk', '#rt': 'record_type', '#cost': 'cost_usd', '#tool': 'tool',
|
|
133
|
+
'#model': 'model', '#ts': 'timestamp', '#in': 'input_tokens', '#out': 'output_tokens',
|
|
134
|
+
'#cr': 'cache_read_tokens', '#cw': 'cache_write_tokens',
|
|
135
|
+
},
|
|
136
|
+
ExpressionAttributeValues: { ':span': { S: 'OTEL_SPAN' }, ':zero': { N: '0' } },
|
|
137
|
+
...(key ? { ExclusiveStartKey: key } : {}),
|
|
138
|
+
}));
|
|
139
|
+
items.push(...((res.Items ?? []) as SpanItem[]));
|
|
140
|
+
key = res.LastEvaluatedKey;
|
|
141
|
+
} while (key);
|
|
142
|
+
return items;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Recover cache-token counts for the given span ids from the raw OTLP archive.
|
|
147
|
+
* Reads only partitions on or after the earliest span's date (a span is
|
|
148
|
+
* archived when pushed, which is never before it happened), and stops once
|
|
149
|
+
* every id is found.
|
|
150
|
+
*/
|
|
151
|
+
async function cacheCountsFromArchive(
|
|
152
|
+
spanIds: Set<string>,
|
|
153
|
+
earliestDay: string,
|
|
154
|
+
): Promise<Map<string, { cacheReadTokens: number; cacheWriteTokens: number }>> {
|
|
155
|
+
const found = new Map<string, { cacheReadTokens: number; cacheWriteTokens: number }>();
|
|
156
|
+
let token: string | undefined;
|
|
157
|
+
let objects = 0;
|
|
158
|
+
do {
|
|
159
|
+
const page = await s3.send(new ListObjectsV2Command({
|
|
160
|
+
Bucket: BUCKET,
|
|
161
|
+
Prefix: 'otlp/',
|
|
162
|
+
StartAfter: `otlp/dt=${earliestDay}`,
|
|
163
|
+
ContinuationToken: token,
|
|
164
|
+
}));
|
|
165
|
+
for (const obj of page.Contents ?? []) {
|
|
166
|
+
if (!obj.Key) continue;
|
|
167
|
+
objects++;
|
|
168
|
+
const res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: obj.Key }));
|
|
169
|
+
let payload: { resourceSpans?: Array<{ scopeSpans?: Array<{ spans?: Array<Record<string, unknown>> }> }> };
|
|
170
|
+
try {
|
|
171
|
+
payload = JSON.parse(await res.Body!.transformToString());
|
|
172
|
+
} catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
for (const rs of payload.resourceSpans ?? []) {
|
|
176
|
+
for (const ss of rs.scopeSpans ?? []) {
|
|
177
|
+
for (const sp of ss.spans ?? []) {
|
|
178
|
+
const id = String(sp.spanId ?? '').toLowerCase();
|
|
179
|
+
if (!spanIds.has(id) || found.has(id)) continue;
|
|
180
|
+
const attrs = (sp.attributes ?? []) as Array<{ key: string; value: { intValue?: string | number } }>;
|
|
181
|
+
const get = (k: string) => Math.max(0, Number(attrs.find((a) => a.key === k)?.value?.intValue ?? 0) || 0);
|
|
182
|
+
found.set(id, {
|
|
183
|
+
cacheReadTokens: get('ai.cache_read_tokens'),
|
|
184
|
+
cacheWriteTokens: get('ai.cache_write_tokens'),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (found.size === spanIds.size) {
|
|
190
|
+
console.log(`[archive] found all ${found.size} spans after ${objects} objects`);
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
token = page.NextContinuationToken;
|
|
195
|
+
} while (token);
|
|
196
|
+
console.log(`[archive] found ${found.size} of ${spanIds.size} spans in ${objects} objects`);
|
|
197
|
+
return found;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type WriteOutcome = 'repriced' | 'alreadyPriced' | 'aggregateMissing';
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One transaction: set the span's cost (only if it is still 0) and add the
|
|
204
|
+
* same amount to its daily aggregate. The condition makes a re-run, or two
|
|
205
|
+
* overlapping runs, a no-op instead of a double count.
|
|
206
|
+
*/
|
|
207
|
+
async function applyPlan(p: RepricePlan, now: string): Promise<WriteOutcome> {
|
|
208
|
+
try {
|
|
209
|
+
await dynamo.send(new TransactWriteItemsCommand({
|
|
210
|
+
TransactItems: [
|
|
211
|
+
{
|
|
212
|
+
Update: {
|
|
213
|
+
TableName: TABLE,
|
|
214
|
+
Key: { pk: { S: p.pk }, sk: { S: p.sk } },
|
|
215
|
+
UpdateExpression:
|
|
216
|
+
'SET #cost = :cost, #est = :t, #src = :src, #key = :key, #cr = :cr, #cw = :cw, #at = :now',
|
|
217
|
+
ConditionExpression: '#cost = :zero',
|
|
218
|
+
ExpressionAttributeNames: {
|
|
219
|
+
'#cost': 'cost_usd', '#est': 'cost_estimated', '#src': 'cost_source', '#key': 'priced_key',
|
|
220
|
+
'#cr': 'cache_read_tokens', '#cw': 'cache_write_tokens', '#at': 'repriced_at',
|
|
221
|
+
},
|
|
222
|
+
ExpressionAttributeValues: {
|
|
223
|
+
':cost': { N: String(p.costUsd) }, ':t': { BOOL: true }, ':src': { S: 'reprice' },
|
|
224
|
+
':key': { S: p.pricedKey }, ':cr': { N: String(p.cacheReadTokens) },
|
|
225
|
+
':cw': { N: String(p.cacheWriteTokens) }, ':now': { S: now }, ':zero': { N: '0' },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
Update: {
|
|
231
|
+
TableName: TABLE,
|
|
232
|
+
Key: { pk: { S: p.pk }, sk: { S: p.aggregateSk } },
|
|
233
|
+
UpdateExpression: 'ADD #cost :cost SET #upd = :now',
|
|
234
|
+
ConditionExpression: 'attribute_exists(#pk)',
|
|
235
|
+
ExpressionAttributeNames: { '#cost': 'cost_usd', '#upd': 'updated_at', '#pk': 'pk' },
|
|
236
|
+
ExpressionAttributeValues: { ':cost': { N: String(p.costUsd) }, ':now': { S: now } },
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
}));
|
|
241
|
+
return 'repriced';
|
|
242
|
+
} catch (e) {
|
|
243
|
+
if (e instanceof TransactionCanceledException) {
|
|
244
|
+
const [span, agg] = e.CancellationReasons ?? [];
|
|
245
|
+
if (span?.Code === 'ConditionalCheckFailed') return 'alreadyPriced';
|
|
246
|
+
if (agg?.Code === 'ConditionalCheckFailed') return 'aggregateMissing';
|
|
247
|
+
}
|
|
248
|
+
throw e;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const CONFLICT_RETRIES = 5;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The receiver can still update the same aggregate while a reprice runs, so a
|
|
256
|
+
* TransactionConflict is retried with backoff. Retrying is safe: the span's
|
|
257
|
+
* `cost_usd = 0` condition means a write that did land is never applied twice.
|
|
258
|
+
*/
|
|
259
|
+
async function applyPlanWithRetry(p: RepricePlan, now: string): Promise<WriteOutcome> {
|
|
260
|
+
for (let attempt = 0; ; attempt++) {
|
|
261
|
+
try {
|
|
262
|
+
return await applyPlan(p, now);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
const conflict = e instanceof TransactionCanceledException
|
|
265
|
+
&& (e.CancellationReasons ?? []).some((r) => r?.Code === 'TransactionConflict');
|
|
266
|
+
if (!conflict || attempt >= CONFLICT_RETRIES) throw e;
|
|
267
|
+
await new Promise((r) => setTimeout(r, 100 * 2 ** attempt + Math.random() * 100));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Step 2. */
|
|
273
|
+
async function reprice(doc: PriceDocument, dryRun: boolean) {
|
|
274
|
+
const items = await scanZeroCostSpans();
|
|
275
|
+
|
|
276
|
+
// Price once without archive data to learn which legacy spans are worth
|
|
277
|
+
// the archive read: only spans whose model now has a price.
|
|
278
|
+
const legacy = items.filter((i) => lacksCacheCounts(i) && planReprice(i, doc.entries));
|
|
279
|
+
let archived = new Map<string, { cacheReadTokens: number; cacheWriteTokens: number }>();
|
|
280
|
+
if (legacy.length > 0 && BUCKET) {
|
|
281
|
+
const ids = new Set(legacy.map((i) => i.sk!.S!.slice(i.sk!.S!.lastIndexOf('#') + 1)));
|
|
282
|
+
const earliest = legacy.map((i) => i.timestamp!.S!.slice(0, 10)).sort()[0];
|
|
283
|
+
archived = await cacheCountsFromArchive(ids, earliest);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const plans = items.map((i) => planReprice(i, doc.entries, archived)).filter((p): p is RepricePlan => p !== null);
|
|
287
|
+
const unmatched = new Map<string, number>();
|
|
288
|
+
for (const i of items) {
|
|
289
|
+
if (!planReprice(i, doc.entries, archived)) {
|
|
290
|
+
const tokens = Number(i.input_tokens?.N ?? 0) + Number(i.output_tokens?.N ?? 0);
|
|
291
|
+
if (tokens > 0) {
|
|
292
|
+
const m = i.model?.S || 'unknown';
|
|
293
|
+
unmatched.set(m, (unmatched.get(m) ?? 0) + 1);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const counts: Record<WriteOutcome, number> = { repriced: 0, alreadyPriced: 0, aggregateMissing: 0 };
|
|
299
|
+
let addedUsd = 0;
|
|
300
|
+
const byModel: Record<string, { spans: number; usd: number; key: string }> = {};
|
|
301
|
+
const cacheSources: Record<string, number> = {};
|
|
302
|
+
const now = new Date().toISOString();
|
|
303
|
+
|
|
304
|
+
const record = (p: RepricePlan, o: WriteOutcome) => {
|
|
305
|
+
counts[o]++;
|
|
306
|
+
if (o !== 'repriced') return;
|
|
307
|
+
addedUsd += p.costUsd;
|
|
308
|
+
const m = (byModel[p.model || 'unknown'] ??= { spans: 0, usd: 0, key: p.pricedKey });
|
|
309
|
+
m.spans++;
|
|
310
|
+
m.usd += p.costUsd;
|
|
311
|
+
cacheSources[p.cacheSource] = (cacheSources[p.cacheSource] ?? 0) + 1;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Every span of one user-day adds to the same OTEL#DAY item, and DynamoDB
|
|
315
|
+
// cancels concurrent transactions that touch one item (TransactionConflict).
|
|
316
|
+
// So spans sharing an aggregate are written one after another, while
|
|
317
|
+
// different aggregates run in parallel.
|
|
318
|
+
const groups = [...groupByAggregate(plans).values()];
|
|
319
|
+
for (let i = 0; i < groups.length; i += WRITE_CONCURRENCY) {
|
|
320
|
+
const chunk = groups.slice(i, i + WRITE_CONCURRENCY);
|
|
321
|
+
await Promise.all(chunk.map(async (group) => {
|
|
322
|
+
for (const p of group) record(p, dryRun ? 'repriced' : await applyPlanWithRetry(p, now));
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
for (const m of Object.values(byModel)) m.usd = Math.round(m.usd * 100) / 100;
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
zeroCostSpans: items.length,
|
|
329
|
+
...counts,
|
|
330
|
+
addedUsd: Math.round(addedUsd * 100) / 100,
|
|
331
|
+
byModel,
|
|
332
|
+
cacheSources,
|
|
333
|
+
stillUnpriced: Object.fromEntries([...unmatched.entries()].slice(0, 30)),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function handler(event: RefreshEvent = {}): Promise<Record<string, unknown>> {
|
|
338
|
+
const dryRun = event.dryRun === true;
|
|
339
|
+
const { doc, failure } = event.skipRefresh
|
|
340
|
+
? { doc: (await readCurrentTable()) ?? BUNDLED_PRICES, failure: null }
|
|
341
|
+
: await refresh(dryRun);
|
|
342
|
+
if (failure) console.error(`[pricing] ${failure}; repricing with table from ${doc.fetchedAt || 'bundle'}`);
|
|
343
|
+
|
|
344
|
+
const result = await reprice(doc, dryRun);
|
|
345
|
+
const summary = { msg: 'reprice complete', dryRun, tableFetchedAt: doc.fetchedAt, ...result };
|
|
346
|
+
console.log(JSON.stringify(summary));
|
|
347
|
+
|
|
348
|
+
// Fail after repricing, not before: a stale table still prices what it can,
|
|
349
|
+
// and the error makes the stale table visible on the Lambda Errors metric.
|
|
350
|
+
if (failure) throw new Error(`price refresh failed: ${failure}`);
|
|
351
|
+
return summary;
|
|
352
|
+
}
|