@ultimat3/ai 9.0.0 → 10.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 CHANGED
@@ -54,7 +54,7 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
54
54
  | `llm-stream.ts` | `.stream()`'s plumbing: the sink, the ambient mark, the one-turn drive |
55
55
  | `agent.ts` | `agent()` — the tool loop, declared as an `action` |
56
56
  | `agent-transcript.ts` | what one turn leaves in the transcript: the assistant replay, the tool results, the correction |
57
- | `agent-facts.ts` | `describeAgents()` — the agent registry and the row a manifest publishes |
57
+ | `agent-facts.ts` | `describeAgents()` — the agent registry, and the row a manifest WOULD publish: nothing reads it yet, because `manifest` is tier 4 like this package and the consumer has to be `cli` at tier 5 |
58
58
  | `agent-job.ts` | `agentJob()` — an agent as a real `JobHandle`, composed from `job()` |
59
59
  | `hive.ts` | `hive()` — one action fanned out over many inputs, declared as an `action` |
60
60
  | `hive-result.ts` | `HiveMember` / `HiveResult`, and the SCHEMA built from the member's own `output` |
@@ -120,6 +120,25 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
120
120
  zero leaks the ceiling upward on every release.
121
121
  - Cost is `Money` (integer minor units), rounded **up**. Never a float, never a division
122
122
  that loses a fraction.
123
+ - **`attempt` collects TRANSPORT failures only, `As of 2026-08-23`.** A caught value that is an
124
+ `UltimateError` whose code is not `X_AI_PROVIDER_UNAVAILABLE` is rethrown verbatim, on the spot:
125
+ those are raised locally, before the socket opens — a missing credential, a control the model does
126
+ not accept — so the same rejection is waiting on every provider and every attempt, which is
127
+ exactly the reason a 400 is not retried. Collecting one flattened `X_AI_KEY_MISSING` and its
128
+ runnable `export ANTHROPIC_API_KEY=…` into "no provider could serve model X", and made
129
+ `generate()` and `stream()` answer the SAME misconfiguration two different ways — `stream()`
130
+ never routes through `attempt`. `AiTransportError` carries `X_AI_PROVIDER_UNAVAILABLE`, so
131
+ "provider one 503'd, provider two timed out" still collects across candidates unchanged.
132
+ **Breaking**: a caller catching `X_AI_PROVIDER_UNAVAILABLE` today starts seeing the real code.
133
+ The read is `stringField(error, 'code')`, never `error.code` — the value came from an app's
134
+ `Provider` and a property read on it is a getter call or a `Proxy` trap.
135
+ - **`Gateway.stream` routes BEFORE it reserves.** `providerFor` throws for a registered model no
136
+ configured provider serves, and it used to run between `reserve()` and the `try` that releases —
137
+ so the estimate was debited, landed on the `BudgetStore` under `actorKey`/`orgKey`, and nothing
138
+ credited it back. `MemoryBudgetStore` is per process and never expires, so five refused streams
139
+ spent an org's whole ceiling with nothing ever sent, and every later call in that process was
140
+ `X_AI_BUDGET_EXCEEDED` forever. `settled = true` moved AFTER `await ledger.record(...)` for the
141
+ same reason: a store that throws at `done` left the reservation both unreleased and half-recorded.
123
142
  - **The gateway's two reads of a provider's throw are total.** A `Provider` is the APP's object, so
124
143
  the value it rejects with is one the framework did not build: `isRetryable` indexes it (a getter,
125
144
  or a `Proxy` trap) and fails closed if the read raises, and the failure line goes through core's
@@ -424,7 +443,12 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
424
443
  - An aborted `ctx` unwinds the whole hive with `X_ABORTED`, which is a DIFFERENT event from
425
444
  `onMemberError: 'abort'`: the latter is a completed run with a partial harvest worth returning,
426
445
  the former has nobody left to hand it to.
427
- - **`describeAgents()` publishes what an `ActionDescriptor` cannot.** An agent projects to the same
446
+ - **`describeAgents()` is OFFERED, not published, `As of 2026-08-23`** no reader exists anywhere
447
+ in the tree, and neither does one for `registeredModels()`. Both are tier-4 exports whose only
448
+ legal consumer is `@ultimat3/cli` (tier 5); `@ultimat3/manifest` is tier 4, so the obvious import
449
+ is a sideways edge `bun run boundaries` refuses. Their doc comments say so now, because a comment
450
+ claiming a consumer that does not exist is the documentation axiom 3 calls no rule at all.
451
+ - **`describeAgents()` describes what an `ActionDescriptor` cannot.** An agent projects to the same
428
452
  descriptor as any other action, and that descriptor knows nothing about turns, tools, models or
429
453
  prompt hashes — so "how far can this loop and what may it call" had no answer outside the source.
430
454
  Same shape as `describePrompts()` / `describeEvals()`, and deliberately NOT a new
package/README.md CHANGED
@@ -79,6 +79,7 @@ default store at `replicas: 6` is six ledgers of twenty million, which is a budg
79
79
  | A control nobody asked for is **omitted**, never defaulted | a default sent as a request is indistinguishable on the wire from one that was declared |
