@trazum/core 1.64.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.
Files changed (46) hide show
  1. package/dist/config-schema.d.ts +32 -1
  2. package/dist/config-schema.d.ts.map +1 -1
  3. package/dist/config-schema.js +55 -3
  4. package/dist/config-schema.js.map +1 -1
  5. package/dist/conform.d.ts +8 -1
  6. package/dist/conform.d.ts.map +1 -1
  7. package/dist/conform.js +82 -0
  8. package/dist/conform.js.map +1 -1
  9. package/dist/gateway.d.ts +32 -1
  10. package/dist/gateway.d.ts.map +1 -1
  11. package/dist/gateway.js +64 -3
  12. package/dist/gateway.js.map +1 -1
  13. package/dist/guard.d.ts +20 -0
  14. package/dist/guard.d.ts.map +1 -1
  15. package/dist/guard.js +41 -3
  16. package/dist/guard.js.map +1 -1
  17. package/dist/index.d.ts +8 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +5 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/judgement.d.ts +159 -0
  22. package/dist/judgement.d.ts.map +1 -0
  23. package/dist/judgement.js +188 -0
  24. package/dist/judgement.js.map +1 -0
  25. package/dist/position.d.ts +74 -0
  26. package/dist/position.d.ts.map +1 -0
  27. package/dist/position.js +93 -0
  28. package/dist/position.js.map +1 -0
  29. package/dist/schemas.d.ts +13 -0
  30. package/dist/schemas.d.ts.map +1 -0
  31. package/dist/schemas.js +144 -0
  32. package/dist/schemas.js.map +1 -0
  33. package/dist/session-cost.d.ts +2 -0
  34. package/dist/session-cost.d.ts.map +1 -1
  35. package/dist/session-cost.js +1 -1
  36. package/dist/session-cost.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/config-schema.ts +89 -3
  39. package/src/conform.ts +79 -0
  40. package/src/gateway.ts +108 -4
  41. package/src/guard.ts +73 -3
  42. package/src/index.ts +18 -2
  43. package/src/judgement.ts +346 -0
  44. package/src/position.ts +126 -0
  45. package/src/schemas.ts +150 -0
  46. package/src/session-cost.ts +1 -1
@@ -137,6 +137,34 @@ export interface SpendConfig {
137
137
  substitute?: Record<string, { to: string; reason: string }>;
138
138
  }
139
139
 
