@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/CLAUDE.md +363 -0
- package/README.md +229 -6
- package/package.json +10 -9
- package/src/agent.ts +287 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +142 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +40 -10
- package/src/index.ts +45 -8
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +260 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +94 -35
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +13 -4
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
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
|
|
122
|
-
* scope that cannot cover it, naming that scope, so the fix line points at one knob
|
|
123
|
-
* than four.
|
|
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<
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
/**
|
|
141
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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';
|
package/src/eval-baseline.ts
CHANGED
|
@@ -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';
|