80
80
  | A refusal is `X_LLM_REFUSED`, not a schema failure | it is a 200 with no answer in it, and a repair turn buys the same refusal again |
81
81
  | The refusal's `alternative` is only ever a **more capable** model | registration order is most-capable-first and `moreCapableThan` walks it upward; retrying a refusal on a weaker model is the one retry that cannot help, so an unbeatable model gets no suggestion at all |
82
+ | A local refusal is never collected into `X_AI_PROVIDER_UNAVAILABLE` | `X_AI_KEY_MISSING` and `X_AI_REQUEST_INVALID` are raised before the request leaves; retrying them across providers burns attempts on the same answer and discards the runnable `fix:`. `generate()` and `stream()` therefore answer the same misconfiguration the same way |
82
83
  | Fallback is across **providers serving one model**, never across models | a silent model swap changes what answered, what it cost and which eval baseline the answer belongs to; the gateway stamps `result.provider`, and `llm()` puts it on the span as `llm.provider`, so the fallback that does exist is never silent |
83
84
  | The repair turn replays the tool call's arguments, never an empty `text` | an answer through the `respond` tool leaves `text` empty, and an empty text block is a 400 — the repair came back as `X_AI_PROVIDER_UNAVAILABLE` |
84
85
  | `reserve()` **debits** the estimate and takes a turn | three concurrent calls otherwise read the same `spent()`, all pass, and all three record against a ceiling only one of them fitted; `record` reconciles and `release` gives it back |
@@ -451,6 +452,12 @@ describeAgents();
451
452
  // mcp: true }]
452
453
  ```
453
454
 
455
+ **Offered, not yet published, `As of 2026-08-23`.** Nothing in the framework reads it: `describeAgents()`
456
+ lives at tier 4, `@ultimat3/manifest` is tier 4 too, and a sideways import is a build error — so the
457
+ consumer has to be `@ultimat3/cli` at tier 5, and that wiring has not landed. Call it yourself and
458
+ the rows are real; wait for `x manifest` to carry them and you will wait. Same for
459
+ `registeredModels()`.
460
+
454
461
  An agent projects to an `ActionDescriptor` like any other action, and that descriptor knows nothing
455
462
  about turns or tools — so "how far can this loop, and what may it call" had no answer outside the
456
463
  source. Names are read when you ask, not when the agent was declared: `registerAction` stamps them
@@ -625,7 +632,7 @@ actor comes from the request context, never from the model.
625
632
 
626
633
  | Code | Meaning |
627
634
  |---|---|
628
- | `X_AI_PROVIDER_UNAVAILABLE` | every provider for the model failed; lists what each said |
635
+ | `X_AI_PROVIDER_UNAVAILABLE` | every provider for the model was unreachable; lists what each said. A TRANSPORT failure only — a coded refusal raised before the socket opens (`X_AI_KEY_MISSING`, `X_AI_REQUEST_INVALID`) reaches the caller as itself, `As of 2026-08-23`, because the same rejection waits on every provider and every attempt and its `fix:` is the whole point of it |
629
636
  | `X_AI_BUDGET_EXCEEDED` | refused pre-flight, naming the scope and what remains |
630
637
  | `X_AI_GATEWAY_MISSING` | an `llm()` action ran before `configureAi` |
631
638
  | `X_AI_PROMPT_VERSION` | version drift, or a render missing a declared variable |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "9.0.0",
3
+ "version": "10.0.0",
4
4
  "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,14 +32,14 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "9.0.0",
36
- "@ultimat3/cache": "9.0.0",
37
- "@ultimat3/core": "9.0.0",
38
- "@ultimat3/db": "9.0.0",
39
- "@ultimat3/jobs": "9.0.0",
40
- "@ultimat3/money": "9.0.0",
41
- "@ultimat3/policy": "9.0.0",
42
- "@ultimat3/schema": "9.0.0",
43
- "@ultimat3/time": "9.0.0"
35
+ "@ultimat3/action": "10.0.0",
36
+ "@ultimat3/cache": "10.0.0",
37
+ "@ultimat3/core": "10.0.0",
38
+ "@ultimat3/db": "10.0.0",
39
+ "@ultimat3/jobs": "10.0.0",
40
+ "@ultimat3/money": "10.0.0",
41
+ "@ultimat3/policy": "10.0.0",
42
+ "@ultimat3/schema": "10.0.0",
43
+ "@ultimat3/time": "10.0.0"
44
44
  }
45
45
  }
@@ -51,6 +51,11 @@ export function registerAgentFact(target: AnyAction, facts: () => Omit<AgentFact
51
51
  /**
52
52
  * Every registered agent, by name.
53
53
  *
54
+ * OFFERED, not yet published. Nothing outside this package reads it: `@ultimat3/manifest` is tier 4
55
+ * and so is this package, so a direct import is a sideways edge the boundary check refuses, and the
56
+ * consumer has to be `@ultimat3/cli` at tier 5. Until that wiring lands, "the row a manifest
57
+ * publishes" describes the SHAPE, never a row any manifest carries.
58
+ *
54
59
  * An agent still carrying no name is left out, and that is not a silent drop: a name is stamped by
55
60
  * `registerAction`, an action without one reaches no route, no tool catalogue and no queue, so
56
61
  * there is no capability for a row to describe. `named()` builds a TWIN rather than naming in
package/src/errors.ts CHANGED
@@ -65,7 +65,12 @@ registerErrorCodes(
65
65
  Object.fromEntries(Object.entries(AI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
66
66
  );
67
67
 
68
- const docsFor = (code: AiErrorCode): string => `https://ultimate.dev/errors/${code}`;
68
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
69
+ // which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
70
+ // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
71
+ // which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
72
+ // answered 404, host included, on every error it has ever thrown; restating the replacement here
73
+ // would be the same constant in eight places waiting to drift again.
69
74
 
