@trazum/core 1.65.0 → 1.66.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/src/guard.ts CHANGED
@@ -25,6 +25,9 @@
25
25
 
26
26
  import { answerCost } from './answer.js';
27
27
  import type { AnswerRequest, CostAnswer } from './answer.js';
28
+ import { judgeLimits, limitSentence, unjudgedSentence } from './judgement.js';
29
+ import type { MeasuredPosition, PolicyJudgement } from './judgement.js';
30
+ import type { LimitsConfig, WaiveEntry } from './config-schema.js';
28
31
  import { effectivePricing, multipliersFor } from './pricing.js';
29
32
  import type { PricingCatalogue } from './pricing.js';
30
33
  import type { ModelPricing } from './types.js';
@@ -71,6 +74,14 @@ export interface GuardAnswer {
71
74
  * machine acts on; this is never the only place a fact appears.
72
75
  */
73
76
  because: string;
77
+ /**
78
+ * The `limits` policy, judged for this call by `judgeLimits` — the same
79
+ * function the gateway and `serve` call, which is what "one policy, three
80
+ * doors" means in code. Always present: with no policy it says `no-policy`
81
+ * rather than being absent, because a missing field and a judged absence
82
+ * are different answers.
83
+ */
84
+ policy: PolicyJudgement;
74
85
  }
75
86
 
76
87
  /** Models cheaper than this one, in the same family, that the prompt fits in. */
