@trazum/core 1.40.0 → 1.41.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/README.md CHANGED
@@ -132,6 +132,21 @@ the caller names the file instead of absorbing it. No forecasts anywhere:
132
132
  shapes are stated with their first and last values, and where they go next
133
133
  is the reader's.
134
134
 
135
+ ## The bill, from the provider
136
+
137
+ `normalizeAnthropicUsage(payload)` and `normalizeOpenAIUsage(payload)` turn a
138
+ usage API response into `UsageBucket`s — token sums per window and model, with
139
+ the two cache-write TTLs kept apart and a request count only where the
140
+ provider actually serves one (`null` otherwise, never zero).
141
+ `bucketedProfile(pull, { catalogue })` prices them, and
142
+ `bucketedCacheEconomics(report)` runs the same counterfactual `cacheEconomics`
143
+ runs per call. The result is deliberately its own shape rather than a
144
+ `UsageProfileReport` with holes in it, so no per-call finding can read a zero
145
+ this module wrote: every finding a sum cannot support is listed in
146
+ `unavailable` with why and what would unlock it. Anything unreadable in the
147
+ payload becomes a named `PullGap`, never a default of zero. Pure and
148
+ browser-safe — the fetch, the credentials and the pagination live in the CLI.
149
+
135
150
  ## Comparing two versions
136
151
 
