@trazum/core 2.2.1 → 2.4.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 (41) hide show
  1. package/dist/anthropic-cost.d.ts +173 -0
  2. package/dist/anthropic-cost.d.ts.map +1 -0
  3. package/dist/anthropic-cost.js +222 -0
  4. package/dist/anthropic-cost.js.map +1 -0
  5. package/dist/anthropic-usage.d.ts +192 -0
  6. package/dist/anthropic-usage.d.ts.map +1 -0
  7. package/dist/anthropic-usage.js +260 -0
  8. package/dist/anthropic-usage.js.map +1 -0
  9. package/dist/claude-code.d.ts +29 -1
  10. package/dist/claude-code.d.ts.map +1 -1
  11. package/dist/claude-code.js +33 -1
  12. package/dist/claude-code.js.map +1 -1
  13. package/dist/index.d.ts +12 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/openai-cost.d.ts +62 -0
  18. package/dist/openai-cost.d.ts.map +1 -0
  19. package/dist/openai-cost.js +151 -0
  20. package/dist/openai-cost.js.map +1 -0
  21. package/dist/openai-usage.d.ts +159 -0
  22. package/dist/openai-usage.d.ts.map +1 -0
  23. package/dist/openai-usage.js +281 -0
  24. package/dist/openai-usage.js.map +1 -0
  25. package/dist/openrouter-activity.d.ts +103 -0
  26. package/dist/openrouter-activity.d.ts.map +1 -0
  27. package/dist/openrouter-activity.js +169 -0
  28. package/dist/openrouter-activity.js.map +1 -0
  29. package/dist/pricing.d.ts +44 -0
  30. package/dist/pricing.d.ts.map +1 -1
  31. package/dist/pricing.js +88 -3
  32. package/dist/pricing.js.map +1 -1
  33. package/package.json +2 -2
  34. package/src/anthropic-cost.ts +303 -0
  35. package/src/anthropic-usage.ts +363 -0
  36. package/src/claude-code.ts +62 -2
  37. package/src/index.ts +20 -1
  38. package/src/openai-cost.ts +170 -0
  39. package/src/openai-usage.ts +353 -0
  40. package/src/openrouter-activity.ts +221 -0
  41. package/src/pricing.ts +91 -3
@@ -23,6 +23,35 @@
23
23
  * and the caller says that one out loud.
24
24
  */
25
25
 
26
+ /**
27
+ * One directory prefix, and the label the work under it belongs to.
28
+ *
29
+ * The answer to a question one session cannot answer any other way: **which
30
+ * project was this call for**, when two of them share a transcript. Claude Code
31
+ * records a `cwd` on every line and this module has never emitted it, on
32
+ * purpose — a working directory is a file path, and a file path says something
33
+ * about somebody's machine that a bill does not need.
34
+ *
35
+ * Reading it to *choose* a label is not the same act as emitting it, and the
36
+ * difference is the whole contract here: the prefix and the label are written
37
+ * by the person running the conversion, the `cwd` decides which of *their own*
38
+ * labels applies, and **nothing derived from the path reaches the output**.
39
+ * `claude-code.test.js` plants a secret in `cwd` and greps the whole output for
40
+ * it, which is the same test that already holds for message text and branch
41
+ * names, and `label-by-cwd.test.js` does it again through the CLI where the
42
+ * stderr summary is searched too.
43
+ *
44
+ * A guessed label would be worse than none. Nothing here decodes, splits or
45
+ * shortens a path: the longest matching prefix wins and an unmatched line
46
+ * falls back to `label`, so a directory nobody wrote a rule for is
47
+ * unattributed rather than attributed to a neighbour.
48
+ */
49
+ export interface CwdLabel {
50
+ /** An absolute directory prefix, compared literally. */
51
+ prefix: string;
52
+ label: string;
53
+ }
54
+
26
55
  /** One converted record, shaped exactly as `parseUsageLine` reads it. */
