@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.
- package/dist/config-schema.d.ts +32 -1
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +55 -3
- package/dist/config-schema.js.map +1 -1
- package/dist/conform.d.ts +8 -1
- package/dist/conform.d.ts.map +1 -1
- package/dist/conform.js +82 -0
- package/dist/conform.js.map +1 -1
- package/dist/gateway.d.ts +32 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +64 -3
- package/dist/gateway.js.map +1 -1
- package/dist/guard.d.ts +20 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +41 -3
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/judgement.d.ts +159 -0
- package/dist/judgement.d.ts.map +1 -0
- package/dist/judgement.js +188 -0
- package/dist/judgement.js.map +1 -0
- package/dist/position.d.ts +74 -0
- package/dist/position.d.ts.map +1 -0
- package/dist/position.js +93 -0
- package/dist/position.js.map +1 -0
- package/dist/schemas.d.ts +13 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +144 -0
- package/dist/schemas.js.map +1 -0
- package/dist/session-cost.d.ts +2 -0
- package/dist/session-cost.d.ts.map +1 -1
- package/dist/session-cost.js +1 -1
- package/dist/session-cost.js.map +1 -1
- package/package.json +1 -1
- package/src/config-schema.ts +89 -3
- package/src/conform.ts +79 -0
- package/src/gateway.ts +108 -4
- package/src/guard.ts +73 -3
- package/src/index.ts +18 -2
- package/src/judgement.ts +346 -0
- package/src/position.ts +126 -0
- package/src/schemas.ts +150 -0
- package/src/session-cost.ts +1 -1
package/src/config-schema.ts
CHANGED
|
@@ -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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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';
|