@ultimat3/ai 11.1.0 → 11.2.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
@@ -145,6 +145,29 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
145
145
  `renderThrowable` rather than `error.message` / `String(error)` — a renderer that throws replaces
146
146
  `X_AI_PROVIDER_UNAVAILABLE` with a bare `TypeError` nothing catches by code, and it bounds a
147
147
  provider's 1MB body out of the `cause`.
148
+ - **`X_AI_PROVIDER_UNAVAILABLE` is classified `retryable`, and it is the only one, `As of
149
+ 2026-08-23`.** `AI_ERROR_RETRY`. It rendered `retry: "terminal"` in every problem document while
150
+ the gateway three lines away was backing off and trying again. `retryable`, not `retry-after`:
151
+ `statedDelayMs` and `@ultimat3/http`'s `retryAfterOf` read one field, `meta.retryAfterSeconds`, and
152
+ neither wire format here parses `Retry-After` off a 429 — so there is no time to name. The code's
153
+ other half, "no configured provider serves this model", is an `app.config.ts` edit and carries a
154
+ per-instance `retry: 'terminal'` from `AiProviderUnavailableError({ unserved: true })`; registering
155
+ the code is what makes that override honoured, because core reads an instance `terminal` on an
156
+ unregistered code as unclassified. Every other code is left UNREGISTERED rather than registered as
157
+ terminal: `@ultimat3/jobs` dead-letters a registered `terminal` on attempt 1, which is very likely
158
+ right for `X_LLM_REFUSED` and `X_AI_BUDGET_EXCEEDED` — a re-run is a second identical bill — and is
159
+ a change to how every app's jobs fail, so it belongs to whoever makes it on purpose.
160
+ - **The retry SCHEDULE is core's, the retry LOOP is this package's, `As of 2026-08-23`.**
161
+ `backoffMs` is `@ultimat3/core`'s `backoffDelay` with `baseDelayMs`/`maxDelayMs` mapped onto
162
+ `base`/`max`, and `isRetryable`'s status half is core's `isRetryableStatus`. Two behaviour changes
163
+ came with it and both are pinned in `gateway-backoff.test.ts`: a delay is **rounded** where it was
164
+ floored (<=1ms), and **408, 409 and 425 are now retried** where only `429 || >= 500` was. What did
165
+ NOT move is `retry()`, core's executor, and the reason is the direction it fails: it stops on a
166
+ `terminal` classification and retries everything else, so an UNCLASSIFIED throw is retried. Every
167
+ value in this loop is an app `Provider`'s — a plain object with a `status`, never an
168
+ `UltimateError` — so adopting it would have retried a 400 three times per provider, which is the
169
+ one thing the loop's own comment says it must not do. `RetryPolicy` and `DEFAULT_RETRY` stay
170
+ declared here for a second reason: their field names are what an app writes.
148
171
  - Every non-2xx and every in-band `error` frame becomes `AiTransportError`, which carries a real
149
172
  `status` field — that field IS the gateway's retry rule. A body parsed as a message would read
150
173
  as an empty, successful answer, which is the one outcome nothing downstream can detect.
package/README.md CHANGED
@@ -84,8 +84,8 @@ default store at `replicas: 6` is six ledgers of twenty million, which is a budg
84
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` |
85
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 |
86
86
  | A refusal is never cached | a cached one keeps serving a classifier decision after the prompt was fixed |
87
- | Retries use **full jitter** | synchronised retries from N workers reproduce the rate limit |
88
- | A 4xx is never retried | the same body gets the same rejection and burns the budget |
87
+ | Retries use **full jitter**, from core's one curve | synchronised retries from N workers reproduce the rate limit. `backoffMs` is `@ultimat3/core`'s `backoffDelay` with the gateway's field names mapped onto it, and the roll is `createGateway({ random })` — injectable, so the schedule is a unit test rather than a range |
88
+ | A 4xx is never retried **except 408, 409 and 425** | the same body gets the same rejection and burns the budget — but a request the server stopped reading (408), a round a concurrent writer won (409) and a handshake that had not finished (425) are transient by construction, and core's `isRetryableStatus` is the one table that says so |
89
89
 
90
90
  ## Streaming
91
91
 
@@ -630,6 +630,14 @@ actor comes from the request context, never from the model.
630
630
 
631
631
  ## Errors
632
632
 
633
+ **`X_AI_PROVIDER_UNAVAILABLE` carries `retry: "retryable"` in `--json`, `As of 2026-08-23`**
634
+ (`AI_ERROR_RETRY`) — it is the one transient code here, and it told every client `terminal` while the
635
+ gateway itself was backing off and trying again. `retryable` and not `retry-after`: that spelling
636
+ means the responder named a time, and no provider in this package parses `Retry-After` off a 429.
637
+ The half of the code that is NOT transient — no configured provider serves the model — carries a
638
+ per-instance `terminal`, because that one is an `app.config.ts` edit. Every other code keeps core's
639
+ fail-closed `terminal` default.
640
+
633
641
  | Code | Meaning |
634
642
  |---|---|
635
643
  | `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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "11.1.0",