@@ -97,6 +108,16 @@ function cheaperThan(
97
108
  export interface GuardRequest extends AnswerRequest {
98
109
  /** Whether the caller says this work can wait for a batch window. */
99
110
  batchEligible?: boolean;
111
+ /** The `limits` block, when the caller holds one. Judged, never re-derived. */
112
+ limits?: LimitsConfig;
113
+ /** Measured spend per scope, from the caller's own log — never a guess. */
114
+ position?: MeasuredPosition;
115
+ /** The workload this call belongs to, for the per-label ceiling. */
116
+ label?: string;
117
+ /** The conversation, for the per-session ceiling. Never echoed back. */
118
+ session?: string;
119
+ /** The config's `waive` list — a silenced limit answers yes, on the record. */
120
+ waive?: readonly WaiveEntry[];
100
121
  }
101
122
 
102
123
  export function guardSpend(
@@ -164,25 +185,74 @@ export function guardSpend(
164
185
 
165
186
  alternatives.sort((a, b) => b.savingUsd - a.savingUsd);
166
187
 
188
+ /**
189
+ * The limits policy, judged by the same function the gateway and `serve`
190
+ * call. A caller that passes no policy gets `no-policy` and an answer
191
+ * byte-identical to what this returned before the field existed.
192
+ */
193
+ const policy = judgeLimits(
194
+ request.limits,
195
+ request.position ?? { dayUsd: null, sessionUsd: null, labelUsd: null },
196
+ {
197
+ model: request.model,
198
+ inputTokens: request.inputTokens,
199
+ outputTokens: request.outputTokens,
200
+ basis: request.basis,
201
+ label: request.label,
202
+ session: request.session,
203
+ },
204
+ { catalogue, on, ...(request.waive === undefined ? {} : { waivers: request.waive }) },
205
+ );
206
+
167
207
  /**
168
208
  * The verdict maps the cost answer's three outcomes onto the three an agent
169
209
  * can act on. `cannot-tell` stays `cannot-tell` rather than defaulting to
170
210
  * yes: a guard that permits whatever it cannot judge is a guard that permits
171
211
  * everything the moment its inputs go missing.
212
+ *
213
+ * A judged limits policy folds in at the same precedence the policy itself
214
+ * uses — no beats cannot-tell beats yes — because a guard whose headline
215
+ * said "yes" over a crossed session ceiling would be two doors in one
216
+ * answer. `no-policy` folds as nothing: the caller asked only the budget
217
+ * question, and gets only that answer.
172
218
  */
173
- const verdict: GuardVerdict =
219
+ const fromCost: GuardVerdict =
174
220
  cost.verdict === 'cannot-tell' ? 'cannot-tell' : cost.verdict === 'over' ? 'no' : 'yes';
221
+ const verdict: GuardVerdict =
222
+ policy.verdict === 'over'
223
+ ? 'no'
224
+ : policy.verdict === 'cannot-tell' && policy.reason !== 'no-policy' && fromCost === 'yes'
225
+ ? 'cannot-tell'
226
+ : fromCost;
175
227
 
176
228
  return {
177
229
  schemaVersion: 1,
178
230
  verdict,
179
231
  cost,
180
232
  alternatives,
181
- because: reasonFor(verdict, cost, alternatives),
233
+ because: reasonFor(verdict, cost, alternatives, policy),
234
+ policy,
182
235
  };
183
236
  }
184
237
 
185
- function reasonFor(verdict: GuardVerdict, cost: CostAnswer, alternatives: GuardAlternative[]): string {
238
+ function reasonFor(
239
+ verdict: GuardVerdict,
240
+ cost: CostAnswer,
241
+ alternatives: GuardAlternative[],
242
+ policy: PolicyJudgement,
243
+ ): string {
244
+ // A refusal that came from the limits policy speaks the shared sentence —
245
+ // the limit, the measured position and the period, same words at every door.
246
+ const overLimit = policy.judgements.find((entry) => entry.verdict === 'over' && entry.waived === null);
247
+ if (verdict === 'no' && overLimit !== undefined) {
248
+ const lead = limitSentence(overLimit);
249
+ return alternatives.length === 0
250
+ ? `${lead} No cheaper way to make this call exists in the catalogue.`
251
+ : `${lead} The cheapest alternative below saves the most.`;
252
+ }
253
+ if (verdict === 'cannot-tell' && cost.verdict !== 'cannot-tell') {
254
+ return unjudgedSentence(policy);
255
+ }
186
256
  if (verdict === 'cannot-tell') {
187
257
  return cost.reason === 'no-budget-configured'
188
258
  ? 'No budget is configured, so there is nothing to judge this against.'
package/src/index.ts CHANGED
@@ -188,6 +188,18 @@ export type {
188
188
  export { waiverHistory, waiverDay, isWaiverUse } from './waivers.js';
189
189
  export type { WaiverHabit, WaiverHistory, WaiverUse, WaiverVerdict } from './waivers.js';
190
190
  export { answerCost } from './answer.js';
191
+ export { judgeLimits } from './judgement.js';
192
+ export type {
193
+ CannotJudgeReason,
194
+ LimitJudgement,
195
+ LimitScope,
196
+ LimitVerdict,
197
+ MeasuredPosition,
198
+ PolicyJudgement,
199
+ ProposedCall,
200
+ } from './judgement.js';
201
+ export { indexUsage, positionAt } from './position.js';
202
+ export type { MeasuredIndex } from './position.js';
191
203
  export { guardSpend } from './guard.js';
192
204
  export type { GuardAlternative, GuardAnswer, GuardRequest, GuardVerdict } from './guard.js';
193
205
  export { proposeInit, HEADLINE_FLOOR_SHARE, MIN_RATE_DAYS } from './init.js';
@@ -539,6 +551,7 @@ export { editDistance, nearestName } from './nearest.js';
539
551
  export {
540
552
  CONFIG_FILENAME,
541
553
  CONFIG_KEYS,
554
+ CONFIG_LIMITS_KEYS,
542
555
  CONFIG_USAGE_KEYS,
543
556
  ConfigError,
544
557
  DEFAULT_EXTENSIONS,
@@ -546,8 +559,10 @@ export {
546
559
  MAX_CONFIG_SEARCH_DEPTH,
547
560
  budgetFor,
548
561
  parseConfig,
562
+ parseLimits,
563
+ parseWaive,
549
564
  } from './config-schema.js';
550
- export type { ResolvedBudget, SpendConfig, TrazumConfig, WaiveEntry } from './config-schema.js';
565
+ export type { LimitsConfig, ResolvedBudget, SpendConfig, TrazumConfig, WaiveEntry } from './config-schema.js';
551
566
  export { WAIVABLE_GATES } from './config-schema.js';
552
567
 
553
568
  export { RULE_LEVELS } from './types.js';
@@ -0,0 +1,346 @@
1
+ /**
2
+ * One policy, judged once — the function every door calls.
3
+ *
4
+ * The `limits` block states the policy: per-day, per-session and per-label
5
+ * USD ceilings. This module answers whether a proposed call fits it, and the
6
+ * design constraint is the 1.62 arc's lesson: **two doors to the same value
7
+ * agreeing by coincidence is a defect waiting for its input.** So the doors
8
+ * — the gateway's 402, `serve`'s cost answer, `spend_guard` over MCP — do
9
+ * not each read a slice of config and do their own arithmetic. They hand
10
+ * this function the policy, the measured position and the call, and forward
11
+ * its answer.
12
+ *
13
+ * Nothing here re-derives the single-ceiling semantics either. Each ceiling
14
+ * is judged by `answerCost`, the function that has answered "does this call
15
+ * fit this budget" for every door since 1.44 — including its refusals: a
16
+ * negative token count is rejected before it can become money, an unpriced
17
+ * model is a cannot-tell rather than a guess, and a verdict always names
18
+ * whether it rests on a measurement alone or needed the estimate. A policy
19
+ * judgement is those answers, one per applicable ceiling, under one verdict.
20
+ *
21
+ * Two refusals are this module's own, and both close the same loophole: a
22
+ * policy with per-label ceilings judging a call that names no label, or a
23
+ * per-session ceiling judging a call that names no session, is `cannot-tell`
24
+ * — not "no ceiling applies". A call that omits its label does not slip past
25
+ * the label's ceiling; it becomes unjudgeable, and the answer says why.
26
+ */
27
+
28
+ import { answerCost } from './answer.js';
29
+ import type { CostAnswer } from './answer.js';
30
+ import type { PricingCatalogue } from './pricing.js';
31
+ import type { LimitsConfig, WaiveEntry } from './config-schema.js';
32
+ import { formatUsd } from './savings.js';
33
+
34
+ /** Which ceiling a judgement is about. */
35
+ export type LimitScope = 'day' | 'session' | 'label';
36
+
37
+ export type LimitVerdict = 'within' | 'over' | 'cannot-tell';
38
+
39
+ /** Why one ceiling could not be judged, when it could not. */
40
+ export type CannotJudgeReason =
41
+ /** The scope's spend has not been measured, so the consumed half is unknown. */
42
+ | 'nothing-measured'
43
+ /** The model is not in the catalogue, so the call cannot be priced. */
44
+ | 'model-unpriced'
45
+ /** Per-label ceilings exist and the call names no label. */
46
+ | 'no-label-on-call'
47
+ /** A per-session ceiling exists and the call names no session. */
48
+ | 'no-session-on-call';
49
+
50
+ /**
51
+ * One ceiling, judged. The three figures a refusal must be auditable from
52
+ * are all here: the limit, the measured spend, and the window the measured
53
+ * figure covers — the denominator, not just the number.
54
+ */
55
+ export interface LimitJudgement {
56
+ scope: LimitScope;
57
+ /** Which label, when the scope is `label`. Null otherwise. */
58
+ label: string | null;
59
+ limitUsd: number;
60
+ verdict: LimitVerdict;
61
+ /**
62
+ * `measured` when the ceiling is already crossed without the estimate;
63
+ * `measured+estimated` when it takes the described call to cross — or to
64
+ * fit. Null when the verdict is `cannot-tell`.
65
+ */
66
+ restsOn: 'measured' | 'measured+estimated' | null;
67
+ reason: CannotJudgeReason | null;
68
+ /** Measured spend inside this scope's period. Null when not measured. */
69
+ measuredUsd: number | null;
70
+ /** The period the measured figure covers. Null when unknown. */
71
+ window: { fromMs: number; toMs: number } | null;
72
+ /** Where the scope would stand after this call. Null when unjudgeable. */
73
+ afterCallUsd: number | null;
74
+ /**
75
+ * The unexpired waiver silencing this ceiling, when the config carries one.
76
+ *
77
+ * The `check` mechanism, applied here unchanged: the verdict stays `over`
78
+ * — the measurement is the measurement — but a waived `over` does not make
79
+ * the policy refuse, and the waiver rides in the judgement so every answer
80
+ * from every door is the record of the silence. Null when nothing is
81
+ * waived, which is almost always.
82
+ */
83
+ waived: { reason: string; until: string } | null;
84
+ }
85
+
86
+ export interface PolicyJudgement {
87
+ schemaVersion: 1;
88
+ /**
89
+ * The strictest verdict wins: one `over` makes the policy `over`, else one
90
+ * `cannot-tell` makes it `cannot-tell`, else everything judged is `within`.
91
+ * The order is the only safe one — a door that read "within" off a policy
92
+ * with an unjudgeable ceiling would be approving a call nobody judged.
93
+ */
94
+ verdict: LimitVerdict;
95
+ /** Set when the verdict is `cannot-tell` and no ceiling produced it. */
96
+ reason: 'no-policy' | null;
97
+ /** One entry per applicable ceiling, in policy order: day, session, label. */
98
+ judgements: LimitJudgement[];
99
+ }
100
+
101
+ /**
102
+ * The measured side: what the store has billed inside each scope's period.
103
+ *
104
+ * Every field is a measurement or `null`, never zero-for-absence — the rule
105
+ * every document in this product holds. A scope the caller could not measure
106
+ * (no clock on the log, a session nothing recorded) is `null`, and the
107
+ * judgement for that ceiling is `cannot-tell` rather than "under".
108
+ */
109
+ export interface MeasuredPosition {
110
+ /** Measured spend in the current UTC day. */
111
+ dayUsd: number | null;
112
+ dayWindow?: { fromMs: number; toMs: number } | null;
113
+ /** Measured spend in the call's session. */
114
+ sessionUsd: number | null;
115
+ sessionWindow?: { fromMs: number; toMs: number } | null;
116
+ /** Measured spend under the call's label. */
117
+ labelUsd: number | null;
118
+ labelWindow?: { fromMs: number; toMs: number } | null;
119
+ }
120
+
121
+ /** The call being asked about — the same fields every door already takes. */
122
+ export interface ProposedCall {
123
+ model?: string;
124
+ inputTokens?: number;
125
+ outputTokens?: number;
126
+ basis?: 'token-count' | 'heuristic';
127
+ label?: string;
128
+ session?: string;
129
+ }
130
+
131
+ const judgementOf = (
132
+ scope: LimitScope,
133
+ label: string | null,
134
+ limitUsd: number,
135
+ answer: CostAnswer,
136
+ ): LimitJudgement => ({
137
+ scope,
138
+ label,
139
+ limitUsd,
140
+ verdict: answer.verdict,
141
+ restsOn: answer.restsOn,
142
+ // The single-ceiling reasons map one-to-one; the two call-shape refusals
143
+ // (`no-label-on-call`, `no-session-on-call`) never reach answerCost.
144
+ reason:
145
+ answer.reason === null ? null : answer.reason === 'model-unpriced' ? 'model-unpriced' : 'nothing-measured',
146
+ measuredUsd: answer.budget?.consumedUsd ?? null,
147
+ window: answer.budget?.window ?? null,
148
+ afterCallUsd: answer.afterCall?.usd ?? null,
149
+ waived: null,
150
+ });
151
+
152
+ const unjudgeable = (
153
+ scope: LimitScope,
154
+ label: string | null,
155
+ limitUsd: number,
156
+ reason: CannotJudgeReason,
157
+ ): LimitJudgement => ({
158
+ scope,
159
+ label,
160
+ limitUsd,
161
+ verdict: 'cannot-tell',
162
+ restsOn: null,
163
+ reason,
164
+ measuredUsd: null,
165
+ window: null,
166
+ afterCallUsd: null,
167
+ waived: null,
168
+ });
169
+
170
+ /** The waiver gate that names one judgement's ceiling. */
171
+ const gateOf = (judgement: LimitJudgement): string =>
172
+ judgement.scope === 'day'
173
+ ? 'limits.dayUsd'
174
+ : judgement.scope === 'session'
175
+ ? 'limits.sessionUsd'
176
+ : `limits.byLabel:${judgement.label ?? ''}`;
177
+
178
+ /**
179
+ * The unexpired waiver for one judgement, if the config carries one.
180
+ *
181
+ * Judged in UTC at run time, the same rule `check` applies: the day the
182
+ * waiver expires, the ceiling refuses again — nobody has to remember.
183
+ */
184
+ const waiverFor = (
185
+ judgement: LimitJudgement,
186
+ waivers: readonly WaiveEntry[],
187
+ on: Date,
188
+ ): { reason: string; until: string } | null => {
189
+ const today = on.toISOString().slice(0, 10);
190
+ const match = waivers.find((entry) => entry.gate === gateOf(judgement) && entry.until >= today);
191
+ return match === undefined ? null : { reason: match.reason, until: match.until };
192
+ };
193
+
194
+ /**
195
+ * Judges a proposed call against the whole `limits` policy.
196
+ *
197
+ * Pure and synchronous for the same reason `answerCost` is: this runs inside
198
+ * a request that has not been sent yet, and a function that reads a file
199
+ * cannot promise single-digit milliseconds. The caller measures; this judges.
200
+ */
201
+ export function judgeLimits(
202
+ policy: LimitsConfig | undefined,
203
+ position: MeasuredPosition,
204
+ call: ProposedCall,
205
+ options: { catalogue: PricingCatalogue; on?: Date; waivers?: readonly WaiveEntry[] },
206
+ ): PolicyJudgement {
207
+ const judgements: LimitJudgement[] = [];
208
+
209
+ const judge = (consumedUsd: number | null, limitUsd: number, window: { fromMs: number; toMs: number } | null) =>
210
+ answerCost(
211
+ {
212
+ model: call.model,
213
+ inputTokens: call.inputTokens,
214
+ outputTokens: call.outputTokens,
215
+ basis: call.basis,
216
+ limitUsd,
217
+ consumedUsd: consumedUsd ?? undefined,
218
+ window,
219
+ },
220
+ options,
221
+ );
222
+
223
+ if (policy?.dayUsd !== undefined) {
224
+ judgements.push(
225
+ judgementOf('day', null, policy.dayUsd, judge(position.dayUsd, policy.dayUsd, position.dayWindow ?? null)),
226
+ );
227
+ }
228
+
229
+ if (policy?.sessionUsd !== undefined) {
230
+ judgements.push(
231
+ call.session === undefined
232
+ ? unjudgeable('session', null, policy.sessionUsd, 'no-session-on-call')
233
+ : judgementOf(
234
+ 'session',
235
+ null,
236
+ policy.sessionUsd,
237
+ judge(position.sessionUsd, policy.sessionUsd, position.sessionWindow ?? null),
238
+ ),
239
+ );
240
+ }
241
+
242
+ if (policy?.byLabel !== undefined && Object.keys(policy.byLabel).length > 0) {
243
+ if (call.label === undefined) {
244
+ /*
245
+ The loophole this closes: per-label ceilings with an unlabelled call.
246
+ Skipping the scope would mean the ceiling only binds calls polite
247
+ enough to name themselves — the same defect as the negative token
248
+ count, worn as an omission. One judgement, no label, cannot-tell.
249
+ The ceiling reported is the smallest, because that is the one the
250
+ call might be dodging.
251
+ */
252
+ const smallest = Math.min(...Object.values(policy.byLabel));
253
+ judgements.push(unjudgeable('label', null, smallest, 'no-label-on-call'));
254
+ } else if (policy.byLabel[call.label] !== undefined) {
255
+ const limitUsd = policy.byLabel[call.label]!;
256
+ judgements.push(
257
+ judgementOf(
258
+ 'label',
259
+ call.label,
260
+ limitUsd,
261
+ judge(position.labelUsd, limitUsd, position.labelWindow ?? null),
262
+ ),
263
+ );
264
+ }
265
+ // A labelled call whose label has no ceiling is judged by the other
266
+ // scopes alone: the policy said nothing about this label, and inventing
267
+ // a ceiling for it would be enforcing config nobody wrote.
268
+ }
269
+
270
+ if (judgements.length === 0) {
271
+ return { schemaVersion: 1, verdict: 'cannot-tell', reason: 'no-policy', judgements: [] };
272
+ }
273
+
274
+ /**
275
+ * Waivers, applied after every ceiling is judged: the measurement stays —
276
+ * a waived judgement still says `over` — but the silence is attached, on
277
+ * the record, and the verdict below treats a waived `over` as not-refusing.
278
+ */
279
+ const { on = new Date(), waivers = [] } = options;
280
+ if (waivers.length > 0) {
281
+ for (const judgement of judgements) {
282
+ if (judgement.verdict === 'over') judgement.waived = waiverFor(judgement, waivers, on);
283
+ }
284
+ }
285
+
286
+ const verdict: LimitVerdict = judgements.some((entry) => entry.verdict === 'over' && entry.waived === null)
287
+ ? 'over'
288
+ : judgements.some((entry) => entry.verdict === 'cannot-tell')
289
+ ? 'cannot-tell'
290
+ : 'within';
291
+
292
+ return { schemaVersion: 1, verdict, reason: null, judgements };
293
+ }
294
+
295
+ /** The scope, as a refusal names it. Labels print; session identifiers never do. */
296
+ export function limitScopeName(judgement: LimitJudgement): string {
297
+ return judgement.scope === 'day'
298
+ ? "the day's ceiling"
299
+ : judgement.scope === 'session'
300
+ ? "this session's ceiling"
301
+ : judgement.label === null
302
+ ? 'the per-label ceiling'
303
+ : `the "${judgement.label}" label's ceiling`;
304
+ }
305
+
306
+ /** The period a measured figure covers, when the judgement carries one. */
307
+ function periodOf(judgement: LimitJudgement): string {
308
+ if (judgement.window === null) return '';
309
+ const from = new Date(judgement.window.fromMs).toISOString();
310
+ const to = new Date(judgement.window.toMs).toISOString();
311
+ return ` between ${from} and ${to}`;
312
+ }
313
+
314
+ /**
315
+ * The refusal, legible — chapter four's rule, written once.
316
+ *
317
+ * Every over-limit sentence names the limit, the measured position and the
318
+ * period, so an agent can log it and a person can audit it without
319
+ * re-running anything. Every door uses this builder rather than composing
320
+ * its own, for the same reason every door uses the same judge.
321
+ */
322
+ export function limitSentence(judgement: LimitJudgement): string {
323
+ const limit = formatUsd(judgement.limitUsd);
324
+ const measured =
325
+ judgement.measuredUsd === null ? 'nothing measured' : `${formatUsd(judgement.measuredUsd)} measured`;
326
+ return judgement.restsOn === 'measured'
327
+ ? `${capital(limitScopeName(judgement))} of ${limit} is already spent: ${measured}${periodOf(judgement)}.`
328
+ : `This call would take ${limitScopeName(judgement)} of ${limit} past its limit: ${measured}${periodOf(judgement)}, plus this call's estimate.`;
329
+ }
330
+
331
+ /** Why the policy could not be judged, in one auditable sentence. */
332
+ export function unjudgedSentence(judged: PolicyJudgement): string {
333
+ const first = judged.judgements.find((entry) => entry.verdict === 'cannot-tell');
334
+ const which = first === undefined ? 'a ceiling' : limitScopeName(first);
335
+ const why =
336
+ first?.reason === 'no-label-on-call'
337
+ ? 'the call names no label'
338
+ : first?.reason === 'no-session-on-call'
339
+ ? 'the call names no session'
340
+ : first?.reason === 'model-unpriced'
341
+ ? 'the model is not in the price catalogue'
342
+ : 'its spend has not been measured';
343
+ return `${capital(which)} in the limits policy cannot be judged: ${why}.`;
344
+ }
345
+
346
+ const capital = (text: string): string => `${text[0]!.toUpperCase()}${text.slice(1)}`;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The measured side of the limits policy: what a usage log says each scope
3
+ * has already spent.
4
+ *
5
+ * `judgeLimits` judges; this measures. The split is the product's oldest
6
+ * rule — the judging function must be pure and instant, so whatever reads
7
+ * and prices records happens once, here, and the doors keep the result.
8
+ *
9
+ * **Absence is `null`, never zero.** A log with no clock cannot say what
10
+ * today has cost; a log that records no sessions cannot say what a session
11
+ * has spent. Both come back `null`, and `judgeLimits` turns a `null` into
12
+ * `cannot-tell` rather than an approval. The one deliberate asymmetry: a log
13
+ * that *does* record sessions and has never seen this one answers `0` — the
14
+ * history of a conversation that has not started is complete and empty,
15
+ * which is a measurement, not an absence.
16
+ *
17
+ * **Session keys never leave this module.** The index groups by them — that
18
+ * is the whole job — but the map lives in door memory, `positionAt` returns
19
+ * only dollar figures, and nothing here is serialised. The guarantee the
20
+ * usage module states (a session key is somebody's conversation and is never
21
+ * printed) holds through this door too.
22
+ */
23
+
24
+ import { costOf } from './session-cost.js';
25
+ import type { UsageRecord } from './usage.js';
26
+ import type { PricingCatalogue } from './pricing.js';
27
+ import type { MeasuredPosition } from './judgement.js';
28
+
29
+ const DAY_MS = 24 * 60 * 60 * 1000;
30
+
31
+ /**
32
+ * Everything a door needs to answer `positionAt` in constant time, built
33
+ * once from the log it was pointed at.
34
+ */
35
+ export interface MeasuredIndex {
36
+ /** Spend inside the current UTC day. Null when no record carries a clock. */
37
+ dayUsd: number | null;
38
+ /** The UTC day the figure covers. Null exactly when `dayUsd` is. */
39
+ dayWindow: { fromMs: number; toMs: number } | null;
40
+ /** Whether any record carried a label / a session at all. */
41
+ labelsSeen: boolean;
42
+ sessionsSeen: boolean;
43
+ /** Priced spend per label. Keys are labels, which do print. */
44
+ labels: ReadonlyMap<string, number>;
45
+ /** Priced spend per session. Keys are session identifiers — never printed. */
46
+ sessions: ReadonlyMap<string, number>;
47
+ /** Whether the log held any records at all. */
48
+ any: boolean;
49
+ /** Records whose model the catalogue cannot price — money nobody can see. */
50
+ unpriced: number;
51
+ }
52
+
53
+ /**
54
+ * Prices and groups a parsed usage log, once.
55
+ *
56
+ * An unpriced record contributes nothing to any figure and is **counted**
57
+ * instead of dropped silently: a door that reports "measured $3" over a log
58
+ * with a thousand unpriced records is wrong by an amount nobody can see, in
59
+ * the flattering direction, and `unpriced` is how the door says so.
60
+ */
61
+ export function indexUsage(
62
+ records: readonly UsageRecord[],
63
+ options: { catalogue: PricingCatalogue; on?: Date },
64
+ ): MeasuredIndex {
65
+ const { catalogue, on = new Date() } = options;
66
+ const dayFromMs = Math.floor(on.getTime() / DAY_MS) * DAY_MS;
67
+ const dayToMs = dayFromMs + DAY_MS;
68
+
69
+ const labels = new Map<string, number>();
70
+ const sessions = new Map<string, number>();
71
+ let labelsSeen = false;
72
+ let sessionsSeen = false;
73
+ let clocksSeen = false;
74
+ let dayUsd = 0;
75
+ let unpriced = 0;
76
+
77
+ for (const record of records) {
78
+ if (record.label !== null) labelsSeen = true;
79
+ if (record.session !== null) sessionsSeen = true;
80
+ if (record.ts !== null) clocksSeen = true;
81
+
82
+ const usd = costOf(record, catalogue, on);
83
+ if (usd === null) {
84
+ unpriced += 1;
85
+ continue;
86
+ }
87
+ if (record.label !== null) labels.set(record.label, (labels.get(record.label) ?? 0) + usd);
88
+ if (record.session !== null) sessions.set(record.session, (sessions.get(record.session) ?? 0) + usd);
89
+ if (record.ts !== null && record.ts >= dayFromMs && record.ts < dayToMs) dayUsd += usd;
90
+ }
91
+
92
+ return {
93
+ dayUsd: clocksSeen ? dayUsd : null,
94
+ dayWindow: clocksSeen ? { fromMs: dayFromMs, toMs: dayToMs } : null,
95
+ labelsSeen,
96
+ sessionsSeen,
97
+ labels,
98
+ sessions,
99
+ any: records.length > 0,
100
+ unpriced,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The measured position for one proposed call, from an index built once.
106
+ *
107
+ * A scope the log cannot see is `null`; a scope it can see and has nothing
108
+ * recorded for is `0`. The difference is the difference between "unknown"
109
+ * and "a conversation that has not started yet", and collapsing them is how
110
+ * a dead log approves a live spend.
111
+ */
112
+ export function positionAt(
113
+ index: MeasuredIndex,
114
+ call: { label?: string; session?: string },
115
+ ): MeasuredPosition {
116
+ if (!index.any) {
117
+ return { dayUsd: null, sessionUsd: null, labelUsd: null };
118
+ }
119
+ return {
120
+ dayUsd: index.dayUsd,
121
+ dayWindow: index.dayWindow,
122
+ sessionUsd:
123
+ call.session !== undefined && index.sessionsSeen ? (index.sessions.get(call.session) ?? 0) : null,
124
+ labelUsd: call.label !== undefined && index.labelsSeen ? (index.labels.get(call.label) ?? 0) : null,
125
+ };
126
+ }
@@ -72,7 +72,7 @@ export interface SessionCostTracker {
72
72
  }
73
73
 
74
74
  /** Every billed dollar of one call, at its own model's rates. */
75
- function costOf(record: UsageRecord, catalogue: PricingCatalogue, on: Date): number | null {
75
+ export function costOf(record: UsageRecord, catalogue: PricingCatalogue, on: Date): number | null {
76
76
  const model = catalogue.byId.get(record.model);
77
77
  if (!model) return null;
78
78
  const { inputPerMTok, outputPerMTok } = effectivePricing(model, on);