70
75
  /** Every configured provider refused or errored. Carries what each one said. */
71
76
  export class AiProviderUnavailableError extends UltimateError {
@@ -74,7 +79,6 @@ export class AiProviderUnavailableError extends UltimateError {
74
79
  code: 'X_AI_PROVIDER_UNAVAILABLE',
75
80
  cause: `no provider could serve model "${input.model}" (${input.attempts.join(' | ')})`,
76
81
  fix: 'check ai.providers in app.config.ts and the provider API key env var',
77
- docs: docsFor('X_AI_PROVIDER_UNAVAILABLE'),
78
82
  });
79
83
  }
80
84
  }
@@ -99,7 +103,6 @@ export class AiBudgetExceededError extends UltimateError {
99
103
  `request needs ${input.requested} ${input.unit ?? 'tokens'} but scope ` +
100
104
  `"${input.scope}" has ${input.remaining} of ${input.limit} left`,
101
105
  fix: `raise ai.budget for "${input.scope}" in app.config.ts, or shorten the prompt`,
102
- docs: docsFor('X_AI_BUDGET_EXCEEDED'),
103
106
  });
104
107
  }
105
108
  }
@@ -115,7 +118,6 @@ export class AiGatewayMissingError extends UltimateError {
115
118
  code: 'X_AI_GATEWAY_MISSING',
116
119
  cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured`,
117
120
  fix: 'configureAi({ gateway: createGateway({ providers: [new AnthropicProvider()] }) }) at boot',
118
- docs: docsFor('X_AI_GATEWAY_MISSING'),
119
121
  });
120
122
  }
121
123
  }
@@ -137,7 +139,6 @@ export class AiModelUnknownError extends UltimateError {
137
139
  // The `errors` gate blanks every interpolation, so the literal half alone has to name the
138
140
  // call. Which ids ARE registered is a fact of the failure, and cause is where facts live.
139
141
  fix: 'registerModel({ id, contextWindow, maxOutput, inputPerMillion, outputPerMillion, cacheMinimumTokens, reasoning }) at boot, before configureAi',
140
- docs: docsFor('X_AI_MODEL_UNKNOWN'),
141
142
  meta: { model: input.model },
142
143
  });
143
144
  }
@@ -155,7 +156,6 @@ export class AiPromptSecretError extends UltimateError {
155
156
  code: 'X_AI_PROMPT_SECRET',
156
157
  cause: `prompt "${input.ref}" was given a Secret in vars(): ${input.keys.join(', ')}`,
157
158
  fix: 'drop the key from vars() and from the template, or revealSecret(value) in vars() if the model genuinely has to read it',
158
- docs: docsFor('X_AI_PROMPT_SECRET'),
159
159
  });
160
160
  }
161
161
  }
@@ -172,7 +172,6 @@ export class LlmStreamInvalidError extends UltimateError {
172
172
  code: 'X_LLM_STREAM_INVALID',
173
173
  cause: `streamed answer to prompt "${input.prompt}" failed its output schema: ${input.issues}`,
174
174
  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',
175
- docs: docsFor('X_LLM_STREAM_INVALID'),
176
175
  });
177
176
  }
178
177
  }
@@ -190,7 +189,6 @@ export class LlmOutputInvalidError extends UltimateError {
190
189
  `prompt "${input.prompt}" returned output failing its schema on all ` +
191
190
  `${input.attempts} attempts: ${input.issues}`,
192
191
  fix: 'describe the output shape in the prompt template and bump its version, or widen `output` in the llm() declaration',
193
- docs: docsFor('X_LLM_OUTPUT_INVALID'),
194
192
  });
195
193
  }
196
194
  }
@@ -213,7 +211,6 @@ export class AgentMaxTurnsError extends UltimateError {
213
211
  `agent "${input.agent}" used all ${input.turns} turns and ${input.calls} tool calls ` +
214
212
  `without calling the respond tool`,
215
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',
216
- docs: docsFor('X_AGENT_MAX_TURNS'),
217
214
  meta: { agent: input.agent, turns: input.turns },
218
215
  });
219
216
  }
@@ -234,7 +231,6 @@ export class AgentToolUnexposedError extends UltimateError {
234
231
  code: 'X_AGENT_TOOL_UNEXPOSED',
235
232
  cause: `agent "${input.agent}" lists tools no MCP surface exposes: ${input.tools.join(', ')}`,
236
233
  fix: 'add mcp: { expose: true } to the action named in cause, or drop it from the agent tools list',
237
- docs: docsFor('X_AGENT_TOOL_UNEXPOSED'),
238
234
  });
239
235
  }
240
236
  }
@@ -270,7 +266,6 @@ export class LlmRefusedError extends UltimateError {
270
266
  input.alternative === undefined
271
267
  ? `edit the template in definePrompt('${input.prompt}') and bump its version — no blessed model is more capable than '${input.model}'`
272
268
  : `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
273
- docs: docsFor('X_LLM_REFUSED'),
274
269
  meta: { model: input.model, category: input.category },
275
270
  });