140
+ /**
141
+ * The enforcement policy: USD ceilings judged at call time, at whichever door
142
+ * the call arrives — the gateway's 402, `serve`'s cost answer, `spend_guard`
143
+ * over MCP.
144
+ *
145
+ * A separate block from `spend`, and the separation is the point. `spend`
146
+ * gates *reports*: it is read after the fact, over a log or a store, and a
147
+ * crossing fails a check. This is read *before a call is made*, to refuse it.
148
+ * Same units, different moments — and the 1.62 arc's lesson is that two
149
+ * surfaces reading "the same" value from different slices of config is how
150
+ * they come to disagree about how much is left. One block, stated once,
151
+ * enforced identically at every door.
152
+ *
153
+ * Every ceiling is a **positive** dollar amount. `spend` accepts zero because
154
+ * a zero report-budget is a gate somebody can waive on the record; a zero
155
+ * enforcement ceiling is a door that refuses everything, which is an outage
156
+ * dressed as a policy — if the intent is "stop all calls", turning the door
157
+ * off is the honest spelling.
158
+ */
159
+ export interface LimitsConfig {
160
+ /** Ceiling per UTC calendar day, in dollars, measured across the store. */
161
+ dayUsd?: number;
162
+ /** Ceiling per session — the unit an agent product actually blows up in. */
163
+ sessionUsd?: number;
164
+ /** Per-label ceilings, each judged against that label's own measured spend. */
165
+ byLabel?: Record<string, number>;
166
+ }
167
+
140
168
  export interface TrazumConfig {
141
169
  level?: RuleLevel;
142
170
  locale?: Locale;
@@ -182,6 +210,7 @@ export interface TrazumConfig {
182
210
  */
183
211
  sources?: Record<string, string[]>;
184
212
  spend?: SpendConfig;
213
+ limits?: LimitsConfig;
185
214
  /**
186
215
  * Findings as policy: a gate failure the team has looked at and decided to
187
216
  * live with, on the record, for a bounded time.
@@ -298,6 +327,7 @@ export const CONFIG_KEYS = [
298
327
  'budgets',
299
328
  'labels',
300
329
  'spend',
330
+ 'limits',
301
331
  'sources',
302
332
  'store',
303
333
  'waive',
@@ -315,6 +345,8 @@ export const CONFIG_BASELINE_KEYS = ['path', 'maxGrowthTokens', 'maxGrowthPct']
315
345
 
316
346
  export const CONFIG_SPEND_KEYS = ['maxUsd', 'monthlyUsd', 'maxDayUsd', 'maxSessionUsd', 'maxCacheLossUsd', 'byLabel', 'bySource', 'substitute'] as const;
317
347
 
348
+ export const CONFIG_LIMITS_KEYS = ['dayUsd', 'sessionUsd', 'byLabel'] as const;
349
+
318
350
  export const CONFIG_WAIVE_KEYS = ['gate', 'reason', 'until'] as const;
319
351
 
320
352
  export const CONFIG_STORE_KEYS = ['keepDays'] as const;
@@ -590,6 +622,53 @@ function parseSpend(raw: unknown, source: string): SpendConfig {
590
622
  return spend;
591
623
  }
592
624
 
625
+ /**
626
+ * Validates the `limits` block.
627
+ *
628
+ * Every ceiling must be a **positive** finite number of dollars — stricter
629
+ * than `spend`, deliberately. A report-budget of zero is a gate somebody can
630
+ * waive on the record; an enforcement ceiling of zero refuses every call at
631
+ * that door, which is an outage dressed as a policy, so the error says what
632
+ * to write instead. Unknown keys are named rather than ignored, because a
633
+ * misspelled ceiling is a policy that is never enforced while CI stays green.
634
+ */
635
+ function requirePositiveUsd(value: unknown, label: string, source: string): number {
636
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
637
+ throw new ConfigError(
638
+ `"${label}" must be a positive number of dollars. A ceiling of 0 would refuse every call at this door — if that is the intent, turn the door off rather than writing an outage as a policy.`,
639
+ source,
640
+ );
641
+ }
642
+ return value;
643
+ }
644
+
645
+ export function parseLimits(raw: unknown, source: string): LimitsConfig {
646
+ if (!isPlainObject(raw)) throw new ConfigError('"limits" must be an object', source);
647
+ rejectUnknownKeys(raw, CONFIG_LIMITS_KEYS, source, 'limits.');
648
+
649
+ const limits: LimitsConfig = {};
650
+ if (raw.dayUsd !== undefined) {
651
+ limits.dayUsd = requirePositiveUsd(raw.dayUsd, 'limits.dayUsd', source);
652
+ }
653
+ if (raw.sessionUsd !== undefined) {
654
+ limits.sessionUsd = requirePositiveUsd(raw.sessionUsd, 'limits.sessionUsd', source);
655
+ }
656
+ if (raw.byLabel !== undefined) {
657
+ if (!isPlainObject(raw.byLabel)) {
658
+ throw new ConfigError('"limits.byLabel" must be an object', source);
659
+ }
660
+ const byLabel: Record<string, number> = {};
661
+ for (const [label, value] of Object.entries(raw.byLabel)) {
662
+ if (label.trim().length === 0) {
663
+ throw new ConfigError('"limits.byLabel" has an empty label', source);
664
+ }
665
+ byLabel[label] = requirePositiveUsd(value, `limits.byLabel["${label}"]`, source);
666
+ }
667
+ limits.byLabel = byLabel;
668
+ }
669
+ return limits;
670
+ }
671
+
593
672
  /**
594
673
  * Validates the `sources` block: named services, each a non-empty list of
595
674
  * glob patterns. Patterns are not checked against a filesystem here — a
@@ -869,7 +948,7 @@ function parseStore(raw: unknown, source: string): { keepDays?: number } {
869
948
  return out;
870
949
  }
871
950
 
872
- function parseWaive(raw: unknown, source: string): WaiveEntry[] {
951
+ export function parseWaive(raw: unknown, source: string): WaiveEntry[] {
873
952
  if (!Array.isArray(raw)) throw new ConfigError('"waive" must be an array', source);
874
953
  return raw.map((entry, index) => {
875
954
  const at = `waive[${index}]`;
@@ -882,10 +961,16 @@ function parseWaive(raw: unknown, source: string): WaiveEntry[] {
882
961
  }
883
962
  const known =
884
963
  (WAIVABLE_GATES as readonly string[]).includes(gate) ||
885
- (gate.startsWith('byLabel:') && gate.length > 'byLabel:'.length);
964
+ (gate.startsWith('byLabel:') && gate.length > 'byLabel:'.length) ||
965
+ // The limits policy's ceilings, waivable since the 1.66 arc: silencing
966
+ // a limit leaves a record, and the doors carry the waiver in every
967
+ // judgement rather than refusing. The forms mirror the config keys.
968
+ gate === 'limits.dayUsd' ||
969
+ gate === 'limits.sessionUsd' ||
970
+ (gate.startsWith('limits.byLabel:') && gate.length > 'limits.byLabel:'.length);
886
971
  if (!known) {
887
972
  throw new ConfigError(
888
- `"${at}.gate" names no gate: "${gate}". Waivable: ${WAIVABLE_GATES.join(', ')}, or byLabel:<label>.`,
973
+ `"${at}.gate" names no gate: "${gate}". Waivable: ${WAIVABLE_GATES.join(', ')}, byLabel:<label>, limits.dayUsd, limits.sessionUsd, or limits.byLabel:<label>.`,
889
974
  source,
890
975
  );
891
976
  }
@@ -1056,6 +1141,7 @@ export function parseConfig(raw: string, source = CONFIG_FILENAME): TrazumConfig
1056
1141
  if (document.budgets !== undefined) config.budgets = parseBudgets(document.budgets, source);
1057
1142
  if (document.labels !== undefined) config.labels = parseLabels(document.labels, source);
1058
1143
  if (document.spend !== undefined) config.spend = parseSpend(document.spend, source);
1144
+ if (document.limits !== undefined) config.limits = parseLimits(document.limits, source);
1059
1145
  if (document.sources !== undefined) config.sources = parseSources(document.sources, source);
1060
1146
  if (document.store !== undefined) config.store = parseStore(document.store, source);
1061
1147
  if (document.waive !== undefined) config.waive = parseWaive(document.waive, source);
package/src/conform.ts CHANGED
@@ -37,14 +37,21 @@
37
37
  export const CONTRACT_NAMES = [
38
38
  'usage-log',
39
39
  'profile',
40
+ 'fleet',
40
41
  'plan',
41
42
  'verification',
42
43
  'history',
43
44
  'connected',
44
45
  'cost-answer',
46
+ 'spend-guard',
45
47
  'outcome-report',
46
48
  'annual-record',
47
49
  'roll-up',
50
+ 'first-run',
51
+ 'pulse',
52
+ 'rule-yield',
53
+ 'gateway-refusal',
54
+ 'bench',
48
55
  'prompt-draft',
49
56
  ] as const;
50
57
 
@@ -356,6 +363,49 @@ const DOCUMENT_RULES: Record<Exclude<ContractName, 'usage-log'>, FieldRule[]> =
356
363
  (v) => v === null || isObject(v),
357
364
  ),
358
365
  ],
366
+
367
+ /*
368
+ The seven below existed as documented tables for releases before they had
369
+ names here. "All eighteen are contracts" was narrower in the product than
370
+ on the page — a claim `docs/format.md` made that `--contract` could not
371
+ honour — and the 1.65 arc exists to close exactly that gap.
372
+ */
373
+ fleet: [
374
+ rule('bySource', 'an array — one entry per configured source with traffic', isArray),
375
+ rule('rollup', 'an object — the fleet read as one thing, sums inside it', isObject),
376
+ ],
377
+ 'spend-guard': [
378
+ rule('verdict', 'yes, no or cannot-tell — and cannot-tell never becomes yes', (v) => typeof v === 'string'),
379
+ rule('cost', 'the full cost answer, halves and provenance intact', isObject),
380
+ rule('alternatives', 'an array, dearest saving first — a refusal never arrives bare', isArray),
381
+ rule('because', 'one line for a human reading a log — never the only place a fact appears', (v) => typeof v === 'string'),
382
+ ],
383
+ 'first-run': [
384
+ rule('config', 'exactly the keys init could justify, and nothing else', isObject),
385
+ rule('justified', 'an array — one entry per key written, with the observation behind it', isArray),
386
+ rule('declined', 'an array — one entry per key left out, each with a typed why', isArray),
387
+ ],
388
+ pulse: [
389
+ rule('beats', 'an array — one per kind', isArray),
390
+ rule('nowMs', 'the instant the ages were taken against', isNumber),
391
+ rule('stale', 'a boolean — never true for never-run', (v) => typeof v === 'boolean'),
392
+ ],
393
+ 'rule-yield': [
394
+ rule('rules', 'an array — per rule, alone and marginal both', isArray),
395
+ rule('tokensBefore', 'a number', isNumber),
396
+ rule('tokensSaved', 'a number — the whole run minus floor', isNumber),
397
+ rule('floor', 'a number, named rather than folded in', isNumber),
398
+ ],
399
+ 'gateway-refusal': [
400
+ rule('error', 'an object shaped like a provider error, typed so it cannot be mistaken for one', isObject),
401
+ rule('reason', 'budget-exhausted, call-would-cross or cannot-tell-and-closed', (v) => typeof v === 'string'),
402
+ rule('alternatives', 'an array — a refusal never arrives bare', isArray),
403
+ ],
404
+ bench: [
405
+ rule('workloads', 'an array — one per standard workload, in a fixed order', isArray),
406
+ rule('node', 'the runtime the numbers were taken on', (v) => typeof v === 'string'),
407
+ rule('cpus', 'a number', isNumber),
408
+ ],
359
409
  };
