@trazum/core 1.9.0 → 1.25.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 +12 -5
- package/dist/advisories.d.ts.map +1 -1
- package/dist/advisories.js +94 -4
- package/dist/advisories.js.map +1 -1
- package/dist/against.d.ts +32 -0
- package/dist/against.d.ts.map +1 -0
- package/dist/against.js +34 -0
- package/dist/against.js.map +1 -0
- package/dist/config-schema.d.ts +42 -1
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +61 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/conversation.d.ts +121 -0
- package/dist/conversation.d.ts.map +1 -0
- package/dist/conversation.js +157 -0
- package/dist/conversation.js.map +1 -0
- package/dist/csv.d.ts +61 -0
- package/dist/csv.d.ts.map +1 -0
- package/dist/csv.js +149 -0
- package/dist/csv.js.map +1 -0
- package/dist/evaluate.d.ts +24 -0
- package/dist/evaluate.d.ts.map +1 -1
- package/dist/evaluate.js +5 -2
- package/dist/evaluate.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +18 -7
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +18 -7
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +28 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/input-shape.d.ts +104 -0
- package/dist/input-shape.d.ts.map +1 -0
- package/dist/input-shape.js +132 -0
- package/dist/input-shape.js.map +1 -0
- package/dist/levers.d.ts +151 -0
- package/dist/levers.d.ts.map +1 -0
- package/dist/levers.js +160 -0
- package/dist/levers.js.map +1 -0
- package/dist/node.d.ts +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/output-shape.d.ts +96 -0
- package/dist/output-shape.d.ts.map +1 -0
- package/dist/output-shape.js +145 -0
- package/dist/output-shape.js.map +1 -0
- package/dist/pricing-overlay.d.ts +1 -1
- package/dist/pricing-overlay.d.ts.map +1 -1
- package/dist/pricing-overlay.js +46 -0
- package/dist/pricing-overlay.js.map +1 -1
- package/dist/repeats.d.ts +75 -0
- package/dist/repeats.d.ts.map +1 -0
- package/dist/repeats.js +82 -0
- package/dist/repeats.js.map +1 -0
- package/dist/reprice.d.ts +143 -0
- package/dist/reprice.d.ts.map +1 -0
- package/dist/reprice.js +82 -0
- package/dist/reprice.js.map +1 -0
- package/dist/session-cost.d.ts +70 -0
- package/dist/session-cost.d.ts.map +1 -0
- package/dist/session-cost.js +90 -0
- package/dist/session-cost.js.map +1 -0
- package/dist/session-ledger.d.ts +77 -0
- package/dist/session-ledger.d.ts.map +1 -0
- package/dist/session-ledger.js +99 -0
- package/dist/session-ledger.js.map +1 -0
- package/dist/tokenizer.d.ts +33 -26
- package/dist/tokenizer.d.ts.map +1 -1
- package/dist/tokenizer.js +95 -34
- package/dist/tokenizer.js.map +1 -1
- package/dist/ttl-fit.d.ts +103 -0
- package/dist/ttl-fit.d.ts.map +1 -0
- package/dist/ttl-fit.js +184 -0
- package/dist/ttl-fit.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts +637 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +634 -0
- package/dist/usage.js.map +1 -0
- package/package.json +1 -1
- package/src/advisories.ts +96 -4
- package/src/against.ts +48 -0
- package/src/config-schema.ts +106 -0
- package/src/conversation.ts +305 -0
- package/src/csv.ts +184 -0
- package/src/evaluate.ts +33 -3
- package/src/i18n/en.ts +19 -6
- package/src/i18n/es.ts +19 -6
- package/src/i18n/types.ts +29 -1
- package/src/index.ts +65 -1
- package/src/input-shape.ts +259 -0
- package/src/levers.ts +331 -0
- package/src/node.ts +1 -1
- package/src/output-shape.ts +254 -0
- package/src/pricing-overlay.ts +52 -1
- package/src/repeats.ts +166 -0
- package/src/reprice.ts +227 -0
- package/src/session-cost.ts +170 -0
- package/src/session-ledger.ts +189 -0
- package/src/tokenizer.ts +93 -30
- package/src/ttl-fit.ts +251 -0
- package/src/types.ts +1 -0
- package/src/usage.ts +1267 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { effectivePricing } from './pricing.js';
|
|
2
|
+
import { UNLABELLED } from './usage.js';
|
|
3
|
+
import type { PricingCatalogue } from './pricing.js';
|
|
4
|
+
import type { UsageRecord } from './usage.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where the output spend concentrates.
|
|
8
|
+
*
|
|
9
|
+
* ## The biggest line, and nothing said anything actionable about it
|
|
10
|
+
*
|
|
11
|
+
* Output is over half of many real bills — **87%** on the support prompt this
|
|
12
|
+
* repository measures itself against. `profile` could say that much and then
|
|
13
|
+
* stopped, because the advice that follows from "output dominates" is about
|
|
14
|
+
* answers rather than prompts, and the rules engine has nothing to offer there.
|
|
15
|
+
*
|
|
16
|
+
* But a total hides the shape, and the shape is the actionable part. Two bills
|
|
17
|
+
* with identical output spend want opposite responses:
|
|
18
|
+
*
|
|
19
|
+
* - **A tail.** Six per cent of calls hold half the output spend. Those calls are
|
|
20
|
+
* doing something the other ninety-four are not — a different path through the
|
|
21
|
+
* prompt, a runaway with no `max_tokens`, a retrieval that returned a book. They
|
|
22
|
+
* are a morning's work and they are worth finding.
|
|
23
|
+
* - **Flat.** Forty-five per cent of calls hold half of it, which is what "evenly
|
|
24
|
+
* spread" looks like. There is no tail to hunt; the answer length is inherent to
|
|
25
|
+
* the task, and the only lever is asking every answer to be shorter.
|
|
26
|
+
*
|
|
27
|
+
* ## The split is derived, not chosen
|
|
28
|
+
*
|
|
29
|
+
* The figure reported is **the smallest group of calls that holds at least half the
|
|
30
|
+
* output spend**. Half is the point that divides the spend in two — a median over
|
|
31
|
+
* money rather than a threshold somebody picked — and the group is found by walking
|
|
32
|
+
* the distribution down from the longest answers until half the spend is covered.
|
|
33
|
+
*
|
|
34
|
+
* "At least half" is meant literally. The walk stops on a bucket boundary, so the
|
|
35
|
+
* group it names is a whole number of buckets and can overshoot; saying "half"
|
|
36
|
+
* flat would be claiming a precision the histogram does not have.
|
|
37
|
+
*
|
|
38
|
+
* ## Bounded memory, exact statement
|
|
39
|
+
*
|
|
40
|
+
* The counts live in fixed buckets rather than a list of every call, because a
|
|
41
|
+
* usage log is measured in megabytes. Every call inside an included bucket is at or
|
|
42
|
+
* above that bucket's lower edge, so **"calls producing more than N tokens" is
|
|
43
|
+
* exact** for the N this reports — it is only ever a bucket edge.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** How the output spend of one label-and-model slice is distributed. */
|
|
47
|
+
export interface OutputShape {
|
|
48
|
+
label: string;
|
|
49
|
+
model: string;
|
|
50
|
+
modelName: string;
|
|
51
|
+
calls: number;
|
|
52
|
+
outputTokens: number;
|
|
53
|
+
outputUsd: number;
|
|
54
|
+
/** The bucket edge the heaviest group sits above. Always a bucket boundary. */
|
|
55
|
+
aboveTokens: number;
|
|
56
|
+
/** How many calls are in that group. */
|
|
57
|
+
heavyCalls: number;
|
|
58
|
+
/** Their share of the calls in this slice. */
|
|
59
|
+
heavyCallShare: number;
|
|
60
|
+
/** Their share of this slice's output spend — at least a half, by construction. */
|
|
61
|
+
heavySpendShare: number;
|
|
62
|
+
/** This slice's output spend as a fraction of the whole bill. */
|
|
63
|
+
shareOfBill: number;
|
|
64
|
+
/**
|
|
65
|
+
* The bucket ceiling that at least half the measured answers fit within.
|
|
66
|
+
*
|
|
67
|
+
* A ceiling by construction, never an interpolation: the histogram knows
|
|
68
|
+
* which bucket the median call landed in, and the honest sentence is "half
|
|
69
|
+
* the answers fit within N tokens" where N is that bucket's upper edge.
|
|
70
|
+
* `null` only when the covering bucket is the open-ended last one, which has
|
|
71
|
+
* no ceiling to name.
|
|
72
|
+
*/
|
|
73
|
+
medianWithinTokens: number | null;
|
|
74
|
+
/**
|
|
75
|
+
* The same ceiling for 95% of the measured answers — the number somebody
|
|
76
|
+
* setting `max_tokens` actually wants. Measured on these calls, promised for
|
|
77
|
+
* nothing.
|
|
78
|
+
*/
|
|
79
|
+
p95WithinTokens: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface OutputShapeOptions {
|
|
83
|
+
catalogue: PricingCatalogue;
|
|
84
|
+
on?: Date;
|
|
85
|
+
/** Slices whose output is below this share of the bill are dropped. Default 5%. */
|
|
86
|
+
minShare?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bucket edges, fine where answers actually land and coarse in the tail.
|
|
91
|
+
*
|
|
92
|
+
* 64 tokens up to 8,192 covers ordinary answers at a resolution finer than anybody
|
|
93
|
+
* would act on; past that the buckets widen, because the difference between a
|
|
94
|
+
* 40,000-token answer and a 41,000-token one changes no decision. The last bucket is
|
|
95
|
+
* open-ended so nothing falls off the end — an answer longer than the largest edge
|
|
96
|
+
* still counts, in the group where it belongs.
|
|
97
|
+
*/
|
|
98
|
+
const EDGES: number[] = (() => {
|
|
99
|
+
const edges: number[] = [];
|
|
100
|
+
for (let t = 0; t < 8192; t += 64) edges.push(t);
|
|
101
|
+
for (let t = 8192; t < 131_072; t += 1024) edges.push(t);
|
|
102
|
+
return edges;
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
/** Index of the bucket a count falls in. The last bucket is open-ended. */
|
|
106
|
+
function bucketOf(tokens: number): number {
|
|
107
|
+
if (tokens >= EDGES[EDGES.length - 1]!) return EDGES.length - 1;
|
|
108
|
+
if (tokens < 8192) return Math.floor(tokens / 64);
|
|
109
|
+
return 128 + Math.floor((tokens - 8192) / 1024);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A bucket's upper edge, or `null` for the open-ended last one. */
|
|
113
|
+
function upperEdgeOf(bucket: number): number | null {
|
|
114
|
+
if (bucket >= EDGES.length - 1) return null;
|
|
115
|
+
return EDGES[bucket + 1]!;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The bucket ceiling covering `share` of the calls, walking up from the
|
|
120
|
+
* shortest answers. Exact over the histogram: every call at or below the
|
|
121
|
+
* returned ceiling is counted, none is interpolated.
|
|
122
|
+
*/
|
|
123
|
+
function ceilingFor(
|
|
124
|
+
buckets: Map<number, { calls: number; tokens: number }>,
|
|
125
|
+
totalCalls: number,
|
|
126
|
+
share: number,
|
|
127
|
+
): number | null {
|
|
128
|
+
const ascending = [...buckets.keys()].sort((a, b) => a - b);
|
|
129
|
+
const target = totalCalls * share;
|
|
130
|
+
let covered = 0;
|
|
131
|
+
for (const b of ascending) {
|
|
132
|
+
covered += buckets.get(b)!.calls;
|
|
133
|
+
if (covered >= target) return upperEdgeOf(b);
|
|
134
|
+
}
|
|
135
|
+
return upperEdgeOf(ascending[ascending.length - 1]!);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface Slice {
|
|
139
|
+
calls: number;
|
|
140
|
+
outputTokens: number;
|
|
141
|
+
outputUsd: number;
|
|
142
|
+
/** Calls and output tokens per bucket, sparse. */
|
|
143
|
+
buckets: Map<number, { calls: number; tokens: number }>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface OutputShapeTracker {
|
|
147
|
+
add(record: UsageRecord): void;
|
|
148
|
+
finish(totalUsd: number): OutputShape[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* An accumulator, fed in the pass a profile already makes.
|
|
153
|
+
*
|
|
154
|
+
* What it holds is bounded by the number of slices times the number of buckets any
|
|
155
|
+
* of them actually touches, not by the size of the log.
|
|
156
|
+
*/
|
|
157
|
+
export function createOutputShapeTracker(options: OutputShapeOptions): OutputShapeTracker {
|
|
158
|
+
const { catalogue, on = new Date(), minShare = 0.05 } = options;
|
|
159
|
+
const slices = new Map<string, Slice>();
|
|
160
|
+
|
|
161
|
+
const add = (record: UsageRecord): void => {
|
|
162
|
+
const model = catalogue.byId.get(record.model);
|
|
163
|
+
// An unpriced model contributes no dollars anywhere else; a shape drawn from
|
|
164
|
+
// one would be a distribution of a bill that was never computed.
|
|
165
|
+
if (!model) return;
|
|
166
|
+
if (record.outputTokens <= 0) return;
|
|
167
|
+
|
|
168
|
+
const key = `${record.label ?? UNLABELLED}\n${record.model}`;
|
|
169
|
+
let slice = slices.get(key);
|
|
170
|
+
if (!slice) {
|
|
171
|
+
slice = { calls: 0, outputTokens: 0, outputUsd: 0, buckets: new Map() };
|
|
172
|
+
slices.set(key, slice);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { outputPerMTok } = effectivePricing(model, on);
|
|
176
|
+
slice.calls += 1;
|
|
177
|
+
slice.outputTokens += record.outputTokens;
|
|
178
|
+
slice.outputUsd += (record.outputTokens / 1_000_000) * outputPerMTok;
|
|
179
|
+
|
|
180
|
+
const b = bucketOf(record.outputTokens);
|
|
181
|
+
const cell = slice.buckets.get(b);
|
|
182
|
+
if (cell) {
|
|
183
|
+
cell.calls += 1;
|
|
184
|
+
cell.tokens += record.outputTokens;
|
|
185
|
+
} else {
|
|
186
|
+
slice.buckets.set(b, { calls: 1, tokens: record.outputTokens });
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const finish = (totalUsd: number): OutputShape[] => {
|
|
191
|
+
const out: OutputShape[] = [];
|
|
192
|
+
|
|
193
|
+
for (const [key, slice] of slices) {
|
|
194
|
+
const split = key.indexOf('\n');
|
|
195
|
+
const label = key.slice(0, split);
|
|
196
|
+
const modelId = key.slice(split + 1);
|
|
197
|
+
const model = catalogue.byId.get(modelId);
|
|
198
|
+
if (!model || slice.outputTokens === 0) continue;
|
|
199
|
+
|
|
200
|
+
const shareOfBill = totalUsd > 0 ? slice.outputUsd / totalUsd : 0;
|
|
201
|
+
if (shareOfBill < minShare) continue;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Walk down from the longest answers until half the output spend is
|
|
205
|
+
* covered. Spend is proportional to tokens inside one slice — one model,
|
|
206
|
+
* one rate — so the tokens are the money here and no second accumulator is
|
|
207
|
+
* needed.
|
|
208
|
+
*/
|
|
209
|
+
const target = slice.outputTokens / 2;
|
|
210
|
+
const descending = [...slice.buckets.keys()].sort((a, b) => b - a);
|
|
211
|
+
let tokens = 0;
|
|
212
|
+
let heavyCalls = 0;
|
|
213
|
+
let lastBucket = descending[0]!;
|
|
214
|
+
for (const b of descending) {
|
|
215
|
+
const cell = slice.buckets.get(b)!;
|
|
216
|
+
tokens += cell.tokens;
|
|
217
|
+
heavyCalls += cell.calls;
|
|
218
|
+
lastBucket = b;
|
|
219
|
+
if (tokens >= target) break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
out.push({
|
|
223
|
+
label,
|
|
224
|
+
model: modelId,
|
|
225
|
+
modelName: model.displayName,
|
|
226
|
+
calls: slice.calls,
|
|
227
|
+
outputTokens: slice.outputTokens,
|
|
228
|
+
outputUsd: slice.outputUsd,
|
|
229
|
+
aboveTokens: EDGES[lastBucket]!,
|
|
230
|
+
heavyCalls,
|
|
231
|
+
heavyCallShare: heavyCalls / slice.calls,
|
|
232
|
+
heavySpendShare: tokens / slice.outputTokens,
|
|
233
|
+
shareOfBill,
|
|
234
|
+
medianWithinTokens: ceilingFor(slice.buckets, slice.calls, 0.5),
|
|
235
|
+
p95WithinTokens: ceilingFor(slice.buckets, slice.calls, 0.95),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return out.sort((a, b) => b.outputUsd - a.outputUsd);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return { add, finish };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The same measurement over a list of records, for a caller holding one. */
|
|
246
|
+
export function outputShapes(
|
|
247
|
+
records: readonly UsageRecord[],
|
|
248
|
+
totalUsd: number,
|
|
249
|
+
options: OutputShapeOptions,
|
|
250
|
+
): OutputShape[] {
|
|
251
|
+
const tracker = createOutputShapeTracker(options);
|
|
252
|
+
for (const record of records) tracker.add(record);
|
|
253
|
+
return tracker.finish(totalUsd);
|
|
254
|
+
}
|
package/src/pricing-overlay.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BUNDLED_CATALOGUE } from './pricing.js';
|
|
2
2
|
import { nearestName } from './nearest.js';
|
|
3
3
|
import type { PricingCatalogue } from './pricing.js';
|
|
4
|
-
import type { Capability, CachingMode, ModelPricing } from './types.js';
|
|
4
|
+
import type { Capability, CachingMode, CostMultipliers, ModelPricing } from './types.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Local price corrections, so a price change does not require a library upgrade.
|
|
@@ -34,10 +34,28 @@ export const PRICING_MODEL_KEYS = [
|
|
|
34
34
|
'caching',
|
|
35
35
|
'notes',
|
|
36
36
|
'promo',
|
|
37
|
+
'multipliers',
|
|
37
38
|
] as const;
|
|
38
39
|
|
|
39
40
|
const PROMO_KEYS = ['inputPerMTok', 'outputPerMTok', 'until'] as const;
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Cache and batch rates, which an overlay had no way to state — and had to.
|
|
44
|
+
*
|
|
45
|
+
* Without this, a model added through `--pricing` silently inherited Anthropic's
|
|
46
|
+
* multipliers: a cache write at 1.25x input, or 2x at the long TTL. Most
|
|
47
|
+
* providers charge plain input for a write, so `trazum profile` computed a
|
|
48
|
+
* premium that model never charged, accused the workload of a caching loss it
|
|
49
|
+
* could not have, and told the reader to turn caching off. Three documents
|
|
50
|
+
* claimed that could not happen to a provider whose writes cost what input costs,
|
|
51
|
+
* and for an overlay model it was exactly what happened.
|
|
52
|
+
*
|
|
53
|
+
* `batch` accepts `null`, which is not the same as leaving it out: "this provider
|
|
54
|
+
* has no batch API" should stop the advisory offering a discount nobody sells,
|
|
55
|
+
* while "nobody has said" should fall back to the default.
|
|
56
|
+
*/
|
|
57
|
+
const MULTIPLIER_KEYS = ['cacheRead', 'cacheWrite5m', 'cacheWrite1h', 'batch'] as const;
|
|
58
|
+
|
|
41
59
|
const TIERS: ModelPricing['tier'][] = ['frontier', 'opus', 'sonnet', 'haiku', 'unknown'];
|
|
42
60
|
const CAPABILITIES: Capability[] = ['small', 'mid', 'large', 'frontier', 'unknown'];
|
|
43
61
|
const CACHING_MODES: CachingMode[] = ['explicit', 'automatic', 'none', 'unknown'];
|
|
@@ -129,6 +147,36 @@ function parsePromo(raw: unknown, label: string, source: string): ModelPricing['
|
|
|
129
147
|
};
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
function parseMultipliers(raw: unknown, label: string, source: string): CostMultipliers {
|
|
151
|
+
if (!isPlainObject(raw)) {
|
|
152
|
+
throw new PricingOverlayError(`"${label}" must be an object`, source);
|
|
153
|
+
}
|
|
154
|
+
rejectUnknownKeys(raw, MULTIPLIER_KEYS, source, `${label}.`);
|
|
155
|
+
if (Object.keys(raw).length === 0) {
|
|
156
|
+
throw new PricingOverlayError(
|
|
157
|
+
`"${label}" is empty — remove it, or say which rate it changes`,
|
|
158
|
+
source,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const multipliers: CostMultipliers = {};
|
|
163
|
+
for (const key of ['cacheRead', 'cacheWrite5m', 'cacheWrite1h'] as const) {
|
|
164
|
+
if (raw[key] === undefined) continue;
|
|
165
|
+
/**
|
|
166
|
+
* Zero is refused along with the negatives. A free cache read is not a price
|
|
167
|
+
* anybody publishes, and admitting one would let an overlay typo turn a real
|
|
168
|
+
* cost into no cost — the flattering direction, and the one this whole file
|
|
169
|
+
* exists to keep out of a report.
|
|
170
|
+
*/
|
|
171
|
+
multipliers[key] = positiveNumber(raw[key], `${label}.${key}`, source);
|
|
172
|
+
}
|
|
173
|
+
if (raw.batch !== undefined) {
|
|
174
|
+
multipliers.batch =
|
|
175
|
+
raw.batch === null ? null : positiveNumber(raw.batch, `${label}.batch`, source);
|
|
176
|
+
}
|
|
177
|
+
return multipliers;
|
|
178
|
+
}
|
|
179
|
+
|
|
132
180
|
function parseModel(
|
|
133
181
|
raw: unknown,
|
|
134
182
|
id: string,
|
|
@@ -209,6 +257,9 @@ function parseModel(
|
|
|
209
257
|
// `null` is how you cancel a bundled promotion that has been withdrawn.
|
|
210
258
|
model.promo = raw.promo === null ? undefined : parsePromo(raw.promo, `models.${id}.promo`, source);
|
|
211
259
|
}
|
|
260
|
+
if (raw.multipliers !== undefined) {
|
|
261
|
+
model.multipliers = parseMultipliers(raw.multipliers, `models.${id}.multipliers`, source);
|
|
262
|
+
}
|
|
212
263
|
|
|
213
264
|
return model;
|
|
214
265
|
}
|
package/src/repeats.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { effectivePricing, multipliersFor } from './pricing.js';
|
|
2
|
+
import { UNLABELLED } from './usage.js';
|
|
3
|
+
import type { PricingCatalogue } from './pricing.js';
|
|
4
|
+
import type { UsageRecord } from './usage.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The same request, sent again a moment later.
|
|
8
|
+
*
|
|
9
|
+
* ## The failure mode nothing else here can see
|
|
10
|
+
*
|
|
11
|
+
* A conversation's input **grows with every turn** — that is the finding
|
|
12
|
+
* `conversations` exists for. So two consecutive calls in one conversation
|
|
13
|
+
* carrying *exactly* the same input size, seconds apart, is the shape of
|
|
14
|
+
* something going wrong rather than something working: a retry after a
|
|
15
|
+
* timeout, an agent step repeating because a tool call failed, a loop that
|
|
16
|
+
* re-sends the whole context and gets nowhere.
|
|
17
|
+
*
|
|
18
|
+
* That is expensive in a way a total hides completely. The retried call is
|
|
19
|
+
* billed in full, and on an agent workload the input is the bill.
|
|
20
|
+
* `duplicateLines` catches the same line recorded twice; this catches two
|
|
21
|
+
* *different* calls that sent the same thing.
|
|
22
|
+
*
|
|
23
|
+
* ## What it compares, and why only that
|
|
24
|
+
*
|
|
25
|
+
* Each call is compared to **the one immediately before it in the same
|
|
26
|
+
* session**, and only when the log carries both a session and a clock. Not to
|
|
27
|
+
* every earlier call: a workload that legitimately sends a fixed-size prompt
|
|
28
|
+
* would light up under a looser rule, and holding every size a session has
|
|
29
|
+
* ever sent would grow without bound on a log measured in megabytes. One
|
|
30
|
+
* previous call per session is bounded and is where a retry actually sits.
|
|
31
|
+
*
|
|
32
|
+
* The gap must be **non-negative and under the window** (a minute by default).
|
|
33
|
+
* Non-negative because a log is not guaranteed to be in time order, and a
|
|
34
|
+
* negative gap means the two records arrived out of order rather than that a
|
|
35
|
+
* call repeated.
|
|
36
|
+
*
|
|
37
|
+
* ## What it refuses to conclude
|
|
38
|
+
*
|
|
39
|
+
* It cannot see content, so it cannot tell a retry from two genuinely
|
|
40
|
+
* identical requests a second apart. It reports the count and the money and
|
|
41
|
+
* stops — the same rule `duplicateLines` follows. Every rendering says the
|
|
42
|
+
* pattern is *usually* a retry or a loop, never that it is one.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** Consecutive same-size calls in one conversation, per slice. */
|
|
46
|
+
export interface RepeatedTurns {
|
|
47
|
+
label: string;
|
|
48
|
+
model: string;
|
|
49
|
+
modelName: string;
|
|
50
|
+
/** How many calls repeated the previous call's input size inside the window. */
|
|
51
|
+
repeats: number;
|
|
52
|
+
/** Calls in this slice that could be checked at all — the denominator. */
|
|
53
|
+
checkedCalls: number;
|
|
54
|
+
/** What those repeated calls cost, in full. */
|
|
55
|
+
usd: number;
|
|
56
|
+
/** The window they had to fall inside, in milliseconds. */
|
|
57
|
+
withinMs: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RepeatsOptions {
|
|
61
|
+
catalogue: PricingCatalogue;
|
|
62
|
+
on?: Date;
|
|
63
|
+
/**
|
|
64
|
+
* How close two calls have to be. A minute by default: long enough to cover
|
|
65
|
+
* a timeout and a retry, short enough that an ordinary next turn — which
|
|
66
|
+
* needs a human or a tool to produce it — rarely lands inside it.
|
|
67
|
+
*/
|
|
68
|
+
withinMs?: number;
|
|
69
|
+
/** Slices below this many repeats are dropped. Default 2. */
|
|
70
|
+
minRepeats?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RepeatsTracker {
|
|
74
|
+
add(record: UsageRecord): void;
|
|
75
|
+
finish(): RepeatedTurns[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Slice {
|
|
79
|
+
repeats: number;
|
|
80
|
+
checkedCalls: number;
|
|
81
|
+
usd: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** An accumulator, fed in the pass a profile already makes. */
|
|
85
|
+
export function createRepeatsTracker(options: RepeatsOptions): RepeatsTracker {
|
|
86
|
+
const { catalogue, on = new Date(), withinMs = 60_000, minRepeats = 2 } = options;
|
|
87
|
+
const slices = new Map<string, Slice>();
|
|
88
|
+
/** The previous call of each session: its input size and when it happened. */
|
|
89
|
+
const previous = new Map<string, { tokens: number; ts: number }>();
|
|
90
|
+
|
|
91
|
+
const add = (record: UsageRecord): void => {
|
|
92
|
+
const model = catalogue.byId.get(record.model);
|
|
93
|
+
// An unpriced model has no dollars anywhere else; naming money here that
|
|
94
|
+
// no total contains would be a figure with nothing behind it.
|
|
95
|
+
if (!model) return;
|
|
96
|
+
if (record.session === null || record.ts === null) return;
|
|
97
|
+
|
|
98
|
+
const tokens =
|
|
99
|
+
record.inputTokens +
|
|
100
|
+
record.cacheReadTokens +
|
|
101
|
+
record.cacheWrite5mTokens +
|
|
102
|
+
record.cacheWrite1hTokens;
|
|
103
|
+
if (tokens <= 0) return;
|
|
104
|
+
|
|
105
|
+
const key = `${record.label ?? UNLABELLED}\n${record.model}`;
|
|
106
|
+
let slice = slices.get(key);
|
|
107
|
+
if (!slice) {
|
|
108
|
+
slice = { repeats: 0, checkedCalls: 0, usd: 0 };
|
|
109
|
+
slices.set(key, slice);
|
|
110
|
+
}
|
|
111
|
+
slice.checkedCalls += 1;
|
|
112
|
+
|
|
113
|
+
const before = previous.get(record.session);
|
|
114
|
+
previous.set(record.session, { tokens, ts: record.ts });
|
|
115
|
+
if (before === undefined) return;
|
|
116
|
+
|
|
117
|
+
const gap = record.ts - before.ts;
|
|
118
|
+
// Out of order is not a repeat, and neither is a call an hour later.
|
|
119
|
+
if (gap < 0 || gap >= withinMs) return;
|
|
120
|
+
if (before.tokens !== tokens) return;
|
|
121
|
+
|
|
122
|
+
const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);
|
|
123
|
+
const rates = multipliersFor(model);
|
|
124
|
+
const per = (count: number, rate: number): number => (count / 1_000_000) * rate;
|
|
125
|
+
slice.repeats += 1;
|
|
126
|
+
slice.usd +=
|
|
127
|
+
per(record.inputTokens, inputPerMTok) +
|
|
128
|
+
per(record.cacheReadTokens, inputPerMTok * rates.cacheRead) +
|
|
129
|
+
per(record.cacheWrite5mTokens, inputPerMTok * rates.cacheWrite5m) +
|
|
130
|
+
per(record.cacheWrite1hTokens, inputPerMTok * rates.cacheWrite1h) +
|
|
131
|
+
per(record.outputTokens, outputPerMTok);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const finish = (): RepeatedTurns[] => {
|
|
135
|
+
const out: RepeatedTurns[] = [];
|
|
136
|
+
for (const [key, slice] of slices) {
|
|
137
|
+
if (slice.repeats < minRepeats) continue;
|
|
138
|
+
const split = key.indexOf('\n');
|
|
139
|
+
const modelId = key.slice(split + 1);
|
|
140
|
+
const model = catalogue.byId.get(modelId);
|
|
141
|
+
if (!model) continue;
|
|
142
|
+
out.push({
|
|
143
|
+
label: key.slice(0, split),
|
|
144
|
+
model: modelId,
|
|
145
|
+
modelName: model.displayName,
|
|
146
|
+
repeats: slice.repeats,
|
|
147
|
+
checkedCalls: slice.checkedCalls,
|
|
148
|
+
usd: slice.usd,
|
|
149
|
+
withinMs,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return out.sort((a, b) => b.usd - a.usd);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return { add, finish };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The same measurement over a list of records, for a caller holding one. */
|
|
159
|
+
export function repeatedTurns(
|
|
160
|
+
records: readonly UsageRecord[],
|
|
161
|
+
options: RepeatsOptions,
|
|
162
|
+
): RepeatedTurns[] {
|
|
163
|
+
const tracker = createRepeatsTracker(options);
|
|
164
|
+
for (const record of records) tracker.add(record);
|
|
165
|
+
return tracker.finish();
|
|
166
|
+
}
|