276
271
  }
@@ -287,7 +282,6 @@ export class LlmTruncatedError extends UltimateError {
287
282
  code: 'X_LLM_TRUNCATED',
288
283
  cause: `prompt "${input.prompt}" was cut off at its ${input.maxTokens}-token ceiling`,
289
284
  fix: `set maxTokens: ${input.maxTokens * 2} on the llm() declaration, or drop fields from its output schema`,
290
- docs: docsFor('X_LLM_TRUNCATED'),
291
285
  });
292
286
  }
293
287
  }
@@ -301,7 +295,6 @@ export class AiPromptVersionError extends UltimateError {
301
295
  input.available.length > 0 ? input.available.join(', ') : 'none'
302
296
  })`,
303
297
  fix: 'bump the version in definePrompt after editing the template, then x manifest',
304
- docs: docsFor('X_AI_PROMPT_VERSION'),
305
298
  });
306
299
  }
307
300
  }
@@ -317,7 +310,6 @@ export class AiPromptRenderError extends UltimateError {
317
310
  code: 'X_AI_PROMPT_VERSION',
318
311
  cause: `prompt "${input.ref}" was rendered without: ${input.missing.join(', ')}`,
319
312
  fix: 'pass every {{variable}} the template declares, or remove it from the template',
320
- docs: docsFor('X_AI_PROMPT_VERSION'),
321
313
  });
322
314
  }
323
315
  }
@@ -331,7 +323,6 @@ export class VectorDimMismatchError extends UltimateError {
331
323
  // Not `x ai reindex`: that command is PLANNED and throws, so a fix line naming it sends an
332
324
  // operator to a wall. A fix has to be performable today, which here means app code.
333
325
  fix: 'use the same embedder that created the store, or re-embed every record at the new width and upsert it',
334
- docs: docsFor('X_VECTOR_DIM_MISMATCH'),
335
326
  });
336
327
  }
337
328
  }
@@ -349,7 +340,6 @@ export class VectorScopeWidenedError extends UltimateError {
349
340
  `store "${input.store}" is bound to tenant "${input.held}" and cannot be re-scoped ` +
350
341
  `to "${input.requested}"`,
351
342
  fix: `derive from the unscoped store instead: vectorStore.scoped({ tenant: '${input.requested}' })`,
352
- docs: docsFor('X_VECTOR_SCOPE_WIDENED'),
353
343
  });
354
344
  }
355
345
  }
@@ -367,7 +357,6 @@ export class EmbedderDimMismatchError extends UltimateError {
367
357
  `embedder "${input.embedder}" is declared with ${input.expected} dimensions but the ` +
368
358
  `provider returned ${input.received}`,
369
359
  fix: `set dimension: ${input.received} on the embedder, then re-embed every record at that width and upsert it`,
370
- docs: docsFor('X_VECTOR_DIM_MISMATCH'),
371
360
  });
372
361
  }
373
362
  }
@@ -386,7 +375,6 @@ export class AiEmbedderInvalidError extends UltimateError {
386
375
  // interpolation — so the literal half alone has to name the call. Which embedder broke the
387
376
  // invariant is a fact of the failure, and the cause and `meta` are where facts live.
388
377
  fix: 'return one vector per input text from embed(), in the order the texts arrived',
389
- docs: docsFor('X_AI_EMBEDDER_INVALID'),
390
378
  meta: { embedder: input.embedder },
391
379
  });
392
380
  }
@@ -399,7 +387,6 @@ export class AiKeyMissingError extends UltimateError {
399
387
  code: 'X_AI_KEY_MISSING',
400
388
  cause: `provider "${input.provider}" has no API key: ${input.envVar} is unset and none was passed to its constructor`,
401
389
  fix: `export ${input.envVar}=<key>, or pass { apiKey } when constructing the provider`,
402
- docs: docsFor('X_AI_KEY_MISSING'),
403
390
  meta: { provider: input.provider, envVar: input.envVar },
404
391
  });
405
392
  }
@@ -416,7 +403,6 @@ export class AiRequestInvalidError extends UltimateError {
416
403
  code: 'X_AI_REQUEST_INVALID',
417
404
  cause: input.detail,
418
405
  fix: input.fix,
419
- docs: docsFor('X_AI_REQUEST_INVALID'),
420
406
  });
421
407
  }
422
408
  }
@@ -446,7 +432,6 @@ export class AiTransportError extends UltimateError {
446
432
  input.status === undefined ? 'failed' : `returned ${input.status}`
447
433
  }: ${input.detail}`,
448
434
  fix: fixForStatus(input.status, input.envVar),
449
- docs: docsFor('X_AI_PROVIDER_UNAVAILABLE'),
450
435
  meta: { provider: input.provider, status: input.status },
451
436
  });
452
437
  this.status = input.status;