3
+ "version": "11.2.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": "11.1.0",
36
- "@ultimat3/cache": "11.1.0",
37
- "@ultimat3/core": "11.1.0",
38
- "@ultimat3/db": "11.1.0",
39
- "@ultimat3/jobs": "11.1.0",
40
- "@ultimat3/money": "11.1.0",
41
- "@ultimat3/policy": "11.1.0",
42
- "@ultimat3/schema": "11.1.0",
43
- "@ultimat3/time": "11.1.0"
35
+ "@ultimat3/action": "11.2.0",
36
+ "@ultimat3/cache": "11.2.0",
37
+ "@ultimat3/core": "11.2.0",
38
+ "@ultimat3/db": "11.2.0",
39
+ "@ultimat3/jobs": "11.2.0",
40
+ "@ultimat3/money": "11.2.0",
41
+ "@ultimat3/policy": "11.2.0",
42
+ "@ultimat3/schema": "11.2.0",
43
+ "@ultimat3/time": "11.2.0"
44
44
  }
45
45
  }
@@ -0,0 +1,45 @@
1
+ // The retry classification for this package's codes, apart from ./errors only because one file has
2
+ // one job and that catalogue is at its ceiling. The codes, their titles and the single
3
+ // `registerErrorCodes` call stay in ./errors — one owner, one registration.
4
+
5
+ import type { ErrorRetry } from '@ultimat3/core';
6
+ import { registerErrorRetry } from '@ultimat3/core';
7
+ import type { AiErrorCode } from './errors';
8
+
9
+ /**
10
+ * May a client run this model call again? Unclassified means `terminal` — core fails closed — and
11
+ * until 2026-08-23 this package classified nothing, so `X_AI_PROVIDER_UNAVAILABLE` told every HTTP
12
+ * client not to come back from the one failure the gateway's own backoff loop exists for.
13
+ *
14
+ * The rule, stated once: **retryable means the same call, made again, has a real chance of a
15
+ * different answer, and costs nothing to be wrong about beyond one more attempt.** In this package
16
+ * that is exactly one thing — a provider that could not answer. Everything else is a statement
17
+ * about the request, the declaration, the catalogue or the money, and a second attempt buys the
18
+ * same answer at full price.
19
+ *
20
+ * **Only the exception is listed.** Everything else keeps the fail-closed default rather than being
21
+ * registered AS terminal, and the difference is not cosmetic: `@ultimat3/jobs` dead-letters a
22
+ * registered `terminal` on attempt 1, where an unclassified code keeps the job's own attempt count.
23
+ * That is very likely the right answer for `X_LLM_REFUSED` and `X_AI_BUDGET_EXCEEDED` — a refusal
24
+ * re-run is a second identical bill — but it is a change to how every app's jobs fail, and it
25
+ * belongs to whoever makes it deliberately rather than to a sweep that was fixing the retryable
26
+ * half.
27
+ */
28
+ export const AI_ERROR_RETRY = {
29
+ // The transport code. A 503, a 429, a reset socket, every candidate exhausted — the request was
30
+ // well formed and nobody could answer it yet. `retryable` and NOT `retry-after`: that spelling
31
+ // means the responder NAMED a time, `statedDelayMs` reads exactly one field for it
32
+ // (`meta.retryAfterSeconds`), and neither wire format in this package parses the `Retry-After`
33
+ // header off a 429 — so the honest answer is "come back", not a delay this package invented.
34
+ //
35
+ // The half of this code that is NOT transient — "no configured provider serves this model" — is
36
+ // an `app.config.ts` edit, and it carries a per-instance `terminal` at its two throw sites
37
+ // instead of a second code. `UltimateError` supports that override precisely because one code can
38
+ // be both, and registering the code here is what makes the override honoured: core reads an
39
+ // instance `terminal` on an UNREGISTERED code as unclassified.
40
+ X_AI_PROVIDER_UNAVAILABLE: 'retryable',
41
+ } as const satisfies Readonly<Partial<Record<AiErrorCode, ErrorRetry>>>;
42
+
43
+ // `Partial`, so the table may be a subset — but every key is still checked against the declared set,
44
+ // so a typo or a renamed code is a build error rather than a classification nothing ever throws.
45
+ registerErrorRetry(AI_ERROR_RETRY);
package/src/errors.ts CHANGED
@@ -74,11 +74,23 @@ registerErrorCodes(
74
74
 
75
75
  /** Every configured provider refused or errored. Carries what each one said. */
76
76
  export class AiProviderUnavailableError extends UltimateError {
77
- constructor(input: { model: string; attempts: readonly string[] }) {
77
+ constructor(input: {
78
+ model: string;
79
+ attempts: readonly string[];
80
+ /**
81
+ * True when no configured provider LISTS this model — a fact about `app.config.ts`, not about
82
+ * the moment. It is the same code because a caller cannot act differently on it, and it carries
83
+ * a per-instance `terminal` because a worker CAN: retrying it burns a whole policy proving the
84
+ * configuration is still what it was. The call site states the fact; the class decides the
85
+ * classification, so the retry vocabulary stays in one file.
86
+ */
87
+ readonly unserved?: boolean;
88
+ }) {
78
89
  super({
79
90
  code: 'X_AI_PROVIDER_UNAVAILABLE',
80
91
  cause: `no provider could serve model "${input.model}" (${input.attempts.join(' | ')})`,
81
92
  fix: 'check ai.providers in app.config.ts and the provider API key env var',
93
+ retry: input.unserved === true ? 'terminal' : undefined,
82
94
  });
83
95
  }
84
96
  }
package/src/gateway.ts CHANGED
@@ -4,7 +4,14 @@
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 { isUltimateError, renderThrowable, stringField } from '@ultimat3/core';
7
+ import type { Random } from '@ultimat3/core';
8
+ import {
9
+ backoffDelay,
10
+ isRetryableStatus,
11
+ isUltimateError,
12
+ renderThrowable,
13
+ stringField,
14
+ } from '@ultimat3/core';
8
15
  import type { Money } from '@ultimat3/money';
9
16
  import type { BudgetLimits, BudgetStore } from './budget';
10
17
  import { BudgetLedger, currentBudget, estimateSpend, withBudget } from './budget';
@@ -22,6 +29,13 @@ export interface GatewayCache {
22
29
  set(key: string, value: string): Promise<void> | void;
23
30
  }
24
31
 
32
+ /**
33
+ * A gateway's retry budget. NOT core's `RetryPolicy`, and deliberately still its own declaration:
34
+ * these three field names are what an app writes in `createGateway({ retry })`, so renaming them
35
+ * onto `base`/`max`/`jitter` would break every caller for no behaviour. What WAS a duplicate is the
36
+ * arithmetic, and that is gone — `backoffMs` is core's `backoffDelay` with this shape mapped onto
37
+ * it, so there is one curve in the framework and one place a jitter bug can live.
38
+ */
25
39
  export interface RetryPolicy {
26
40
  /** Total attempts including the first. */
27
41
  readonly attempts: number;
@@ -45,6 +59,13 @@ export interface CreateGatewayInput {
45
59
  readonly defaultModel?: ModelId;
46
60
  /** Overridable so a test can run backoff without waiting. */
47
61
  sleep?(ms: number): Promise<void>;
62
+ /**
63
+ * The roll behind the backoff's jitter. Injectable for the same reason `sleep` is, and it was the
64
+ * half that was missing: `Math.random()` read inline made this gateway's retry SCHEDULE provable
65
+ * only by observing a range, so nothing asserted it and a jitter bug here would have shipped
66
+ * green. Production never passes one.
67
+ */
68
+ readonly random?: Random | undefined;
48
69
  }
49
70
 
50
71
  export interface Gateway {
@@ -64,11 +85,14 @@ class GatewayImpl implements Gateway {
64
85
  private readonly config: CreateGatewayInput;
65
86
  private readonly retry: RetryPolicy;
66
87
  private readonly sleep: (ms: number) => Promise<void>;
88
+ /** Left `undefined` rather than defaulted, so `backoffDelay` owns the one fallback to `Math.random`. */
89
+ private readonly random: Random | undefined;
67
90
 
68
91
  constructor(config: CreateGatewayInput) {
69
92
  this.config = config;
70
93
  this.retry = config.retry ?? DEFAULT_RETRY;
71
94
  this.sleep = config.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
95
+ this.random = config.random;
72
96
  }
73
97
 
74
98
  scope<T>(input: { actorKey?: string; orgKey?: string }, fn: () => Promise<T>): Promise<T> {
@@ -172,6 +196,7 @@ class GatewayImpl implements Gateway {
172
196
  throw new AiProviderUnavailableError({
173
197
  model,
174
198
  attempts: this.config.providers.map((p) => `${p.name}: does not serve ${model}`),
199
+ unserved: true,
175
200
  });
176
201
  }
177
202
  return provider;
@@ -194,7 +219,11 @@ class GatewayImpl implements Gateway {
194
219
  const candidates = this.config.providers.filter((p) => p.models.includes(model));
195
220
  const failures: string[] = [];
196
221
  if (candidates.length === 0) {
197
- throw new AiProviderUnavailableError({ model, attempts: [`no provider serves ${model}`] });
222
+ throw new AiProviderUnavailableError({
223
+ model,
224
+ attempts: [`no provider serves ${model}`],
225
+ unserved: true,
226
+ });
198
227
  }
199
228
 
200
229
  for (const provider of candidates) {
@@ -221,7 +250,7 @@ class GatewayImpl implements Gateway {
221
250
  // too — a provider's 1MB body is not a cause.
222
251
  failures.push(`${provider.name}#${attempt}: ${renderThrowable(error)}`);
223
252
  if (!isRetryable(error) || attempt === this.retry.attempts) break;
224
- await this.sleep(backoffMs(this.retry, attempt));
253
+ await this.sleep(backoffMs(this.retry, attempt, this.random));
225
254
  }
226
255
  }
227
256
  }
@@ -229,15 +258,37 @@ class GatewayImpl implements Gateway {
229
258
  }
230
259
  }
231
260
 
232
- /** Full jitter: a uniform pick from [0, exponential], capped. */
233
- export function backoffMs(policy: RetryPolicy, attempt: number): number {
234
- const ceiling = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs);
235
- return Math.floor(Math.random() * ceiling);
261
+ /**
262
+ * Full jitter: a uniform pick from [0, exponential], capped BEFORE the roll.
263
+ *
264
+ * The arithmetic is core's `backoffDelay` — this is the mapping from the gateway's own field names
265
+ * onto it, and nothing else. Two things came with the delegation and neither is cosmetic: the
266
+ * result is ROUNDED where this floored it (a shift of at most 1ms, and the same rounding
267
+ * `@ultimat3/jobs` and `@ultimat3/realtime` already use), and a policy carrying a `NaN` — which is
268
+ * what `Number(process.env.…)` answers for an unset variable — waits 0 instead of handing
269
+ * `setTimeout` a `NaN` it fires on the next tick, i.e. a backoff that is a tight spin.
270
+ */
271
+ export function backoffMs(policy: RetryPolicy, attempt: number, random?: Random): number {
272
+ return backoffDelay({
273
+ attempt,
274
+ base: policy.baseDelayMs,
275
+ max: policy.maxDelayMs,
276
+ curve: 'exponential',
277
+ jitter: 'full',
278
+ random,
279
+ });
236
280
  }
237
281
 
238
282
  /**
239
283
  * Retryable = the request was well formed and the provider was momentarily unable. A 400 is
240
284
  * never retried: the same body produces the same rejection and only burns the budget.
285
+ *
286
+ * The status half is core's `isRetryableStatus`, which is WIDER than the `429 || >= 500` this
287
+ * gateway shipped: 408, 409 and 425 join it. Each is transient by construction — the server gave up
288
+ * waiting for a body it never fully read, a concurrent writer won the round, the handshake was not
289
+ * finished — so not retrying them was a gap rather than a policy, and one narrower table in one
290
+ * package was how it stayed invisible. The `code` branch has no equivalent in core and stays here:
291
+ * that table is HTTP status only, and a socket that timed out never produced one.
241
292
  */
242
293
  export function isRetryable(error: unknown): boolean {
243
294
  if (typeof error !== 'object' || error === null) return false;
@@ -247,7 +298,7 @@ export function isRetryable(error: unknown): boolean {
247
298
  // to answer with — so it fails closed rather than raising.
248
299
  try {
249
300
  const e = error as { status?: unknown; code?: unknown };
250
- if (typeof e.status === 'number') return e.status === 429 || e.status >= 500;
301
+ if (typeof e.status === 'number') return isRetryableStatus(e.status);
251
302
  return e.code === 'ETIMEDOUT' || e.code === 'ECONNRESET';
252
303
  } catch {
253
304
  return false;
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ export {
58
58
  VectorDimMismatchError,
59
59
  VectorScopeWidenedError,
60
60
  } from './errors';
61
+ export { AI_ERROR_RETRY } from './errors-retry';
61
62
  export type { EvalBaseline, Regression } from './eval-baseline';
62
63
  export {
63
64
  baselinePath,