27
56
  export interface ClaudeCodeRecord {
28
57
  model: string;
@@ -123,9 +152,27 @@ const asCount = (value: unknown): number | undefined =>
123
152
  typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
124
153
 
125
154
  /** Convert one transcript's text. Pure, like every measuring function here. */
155
+ /**
156
+ * The label a line's own working directory selects, or nothing.
157
+ *
158
+ * **Longest prefix wins**, so `/src/app/api` beats `/src/app` and a nested
159
+ * project is not swallowed by the repository above it. Ties cannot happen:
160
+ * two rules with the same prefix are the same rule, and the first one written
161
+ * stands rather than this function deciding between them silently.
162
+ */
163
+ const labelForCwd = (cwd: unknown, rules: readonly CwdLabel[]): string | undefined => {
164
+ if (typeof cwd !== 'string' || cwd === '') return undefined;
165
+ let best: CwdLabel | undefined;
166
+ for (const rule of rules) {
167
+ if (!cwd.startsWith(rule.prefix)) continue;
168
+ if (best === undefined || rule.prefix.length > best.prefix.length) best = rule;
169
+ }
170
+ return best?.label;
171
+ };
172
+
126
173
  export function claudeCodeRecords(
127
174
  text: string,
128
- options: { label?: string } = {},
175
+ options: { label?: string; labelByCwd?: readonly CwdLabel[] } = {},
129
176
  ): ClaudeCodeConversion {
130
177
  const out: ClaudeCodeConversion = {
131
178
  records: [],
@@ -190,7 +237,20 @@ export function claudeCodeRecords(
190
237
  ts: typeof entry.timestamp === 'string' ? entry.timestamp : '',
191
238
  session: typeof entry.sessionId === 'string' ? entry.sessionId : '',
192
239
  ...(typeof stopReason === 'string' && stopReason !== '' ? { stop_reason: stopReason } : {}),
193
- ...(options.label !== undefined ? { label: options.label } : {}),
240
+ /*
241
+ The directory's own rule first, then the flat label. A `--label` given
242
+ alongside directory rules is the fallback for work outside all of
243
+ them, which is what somebody splitting two projects out of one session
244
+ means by giving both.
245
+ */
246
+ ...(((): { label?: string } => {
247
+ const chosen =
248
+ options.labelByCwd === undefined
249
+ ? undefined
250
+ : labelForCwd(entry.cwd, options.labelByCwd);
251
+ const label = chosen ?? options.label;
252
+ return label === undefined ? {} : { label };
253
+ })()),
194
254
  usage: {
195
255
  input_tokens: input,
196
256
  output_tokens: output,
package/src/index.ts CHANGED
@@ -224,11 +224,29 @@ export { otelRecords, looksLikeOtel } from './otel.js';
224
224
  export type { OtelConversion, OtelRecord } from './otel.js';
225
225
  export { litellmRecords, looksLikeLiteLlm } from './litellm.js';
226
226
  export type { LiteLlmConversion, LiteLlmRecord } from './litellm.js';
227
+ export { anthropicCostReport, looksLikeAnthropicCost, reconcile } from './anthropic-cost.js';
228
+ export type { AnthropicCostReading, BilledReading, Reconciliation } from './anthropic-cost.js';
229
+ export { openaiCostReport, looksLikeOpenaiCost } from './openai-cost.js';
230
+ export type { OpenaiCostReading } from './openai-cost.js';
231
+ export { openaiUsageRecords, looksLikeOpenaiUsage } from './openai-usage.js';
232
+ export { openrouterActivityRecords, looksLikeOpenrouterActivity } from './openrouter-activity.js';
233
+ export type {
234
+ OpenrouterActivityConversion,
235
+ OpenrouterActivityRecord,
236
+ OpenrouterWorkspaceLabel,
237
+ } from './openrouter-activity.js';
238
+ export type { OpenaiUsageConversion, OpenaiUsageRecord, ProjectLabel } from './openai-usage.js';
239
+ export { anthropicUsageRecords, looksLikeAnthropicUsage } from './anthropic-usage.js';
240
+ export type {
241
+ AnthropicUsageConversion,
242
+ AnthropicUsageRecord,
243
+ WorkspaceLabel,
244
+ } from './anthropic-usage.js';
227
245
  export { heliconeRecords, looksLikeHelicone } from './helicone.js';
228
246
  export type { HeliconeConversion, HeliconeRecord } from './helicone.js';
229
247
  export { langsmithRecords, looksLikeLangsmith } from './langsmith.js';
230
248
  export type { LangsmithConversion, LangsmithRecord } from './langsmith.js';
231
- export type { ClaudeCodeConversion, ClaudeCodeRecord } from './claude-code.js';
249
+ export type { ClaudeCodeConversion, ClaudeCodeRecord, CwdLabel } from './claude-code.js';
232
250
  export { positionReport } from './position-report.js';
233
251
  export type {
234
252
  PositionDistance,
@@ -399,6 +417,7 @@ export {
399
417
  DEFAULT_MODEL,
400
418
  COST_MULTIPLIERS,
401
419
  PRICING_LAST_REVIEWED,
420
+ reviewedForModels,
402
421
  STALE_PRICING_DAYS,
403
422
  PROVIDER_REVIEWED,
404
423
  reviewAgeDays,
@@ -0,0 +1,170 @@
1
+ /**
2
+ * What OpenAI billed, read from its own cost report.
3
+ *
4
+ * The second provider `reconcile` can set a receipt beside, and the same
5
+ * rule holds: the billed figure and the computed one are never added, and
6
+ * neither corrects the other. `anthropic-cost.ts` makes that argument once;
7
+ * this file adds only what is different about this report.
8
+ *
9
+ * ## The unit, which is *not* the trap here
10
+ *
11
+ * Anthropic's `amount` is a decimal string in cents. OpenAI's is an object,
12
+ * `{"value": 0.06, "currency": "usd"}`, and the schema's own example is a
13
+ * day's bucket at six cents: `value` is **a number in the currency's major
14
+ * unit**, dollars for USD. Nothing is divided here, and a test asserts that
15
+ * against the published example so the cents rule from the other converter
16
+ * cannot leak across by habit. The currency is documented as lowercase
17
+ * ISO-4217; it is compared case-insensitively and anything but USD is
18
+ * refused rather than converted, for the reason the other file gives.
19
+ *
20
+ * ## What the decomposition is for, and how little of it this report allows
21
+ *
22
+ * With `group_by[]=line_item` each row names what it charged for, in words
23
+ * this schema does not enumerate: *"gpt-6-astra, input_tokens"* is the
24
+ * example. What it does enumerate is `quantity_unit`, and that is the
25
+ * honest hook: a row measured in `tokens` or `1000_tokens` is a token
26
+ * charge, a row measured in `duration_seconds`, `images`, `characters` or
27
+ * `gibibyte_hours` is money no token rate ever covered. The first is the
28
+ * remainder's business; the second is named and set aside.
29
+ *
30
+ * A row with a line item but a `null` unit is neither, and the schema says
31
+ * `null` means *no single supported unit applies*. It is not guessed either
32
+ * way: its money is counted under its own name so the reader knows how much
33
+ * of the remainder is standing on a unit nobody stated.
34
+ *
35
+ * **Batch is not separable.** Nothing in this schema says whether a line
36
+ * item was a batch job, so unlike the Anthropic reading no batch figure can
37
+ * be taken out of the difference. `batchSeparable: false` says so, and the
38
+ * command repeats it: a batch discount, if any, is inside the remainder.
39
+ */
40
+
41
+ import type { BilledReading } from './anthropic-cost.js';
42
+
43
+ export interface OpenaiCostReading extends BilledReading {
44
+ /** Billed on a line item whose unit is `null`: neither tokens nor not. */
45
+ unknownUnitUsd: number;
46
+ buckets: number;
47
+ rows: number;
48
+ /** Rows whose `amount.value` was not a number. Counted, never read as zero. */
49
+ unreadableAmount: number;
50
+ /** `has_more`: one page of several, so the billed figure is understated. */
51
+ truncated: boolean;
52
+ /** The input was not the JSON this endpoint returns. */
53
+ unparseable: boolean;
54
+ }
55
+
56
+ const asObject = (value: unknown): Record<string, unknown> | null =>
57
+ typeof value === 'object' && value !== null && !Array.isArray(value)
58
+ ? (value as Record<string, unknown>)
59
+ : null;
60
+
61
+ const TOKEN_UNITS = new Set(['tokens', '1000_tokens']);
62
+
63
+ /** Read a cost report. Pure, and takes the answer rather than the key. */
64
+ export function openaiCostReport(text: string): OpenaiCostReading {
65
+ const empty: OpenaiCostReading = {
66
+ usd: 0,
67
+ notTokensUsd: 0,
68
+ batchUsd: 0,
69
+ batchSeparable: false,
70
+ unknownUnitUsd: 0,
71
+ described: false,
72
+ window: null,
73
+ buckets: 0,
74
+ rows: 0,
75
+ otherCurrencies: [],
76
+ unreadableAmount: 0,
77
+ truncated: false,
78
+ unparseable: false,
79
+ };
80
+
81
+ let parsed: unknown;
82
+ try {
83
+ parsed = JSON.parse(text);
84
+ } catch {
85
+ return { ...empty, unparseable: true };
86
+ }
87
+ const report = asObject(parsed);
88
+ if (report === null || !Array.isArray(report.data)) return { ...empty, unparseable: true };
89
+
90
+ let usd = 0;
91
+ let notTokensUsd = 0;
92
+ let unknownUnitUsd = 0;
93
+ let described = false;
94
+ let buckets = 0;
95
+ let rows = 0;
96
+ let unreadableAmount = 0;
97
+ let fromMs: number | null = null;
98
+ let toMs: number | null = null;
99
+ const otherCurrencies = new Set<string>();
100
+
101
+ for (const entry of report.data) {
102
+ const bucket = asObject(entry);
103
+ if (bucket === null || typeof bucket.start_time !== 'number' || !Number.isFinite(bucket.start_time)) {
104
+ continue;
105
+ }
106
+ buckets += 1;
107
+ const from = bucket.start_time * 1000;
108
+ fromMs = fromMs === null ? from : Math.min(fromMs, from);
109
+ if (typeof bucket.end_time === 'number' && Number.isFinite(bucket.end_time)) {
110
+ const to = bucket.end_time * 1000;
111
+ toMs = toMs === null ? to : Math.max(toMs, to);
112
+ }
113
+
114
+ if (!Array.isArray(bucket.results)) continue;
115
+ for (const found of bucket.results) {
116
+ const result = asObject(found);
117
+ if (result === null) continue;
118
+ rows += 1;
119
+
120
+ const amount = asObject(result.amount);
121
+ const currency = typeof amount?.currency === 'string' ? amount.currency.toLowerCase() : 'usd';
122
+ if (currency !== 'usd') otherCurrencies.add(amount?.currency as string);
123
+
124
+ const value = amount?.value;
125
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
126
+ unreadableAmount += 1;
127
+ continue;
128
+ }
129
+ usd += value;
130
+
131
+ if (typeof result.line_item === 'string') {
132
+ described = true;
133
+ const unit = result.quantity_unit;
134
+ if (typeof unit === 'string') {
135
+ if (!TOKEN_UNITS.has(unit)) notTokensUsd += value;
136
+ } else {
137
+ unknownUnitUsd += value;
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ return {
144
+ usd,
145
+ notTokensUsd,
146
+ batchUsd: 0,
147
+ batchSeparable: false,
148
+ unknownUnitUsd,
149
+ described,
150
+ window: fromMs === null || toMs === null ? null : { fromMs, toMs },
151
+ buckets,
152
+ rows,
153
+ otherCurrencies: [...otherCurrencies],
154
+ unreadableAmount,
155
+ truncated: report.has_more === true,
156
+ unparseable: false,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Whether this text is an OpenAI cost report rather than Anthropic's or
162
+ * some other JSON. The result object names its own type; failing that, an
163
+ * `amount` beside a numeric `start_time` is this shape and not the other
164
+ * provider's, whose buckets say `starting_at`.
165
+ */
166
+ export function looksLikeOpenaiCost(text: string, prefixBytes = 8192): boolean {
167
+ const head = text.slice(0, prefixBytes);
168
+ if (head.includes('"organization.costs.result"')) return true;
169
+ return head.includes('"amount"') && head.includes('"start_time"') && !head.includes('"starting_at"');
170
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * OpenAI's own usage report, read as a usage log.
3
+ *
4
+ * The seventh converter, and the second one that reads a **provider** rather
5
+ * than a tool sitting in front of one. `anthropic-usage.ts` argues why such a
6
+ * converter takes a file and never a key, and every word of that argument
7
+ * holds here: the Usage API needs an admin key, an admin key manages the
8
+ * organisation, and this project holds no provider credential of any kind.
9
+ * The operator runs one `curl`, in their own shell, and pipes the answer in.
10
+ *
11
+ * ## The format is derived, not guessed
12
+ *
13
+ * Every field below is from the published OpenAPI schema of
14
+ * `GET /v1/organization/usage/completions`: a page of `bucket` objects with
15
+ * `start_time` and `end_time` in **Unix seconds**, each holding `results[]`
16
+ * of `organization.usage.completions.result`. The schema's own example is the
17
+ * test fixture, field for field, because a converter tested against a shape
18
+ * somebody remembered is a converter that mis-reads a real bill and passes
19
+ * its own tests doing it.
20
+ *
21
+ * ## Why the record is the OpenAI shape and not the Anthropic one
22
+ *
23
+ * The schema says `input_tokens` is *"the aggregated number of input tokens
24
+ * used, including cached and cache-write tokens"*. That is the Chat
25
+ * Completions convention — `prompt_tokens` counts the cached half and
26
+ * `prompt_tokens_details.cached_tokens` says how much of it was cached — and
27
+ * `parseUsageLine` already subtracts through exactly that pair. So the record
28
+ * is written in that shape: `prompt_tokens` from `input_tokens`,
29
+ * `prompt_tokens_details.cached_tokens` from `input_cached_tokens`,
30
+ * `completion_tokens` from `output_tokens`. Writing it as Anthropic's
31
+ * `input_tokens` would double-charge the cached half, silently, on the
32
+ * largest line of the bill; the test that parses a converted record back is
33
+ * the one that proves the shape.
34
+ *
35
+ * The example in the schema is also an arithmetic check the converter relies
36
+ * on: `input_tokens` = `input_uncached_tokens` + `input_cached_tokens` +
37
+ * `input_cache_write_tokens`, and each of the uncached and cached totals is
38
+ * the sum of its text, audio and image parts. Every figure below follows from
39
+ * that identity and none is invented beside it.
40
+ *
41
+ * ## What it refuses to price, and counts instead
42
+ *
43
+ * **A result with no model.** `model` is `null` unless the caller passed
44
+ * `group_by[]=model`. Nothing on such a row says what answered, so nothing
45
+ * can price it: refused, counted, and the count names the missing parameter.
46
+ *
47
+ * **A batch row.** `batch` is `true` only with `group_by[]=batch`, and a batch
48
+ * job is billed at a discount a catalogue rate knows nothing about. Priced
49
+ * from that rate it would overstate the bill and look right doing it. Left
50
+ * out and counted. When the report never names `batch` at all, the
51
+ * conversion says the question was not asked, because the alternative is a
52
+ * reader assuming it was answered.
53
+ *
54
+ * **A service tier that is not `default`.** The schema does not enumerate
55
+ * the values, so this does not either: any string but `default` is left out,
56
+ * counted, and *named* in the conversion, so the operator sees `flex` or
57
+ * `priority` or whatever the report said rather than a number. Refusing
58
+ * by name is the honest version of not knowing the list.
59
+ *
60
+ * **Audio and image tokens.** `input_tokens` folds text, audio and image
61
+ * tokens together, and a text rate is not the rate for the other two. A row
62
+ * with any of them is reduced to its **text part** — `input_text_tokens`,
63
+ * `input_cached_text_tokens`, `output_text_tokens` are the schema's own split
64
+ * — and the audio and image tokens are counted as a named gap that no line
65
+ * of the output claims. Cache-write tokens carry no modality in the schema,
66
+ * so on such a row they are left out too, and counted, rather than guessed
67
+ * text. A mixed row whose report lacks the split cannot be reduced and is
68
+ * refused whole.
69
+ *
70
+ * **A truncated report.** `has_more: true` means one page of several, and a
71
+ * bill built from it is understated by whatever was not fetched.
72
+ *
73
+ * ## What deliberately does not cross
74
+ *
75
+ * `user_id` and `api_key_id` are read by nothing here: one names a person,
76
+ * the other a credential, and neither is a project name. `project_id` is
77
+ * read only through a mapping the operator writes, exactly as
78
+ * `--label-by-workspace` does for Anthropic, matched exactly because an
79
+ * opaque id has no hierarchy to take a prefix of.
80
+ *
81
+ * Unlike Anthropic's `workspace_id`, a `null` here means one thing only:
82
+ * the report was not grouped by project. Every OpenAI request belongs to a
83
+ * project and every project has an id, so there is no default named by
84
+ * absence and no second reading to derive.
85
+ */
86
+
87
+ /** One project, and the label the operator gives it. Matched exactly. */
88
+ export interface ProjectLabel {
89
+ project: string;
90
+ label: string;
91
+ }
92
+
93
+ /** One converted record, in the shape `parseUsageLine` reads OpenAI usage. */
94
+ export interface OpenaiUsageRecord {
95
+ model: string;
96
+ ts: string;
97
+ label?: string;
98
+ usage: {
99
+ prompt_tokens: number;
100
+ completion_tokens: number;
101
+ prompt_tokens_details?: { cached_tokens: number };
102
+ };
103
+ }
104
+
105
+ export interface OpenaiUsageConversion {
106
+ records: OpenaiUsageRecord[];
107
+ /** Time buckets in the report, including intervals with no usage. */
108
+ buckets: number;
109
+ /** Results that became records. */
110
+ rows: number;
111
+ /** `num_model_requests`, summed. Informative: a bucket is not a call. */
112
+ requests: number;
113
+ /** Results with `model: null`, which nothing can price. */
114
+ unnamedModel: number;
115
+ /** Results with `batch: true`, billed at a discount no catalogue rate is. */
116
+ batch: number;
117
+ /** Whether any result said whether it was batch at all. */
118
+ batchNamed: boolean;
119
+ /** Results at a service tier other than `default`, left out. */
120
+ nonDefaultTier: number;
121
+ /** The tiers those results named, so the refusal has a name. */
122
+ tiersRefused: string[];
123
+ /** Whether any result named a tier at all. */
124
+ tierNamed: boolean;
125
+ /** Results that carried audio or image tokens and were reduced to text. */
126
+ mixedRows: number;
127
+ /** Audio and image tokens, input and output, that no line of the output prices. */
128
+ nonTextTokens: number;
129
+ /** Cache-write tokens on mixed rows, left out because the schema gives them no modality. */
130
+ cacheWriteUnplaced: number;
131
+ /** Mixed rows the report gave no text split for, refused whole. */
132
+ unsplitRows: number;
133
+ /** `has_more`: this is one page and the bill from it is understated. */
134
+ truncated: boolean;
135
+ /** Records whose label came from a project rule rather than `--label`. */
136
+ labelledByProject: number;
137
+ /** Results carrying a project no rule named. They keep `--label` or none. */
138
+ unruledProject: number;
139
+ /** Rules were given and no result carried a project id. */
140
+ projectNotGrouped: boolean;
141
+ /** The input was not the JSON this endpoint returns. */
142
+ unparseable: number;
143
+ }
144
+
145
+ const count = (value: unknown): number =>
146
+ typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
147
+
148
+ const asObject = (value: unknown): Record<string, unknown> | null =>
149
+ typeof value === 'object' && value !== null && !Array.isArray(value)
150
+ ? (value as Record<string, unknown>)
151
+ : null;
152
+
153
+ /** The modality fields that a text rate is not the rate for. */
154
+ const NON_TEXT = [
155
+ 'input_audio_tokens',
156
+ 'input_cached_audio_tokens',
157
+ 'output_audio_tokens',
158
+ 'input_image_tokens',
159
+ 'input_cached_image_tokens',
160
+ 'output_image_tokens',
161
+ ] as const;
162
+
163
+ /**
164
+ * @param label the project this usage belongs to, chosen by the operator.
165
+ * @param labelByProject one label per project id, exact match, `--label` as fallback.
166
+ */
167
+ export function openaiUsageRecords(
168
+ text: string,
169
+ options: { label?: string; labelByProject?: readonly ProjectLabel[] } = {},
170
+ ): OpenaiUsageConversion {
171
+ const empty: OpenaiUsageConversion = {
172
+ records: [],
173
+ buckets: 0,
174
+ rows: 0,
175
+ requests: 0,
176
+ unnamedModel: 0,
177
+ batch: 0,
178
+ batchNamed: false,
179
+ nonDefaultTier: 0,
180
+ tiersRefused: [],
181
+ tierNamed: false,
182
+ mixedRows: 0,
183
+ nonTextTokens: 0,
184
+ cacheWriteUnplaced: 0,
185
+ unsplitRows: 0,
186
+ truncated: false,
187
+ labelledByProject: 0,
188
+ unruledProject: 0,
189
+ projectNotGrouped: false,
190
+ unparseable: 0,
191
+ };
192
+
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(text);
196
+ } catch {
197
+ return { ...empty, unparseable: 1 };
198
+ }
199
+ const report = asObject(parsed);
200
+ if (report === null || !Array.isArray(report.data)) return { ...empty, unparseable: 1 };
201
+
202
+ const rules = options.labelByProject;
203
+ const records: OpenaiUsageRecord[] = [];
204
+ const tiersRefused = new Set<string>();
205
+ let buckets = 0;
206
+ let rows = 0;
207
+ let requests = 0;
208
+ let unnamedModel = 0;
209
+ let batch = 0;
210
+ let batchNamed = false;
211
+ let nonDefaultTier = 0;
212
+ let tierNamed = false;
213
+ let mixedRows = 0;
214
+ let nonTextTokens = 0;
215
+ let cacheWriteUnplaced = 0;
216
+ let unsplitRows = 0;
217
+ let labelledByProject = 0;
218
+ let unruledProject = 0;
219
+ let anyProject = false;
220
+
221
+ for (const entry of report.data) {
222
+ const bucket = asObject(entry);
223
+ if (bucket === null || typeof bucket.start_time !== 'number' || !Number.isFinite(bucket.start_time)) {
224
+ continue;
225
+ }
226
+ buckets += 1;
227
+ /* Unix seconds, per the schema, and the bucket's start is the only
228
+ instant a summed interval has. */
229
+ const ts = new Date(bucket.start_time * 1000).toISOString();
230
+ if (!Array.isArray(bucket.results)) continue;
231
+
232
+ for (const found of bucket.results) {
233
+ const result = asObject(found);
234
+ if (result === null) continue;
235
+ requests += count(result.num_model_requests);
236
+
237
+ if (typeof result.batch === 'boolean') {
238
+ batchNamed = true;
239
+ if (result.batch) {
240
+ batch += 1;
241
+ continue;
242
+ }
243
+ }
244
+
245
+ if (typeof result.service_tier === 'string') {
246
+ tierNamed = true;
247
+ if (result.service_tier !== 'default') {
248
+ nonDefaultTier += 1;
249
+ tiersRefused.add(result.service_tier);
250
+ continue;
251
+ }
252
+ }
253
+
254
+ if (typeof result.model !== 'string' || result.model === '') {
255
+ unnamedModel += 1;
256
+ continue;
257
+ }
258
+
259
+ let label = options.label;
260
+ if (rules !== undefined) {
261
+ const project = typeof result.project_id === 'string' ? result.project_id : null;
262
+ if (project !== null) {
263
+ anyProject = true;
264
+ const named = rules.find((rule) => rule.project === project);
265
+ if (named !== undefined) {
266
+ label = named.label;
267
+ labelledByProject += 1;
268
+ } else {
269
+ unruledProject += 1;
270
+ }
271
+ }
272
+ }
273
+
274
+ /*
275
+ The whole row is priced only when every token on it is text. Otherwise
276
+ the schema's own split is used and what is not text is counted where
277
+ the reader can see it, never folded into a text rate.
278
+ */
279
+ const nonText = NON_TEXT.reduce((sum, field) => sum + count(result[field]), 0);
280
+ let prompt: number;
281
+ let cached: number;
282
+ let completion: number;
283
+ if (nonText === 0) {
284
+ prompt = count(result.input_tokens);
285
+ cached = count(result.input_cached_tokens);
286
+ completion = count(result.output_tokens);
287
+ } else {
288
+ const split =
289
+ typeof result.input_text_tokens === 'number'
290
+ && typeof result.input_cached_text_tokens === 'number'
291
+ && typeof result.output_text_tokens === 'number';
292
+ if (!split) {
293
+ unsplitRows += 1;
294
+ nonTextTokens += nonText;
295
+ continue;
296
+ }
297
+ mixedRows += 1;
298
+ nonTextTokens += nonText;
299
+ cacheWriteUnplaced += count(result.input_cache_write_tokens);
300
+ cached = count(result.input_cached_text_tokens);
301
+ prompt = count(result.input_text_tokens) + cached;
302
+ completion = count(result.output_text_tokens);
303
+ }
304
+
305
+ records.push({
306
+ model: result.model,
307
+ ts,
308
+ ...(label === undefined ? {} : { label }),
309
+ usage: {
310
+ prompt_tokens: prompt,
311
+ completion_tokens: completion,
312
+ ...(cached > 0 ? { prompt_tokens_details: { cached_tokens: cached } } : {}),
313
+ },
314
+ });
315
+ rows += 1;
316
+ }
317
+ }
318
+
319
+ return {
320
+ records,
321
+ buckets,
322
+ rows,
323
+ requests,
324
+ unnamedModel,
325
+ batch,
326
+ batchNamed,
327
+ nonDefaultTier,
328
+ tiersRefused: [...tiersRefused],
329
+ tierNamed,
330
+ mixedRows,
331
+ nonTextTokens,
332
+ cacheWriteUnplaced,
333
+ unsplitRows,
334
+ truncated: report.has_more === true,
335
+ labelledByProject,
336
+ unruledProject,
337
+ projectNotGrouped: rules !== undefined && !anyProject,
338
+ unparseable: 0,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Whether this text is an OpenAI completions usage report.
344
+ *
345
+ * The result object carries its own type name, which no other document does;
346
+ * failing that, `num_model_requests` beside a numeric `start_time` is this
347
+ * endpoint's shape and not a saved response's.
348
+ */
349
+ export function looksLikeOpenaiUsage(text: string, prefixBytes = 8192): boolean {
350
+ const head = text.slice(0, prefixBytes);
351
+ if (head.includes('"organization.usage.completions.result"')) return true;
352
+ return head.includes('"num_model_requests"') && head.includes('"start_time"');
353
+ }