360
410
 
361
411
  /**
@@ -467,7 +517,25 @@ function contractOf(doc: Record<string, unknown>): Exclude<ContractName, 'usage-
467
517
  // first would classify every roll-up as a profile, accept it as conformant,
468
518
  // and never apply the two refusals that only a roll-up has to carry.
469
519
  if (Array.isArray(doc.contributors) && Array.isArray(doc.notMerged)) return 'roll-up';
520
+ // Before the profile check, same reasoning as the roll-up: a fleet document
521
+ // is a list of full profile reports, and any looser ordering would classify
522
+ // it by the shape of what it contains rather than what it is.
523
+ if (Array.isArray(doc.bySource) && isObject(doc.rollup)) return 'fleet';
470
524
  if (Array.isArray(doc.byLabelAndModel)) return 'profile';
525
+ // Before the cost answer: a spend-guard verdict *contains* a cost answer,
526
+ // and `verdict` is a string on both documents.
527
+ if (typeof doc.verdict === 'string' && isObject(doc.cost) && Array.isArray(doc.alternatives)) {
528
+ return 'spend-guard';
529
+ }
530
+ if (isObject(doc.config) && Array.isArray(doc.justified) && Array.isArray(doc.declined)) {
531
+ return 'first-run';
532
+ }
533
+ if (Array.isArray(doc.beats) && typeof doc.nowMs === 'number') return 'pulse';
534
+ if (Array.isArray(doc.rules) && typeof doc.floor === 'number') return 'rule-yield';
535
+ if (isObject(doc.error) && typeof doc.reason === 'string' && Array.isArray(doc.alternatives)) {
536
+ return 'gateway-refusal';
537
+ }
538
+ if (Array.isArray(doc.workloads) && typeof doc.node === 'string') return 'bench';
471
539
  if (Array.isArray(doc.missingMonths) && isObject(doc.promises)) return 'annual-record';
472
540
  if (Array.isArray(doc.undeclared) && isObject(doc.coverage)) return 'outcome-report';
473
541
  if (Array.isArray(doc.periods) && Array.isArray(doc.runs)) return 'history';
@@ -478,6 +546,17 @@ function contractOf(doc: Record<string, unknown>): Exclude<ContractName, 'usage-
478
546
  return null;
479
547
  }
480
548
 
549
+ /**
550
+ * The required top-level fields of a named contract, as `conform` enforces
551
+ * them — the single source the JSON Schemas are held to. `schemaVersion` is
552
+ * checked separately by `conform` and is not in this list; the usage log's
553
+ * one requirement is the model, same as the line parser's.
554
+ */
555
+ export function requiredFieldsOf(name: ContractName): string[] {
556
+ if (name === 'usage-log') return ['model'];
557
+ return DOCUMENT_RULES[name].map((fieldRule) => fieldRule.path);
558
+ }
559
+
481
560
  export interface ConformOptions {
482
561
  /** Force a contract instead of detecting one, for an emitter under test. */
483
562
  contract?: ContractName;
package/src/gateway.ts CHANGED
@@ -51,6 +51,9 @@ import type { PlanAssumption } from './plan.js';
51
51
  import { effectivePricing, multipliersFor } from './pricing.js';
52
52
  import type { PricingCatalogue } from './pricing.js';
53
53
  import type { ModelPricing } from './types.js';
54
+ import { judgeLimits, limitSentence, unjudgedSentence } from './judgement.js';
55
+ import type { MeasuredPosition, PolicyJudgement } from './judgement.js';
56
+ import type { LimitsConfig, WaiveEntry } from './config-schema.js';
54
57
 
55
58
  /**
56
59
  * What the operator has decided happens when the gateway cannot judge.
@@ -80,6 +83,12 @@ export interface GatewayCall {
80
83
  maxOutputTokens: number | null;
81
84
  /** The workload, when the caller labelled it. */
82
85
  label: string | null;
86
+ /**
87
+ * The conversation, when the caller identified it — `metadata.trazum_session`,
88
+ * the same seam as the label. Used to judge the per-session ceiling and
89
+ * never recorded, printed, or forwarded anywhere by this module.
90
+ */
91
+ session: string | null;
83
92
  }