@@ -3,9 +3,13 @@
3
3
  // stay in ./errors — one owner, one registration, one place a duplicate can surface.
4
4
 
5
5
  import { UltimateError } from '@ultimat3/core';
6
- import type { AiErrorCode } from './errors';
7
6
 
8
- const docsFor = (code: AiErrorCode): string => `https://ultimate.dev/errors/${code}`;
7
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
8
+ // which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
9
+ // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
10
+ // which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
11
+ // answered 404, host included, on every error it has ever thrown; restating the replacement here
12
+ // would be the same constant in eight places waiting to drift again.
9
13
 
10
14
  /**
11
15
  * An eval scored further below its recorded baseline than its tolerance allows. The gate is the
@@ -33,7 +37,6 @@ export class EvalThresholdError extends UltimateError {
33
37
  // so the eval's own name there is `X_CLI_BAD_FLAG` ("not a test type") — a fix line that
34
38
  // cannot be run is axiom 4 broken at the one moment it is needed.
35
39
  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
40
  });
38
41
  }
39
42
  }
@@ -48,7 +51,6 @@ export class EvalBaselineMissingError extends UltimateError {
48
51
  code: 'X_EVAL_BASELINE_MISSING',
49
52
  cause: `eval "${input.eval}" gates against ${input.path}, which ${input.reason}`,
50
53
  fix: input.fix ?? `ULTIMATE_EVAL_RECORD=1 x test eval, then commit ${input.path}`,
51
- docs: docsFor('X_EVAL_BASELINE_MISSING'),
52
54
  });
53
55
  }
54
56
  }
@@ -60,7 +62,6 @@ export class EvalBaselineInvalidError extends UltimateError {
60
62
  code: 'X_EVAL_BASELINE_INVALID',
61
63
  cause: `the recorded baseline ${input.path} ${input.problem}`,
62
64
  fix: `ULTIMATE_EVAL_RECORD=1 x test eval to re-record ${input.path}`,
63
- docs: docsFor('X_EVAL_BASELINE_INVALID'),
64
65
  });
65
66
  }
66
67
  }
@@ -75,7 +76,6 @@ export class EvalMissingError extends UltimateError {
75
76
  code: 'X_EVAL_MISSING',
76
77
  cause: `prompt "${input.prompt}" has no eval`,
77
78
  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
79
  });
80
80
  }
81
81
  }
@@ -92,7 +92,6 @@ export class EvalRecordingError extends UltimateError {
92
92
  code: 'X_EVAL_RECORDING',
93
93
  cause: `${input.env} is set, so every eval would re-record its baseline instead of gating on it`,
94
94
  fix: `env -u ${input.env} x verify`,
95
- docs: docsFor('X_EVAL_RECORDING'),
96
95
  });
97
96
  }
98
97
  }
package/src/evals.ts CHANGED
@@ -183,6 +183,10 @@ async function runEval<V extends PromptVars>(
183
183
  ...(input.prompt.system !== undefined ? { system: input.prompt.system } : {}),
184
184
  ...(input.prompt.model !== undefined ? { model: input.prompt.model } : {}),
185
185
  ...(input.prompt.effort !== undefined ? { effort: input.prompt.effort } : {}),
186
+ // `thinking` too, and not because it is tidy: `contentHash` covers it, so the baseline is
187
+ // filed under a hash describing a configuration the measurement did not use. `llm()` and
188
+ // `agent()` have always sent both.
189
+ ...(input.prompt.thinking !== undefined ? { thinking: input.prompt.thinking } : {}),
186
190
  });
187
191
  const perScorer: Record<string, number> = {};
188
192
  for (const scorer of input.scorers) {
package/src/gateway.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // budgeted, and cost-accounted in integer minor units. Everything an app does with a model
5
5
  // goes through here, so budgets and accounting cannot be bypassed by a stray fetch.
6
6
 
7
- import { renderThrowable } from '@ultimat3/core';
7
+ import { isUltimateError, renderThrowable, stringField } from '@ultimat3/core';
8
8
  import type { Money } from '@ultimat3/money';
9
9
  import type { BudgetLimits, BudgetStore } from './budget';
10
10
  import { BudgetLedger, currentBudget, estimateSpend, withBudget } from './budget';
@@ -32,6 +32,9 @@ export interface RetryPolicy {
32
32
 
33
33
  export const DEFAULT_RETRY: RetryPolicy = { attempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 };
34
34
 
35
+ /** The one code `attempt` may collect into. Every other coded refusal is the caller's answer. */
36
+ const PROVIDER_UNAVAILABLE = 'X_AI_PROVIDER_UNAVAILABLE';
37
+
35
38
  export interface CreateGatewayInput {
36
39
  /** Tried in order for a given model. First provider that lists the model wins. */
37
40
  readonly providers: readonly Provider[];
@@ -124,9 +127,13 @@ class GatewayImpl implements Gateway {
124
127
  async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
125
128
  const model = request.model ?? this.config.defaultModel ?? DEFAULT_MODEL;
126
129
  const resolved: GenerateRequest = { ...request, model };
127
- const ledger = currentBudget();
128
- const reservation = await ledger?.reserve(estimateSpend(resolved));
129
130
 
131
+ // Routed BEFORE the reservation, not after it. `providerFor` throws for a registered model no
132
+ // configured provider serves — an ordinary boot misconfiguration — and a debit taken first has
133
+ // nothing to credit it back: the throw is outside the `finally` below, and the estimate has
134
+ // already landed on the `BudgetStore`, which is per process and never expires. Five refused
135
+ // streams and the org's ceiling is gone for the life of the process, with nothing ever sent.
136
+ //
130
137
  // The streaming path does not retry AT ALL — not mid-flight, and not on the handshake either.
131
138
  // Mid-flight is the obvious one: the consumer has already been handed tokens, and replaying
132
139
  // from the top would duplicate them. The handshake is not separable from it here, because
@@ -136,6 +143,9 @@ class GatewayImpl implements Gateway {
136
143
  // its fallback across providers belong to `generate` alone. A caller that wants either uses
137
144
  // `generate`, or reconnects itself and knows what it has already shown.
138
145
  const provider = this.providerFor(model);
146
+
147
+ const ledger = currentBudget();
148
+ const reservation = await ledger?.reserve(estimateSpend(resolved));
139
149
  let settled = false;
140
150
  try {
141
151
  for await (const chunk of provider.stream(resolved)) {
@@ -143,8 +153,10 @@ class GatewayImpl implements Gateway {
143
153
  yield chunk;
144
154
  continue;
145
155
  }
146
- settled = true;
156
+ // Settled only once `record` has LANDED. Marking it first left a store that threw here
157
+ // holding the reservation and half the record — the `finally` saw a settled stream.
147
158
  await ledger?.record(chunk.result.usage, chunk.result.cost, reservation);
159
+ settled = true;
148
160
  yield { type: 'done', result: { ...chunk.result, provider: provider.name } };
149
161
  }
150
162
  } finally {
@@ -190,6 +202,19 @@ class GatewayImpl implements Gateway {
190
202
  try {
191
203
  return { ...(await call(provider)), provider: provider.name };
192
204
  } catch (error) {
205
+ // A coded refusal that is NOT `X_AI_PROVIDER_UNAVAILABLE` reaches the caller verbatim.
206
+ // Those are raised locally, before the socket opens — a missing credential, a control
207
+ // the model does not accept — so the same rejection is waiting on every provider and
208
+ // every attempt, which is the reason a 400 is not retried three lines below. Collecting
209
+ // one into `X_AI_PROVIDER_UNAVAILABLE` discards its runnable `fix:` and answers the same
210
+ // failure a different way from `stream`, which does not route through here at all.
211
+ // A transport failure keeps the old path: `AiTransportError` IS
212
+ // `X_AI_PROVIDER_UNAVAILABLE`, so "provider one 503'd, provider two timed out" still
213
+ // collects across the candidates. `stringField` rather than `error.code`, because the
214
+ // value came from an app's `Provider` and a property read on it can trap.
215
+ if (isUltimateError(error) && stringField(error, 'code') !== PROVIDER_UNAVAILABLE) {
216
+ throw error;
217
+ }
193
218
  // `renderThrowable`, never `error.message` or `String(error)`: this line becomes the
194
219
  // `cause` of `X_AI_PROVIDER_UNAVAILABLE`, and a renderer that throws replaces the coded
195
220
  // refusal with a `TypeError` nothing downstream can catch by code. It bounds the text
@@ -4,9 +4,13 @@
4
4
  // duplicate can surface.
5
5
 
6
6
  import { UltimateError } from '@ultimat3/core';
7
- import type { AiErrorCode } from './errors';
8
7
 
9
- const docsFor = (code: AiErrorCode): string => `https://ultimate.dev/errors/${code}`;
8
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
9
+ // which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
10
+ // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
11
+ // which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
12
+ // answered 404, host included, on every error it has ever thrown; restating the replacement here
13
+ // would be the same constant in eight places waiting to drift again.
10
14
 
11
15
  /**
12
16
  * `split` handed back an empty list, so the hive fanned out to nobody and would have reported a
@@ -23,7 +27,6 @@ export class HiveEmptyError extends UltimateError {
23
27
  code: 'X_HIVE_EMPTY',
24
28
  cause: `the hive over "${input.member}" split into 0 members, so no member ran`,
25
29
  fix: `return at least one member input from the hive's split() over "${input.member}", or skip the hive call when the source is empty — a hive reporting 0 ok and 0 failed cannot be told apart from one whose query returned no rows`,
26
- docs: docsFor('X_HIVE_EMPTY'),
27
30
  meta: { member: input.member },
28
31
  });
29
32
  }
package/src/models.ts CHANGED
@@ -128,7 +128,14 @@ export function modelIds(): readonly ModelId[] {
128
128
  return [...registry.keys()];
129
129
  }
130
130
 
131
- /** Every registered spec, in ladder order. Consumed by `x manifest` and by doctor-style checks. */
131
+ /**
132
+ * Every registered spec, in ladder order.
133
+ *
134
+ * OFFERED, not yet published: nothing in the tree reads it. `@ultimat3/manifest` is tier 4 and so
135
+ * is this package, so the consumer has to be `@ultimat3/cli` (tier 5) — a direct import would be a
136
+ * sideways edge the boundary check refuses. The doc claimed `x manifest` consumed it, which is
137
+ * exactly the kind of statement axiom 3 says is not a rule.
138
+ */
132
139
  export function registeredModels(): readonly ModelSpec[] {
133
140
  return [...registry.values()];
134
141
  }
@@ -113,8 +113,13 @@ class OpenAiProvider implements Provider {
113
113
  * open socket past the HTTP timeout and fails after the completion was generated and billed.
114
114
  */
115
115
  async generate(request: GenerateRequest): Promise<GenerateResult> {
116
- if (requiresStreaming(request)) return this.assemble(request);
116
+ // Resolved BEFORE the transport question is asked. `requiresStreaming` clamps `maxTokens` to
117
+ // the model's `maxOutput`, and its own fallback for an absent model is the framework's
118
+ // `DEFAULT_MODEL` — a Claude id no OpenAI-format endpoint serves. Sized against that, a
119
+ // request naming no model was measured against somebody else's ceiling, seventy lines above
120
+ // `modelOf`'s comment saying this provider never does that.
117
121
  const model = this.modelOf(request);
122
+ if (requiresStreaming({ ...request, model })) return this.assemble(request);
118
123
  const response = await this.send(
119
124
  chatCompletionBody({ request, model, stream: false }),
120
125
  false,
package/src/prompt.ts CHANGED
@@ -27,7 +27,17 @@ export interface DefinePromptInput<V extends PromptVars> {
27
27
  readonly system?: string;
28
28
  /** Schema of the variables, for the manifest. */
29
29
  readonly input?: JsonSchema;
30
- /** Expected output shape, fed to `output_config.format` when the caller opts in. */
30
+ /**
31
+ * The output shape this prompt PROMISES. Declarative: it is hashed into `ref` and published by
32
+ * `describePrompts()`, and it is deliberately never sent on the wire — structured output is the
33
+ * `respond` tool `llm()` projects from the ACTION's `output`, and a second path through
34
+ * `output_config.format` / `response_format` is the ambiguity axiom 1 refuses (it is also the
35
+ * one feature most OpenAI-compatible servers do not implement).
36
+ *
37
+ * Editing it moves the hash and so needs a `version` bump, which is the point: a prompt whose
38
+ * promised shape changed is a different prompt, and every score already filed against the old
39
+ * hash describes the old one.
40
+ */
31
41
  readonly output?: JsonSchema;
32
42
  readonly model?: ModelId;
33
43
  readonly effort?: Effort;
package/src/scorers.ts CHANGED
@@ -13,8 +13,22 @@ export interface Scorer {
13
13
  score(input: { output: string; expected?: string }): Promise<number> | number;
14
14
  }
15
15
 
16
- /** Scores outside 0..1 are a scorer bug; clamping keeps one from skewing a whole run's mean. */
17
- export const clampScore = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);
16
+ /**
17
+ * Scores outside 0..1 are a scorer bug; clamping keeps one from skewing a whole run's mean.
18
+ *
19
+ * `NaN` is the case a pair of comparisons cannot express, and it is the dangerous one: both
20
+ * `n < 0` and `n > 1` are false for it, so it propagated through `mean()` into `EvalResult.score`,
21
+ * and `regressionsAgainst`'s `now < was - tolerance` is false for `NaN` too — the run reported a
22
+ * pass over a number that measured nothing. The trigger is ordinary arithmetic in an app's own
23
+ * scorer: `output.length / expected.length` on a case that declares no `expected`.
24
+ *
25
+ * Zero rather than a throw, and rather than a new code: zero is a REGRESSION against any recorded
26
+ * baseline, so the scorer bug surfaces through `X_EVAL_THRESHOLD`, which already names the case
27
+ * that produced it. It also makes recording and gating agree — `ULTIMATE_EVAL_RECORD=1` wrote
28
+ * `"score": null` for a `NaN`, which `parseBaseline` then refuses as `X_EVAL_BASELINE_INVALID`.
29
+ */
30
+ export const clampScore = (n: number): number =>
31
+ Number.isFinite(n) ? (n < 0 ? 0 : n > 1 ? 1 : n) : n > 0 ? 1 : 0;
18
32
 
19
33
  /** Exact match after trimming. The strictest and cheapest scorer; prefer it when it fits. */
20
34
  export const exact: Scorer = {
@@ -98,6 +112,10 @@ export function llmJudge(input: {
98
112
  maxTokens: input.maxTokens ?? 256,
99
113
  ...(input.judge.system !== undefined ? { system: input.judge.system } : {}),
100
114
  ...(input.judge.model !== undefined ? { model: input.judge.model } : {}),
115
+ // The judge prompt's hash is this scorer's NAME, and `contentHash` covers `effort` and
116
+ // `thinking` — so dropping either measures with a judge the name does not describe.
117
+ ...(input.judge.effort !== undefined ? { effort: input.judge.effort } : {}),
118
+ ...(input.judge.thinking !== undefined ? { thinking: input.judge.thinking } : {}),
101
119
  });
102
120
  // The judge is asked for a bare 0..1; anything else scores 0 rather than guessing.
103
121
  const parsed = Number.parseFloat(generated.text.trim());
package/src/sse.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  import { AiTransportError } from './errors';
9
9
 
10
10
  /**
11
- * The most one unterminated frame may buffer. A peer that sends a body with no frame boundary in
11
+ * The most one unterminated frame may buffer the REMAINDER after every complete frame has been
12
+ * split off, never the whole decode buffer. A peer that sends a body with no frame boundary in
12
13
  * it — an HTML error page, a proxy answering on the model's port, a hung gateway — grows `buffer`
13
14
  * without limit, and no read deadline interrupts it because every individual read SUCCEEDS. Coded
14
15
  * failure > OOM, the same call `@ultimat3/mail`'s `createReplyParser` makes for the same shape.
@@ -86,10 +87,14 @@ export async function* readSse(
86
87
  const { done, value } = await reader.read();
87
88
  if (done) break;
88
89
  buffer += decoder.decode(value, { stream: true });
89
- guard(buffer, provider);
90
90
  const decoded = decodeSse(buffer);
91
91
  buffer = decoded.rest;
92
92
  for (const frame of decoded.frames) yield frame;
93
+ // The REMAINDER, after the split, and after the frames that did complete were delivered.
94
+ // Measured against the whole decode buffer it refused a busy provider that landed a
95
+ // megabyte of perfectly framed deltas in one read — every one of them terminated — for
96
+ // "sending more than 1048576 characters without completing one SSE frame".
97
+ guard(buffer, provider);
93
98
  }
94
99
  buffer += decoder.decode();
95
100
  const tail = frameOf(buffer);
@@ -49,17 +49,28 @@ function narrowTenant(
49
49
  throw new VectorScopeWidenedError({ store, held: base, requested: next });
50
50
  }
51
51
 
52
+ /**
53
+ * A `Map`, never a `Record`, because every key here is a CALLER's string — an app's metadata field
54
+ * name, chosen by whoever wrote the policy. An object gets it wrong in both directions:
55
+ * - reading `merged['constructor']` answers the `Object` function off the prototype chain, so
56
+ * `held === undefined` is false and `held.includes(value)` is a bare `TypeError` raised inside
57
+ * a path whose only contract is `X_VECTOR_SCOPE_WIDENED`;
58
+ * - writing `merged['__proto__'] = [...]` runs `Object.prototype`'s setter rather than creating
59
+ * the key, so the rule the caller declared is absent from `Object.entries` and the derived
60
+ * scope comes out WIDER than the one that was asked for.
61
+ * `Object.fromEntries` defines own properties, so the object handed back has neither hazard.
62
+ */
52
63
  function narrowAllow(
53
64
  base: Readonly<Record<string, readonly string[]>> | undefined,
54
65
  next: Readonly<Record<string, readonly string[]>> | undefined,
55
66
  ): Readonly<Record<string, readonly string[]>> | undefined {
56
67
  if (next === undefined) return base;
57
- const merged: Record<string, readonly string[]> = { ...(base ?? {}) };
68
+ const merged = new Map<string, readonly string[]>(Object.entries(base ?? {}));
58
69
  for (const [key, values] of Object.entries(next)) {
59
- const held = merged[key];
60
- merged[key] = held === undefined ? values : values.filter((value) => held.includes(value));
70
+ const held = merged.get(key);
71
+ merged.set(key, held === undefined ? values : values.filter((value) => held.includes(value)));
61
72
  }
62
- return merged;
73
+ return Object.fromEntries(merged);
63
74
  }
64
75
 
65
76
  /** Whether one stored row survives the scope. The in-memory twin of the SQL conditions. */
@@ -70,7 +81,11 @@ export function scopeAdmits(
70
81
  ): boolean {
71
82
  if (scope.tenant !== undefined && scope.tenant !== tenant) return false;
72
83
  return Object.entries(scope.allow ?? {}).every(([key, values]) => {
73
- const value = metadata[key];
84
+ // Own properties only, for the reason `narrowAllow` uses a `Map`: `key` is a caller's string
85
+ // and a metadata bag is a caller's object. This half already failed CLOSED — an inherited
86
+ // member is never one of the allowed strings — but relying on that is relying on the value
87
+ // types, not on the rule, and the rule is that a caller's string is never an object key.
88
+ const value = Object.hasOwn(metadata, key) ? metadata[key] : undefined;
74
89
  return value !== undefined && values.includes(value);
75
90
  });
76
91
  }
package/src/vector.ts CHANGED
@@ -218,4 +218,12 @@ export function fuse(rankings: readonly (readonly SearchHit[])[], rrfK = 60): re
218
218
  .sort(byScoreDesc);
219
219
  }
220
220
 
221
- const byScoreDesc = (a: SearchHit, b: SearchHit): number => b.score - a.score;
221
+ /**
222
+ * Score descending, then id ascending — the second key is `pg-vector-sql.ts`'s
223
+ * `order by f.score desc, d."id" asc`, and the two have to agree or the developer machine and the
224
+ * deployed app return different pages of the same search. Ties are the COMMON case in RRF: two
225
+ * documents that swap rank between the dense and the lexical list score identically, and a stable
226
+ * sort then resolves them by dense-list insertion order, which no SQL engine reproduces.
227
+ */
228
+ const byScoreDesc = (a: SearchHit, b: SearchHit): number =>
229
+ b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);