137
152
  ```ts
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Your bill, read from the provider, without anybody exporting anything.
3
+ *
4
+ * Every command in this product reads a file somebody produced by hand, and
5
+ * the export step is where adoption dies: the person who would benefit most
6
+ * from a cost report is the person least likely to have a `usage.jsonl` lying
7
+ * around. Every provider that bills by the token also serves that data over an
8
+ * API, and this module turns those payloads into figures the rest of Trazum
9
+ * already knows how to reason about.
10
+ *
11
+ * **Pure, and in the core, so it is testable without a network.** The fetch,
12
+ * the credentials and the pagination live in the CLI — the same split
13
+ * `openrouterOverlay` has had since 1.13. Everything here is a transformation
14
+ * of a document the caller already holds.
15
+ *
16
+ * **The honest part is what the providers cannot tell you.** Usage APIs serve
17
+ * *aggregates*: tokens per bucket per model, and — depending on the provider —
18
+ * a request count or nothing at all. They do not serve per-call rows. That
19
+ * makes a whole class of Trazum's findings impossible on this source: the
20
+ * shape of the calls, the truncation retries, the conversations, the largest
21
+ * call's context pressure. Those findings need per-call data and no amount of
22
+ * arithmetic recovers them from a sum.
23
+ *
24
+ * So a connected report is a **restricted** report, and it is restricted out
25
+ * loud. It carries its own shape rather than a `UsageProfileReport` with holes
26
+ * in it, precisely so a per-call finding can never read a zero this module
27
+ * wrote and report "nothing found" about something nobody measured. Not
28
+ * recorded is not not-happened, at the level of the type system.
29
+ */
30
+ import type { PricingCatalogue } from './pricing.js';
31
+ /**
32
+ * `per-call` sources serve one row per request and unlock every finding in
33
+ * the product. `bucketed` sources serve sums over a window.
34
+ */
35
+ export type ConnectorGranularity = 'per-call' | 'bucketed';
36
+ /**
37
+ * A finding this source cannot support, why, and what would unlock it.
38
+ *
39
+ * Carried into the report and printed there. A restricted report that only
40
+ * omits things reads as a report that found nothing wrong.
41
+ */
42
+ export interface UnavailableFinding {
43
+ finding: string;
44
+ because: string;
45
+ unlockedBy: string;
46
+ }
47
+ export interface ConnectorDescriptor {
48
+ id: string;
49
+ displayName: string;
50
+ granularity: ConnectorGranularity;
51
+ /**
52
+ * Environment variables the CLI reads the credential from, in order.
53
+ *
54
+ * Named here so `trazum connect` can say exactly what it looked for when it
55
+ * finds nothing. Trazum stores no secret: a key lives in the environment or
56
+ * in a keychain the operating system owns, and never in this repository's
57
+ * config, cache or output.
58
+ */
59
+ credentialEnv: readonly string[];
60
+ /** The narrowest key that works, so nobody hands this tool a wider one. */
61
+ keyKind: string;
62
+ /** Whether the source serves a request count, or only token sums. */
63
+ servesCallCounts: boolean;
64
+ /** Findings impossible on this source. */
65
+ unavailable: readonly UnavailableFinding[];
66
+ docs: string;
67
+ }
68
+ /**
69
+ * The two providers this release connects to, and the asymmetry between them
70
+ * that a report must not paper over.
71
+ *
72
+ * OpenAI's usage endpoint serves a request count per bucket; Anthropic's
73
+ * serves token sums without one. So a connected OpenAI report can say "$412
74
+ * over 9,004 calls" and a connected Anthropic report can only say "$412", and
75
+ * every per-call average is available on one and absent on the other. Printing
76
+ * a call count of zero, or dividing by a denominator that does not exist,
77
+ * would be this module inventing the number it is here to stop inventing.
78
+ */
79
+ export declare const CONNECTORS: readonly ConnectorDescriptor[];
80
+ export declare function connectorFor(id: string): ConnectorDescriptor | null;
81
+ /**
82
+ * One provider bucket: a window, a model, and the tokens billed inside it.
83
+ *
84
+ * The cache-write TTL split is kept apart for the same reason `UsageBreakdown`
85
+ * keeps it apart — the two are billed at different multipliers, and a total
86
+ * that has lost the split cannot be repriced, only guessed at.
87
+ */
88
+ export interface UsageBucket {
89
+ fromMs: number;
90
+ toMs: number;
91
+ model: string;
92
+ /** null when the provider serves no request count. Never zero for absent. */
93
+ calls: number | null;
94
+ inputTokens: number;
95
+ cacheReadTokens: number;
96
+ cacheWrite5mTokens: number;
97
+ cacheWrite1hTokens: number;
98
+ /** False when the provider reported writes without saying which TTL. */
99
+ writeTtlKnown: boolean;
100
+ outputTokens: number;
101
+ /** Whatever the provider grouped by beyond the model — workspace, key, tier. */
102
+ group: Record<string, string>;
103
+ }
104
+ /**
105
+ * Something the pull did not get.
106
+ *
107
+ * A bill quietly short by an unknown amount is the failure this repository
108
+ * refuses everywhere it can occur, and a paginated API behind a rate limit is
109
+ * exactly where it occurs. Every gap is carried to the report and printed.
110
+ */
111
+ export interface PullGap {
112
+ kind: 'rate-limited' | 'retention-boundary' | 'cursor-expired' | 'page-limit' | 'unreadable-entry' | 'unreadable-field';
113
+ detail: string;
114
+ }
115
+ export interface ConnectorPull {
116
+ provider: string;
117
+ granularity: ConnectorGranularity;
118
+ buckets: UsageBucket[];
119
+ /** The window the buckets actually cover, or null when none parsed. */
120
+ window: {
121
+ fromMs: number;
122
+ toMs: number;
123
+ } | null;
124
+ gaps: PullGap[];
125
+ unavailable: readonly UnavailableFinding[];
126
+ }
127
+ /**
128
+ * Anthropic's messages usage report.
129
+ *
130
+ * Shape: `{ data: [ { starting_at, ending_at, results: [ {...tokens, model} ] } ] }`.
131
+ * The fields read are the documented ones; anything unreadable is reported as
132
+ * a gap rather than defaulted to zero, because a zero here is a bill that is
133
+ * quietly smaller than the real one.
134
+ */
135
+ export declare function normalizeAnthropicUsage(payload: unknown): ConnectorPull;
136
+ /**
137
+ * OpenAI's completions usage endpoint.
138
+ *
139
+ * Shape: `{ data: [ { start_time, end_time, results: [ { input_tokens,
140
+ * output_tokens, input_cached_tokens, num_model_requests, model } ] } ] }`.
141
+ *
142
+ * This one serves a request count, so every per-call average is available on
143
+ * it — and the report says so, rather than making both providers look alike.
144
+ */
145
+ export declare function normalizeOpenAIUsage(payload: unknown): ConnectorPull;
146
+ export interface BucketedSlice {
147
+ model: string;
148
+ /** null when the source serves no request count. */
149
+ calls: number | null;
150
+ inputTokens: number;
151
+ cacheReadTokens: number;
152
+ cacheWriteTokens: number;
153
+ outputTokens: number;
154
+ inputUsd: number;
155
+ cacheReadUsd: number;
156
+ cacheWriteUsd: number;
157
+ outputUsd: number;
158
+ totalUsd: number;
159
+ /** What the cache-touched tokens would have cost as ordinary input. */
160
+ cachedTokensAtInputRateUsd: number;
161
+ /** Writes priced at the 1-hour rate, when the source did not state the TTL. */
162
+ cacheWriteUsdIfAssumed1h: number;
163
+ writeTtlKnown: boolean;
164
+ }
165
+ export interface BucketedReport {
166
+ schemaVersion: 1;
167
+ provider: string;
168
+ granularity: ConnectorGranularity;
169
+ span: {
170
+ fromMs: number;
171
+ toMs: number;
172
+ } | null;
173
+ total: {
174
+ totalUsd: number;
175
+ /** null when unknown — never zero, which would read as "no traffic". */
176
+ calls: number | null;
177
+ inputTokens: number;
178
+ cacheReadTokens: number;
179
+ cacheWriteTokens: number;
180
+ outputTokens: number;
181
+ };
182
+ byModel: BucketedSlice[];
183
+ /** Spend per UTC day, oldest first — the shape a total hides. */
184
+ byDay: {
185
+ day: string;
186
+ usd: number;
187
+ calls: number | null;
188
+ }[];
189
+ /** Models the catalogue could not price: named, with their tokens kept. */
190
+ unpricedModels: {
191
+ model: string;
192
+ inputTokens: number;
193
+ outputTokens: number;
194
+ }[];
195
+ gaps: PullGap[];
196
+ unavailable: readonly UnavailableFinding[];
197
+ }
198
+ /**
199
+ * Prices the buckets a connector pulled.
200
+ *
201
+ * Every figure here is the provider's own billed token count at the
202
+ * catalogue's rates — the same arithmetic `profile` does, over sums instead of
203
+ * rows. What it deliberately does not do is synthesise the per-call findings:
204
+ * they are listed as unavailable and left absent, so nothing downstream can
205
+ * read a zero this function wrote.
206
+ */
207
+ export declare function bucketedProfile(pull: ConnectorPull, options: {
208
+ catalogue: PricingCatalogue;
209
+ on?: Date;
210
+ }): BucketedReport;
211
+ /**
212
+ * The cache verdict over a connected report.
213
+ *
214
+ * Same counterfactual `cacheEconomics` runs on a per-call report: what the
215
+ * cache-touched tokens cost, against what they would have cost as ordinary
216
+ * input. The worst case is carried separately for the same reason it is
217
+ * there — when the source did not state the write TTL, the cheaper rate was
218
+ * assumed for the headline and the verdict can move under the other one.
219
+ */
220
+ export declare function bucketedCacheEconomics(report: BucketedReport): {
221
+ spentUsd: number;
222
+ withoutCachingUsd: number;
223
+ deltaUsd: number;
224
+ verdict: 'paid-off' | 'lost-money' | 'no-cache';
225
+ worstCaseVerdict: 'paid-off' | 'lost-money' | 'no-cache';
226
+ };
227
+ //# sourceMappingURL=connector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connector.d.ts","sourceRoot":"","sources":["../src/connector.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Your bill, read from the provider, without anybody exporting anything.
3
+ *
4
+ * Every command in this product reads a file somebody produced by hand, and
5
+ * the export step is where adoption dies: the person who would benefit most
6
+ * from a cost report is the person least likely to have a `usage.jsonl` lying
7
+ * around. Every provider that bills by the token also serves that data over an
8
+ * API, and this module turns those payloads into figures the rest of Trazum
9
+ * already knows how to reason about.
10
+ *
11
+ * **Pure, and in the core, so it is testable without a network.** The fetch,
12
+ * the credentials and the pagination live in the CLI — the same split
13
+ * `openrouterOverlay` has had since 1.13. Everything here is a transformation
14
+ * of a document the caller already holds.
15
+ *
16
+ * **The honest part is what the providers cannot tell you.** Usage APIs serve
17
+ * *aggregates*: tokens per bucket per model, and — depending on the provider —
18
+ * a request count or nothing at all. They do not serve per-call rows. That
19
+ * makes a whole class of Trazum's findings impossible on this source: the
20
+ * shape of the calls, the truncation retries, the conversations, the largest
21
+ * call's context pressure. Those findings need per-call data and no amount of
22
+ * arithmetic recovers them from a sum.
23
+ *
24
+ * So a connected report is a **restricted** report, and it is restricted out
25
+ * loud. It carries its own shape rather than a `UsageProfileReport` with holes
26
+ * in it, precisely so a per-call finding can never read a zero this module
27
+ * wrote and report "nothing found" about something nobody measured. Not
28
+ * recorded is not not-happened, at the level of the type system.
29
+ */
30
+ import { effectivePricing, multipliersFor } from './pricing.js';
31
+ /** Every finding that needs a row per call, named once. */
32
+ const PER_CALL_FINDINGS = [
33
+ {
34
+ finding: 'inputShapes',
35
+ because: 'the provider serves sums over a window, and the spread of call sizes is not in a sum',
36
+ unlockedBy: 'a per-call usage log, or the gateway',
37
+ },
38
+ {
39
+ finding: 'truncationRetries',
40
+ because: 'pairing a truncated answer with its retry needs both calls, their order and their stop reasons',
41
+ unlockedBy: 'a per-call usage log recording stop_reason and session',
42
+ },
43
+ {
44
+ finding: 'repeatedTurns',
45
+ because: 'the same request sent twice is invisible once both are added together',
46
+ unlockedBy: 'a per-call usage log recording session',
47
+ },
48
+ {
49
+ finding: 'sessionCosts',
50
+ because: 'conversations are not a dimension any usage API groups by',
51
+ unlockedBy: 'a per-call usage log recording session',
52
+ },
53
+ {
54
+ finding: 'contextPressure',
55
+ because: 'it reads the largest single call, and a total has lost the maximum',
56
+ unlockedBy: 'a per-call usage log, or the gateway',
57
+ },
58
+ {
59
+ finding: 'duplicateLines',
60
+ because: 'a doubled bill is caught by finding identical rows, and there are no rows here',
61
+ unlockedBy: 'a per-call usage log',
62
+ },
63
+ ];
64
+ /**
65
+ * The two providers this release connects to, and the asymmetry between them
66
+ * that a report must not paper over.
67
+ *
68
+ * OpenAI's usage endpoint serves a request count per bucket; Anthropic's
69
+ * serves token sums without one. So a connected OpenAI report can say "$412
70
+ * over 9,004 calls" and a connected Anthropic report can only say "$412", and
71
+ * every per-call average is available on one and absent on the other. Printing
72
+ * a call count of zero, or dividing by a denominator that does not exist,
73
+ * would be this module inventing the number it is here to stop inventing.
74
+ */
75
+ export const CONNECTORS = [
76
+ {
77
+ id: 'anthropic',
78
+ displayName: 'Anthropic',
79
+ granularity: 'bucketed',
80
+ credentialEnv: ['TRAZUM_ANTHROPIC_ADMIN_KEY', 'ANTHROPIC_ADMIN_KEY'],
81
+ keyKind: 'an Admin API key (read access to the usage report)',
82
+ servesCallCounts: false,
83
+ unavailable: [
84
+ ...PER_CALL_FINDINGS,
85
+ {
86
+ finding: 'calls',
87
+ because: 'the usage report serves token sums per bucket and no request count',
88
+ unlockedBy: 'a per-call usage log, or the gateway',
89
+ },
90
+ ],
91
+ docs: 'https://docs.anthropic.com/en/api/admin-api/usage-cost/get-messages-usage-report',
92
+ },
93
+ {
94
+ id: 'openai',
95
+ displayName: 'OpenAI',
96
+ granularity: 'bucketed',
97
+ credentialEnv: ['TRAZUM_OPENAI_ADMIN_KEY', 'OPENAI_ADMIN_KEY'],
98
+ keyKind: 'an Admin key with the api.usage.read scope',
99
+ servesCallCounts: true,
100
+ unavailable: PER_CALL_FINDINGS,
101
+ docs: 'https://platform.openai.com/docs/api-reference/usage',
102
+ },
103
+ ];
104
+ export function connectorFor(id) {
105
+ return CONNECTORS.find((c) => c.id === id) ?? null;
106
+ }
107
+ // --------------------------------------------------------------------------
108
+ // Normalising provider payloads
109
+ // --------------------------------------------------------------------------
110
+ const num = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
111
+ const ms = (value) => {
112
+ if (typeof value === 'number' && Number.isFinite(value))
113
+ return value * 1000;
114
+ if (typeof value !== 'string')
115
+ return null;
116
+ const parsed = Date.parse(value);
117
+ return Number.isNaN(parsed) ? null : parsed;
118
+ };
119
+ /** Merges buckets that share a window, model and grouping. */
120
+ function collect(buckets) {
121
+ const merged = new Map();
122
+ for (const bucket of buckets) {
123
+ const key = `${bucket.fromMs}\n${bucket.toMs}\n${bucket.model}\n${JSON.stringify(bucket.group)}`;
124
+ const seen = merged.get(key);
125
+ if (seen === undefined) {
126
+ merged.set(key, { ...bucket });
127
+ continue;
128
+ }
129
+ seen.inputTokens += bucket.inputTokens;
130
+ seen.cacheReadTokens += bucket.cacheReadTokens;
131
+ seen.cacheWrite5mTokens += bucket.cacheWrite5mTokens;
132
+ seen.cacheWrite1hTokens += bucket.cacheWrite1hTokens;
133
+ seen.outputTokens += bucket.outputTokens;
134
+ seen.writeTtlKnown = seen.writeTtlKnown && bucket.writeTtlKnown;
135
+ // A count merged with an absent count is still absent: adding a number to
136
+ // "unknown" produces a number that describes only part of the traffic.
137
+ seen.calls = seen.calls === null || bucket.calls === null ? null : seen.calls + bucket.calls;
138
+ }
139
+ return [...merged.values()].sort((a, b) => a.fromMs - b.fromMs || a.model.localeCompare(b.model));
140
+ }
141
+ function windowOf(buckets) {
142
+ if (buckets.length === 0)
143
+ return null;
144
+ return {
145
+ fromMs: Math.min(...buckets.map((b) => b.fromMs)),
146
+ toMs: Math.max(...buckets.map((b) => b.toMs)),
147
+ };
148
+ }
149
+ /**
150
+ * Anthropic's messages usage report.
151
+ *
152
+ * Shape: `{ data: [ { starting_at, ending_at, results: [ {...tokens, model} ] } ] }`.
153
+ * The fields read are the documented ones; anything unreadable is reported as
154
+ * a gap rather than defaulted to zero, because a zero here is a bill that is
155
+ * quietly smaller than the real one.
156
+ */
157
+ export function normalizeAnthropicUsage(payload) {
158
+ const descriptor = connectorFor('anthropic');
159
+ const gaps = [];
160
+ const buckets = [];
161
+ const data = payload?.data;
162
+ if (!Array.isArray(data)) {
163
+ throw new Error('This payload has no "data" array — is it the response from the Anthropic usage report endpoint?');
164
+ }
165
+ for (const [index, entry] of data.entries()) {
166
+ const row = entry;
167
+ const fromMs = ms(row.starting_at);
168
+ const toMs = ms(row.ending_at) ?? (fromMs === null ? null : fromMs);
169
+ if (fromMs === null || toMs === null) {
170
+ gaps.push({
171
+ kind: 'unreadable-entry',
172
+ detail: `bucket ${index} has no readable time window, so its tokens are in no period and were left out`,
173
+ });
174
+ continue;
175
+ }
176
+ const results = Array.isArray(row.results) ? row.results : [];
177
+ if (results.length === 0 && row.results !== undefined && !Array.isArray(row.results)) {
178
+ gaps.push({ kind: 'unreadable-entry', detail: `bucket ${index} has an unreadable "results" field` });
179
+ continue;
180
+ }
181
+ for (const result of results) {
182
+ const r = result;
183
+ const model = typeof r.model === 'string' ? r.model : null;
184
+ if (model === null) {
185
+ gaps.push({
186
+ kind: 'unreadable-entry',
187
+ detail: `a result in bucket ${index} names no model, so its tokens could not be priced and were left out`,
188
+ });
189
+ continue;
190
+ }
191
+ const creation = (r.cache_creation ?? {});
192
+ const write5m = num(creation.ephemeral_5m_input_tokens) ?? 0;
193
+ const write1h = num(creation.ephemeral_1h_input_tokens) ?? 0;
194
+ const flatWrite = num(r.cache_creation_input_tokens) ?? 0;
195
+ const ttlKnown = !(flatWrite > 0 && write5m === 0 && write1h === 0);
196
+ buckets.push({
197
+ fromMs,
198
+ toMs,
199
+ model,
200
+ // Documented and deliberate: the usage report has no request count.
201
+ calls: null,
202
+ inputTokens: num(r.uncached_input_tokens) ?? num(r.input_tokens) ?? 0,
203
+ cacheReadTokens: num(r.cache_read_input_tokens) ?? 0,
204
+ cacheWrite5mTokens: ttlKnown ? write5m : flatWrite,
205
+ cacheWrite1hTokens: ttlKnown ? write1h : 0,
206
+ writeTtlKnown: ttlKnown,
207
+ outputTokens: num(r.output_tokens) ?? 0,
208
+ group: groupOf(r, ['workspace_id', 'api_key_id', 'service_tier', 'context_window']),
209
+ });
210
+ }
211
+ }
212
+ const merged = collect(buckets);
213
+ return {
214
+ provider: 'anthropic',
215
+ granularity: 'bucketed',
216
+ buckets: merged,
217
+ window: windowOf(merged),
218
+ gaps,
219
+ unavailable: descriptor.unavailable,
220
+ };
221
+ }
222
+ /**
223
+ * OpenAI's completions usage endpoint.
224
+ *
225
+ * Shape: `{ data: [ { start_time, end_time, results: [ { input_tokens,
226
+ * output_tokens, input_cached_tokens, num_model_requests, model } ] } ] }`.
227
+ *
228
+ * This one serves a request count, so every per-call average is available on
229
+ * it — and the report says so, rather than making both providers look alike.
230
+ */
231
+ export function normalizeOpenAIUsage(payload) {
232
+ const descriptor = connectorFor('openai');
233
+ const gaps = [];
234
+ const buckets = [];
235
+ const data = payload?.data;
236
+ if (!Array.isArray(data)) {
237
+ throw new Error('This payload has no "data" array — is it the response from the OpenAI usage endpoint?');
238
+ }
239
+ for (const [index, entry] of data.entries()) {
240
+ const row = entry;
241
+ const fromMs = ms(row.start_time);
242
+ const toMs = ms(row.end_time) ?? (fromMs === null ? null : fromMs);
243
+ if (fromMs === null || toMs === null) {
244
+ gaps.push({
245
+ kind: 'unreadable-entry',
246
+ detail: `bucket ${index} has no readable time window, so its tokens are in no period and were left out`,
247
+ });
248
+ continue;
249
+ }
250
+ const results = Array.isArray(row.results) ? row.results : [];
251
+ for (const result of results) {
252
+ const r = result;
253
+ const model = typeof r.model === 'string' ? r.model : null;
254
+ if (model === null) {
255
+ gaps.push({
256
+ kind: 'unreadable-entry',
257
+ detail: `a result in bucket ${index} names no model, so its tokens could not be priced and were left out`,
258
+ });
259
+ continue;
260
+ }
261
+ const cached = num(r.input_cached_tokens) ?? 0;
262
+ const input = num(r.input_tokens) ?? 0;
263
+ buckets.push({
264
+ fromMs,
265
+ toMs,
266
+ model,
267
+ calls: num(r.num_model_requests),
268
+ // OpenAI reports cached tokens *inside* the input total, so the
269
+ // uncached half is the subtraction. Reporting both at face value
270
+ // would bill the cached tokens twice, at the dearer rate.
271
+ inputTokens: Math.max(0, input - cached),
272
+ cacheReadTokens: cached,
273
+ cacheWrite5mTokens: 0,
274
+ cacheWrite1hTokens: 0,
275
+ // Nothing was assumed: this API reports no cache writes at all, and an
276
+ // absent field is not an assumed TTL.
277
+ writeTtlKnown: true,
278
+ outputTokens: num(r.output_tokens) ?? 0,
279
+ group: groupOf(r, ['project_id', 'api_key_id', 'batch']),
280
+ });
281
+ }
282
+ }
283
+ const merged = collect(buckets);
284
+ return {
285
+ provider: 'openai',
286
+ granularity: 'bucketed',
287
+ buckets: merged,
288
+ window: windowOf(merged),
289
+ gaps,
290
+ unavailable: descriptor.unavailable,
291
+ };
292
+ }
293
+ function groupOf(row, keys) {
294
+ const group = {};
295
+ for (const key of keys) {
296
+ const value = row[key];
297
+ if (typeof value === 'string' && value !== '')
298
+ group[key] = value;
299
+ else if (typeof value === 'number' && Number.isFinite(value))
300
+ group[key] = String(value);
301
+ else if (typeof value === 'boolean')
302
+ group[key] = String(value);
303
+ }
304
+ return group;
305
+ }
306
+ /**
307
+ * Prices the buckets a connector pulled.
308
+ *
309
+ * Every figure here is the provider's own billed token count at the
310
+ * catalogue's rates — the same arithmetic `profile` does, over sums instead of
311
+ * rows. What it deliberately does not do is synthesise the per-call findings:
312
+ * they are listed as unavailable and left absent, so nothing downstream can
313
+ * read a zero this function wrote.
314
+ */
315
+ export function bucketedProfile(pull, options) {
316
+ const { catalogue, on = new Date() } = options;
317
+ const slices = new Map();
318
+ const days = new Map();
319
+ const unpriced = new Map();
320
+ for (const bucket of pull.buckets) {
321
+ const model = catalogue.byId.get(bucket.model);
322
+ if (model === undefined) {
323
+ const seen = unpriced.get(bucket.model) ?? { inputTokens: 0, outputTokens: 0 };
324
+ seen.inputTokens += bucket.inputTokens + bucket.cacheReadTokens;
325
+ seen.outputTokens += bucket.outputTokens;
326
+ unpriced.set(bucket.model, seen);
327
+ continue;
328
+ }
329
+ const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
330
+ const rates = multipliersFor(model);
331
+ const per = (count, rate) => (count / 1_000_000) * rate;
332
+ const inputUsd = per(bucket.inputTokens, inputPerMTok);
333
+ const cacheReadUsd = per(bucket.cacheReadTokens, inputPerMTok * rates.cacheRead);
334
+ const cacheWriteUsd = per(bucket.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
335
+ per(bucket.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h);
336
+ const outputUsd = per(bucket.outputTokens, outputPerMTok);
337
+ const writeTokens = bucket.cacheWrite5mTokens + bucket.cacheWrite1hTokens;
338
+ const atInputRate = per(bucket.cacheReadTokens + writeTokens, inputPerMTok);
339
+ const ifAssumed1h = bucket.writeTtlKnown
340
+ ? cacheWriteUsd
341
+ : per(writeTokens, inputPerMTok * rates.cacheWrite1h);
342
+ const slice = slices.get(bucket.model) ?? {
343
+ model: bucket.model,
344
+ calls: bucket.calls === null ? null : 0,
345
+ inputTokens: 0,
346
+ cacheReadTokens: 0,
347
+ cacheWriteTokens: 0,
348
+ outputTokens: 0,
349
+ inputUsd: 0,
350
+ cacheReadUsd: 0,
351
+ cacheWriteUsd: 0,
352
+ outputUsd: 0,
353
+ totalUsd: 0,
354
+ cachedTokensAtInputRateUsd: 0,
355
+ cacheWriteUsdIfAssumed1h: 0,
356
+ writeTtlKnown: true,
357
+ };
358
+ slice.calls = slice.calls === null || bucket.calls === null ? null : slice.calls + bucket.calls;
359
+ slice.inputTokens += bucket.inputTokens;
360
+ slice.cacheReadTokens += bucket.cacheReadTokens;
361
+ slice.cacheWriteTokens += writeTokens;
362
+ slice.outputTokens += bucket.outputTokens;
363
+ slice.inputUsd += inputUsd;
364
+ slice.cacheReadUsd += cacheReadUsd;
365
+ slice.cacheWriteUsd += cacheWriteUsd;
366
+ slice.outputUsd += outputUsd;
367
+ slice.totalUsd += inputUsd + cacheReadUsd + cacheWriteUsd + outputUsd;
368
+ slice.cachedTokensAtInputRateUsd += atInputRate;
369
+ slice.cacheWriteUsdIfAssumed1h += ifAssumed1h;
370
+ slice.writeTtlKnown = slice.writeTtlKnown && bucket.writeTtlKnown;
371
+ slices.set(bucket.model, slice);
372
+ const day = new Date(bucket.fromMs).toISOString().slice(0, 10);
373
+ const entry = days.get(day) ?? { usd: 0, calls: bucket.calls === null ? null : 0 };
374
+ entry.usd += inputUsd + cacheReadUsd + cacheWriteUsd + outputUsd;
375
+ entry.calls = entry.calls === null || bucket.calls === null ? null : entry.calls + bucket.calls;
376
+ days.set(day, entry);
377
+ }
378
+ const byModel = [...slices.values()].sort((a, b) => b.totalUsd - a.totalUsd);
379
+ const anyCallsUnknown = byModel.some((s) => s.calls === null) || byModel.length === 0;
380
+ return {
381
+ schemaVersion: 1,
382
+ provider: pull.provider,
383
+ granularity: pull.granularity,
384
+ span: pull.window,
385
+ total: {
386
+ totalUsd: byModel.reduce((sum, s) => sum + s.totalUsd, 0),
387
+ calls: anyCallsUnknown ? null : byModel.reduce((sum, s) => sum + (s.calls ?? 0), 0),
388
+ inputTokens: byModel.reduce((sum, s) => sum + s.inputTokens, 0),
389
+ cacheReadTokens: byModel.reduce((sum, s) => sum + s.cacheReadTokens, 0),
390
+ cacheWriteTokens: byModel.reduce((sum, s) => sum + s.cacheWriteTokens, 0),
391
+ outputTokens: byModel.reduce((sum, s) => sum + s.outputTokens, 0),
392
+ },
393
+ byModel,
394
+ byDay: [...days.entries()]
395
+ .sort((a, b) => a[0].localeCompare(b[0]))
396
+ .map(([day, entry]) => ({ day, usd: entry.usd, calls: entry.calls })),
397
+ unpricedModels: [...unpriced.entries()]
398
+ .map(([model, tokens]) => ({ model, ...tokens }))
399
+ .sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens)),
400
+ gaps: pull.gaps,
401
+ unavailable: pull.unavailable,
402
+ };
403
+ }
404
+ /**
405
+ * The cache verdict over a connected report.
406
+ *
407
+ * Same counterfactual `cacheEconomics` runs on a per-call report: what the
408
+ * cache-touched tokens cost, against what they would have cost as ordinary
409
+ * input. The worst case is carried separately for the same reason it is
410
+ * there — when the source did not state the write TTL, the cheaper rate was
411
+ * assumed for the headline and the verdict can move under the other one.
412
+ */
413
+ export function bucketedCacheEconomics(report) {
414
+ const spent = report.byModel.reduce((sum, s) => sum + s.cacheReadUsd + s.cacheWriteUsd, 0);
415
+ const without = report.byModel.reduce((sum, s) => sum + s.cachedTokensAtInputRateUsd, 0);
416
+ const worst = report.byModel.reduce((sum, s) => sum + s.cacheReadUsd + s.cacheWriteUsdIfAssumed1h, 0);
417
+ const touched = report.byModel.reduce((sum, s) => sum + s.cacheReadTokens + s.cacheWriteTokens, 0);
418
+ const verdictOf = (paid) => {
419
+ if (touched === 0)
420
+ return 'no-cache';
421
+ return paid <= without ? 'paid-off' : 'lost-money';
422
+ };
423
+ return {
424
+ spentUsd: spent,
425
+ withoutCachingUsd: without,
426
+ deltaUsd: spent - without,
427
+ verdict: verdictOf(spent),
428
+ worstCaseVerdict: verdictOf(worst),
429
+ };
430
+ }
431
+ //# sourceMappingURL=connector.js.map