84
93
 
85
94
  /** Where the budget stands, as the gateway was told at the last refresh. */
@@ -97,10 +106,17 @@ export type RefuseReason =
97
106
  | 'budget-exhausted'
98
107
  /** This call would take it past, on an estimate of this call. */
99
108
  | 'call-would-cross'
109
+ /** A ceiling in the `limits` policy is over — the judgement names which. */
110
+ | 'limit-over'
100
111
  /** Cannot tell, and the operator chose fail-closed. */
101
112
  | 'cannot-tell-and-closed';
102
113
 
103
- export type CannotTellCause = 'no-budget' | 'nothing-measured' | 'model-unpriced';
114
+ export type CannotTellCause =
115
+ | 'no-budget'
116
+ | 'nothing-measured'
117
+ | 'model-unpriced'
118
+ /** A ceiling in the `limits` policy could not be judged — see `policy`. */
119
+ | 'limit-unjudged';
104
120
 
105
121
  /** A cheaper way to make the same call, named on a refusal. */
106
122
  export interface GatewayAlternative {
@@ -124,6 +140,8 @@ export type GatewayDecision =
124
140
  estimatedUsd: number | null;
125
141
  /** Present when the gateway could not judge and the operator fails open. */
126
142
  unjudged: CannotTellCause | null;
143
+ /** The limits policy, judged for this call — same judge as every door. */
144
+ policy: PolicyJudgement;
127
145
  }
