@trazum/core 1.65.0 → 1.67.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 +1 -1
- package/dist/conform.d.ts.map +1 -1
- package/dist/conform.js +13 -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 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- 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-report.d.ts +107 -0
- package/dist/position-report.d.ts.map +1 -0
- package/dist/position-report.js +180 -0
- package/dist/position-report.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.map +1 -1
- package/dist/schemas.js +9 -0
- package/dist/schemas.js.map +1 -1
- 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 +12 -0
- package/src/gateway.ts +108 -4
- package/src/guard.ts +73 -3
- package/src/index.ts +24 -1
- package/src/judgement.ts +346 -0
- package/src/position-report.ts +275 -0
- package/src/position.ts +126 -0
- package/src/schemas.ts +9 -0
- package/src/session-cost.ts +1 -1
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
|
@@ -188,6 +188,26 @@ 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';
|
|
203
|
+
export { positionReport } from './position-report.js';
|
|
204
|
+
export type {
|
|
205
|
+
PositionDistance,
|
|
206
|
+
PositionDocument,
|
|
207
|
+
PositionScope,
|
|
208
|
+
PositionStanding,
|
|
209
|
+
UnmeasuredPosition,
|
|
210
|
+
} from './position-report.js';
|
|
191
211
|
export { guardSpend } from './guard.js';
|
|
192
212
|
export type { GuardAlternative, GuardAnswer, GuardRequest, GuardVerdict } from './guard.js';
|
|
193
213
|
export { proposeInit, HEADLINE_FLOOR_SHARE, MIN_RATE_DAYS } from './init.js';
|
|
@@ -539,6 +559,7 @@ export { editDistance, nearestName } from './nearest.js';
|
|
|
539
559
|
export {
|
|
540
560
|
CONFIG_FILENAME,
|
|
541
561
|
CONFIG_KEYS,
|
|
562
|
+
CONFIG_LIMITS_KEYS,
|
|
542
563
|
CONFIG_USAGE_KEYS,
|
|
543
564
|
ConfigError,
|
|
544
565
|
DEFAULT_EXTENSIONS,
|
|
@@ -546,8 +567,10 @@ export {
|
|
|
546
567
|
MAX_CONFIG_SEARCH_DEPTH,
|
|
547
568
|
budgetFor,
|
|
548
569
|
parseConfig,
|
|
570
|
+
parseLimits,
|
|
571
|
+
parseWaive,
|
|
549
572
|
} from './config-schema.js';
|
|
550
|
-
export type { ResolvedBudget, SpendConfig, TrazumConfig, WaiveEntry } from './config-schema.js';
|
|
573
|
+
export type { LimitsConfig, ResolvedBudget, SpendConfig, TrazumConfig, WaiveEntry } from './config-schema.js';
|
|
551
574
|
export { WAIVABLE_GATES } from './config-schema.js';
|
|
552
575
|
|
|
553
576
|
export { RULE_LEVELS } from './types.js';
|
package/src/judgement.ts
ADDED
|
@@ -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)}`;
|