@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.
- package/dist/anthropic-cost.d.ts +173 -0
- package/dist/anthropic-cost.d.ts.map +1 -0
- package/dist/anthropic-cost.js +222 -0
- package/dist/anthropic-cost.js.map +1 -0
- package/dist/anthropic-usage.d.ts +192 -0
- package/dist/anthropic-usage.d.ts.map +1 -0
- package/dist/anthropic-usage.js +260 -0
- package/dist/anthropic-usage.js.map +1 -0
- package/dist/claude-code.d.ts +29 -1
- package/dist/claude-code.d.ts.map +1 -1
- package/dist/claude-code.js +33 -1
- package/dist/claude-code.js.map +1 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/openai-cost.d.ts +62 -0
- package/dist/openai-cost.d.ts.map +1 -0
- package/dist/openai-cost.js +151 -0
- package/dist/openai-cost.js.map +1 -0
- package/dist/openai-usage.d.ts +159 -0
- package/dist/openai-usage.d.ts.map +1 -0
- package/dist/openai-usage.js +281 -0
- package/dist/openai-usage.js.map +1 -0
- package/dist/openrouter-activity.d.ts +103 -0
- package/dist/openrouter-activity.d.ts.map +1 -0
- package/dist/openrouter-activity.js +169 -0
- package/dist/openrouter-activity.js.map +1 -0
- package/dist/pricing.d.ts +44 -0
- package/dist/pricing.d.ts.map +1 -1
- package/dist/pricing.js +88 -3
- package/dist/pricing.js.map +1 -1
- package/package.json +2 -2
- package/src/anthropic-cost.ts +303 -0
- package/src/anthropic-usage.ts +363 -0
- package/src/claude-code.ts +62 -2
- package/src/index.ts +20 -1
- package/src/openai-cost.ts +170 -0
- package/src/openai-usage.ts +353 -0
- package/src/openrouter-activity.ts +221 -0
- package/src/pricing.ts +91 -3
package/src/claude-code.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|