128
146
  | {
129
147
  kind: 'refuse';
@@ -137,6 +155,8 @@ export type GatewayDecision =
137
155
  /** A refusal never arrives bare. Dearest saving first; may be empty. */
138
156
  alternatives: GatewayAlternative[];
139
157
  because: string;
158
+ /** The limits policy, judged for this call — same judge as every door. */
159
+ policy: PolicyJudgement;
140
160
  }
141
161
  | {
142
162
  /**
@@ -154,6 +174,8 @@ export type GatewayDecision =
154
174
  configuredReason: string;
155
175
  estimatedUsd: number | null;
156
176
  markedInStore: true;
177
+ /** The limits policy, judged for this call — same judge as every door. */
178
+ policy: PolicyJudgement;
157
179
  };
158
180
 
159
181
  export interface GatewayPolicy {
@@ -172,6 +194,19 @@ export interface GatewayOptions {
172
194
  catalogue: PricingCatalogue;
173
195
  policy: GatewayPolicy;
174
196
  on?: Date;
197
+ /**
198
+ * The `limits` block and the measured position it is judged against —
199
+ * chapter three of the 1.66 arc. Both optional: a gateway with no limits
200
+ * config behaves exactly as before, and the judgement is `no-policy`.
201
+ *
202
+ * The judging is `judgeLimits`, verbatim — the same function `serve` and
203
+ * `spend_guard` call, which is the whole point: three doors, one judge,
204
+ * no door doing its own arithmetic.
205
+ */
206
+ limits?: LimitsConfig;
207
+ position?: MeasuredPosition;
208
+ /** The config's `waive` list — a silenced limit forwards, on the record. */
209
+ waivers?: readonly WaiveEntry[];
175
210
  }
176
211
 
177
212
  /** What this call costs at a model's rates, or null when it cannot be priced. */
@@ -258,6 +293,63 @@ export function gatewayDecision(
258
293
  const model = catalogue.byId.get(call.model);
259
294
  const estimatedUsd = priceCall(model, call, on);
260
295
 
296
+ /**
297
+ * The limits policy, judged first and by the shared judge — never by
298
+ * arithmetic of this module's own. An `over` here refuses regardless of
299
+ * where the monthly budget stands: a session ceiling exists precisely for
300
+ * the call that is fine by the month and ruinous by the conversation.
301
+ */
302
+ const judged = judgeLimits(
303
+ options.limits,
304
+ options.position ?? { dayUsd: null, sessionUsd: null, labelUsd: null },
305
+ {
306
+ model: call.model,
307
+ inputTokens: call.inputTokens ?? undefined,
308
+ outputTokens: call.maxOutputTokens ?? undefined,
309
+ label: call.label ?? undefined,
310
+ session: call.session ?? undefined,
311
+ },
312
+ { catalogue, on, ...(options.waivers === undefined ? {} : { waivers: options.waivers }) },
313
+ );
314
+
315
+ if (judged.verdict === 'over') {
316
+ // The unwaived crossing — a waived `over` is present in the judgement
317
+ // and deliberately not the one a refusal is written about.
318
+ const over = judged.judgements.find((entry) => entry.verdict === 'over' && entry.waived === null)!;
319
+ /*
320
+ A configured substitution is deliberately NOT consulted here. The
321
+ operator wrote that rule for the budget; rerouting a call because a
322
+ *session* ceiling crossed would spend more money in the very unit that
323
+ is over, under a rule written about something else.
324
+ */
325
+ return {
326
+ kind: 'refuse',
327
+ reason: 'limit-over',
328
+ cause: null,
329
+ restsOn: over.restsOn,
330
+ standing,
331
+ estimatedUsd,
332
+ alternatives: model === undefined ? [] : alternativesFor(model, call, catalogue, on),
333
+ because: limitSentence(over),
334
+ policy: judged,
335
+ };
336
+ }
337
+
338
+ const limitUnjudged = judged.verdict === 'cannot-tell' && judged.reason !== 'no-policy';
339
+ if (limitUnjudged && policy.onCannotTell === 'fail-closed') {
340
+ return {
341
+ kind: 'refuse',
342
+ reason: 'cannot-tell-and-closed',
343
+ cause: 'limit-unjudged',
344
+ restsOn: null,
345
+ standing,
346
+ estimatedUsd,
347
+ alternatives: [],
348
+ because: `${unjudgedSentence(judged)} This gateway is configured to fail closed.`,
349
+ policy: judged,
350
+ };
351
+ }
352
+
261
353
  const cause = whyCannotTell(standing, estimatedUsd);
262
354
  if (cause !== null) {
263
355
  /**
@@ -267,7 +359,7 @@ export function gatewayDecision(
267
359
  * nothing to do with the caller's request.
268
360
  */
269
361
  if (policy.onCannotTell === 'fail-open') {
270
- return { kind: 'forward', estimatedUsd, unjudged: cause };
362
+ return { kind: 'forward', estimatedUsd, unjudged: cause, policy: judged };
271
363
  }
272
364
  return {
273
365
  kind: 'refuse',
@@ -278,6 +370,7 @@ export function gatewayDecision(
278
370
  estimatedUsd,
279
371
  alternatives: [],
280
372
  because: becauseCannotTell(cause),
373
+ policy: judged,
281
374
  };
282
375
  }
283
376
 
@@ -285,13 +378,21 @@ export function gatewayDecision(
285
378
  // it — narrowed here rather than asserted, so a change to `whyCannotTell`
286
379
  // that stopped guaranteeing them fails the build instead of the request.
287
380
  if (standing === null || estimatedUsd === null || model === undefined) {
288
- return { kind: 'forward', estimatedUsd, unjudged: 'no-budget' };
381
+ return {
382
+ kind: 'forward',
383
+ estimatedUsd,
384
+ unjudged: limitUnjudged ? 'limit-unjudged' : 'no-budget',
385
+ policy: judged,
386
+ };
289
387
  }
290
388
 
291
389
  const already = standing.consumedUsd > standing.limitUsd;
292
390
  const wouldCross = standing.consumedUsd + estimatedUsd > standing.limitUsd;
293
391
  if (!already && !wouldCross) {
294
- return { kind: 'forward', estimatedUsd, unjudged: null };
392
+ // The monthly budget holds. A limits ceiling nobody could judge still
393
+ // rides along as `unjudged` under fail-open, so the forward says what it
394
+ // did not check rather than implying everything was.
395
+ return { kind: 'forward', estimatedUsd, unjudged: limitUnjudged ? 'limit-unjudged' : null, policy: judged };
295
396
  }
296
397
 
297
398
  const configured = policy.substitute?.[call.model];
@@ -303,6 +404,7 @@ export function gatewayDecision(
303
404
  configuredReason: configured.reason,
304
405
  estimatedUsd: priceCall(target, call, on),
305
406
  markedInStore: true,
407
+ policy: judged,
306
408
  };
307
409
  }
308
410
 
@@ -319,9 +421,11 @@ export function gatewayDecision(
319
421
  because: already
320
422
  ? 'The budget for this period is already spent, measured.'
321
423
  : 'This call would take the budget past its limit, on an estimate of this call.',
424
+ policy: judged,
322
425
  };
323
426
  }
324
427
 
428
+
325
429
  function becauseCannotTell(cause: CannotTellCause): string {
326
430
  return cause === 'no-budget'
327
431
  ? 'No budget is configured, so there is nothing to judge this against, and this gateway is configured to fail closed.'
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
@@ -166,7 +166,8 @@ export type {
166
166
  GatewayStanding,
167
167
  RefuseReason,
168
168
  } from './gateway.js';
169
- export { conform, CONTRACT_NAMES } from './conform.js';
169
+ export { conform, CONTRACT_NAMES, requiredFieldsOf } from './conform.js';
170
+ export { CONTRACT_SCHEMAS, contractSchema } from './schemas.js';
170
171
  export type {
171
172
  ConformOptions,
172
173
  ConformanceProblem,
@@ -187,6 +188,18 @@ export type {
187
188
  export { waiverHistory, waiverDay, isWaiverUse } from './waivers.js';
188
189
  export type { WaiverHabit, WaiverHistory, WaiverUse, WaiverVerdict } from './waivers.js';
189
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';
190
203
  export { guardSpend } from './guard.js';
191
204
  export type { GuardAlternative, GuardAnswer, GuardRequest, GuardVerdict } from './guard.js';
192
205
  export { proposeInit, HEADLINE_FLOOR_SHARE, MIN_RATE_DAYS } from './init.js';
@@ -538,6 +551,7 @@ export { editDistance, nearestName } from './nearest.js';
538
551
  export {
539
552
  CONFIG_FILENAME,
540
553
  CONFIG_KEYS,
554
+ CONFIG_LIMITS_KEYS,
541
555
  CONFIG_USAGE_KEYS,
542
556
  ConfigError,
543
557
  DEFAULT_EXTENSIONS,
@@ -545,8 +559,10 @@ export {
545
559
  MAX_CONFIG_SEARCH_DEPTH,
546
560
  budgetFor,
547
561
  parseConfig,
562
+ parseLimits,
563
+ parseWaive,
548
564
  } from './config-schema.js';
549
- export type { ResolvedBudget, SpendConfig, TrazumConfig, WaiveEntry } from './config-schema.js';
565
+ export type { LimitsConfig, ResolvedBudget, SpendConfig, TrazumConfig, WaiveEntry } from './config-schema.js';
550
566
  export { WAIVABLE_GATES } from './config-schema.js';
551
567
 
552
568
  export { RULE_LEVELS } from './types.js';