@ultimat3/ai 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/budget.ts CHANGED
@@ -62,10 +62,20 @@ export function estimateSpend(request: GenerateRequest): SpendEstimate {
62
62
  /** Where cross-request counters live. Swap for Redis in a multi-process deployment. */
63
63
  export interface BudgetStore {
64
64
  spent(key: string): Promise<number> | number;
65
+ /** `tokens` may be NEGATIVE: releasing a reservation the call never spent is a credit. */
65
66
  add(key: string, tokens: number): Promise<void> | void;
66
67
  reset(key?: string): Promise<void> | void;
67
68
  }
68
69
 
70
+ /**
71
+ * What `reserve` debited, so `record` can reconcile it against the provider's real counts and
72
+ * `release` can give it back. Held by the caller rather than the ledger because one ledger serves
73
+ * every concurrent call in a request, and each one owns its own reservation.
74
+ */
75
+ export interface BudgetReservation {
76
+ readonly tokens: number;
77
+ }
78
+
69
79
  export class MemoryBudgetStore implements BudgetStore {
70
80
  private readonly counters = new Map<string, number>();
71
81
 
@@ -108,6 +118,23 @@ export class BudgetLedger {
108
118
  private requestTokens = 0;
109
119
  private costMinor = 0;
110
120
  private readonly currency: string;
121
+ /**
122
+ * The ledger this one was `derive`d from, or `undefined` for a scope's root. Set by `derive`
123
+ * rather than taken through `BudgetLedgerInput`, so the chain is always the derivation and a
124
+ * caller cannot build a cycle out of it.
125
+ *
126
+ * Without it a derived ledger reported to nobody: `llm()` derives one per call, so the ambient
127
+ * ledger `gateway.scope()` installed counted zero tokens and zero cost however many calls ran
128
+ * inside it, and its `request` ceiling was re-granted in full to every one of them.
129
+ */
130
+ private parent: BudgetLedger | undefined;
131
+ /**
132
+ * Reservations take turns. Check-then-debit spans an `await store.spent()`, and three callers
133
+ * interleaving inside it is the bypass this ledger exists to close — one event loop, so a
134
+ * promise chain IS the lock. A store shared across PROCESSES needs an atomic increment of its
135
+ * own; this closes the parallelism inside one.
136
+ */
137
+ private turnstile: Promise<unknown> = Promise.resolve();
111
138
 
112
139
  constructor(input: BudgetLedgerInput) {
113
140
  this.limits = input.limits;
@@ -118,12 +145,40 @@ export class BudgetLedger {
118
145
  }
119
146
 
120
147
  /**
121
- * Check an estimate against every applicable scope BEFORE the call. Throws on the first
122
- * scope that cannot cover it, naming that scope, so the fix line points at one knob rather
123
- * than four. Nothing is debited here: `record` does that with the provider's real counts.
148
+ * Check an estimate against every applicable scope BEFORE the call, then DEBIT it. Throws on
149
+ * the first scope that cannot cover it, naming that scope, so the fix line points at one knob
150
+ * rather than four.
151
+ *
152
+ * The debit is what makes the ceiling hold under parallelism. Checking without debiting meant
153
+ * three concurrent calls under one ledger all read `spent() === 0`, all passed, and all three
154
+ * recorded against a ceiling only one of them fitted — an "un-bypassable" org budget bypassed
155
+ * by `Promise.all`. `record` replaces the estimate with the real counts; `release` gives it
156
+ * back when the call never happened.
124
157
  */
125
- async reserve(estimate: SpendEstimate): Promise<void> {
126
- this.assertScope('request', this.limits.request, this.requestTokens, estimate.tokens);
158
+ async reserve(estimate: SpendEstimate): Promise<BudgetReservation> {
159
+ // The ROOT's turnstile, not this ledger's: reservations under one scope take turns even when
160
+ // each call derived its own ledger, which is every `llm()` call. A per-ledger queue serialised
161
+ // nothing once `derive` existed — `Promise.all` of three derived ledgers all read the chain
162
+ // before any of them debited it.
163
+ const gate = this.rootLedger();
164
+ const turn = gate.turnstile.then(() => this.reserveNow(estimate));
165
+ // Chained on a settled shadow: one refusal must not reject every reservation queued behind it.
166
+ gate.turnstile = turn.catch(() => undefined);
167
+ return await turn;
168
+ }
169
+
170
+ private rootLedger(): BudgetLedger {
171
+ let ledger: BudgetLedger = this;
172
+ while (ledger.parent !== undefined) ledger = ledger.parent;
173
+ return ledger;
174
+ }
175
+
176
+ private async reserveNow(estimate: SpendEstimate): Promise<BudgetReservation> {
177
+ // Every ledger in the chain, because each keeps its own counter and the tightest limit is not
178
+ // always the one with the most spent against it.
179
+ for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
180
+ l.assertScope('request', l.limits.request, l.requestTokens, estimate.tokens);
181
+ }
127
182
  // Per call, so nothing is "already spent" against it.
128
183
  this.assertScope('tokensIn', this.limits.tokensIn, 0, estimate.inputTokens);
129
184
  if (this.limits.actor !== undefined && this.actorKey !== undefined) {
@@ -135,6 +190,14 @@ export class BudgetLedger {
135
190
  this.assertScope(`org:${this.orgKey}`, this.limits.org, spent, estimate.tokens);
136
191
  }
137
192
  this.assertCost(estimate.cost);
193
+ await this.debit(estimate.tokens);
194
+ return { tokens: estimate.tokens };
195
+ }
196
+
197
+ /** Give a reservation back: a provider that threw, a stream abandoned before `done`. */
198
+ async release(reservation: BudgetReservation | undefined): Promise<void> {
199
+ if (reservation === undefined) return;
200
+ await this.debit(-reservation.tokens);
138
201
  }
139
202
 
140
203
  /**
@@ -143,7 +206,7 @@ export class BudgetLedger {
143
206
  * an `llm()` action must not be able to widen the actor or org ceiling it runs inside.
144
207
  */
145
208
  derive(limits: BudgetLimits): BudgetLedger {
146
- return new BudgetLedger({
209
+ const child = new BudgetLedger({
147
210
  limits: {
148
211
  ...pick('request', tighterNumber(this.limits.request, limits.request)),
149
212
  ...pick('tokensIn', tighterNumber(this.limits.tokensIn, limits.tokensIn)),
@@ -156,13 +219,37 @@ export class BudgetLedger {
156
219
  store: this.store,
157
220
  currency: this.currency,
158
221
  });
222
+ child.parent = this;
223
+ return child;
224
+ }
225
+
226
+ /**
227
+ * Debit ACTUAL usage after the call, replacing the estimate `reserve` worked from — so only
228
+ * the DIFFERENCE lands here. Called without the reservation it behaves as it always did and
229
+ * debits the full amount, which double-counts a reserved call: pass the handle `reserve`
230
+ * returned.
231
+ */
232
+ async record(usage: TokenUsage, cost: Money, reservation?: BudgetReservation): Promise<void> {
233
+ // Up the chain, because `derive` copies the currency: a scope's reported cost is its own
234
+ // calls plus every call made under a ledger derived from it.
235
+ for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
236
+ l.costMinor += cost.minor;
237
+ }
238
+ await this.debit(totalTokens(usage) - (reservation?.tokens ?? 0));
159
239
  }
160
240
 
161
- /** Debit ACTUAL usage after the call, replacing the estimate `reserve` worked from. */
162
- async record(usage: TokenUsage, cost: Money): Promise<void> {
163
- const tokens = totalTokens(usage);
164
- this.requestTokens += tokens;
165
- this.costMinor += cost.minor;
241
+ /**
242
+ * The one write path. Negative credits a release or an over-estimate back.
243
+ *
244
+ * The in-memory counters walk the chain; the STORE is written once, by the ledger the call was
245
+ * made on. A child shares its parent's store and identity keys, so debiting through the parent
246
+ * as well would bill the actor and the org twice for one call.
247
+ */
248
+ private async debit(tokens: number): Promise<void> {
249
+ if (tokens === 0) return;
250
+ for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
251
+ l.requestTokens += tokens;
252
+ }
166
253
  if (this.actorKey !== undefined) await this.store.add(this.actorKey, tokens);
167
254
  if (this.orgKey !== undefined) await this.store.add(this.orgKey, tokens);
168
255
  }
@@ -0,0 +1,44 @@
1
+ // Single responsibility: what a provider's own failure body says, and the credential that must
2
+ // never survive into it.
3
+ //
4
+ // Shared by both transports rather than copied: every endpoint this package speaks to reports a
5
+ // failure in the same `{ error: { message } }` envelope, and every one of them carries a key in a
6
+ // header a proxy can echo into its own 4xx body. Two copies of either rule is two behaviours to
7
+ // keep in step — and the scrub was on one provider only until 2026-08.
8
+
9
+ import { REDACTED } from '@ultimat3/core';
10
+
11
+ /** Enough of an error body to name the field that was wrong, not enough to fill a log. */
12
+ const DETAIL_LIMIT = 300;
13
+
14
+ /**
15
+ * The provider's own message, when it sent one — it names the offending field, we name the fix.
16
+ * Falls back to the raw text because a proxy or a gateway timeout page is not JSON and is still
17
+ * the best evidence there is.
18
+ */
19
+ export async function detailOf(response: Response): Promise<string> {
20
+ const body = await response.text().catch(() => '');
21
+ try {
22
+ const parsed: unknown = JSON.parse(body);
23
+ if (typeof parsed === 'object' && parsed !== null) {
24
+ const error = (parsed as Record<string, unknown>)['error'];
25
+ if (typeof error === 'object' && error !== null) {
26
+ const message = (error as Record<string, unknown>)['message'];
27
+ if (typeof message === 'string') return message.slice(0, DETAIL_LIMIT);
28
+ }
29
+ }
30
+ } catch {
31
+ // Not JSON — a proxy or a gateway timeout page. The raw text is still the best evidence.
32
+ }
33
+ return body === '' ? response.statusText : body.slice(0, DETAIL_LIMIT);
34
+ }
35
+
36
+ /**
37
+ * Every occurrence of the credential replaced with `[redacted]`. Cheap, and the one leak path:
38
+ * a proxy that echoes the request headers into its own 400 body puts the key in an error, and an
39
+ * error reaches a log index, a span and an HTTP problem document.
40
+ */
41
+ export function withoutKey(detail: string, apiKey: string): string {
42
+ if (apiKey === '') return detail;
43
+ return detail.split(apiKey).join(REDACTED);
44
+ }
package/src/errors.ts CHANGED
@@ -10,9 +10,14 @@ export const AI_ERROR_CODES = [
10
10
  'X_AI_BUDGET_EXCEEDED',
11
11
  'X_AI_GATEWAY_MISSING',
12
12
  'X_AI_PROMPT_VERSION',
13
+ 'X_AI_MODEL_UNKNOWN',
14
+ 'X_AI_PROMPT_SECRET',
13
15
  'X_LLM_OUTPUT_INVALID',
14
16
  'X_LLM_REFUSED',
15
17
  'X_LLM_TRUNCATED',
18
+ 'X_LLM_STREAM_INVALID',
19
+ 'X_AGENT_MAX_TURNS',
20
+ 'X_AGENT_TOOL_UNEXPOSED',
16
21
  'X_EVAL_THRESHOLD',
17
22
  'X_EVAL_BASELINE_MISSING',
18
23
  'X_EVAL_BASELINE_INVALID',
@@ -32,9 +37,14 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
32
37
  X_AI_BUDGET_EXCEEDED: 'a model call would exceed its budget',
33
38
  X_AI_GATEWAY_MISSING: 'an llm() action ran with no gateway installed',
34
39
  X_AI_PROMPT_VERSION: 'prompt version or slots are wrong',
40
+ X_AI_MODEL_UNKNOWN: 'a model id nothing registered in the catalogue',
41
+ X_AI_PROMPT_SECRET: 'a Secret was about to be rendered into a prompt',
35
42
  X_LLM_OUTPUT_INVALID: 'structured output failed its schema on the answer and the repair turn',
36
43
  X_LLM_REFUSED: 'the model declined the request',
37
44
  X_LLM_TRUNCATED: 'the answer hit its maxTokens ceiling before it was complete',
45
+ X_LLM_STREAM_INVALID: 'a streamed answer failed its output schema, and a stream cannot repair',
46
+ X_AGENT_MAX_TURNS: 'an agent hit its turn ceiling without answering',
47
+ X_AGENT_TOOL_UNEXPOSED: 'an agent lists a tool that is not an MCP-exposed action',
38
48
  X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
39
49
  X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
40
50
  X_EVAL_BASELINE_INVALID: 'a recorded baseline cannot be read',
@@ -108,6 +118,63 @@ export class AiGatewayMissingError extends UltimateError {
108
118
  }
109
119
  }
110
120
 
121
+ /**
122
+ * A model id nothing put in the catalogue. This is what replaced the closed `ModelId` union: the
123
+ * union made a company's own model id inexpressible, so the only way past `tsc` was to claim a
124
+ * Claude id — and then `costOf` priced an internal model at Anthropic list rates and the budget
125
+ * ledger reserved against a number belonging to a model nobody ran. A wrong id is still refused;
126
+ * it is refused HERE, at the first read of the spec, instead of by making a right one impossible.
127
+ */
128
+ export class AiModelUnknownError extends UltimateError {
129
+ constructor(input: { model: string; registered: readonly string[] }) {
130
+ super({
131
+ code: 'X_AI_MODEL_UNKNOWN',
132
+ cause:
133
+ `model "${input.model}" has no registered spec, so nothing can price it ` +
134
+ `(registered: ${input.registered.length > 0 ? input.registered.join(', ') : 'none'})`,
135
+ // The `errors` gate blanks every interpolation, so the literal half alone has to name the
136
+ // call. Which ids ARE registered is a fact of the failure, and cause is where facts live.
137
+ fix: 'registerModel({ id, contextWindow, maxOutput, inputPerMillion, outputPerMillion, cacheMinimumTokens, reasoning }) at boot, before configureAi',
138
+ docs: docsFor('X_AI_MODEL_UNKNOWN'),
139
+ meta: { model: input.model },
140
+ });
141
+ }
142
+ }
143
+
144
+ /**
145
+ * A `Secret` reached a prompt variable. `Secret` redacts by VALUE, so this would not have leaked
146
+ * — it would have rendered `[redacted]` into the template and asked the model to reason about it,
147
+ * which is a prompt that reads fine and means something else. The same class of failure as an
148
+ * unfilled `{{slot}}`, and refused for the same reason: loudly, before a token is spent.
149
+ */
150
+ export class AiPromptSecretError extends UltimateError {
151
+ constructor(input: { ref: string; keys: readonly string[] }) {
152
+ super({
153
+ code: 'X_AI_PROMPT_SECRET',
154
+ cause: `prompt "${input.ref}" was given a Secret in vars(): ${input.keys.join(', ')}`,
155
+ fix: 'drop the key from vars() and from the template, or revealSecret(value) in vars() if the model genuinely has to read it',
156
+ docs: docsFor('X_AI_PROMPT_SECRET'),
157
+ });
158
+ }
159
+ }
160
+
161
+ /**
162
+ * A streamed answer did not satisfy its `output` schema. Distinct from `X_LLM_OUTPUT_INVALID`
163
+ * because there is no repair turn to have failed: the consumer has already read the tokens, and
164
+ * replaying a second answer over the top is two answers to one question. So a stream gets one
165
+ * attempt, and the fix is either a looser schema or the non-streaming call that CAN repair.
166
+ */
167
+ export class LlmStreamInvalidError extends UltimateError {
168
+ constructor(input: { prompt: string; issues: string }) {
169
+ super({
170
+ code: 'X_LLM_STREAM_INVALID',
171
+ cause: `streamed answer to prompt "${input.prompt}" failed its output schema: ${input.issues}`,
172
+ fix: 'call the action instead of .stream() when the answer must satisfy a structured schema — a stream has already delivered its tokens and cannot take a repair turn',
173
+ docs: docsFor('X_LLM_STREAM_INVALID'),
174
+ });
175
+ }
176
+ }
177
+
111
178
  /**
112
179
  * The model's answer failed the action's `output` schema on the first turn AND on the repair
113
180
  * turn that followed. Two failures is a disagreement between the prompt and the schema, not a
@@ -126,6 +193,50 @@ export class LlmOutputInvalidError extends UltimateError {
126
193
  }
127
194
  }
128
195
 
196
+ /**
197
+ * An `agent()` ran out of turns with no answer. Never a partial one: the loop's whole contract is
198
+ * that it either satisfies `output` or says it did not, and a half-finished transcript returned as
199
+ * a result is a model's working notes presented as a decision.
200
+ *
201
+ * Reaching the ceiling almost always means the loop has no exit condition — a tool that answers
202
+ * the same thing every turn, or a prompt that never tells the model to finish. Raising the
203
+ * ceiling on that spends more money on the same non-answer, which is why the fix names the prompt
204
+ * before it names the number.
205
+ */
206
+ export class AgentMaxTurnsError extends UltimateError {
207
+ constructor(input: { agent: string; turns: number; calls: number }) {
208
+ super({
209
+ code: 'X_AGENT_MAX_TURNS',
210
+ cause:
211
+ `agent "${input.agent}" used all ${input.turns} turns and ${input.calls} tool calls ` +
212
+ `without calling the respond tool`,
213
+ fix: 'tell the template when to stop and answer through the respond tool, then bump its version — raise maxTurns only once the run demonstrably converges',
214
+ docs: docsFor('X_AGENT_MAX_TURNS'),
215
+ meta: { agent: input.agent, turns: input.turns },
216
+ });
217
+ }
218
+ }
219
+
220
+ /**
221
+ * An `agent()` lists an action that is not an MCP-exposed tool. Refused at DECLARATION rather
222
+ * than filtered at the call, because a silently dropped tool is the worst of both: the
223
+ * declaration reads as if the model can call it, and the model is never offered it.
224
+ *
225
+ * `isMcpExposed` is the one predicate — an in-app agent and an external MCP client see exactly
226
+ * the same catalogue, which is what keeps "there is no second authz system" true of the catalogue
227
+ * too.
228
+ */
229
+ export class AgentToolUnexposedError extends UltimateError {
230
+ constructor(input: { agent: string; tools: readonly string[] }) {
231
+ super({
232
+ code: 'X_AGENT_TOOL_UNEXPOSED',
233
+ cause: `agent "${input.agent}" lists tools no MCP surface exposes: ${input.tools.join(', ')}`,
234
+ fix: 'add mcp: { expose: true } to the action named in cause, or drop it from the agent tools list',
235
+ docs: docsFor('X_AGENT_TOOL_UNEXPOSED'),
236
+ });
237
+ }
238
+ }
239
+
129
240
  /**
130
241
  * The provider's safety classifiers declined the request. A refusal is a 200 with no answer in
131
242
  * it, so it has to become an error HERE or it becomes an empty string somewhere downstream that
@@ -137,8 +248,13 @@ export class LlmRefusedError extends UltimateError {
137
248
  constructor(input: {
138
249
  prompt: string;
139
250
  model: string;
140
- /** A blessed model that is NOT the one that refused — the fix has to be pasteable. */
141
- alternative: string;
251
+ /**
252
+ * A blessed model MORE capable than the one that refused, or `undefined` when the refusal
253
+ * came from the most capable one this build knows. Retrying a refusal on a weaker model is
254
+ * the one retry that cannot help, so the fix line drops the suggestion rather than inventing
255
+ * a downgrade.
256
+ */
257
+ alternative: string | undefined;
142
258
  category: string | undefined;
143
259
  explanation: string | undefined;
144
260
  }) {
@@ -148,7 +264,10 @@ export class LlmRefusedError extends UltimateError {
148
264
  `model "${input.model}" declined prompt "${input.prompt}"` +
149
265
  `${input.category === undefined ? '' : ` (${input.category})`}` +
150
266
  `${input.explanation === undefined ? '' : `: ${input.explanation}`}`,
151
- fix: `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
267
+ fix:
268
+ input.alternative === undefined
269
+ ? `edit the template in definePrompt('${input.prompt}') and bump its version — no blessed model is more capable than '${input.model}'`
270
+ : `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
152
271
  docs: docsFor('X_LLM_REFUSED'),
153
272
  meta: { model: input.model, category: input.category },
154
273
  });
@@ -201,100 +320,15 @@ export class AiPromptRenderError extends UltimateError {
201
320
  }
202
321
  }
203
322
 
204
- /**
205
- * An eval scored further below its recorded baseline than its tolerance allows. The gate is the
206
- * DROP, not an absolute number — a model that got marginally worse everywhere is not the same
207
- * event as a prompt edit that broke one case, and only the second one is anybody's fault.
208
- *
209
- * This is a test failure, not a warning.
210
- */
211
- export class EvalThresholdError extends UltimateError {
212
- constructor(input: {
213
- eval: string;
214
- score: number;
215
- baseline: number;
216
- tolerance: number;
217
- promptVersion: string;
218
- regressed: readonly string[];
219
- }) {
220
- super({
221
- code: 'X_EVAL_THRESHOLD',
222
- cause:
223
- `eval "${input.eval}" scored ${input.score.toFixed(3)} against a recorded baseline of ` +
224
- `${input.baseline.toFixed(3)} (tolerance ${input.tolerance.toFixed(3)}) on prompt ` +
225
- `version ${input.promptVersion}; regressed: ${input.regressed.join(', ')}`,
226
- fix: `x test ${input.eval} to see per-case scores, then fix the prompt — or ULTIMATE_EVAL_RECORD=1 x test eval to accept the new numbers as a reviewed diff`,
227
- docs: docsFor('X_EVAL_THRESHOLD'),
228
- });
229
- }
230
- }
231
-
232
- /**
233
- * An eval declared a baseline that has never been recorded. Not a pass: an eval with nothing to
234
- * compare against gates on nothing, and a step that cannot fail is a step that is not running.
235
- */
236
- export class EvalBaselineMissingError extends UltimateError {
237
- constructor(input: { eval: string; path: string; reason: string; fix?: string }) {
238
- super({
239
- code: 'X_EVAL_BASELINE_MISSING',
240
- cause: `eval "${input.eval}" gates against ${input.path}, which ${input.reason}`,
241
- fix: input.fix ?? `ULTIMATE_EVAL_RECORD=1 x test eval, then commit ${input.path}`,
242
- docs: docsFor('X_EVAL_BASELINE_MISSING'),
243
- });
244
- }
245
- }
246
-
247
- /** A recorded baseline that cannot be read. Never treated as absent — that would erase a gate. */
248
- export class EvalBaselineInvalidError extends UltimateError {
249
- constructor(input: { path: string; problem: string }) {
250
- super({
251
- code: 'X_EVAL_BASELINE_INVALID',
252
- cause: `the recorded baseline ${input.path} ${input.problem}`,
253
- fix: `ULTIMATE_EVAL_RECORD=1 x test eval to re-record ${input.path}`,
254
- docs: docsFor('X_EVAL_BASELINE_INVALID'),
255
- });
256
- }
257
- }
258
-
259
- /**
260
- * A registered prompt that no eval names. An unevaluated prompt is untested code that costs
261
- * money and answers users, so the gate fails on it exactly like an untyped module.
262
- */
263
- export class EvalMissingError extends UltimateError {
264
- constructor(input: { prompt: string; id: string }) {
265
- super({
266
- code: 'X_EVAL_MISSING',
267
- cause: `prompt "${input.prompt}" has no eval`,
268
- fix: `defineEval({ name: '${input.id}', prompt, cases, scorers, tolerance, baseline }) beside the prompt, then ULTIMATE_EVAL_RECORD=1 x test eval`,
269
- docs: docsFor('X_EVAL_MISSING'),
270
- });
271
- }
272
- }
273
-
274
- /**
275
- * The gate ran with baseline recording switched on. Recording makes every eval write the numbers
276
- * it just measured and pass, so a `x verify` that inherited the flag reports green over scores
277
- * nothing compared — and rewrites the committed baselines on its way through, which is the half
278
- * a red step alone would not undo. Recording is a deliberate, reviewable diff, never a gate run.
279
- */
280
- export class EvalRecordingError extends UltimateError {
281
- constructor(input: { env: string }) {
282
- super({
283
- code: 'X_EVAL_RECORDING',
284
- cause: `${input.env} is set, so every eval would re-record its baseline instead of gating on it`,
285
- fix: `env -u ${input.env} x verify`,
286
- docs: docsFor('X_EVAL_RECORDING'),
287
- });
288
- }
289
- }
290
-
291
323
  /** A vector's length does not match the store's declared dimension. */
292
324
  export class VectorDimMismatchError extends UltimateError {
293
325
  constructor(input: { store: string; expected: number; received: number }) {
294
326
  super({
295
327
  code: 'X_VECTOR_DIM_MISMATCH',
296
328
  cause: `store "${input.store}" expects ${input.expected} dimensions, got ${input.received}`,
297
- fix: 'use the same embedder that created the store, or x ai reindex to rebuild it',
329
+ // Not `x ai reindex`: that command is PLANNED and throws, so a fix line naming it sends an
330
+ // operator to a wall. A fix has to be performable today, which here means app code.
331
+ fix: 'use the same embedder that created the store, or re-embed every record at the new width and upsert it',
298
332
  docs: docsFor('X_VECTOR_DIM_MISMATCH'),
299
333
  });
300
334
  }
@@ -330,7 +364,7 @@ export class EmbedderDimMismatchError extends UltimateError {
330
364
  cause:
331
365
  `embedder "${input.embedder}" is declared with ${input.expected} dimensions but the ` +
332
366
  `provider returned ${input.received}`,
333
- fix: `set dimension: ${input.received} on the embedder, then x ai reindex to rebuild the store`,
367
+ fix: `set dimension: ${input.received} on the embedder, then re-embed every record at that width and upsert it`,
334
368
  docs: docsFor('X_VECTOR_DIM_MISMATCH'),
335
369
  });
336
370
  }
@@ -393,13 +427,23 @@ export class AiRequestInvalidError extends UltimateError {
393
427
  export class AiTransportError extends UltimateError {
394
428
  readonly status: number | undefined;
395
429
 
396
- constructor(input: { provider: string; status?: number | undefined; detail: string }) {
430
+ constructor(input: {
431
+ provider: string;
432
+ status?: number | undefined;
433
+ detail: string;
434
+ /**
435
+ * The env var holding THIS provider's key. Passed on the HTTP path, where a 401 has one fix:
436
+ * a hardcoded `ANTHROPIC_API_KEY` was the whole fix line, so an OpenAI-format endpoint
437
+ * rejecting a key sent an operator to set a variable it never reads.
438
+ */
439
+ envVar?: string | undefined;
440
+ }) {
397
441
  super({
398
442
  code: 'X_AI_PROVIDER_UNAVAILABLE',
399
443
  cause: `provider "${input.provider}" ${
400
444
  input.status === undefined ? 'failed' : `returned ${input.status}`
401
445
  }: ${input.detail}`,
402
- fix: fixForStatus(input.status),
446
+ fix: fixForStatus(input.status, input.envVar),
403
447
  docs: docsFor('X_AI_PROVIDER_UNAVAILABLE'),
404
448
  meta: { provider: input.provider, status: input.status },
405
449
  });
@@ -407,9 +451,11 @@ export class AiTransportError extends UltimateError {
407
451
  }
408
452
  }
409
453
 
410
- function fixForStatus(status: number | undefined): string {
454
+ function fixForStatus(status: number | undefined, envVar: string | undefined): string {
411
455
  if (status === 401 || status === 403) {
412
- return 'export ANTHROPIC_API_KEY=<key> with a key that is active for this model';
456
+ return envVar === undefined
457
+ ? 'export the API key env var of the provider named in cause, with a key that is active for this model'
458
+ : `export ${envVar}=<key> with a key that is active for this model`;
413
459
  }
414
460
  if (status === 429) {
415
461
  return 'lower concurrency or raise the provider rate limit; the gateway already backs off';
@@ -9,7 +9,7 @@
9
9
  // not a measurement.
10
10
 
11
11
  import { isAbsolute } from 'node:path';
12
- import { EvalBaselineInvalidError, EvalBaselineMissingError } from './errors';
12
+ import { EvalBaselineInvalidError, EvalBaselineMissingError } from './eval-errors';
13
13
 
14
14
  export interface EvalBaseline {
15
15
  readonly eval: string;
@@ -0,0 +1,98 @@
1
+ // The five X_EVAL_* codes, apart from ./errors only because one file has one job and the catalogue
2
+ // outgrew its ceiling. The codes themselves, their titles and the single `registerErrorCodes` call
3
+ // stay in ./errors — one owner, one registration, one place a duplicate can surface.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+ import type { AiErrorCode } from './errors';
7
+
8
+ const docsFor = (code: AiErrorCode): string => `https://ultimate.dev/errors/${code}`;
9
+
10
+ /**
11
+ * An eval scored further below its recorded baseline than its tolerance allows. The gate is the
12
+ * DROP, not an absolute number — a model that got marginally worse everywhere is not the same
13
+ * event as a prompt edit that broke one case, and only the second one is anybody's fault.
14
+ *
15
+ * This is a test failure, not a warning.
16
+ */
17
+ export class EvalThresholdError extends UltimateError {
18
+ constructor(input: {
19
+ eval: string;
20
+ score: number;
21
+ baseline: number;
22
+ tolerance: number;
23
+ promptVersion: string;
24
+ regressed: readonly string[];
25
+ }) {
26
+ super({
27
+ code: 'X_EVAL_THRESHOLD',
28
+ cause:
29
+ `eval "${input.eval}" scored ${input.score.toFixed(3)} against a recorded baseline of ` +
30
+ `${input.baseline.toFixed(3)} (tolerance ${input.tolerance.toFixed(3)}) on prompt ` +
31
+ `version ${input.promptVersion}; regressed: ${input.regressed.join(', ')}`,
32
+ // `x test eval --filter <name>`, never `x test <name>`: `x test`'s positional is a TestType,
33
+ // so the eval's own name there is `X_CLI_BAD_FLAG` ("not a test type") — a fix line that
34
+ // cannot be run is axiom 4 broken at the one moment it is needed.
35
+ fix: `x test eval --filter ${input.eval} to see per-case scores, then fix the prompt — or ULTIMATE_EVAL_RECORD=1 x test eval to accept the new numbers as a reviewed diff`,
36
+ docs: docsFor('X_EVAL_THRESHOLD'),
37
+ });
38
+ }
39
+ }
40
+
41
+ /**
42
+ * An eval declared a baseline that has never been recorded. Not a pass: an eval with nothing to
43
+ * compare against gates on nothing, and a step that cannot fail is a step that is not running.
44
+ */
45
+ export class EvalBaselineMissingError extends UltimateError {
46
+ constructor(input: { eval: string; path: string; reason: string; fix?: string }) {
47
+ super({
48
+ code: 'X_EVAL_BASELINE_MISSING',
49
+ cause: `eval "${input.eval}" gates against ${input.path}, which ${input.reason}`,
50
+ fix: input.fix ?? `ULTIMATE_EVAL_RECORD=1 x test eval, then commit ${input.path}`,
51
+ docs: docsFor('X_EVAL_BASELINE_MISSING'),
52
+ });
53
+ }
54
+ }
55
+
56
+ /** A recorded baseline that cannot be read. Never treated as absent — that would erase a gate. */
57
+ export class EvalBaselineInvalidError extends UltimateError {
58
+ constructor(input: { path: string; problem: string }) {
59
+ super({
60
+ code: 'X_EVAL_BASELINE_INVALID',
61
+ cause: `the recorded baseline ${input.path} ${input.problem}`,
62
+ fix: `ULTIMATE_EVAL_RECORD=1 x test eval to re-record ${input.path}`,
63
+ docs: docsFor('X_EVAL_BASELINE_INVALID'),
64
+ });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A registered prompt that no eval names. An unevaluated prompt is untested code that costs
70
+ * money and answers users, so the gate fails on it exactly like an untyped module.
71
+ */
72
+ export class EvalMissingError extends UltimateError {
73
+ constructor(input: { prompt: string; id: string }) {
74
+ super({
75
+ code: 'X_EVAL_MISSING',
76
+ cause: `prompt "${input.prompt}" has no eval`,
77
+ fix: `defineEval({ name: '${input.id}', prompt, cases, scorers, tolerance, baseline }) beside the prompt, then ULTIMATE_EVAL_RECORD=1 x test eval`,
78
+ docs: docsFor('X_EVAL_MISSING'),
79
+ });
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The gate ran with baseline recording switched on. Recording makes every eval write the numbers
85
+ * it just measured and pass, so a `x verify` that inherited the flag reports green over scores
86
+ * nothing compared — and rewrites the committed baselines on its way through, which is the half
87
+ * a red step alone would not undo. Recording is a deliberate, reviewable diff, never a gate run.
88
+ */
89
+ export class EvalRecordingError extends UltimateError {
90
+ constructor(input: { env: string }) {
91
+ super({
92
+ code: 'X_EVAL_RECORDING',
93
+ cause: `${input.env} is set, so every eval would re-record its baseline instead of gating on it`,
94
+ fix: `env -u ${input.env} x verify`,
95
+ docs: docsFor('X_EVAL_RECORDING'),
96
+ });
97
+ }
98
+ }
package/src/evals.ts CHANGED
@@ -11,7 +11,6 @@
11
11
  // Every result is filed against a prompt's content hash, so a score is always attributable
12
12
  // to an exact prompt rather than "whatever was in main that day".
13
13
 
14
- import { EvalBaselineMissingError, EvalThresholdError } from './errors';
15
14
  import type { EvalBaseline, Regression } from './eval-baseline';
16
15
  import {
17
16
  baselinePath,
@@ -21,6 +20,7 @@ import {
21
20
  regressionsAgainst,
22
21
  writeBaseline,
23
22
  } from './eval-baseline';
23
+ import { EvalBaselineMissingError, EvalThresholdError } from './eval-errors';
24
24
  import type { Gateway } from './gateway';
25
25
  import type { Prompt, PromptVars } from './prompt';
26
26
  import { describePrompts } from './prompt';