@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 +23 -0
- package/README.md +10 -2
- package/package.json +10 -10
- package/src/errors-retry.ts +45 -0
- package/src/errors.ts +13 -1
- package/src/gateway.ts +59 -8
- package/src/index.ts +1 -0
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
|
|
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.
|
|
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.
|
|
36
|
-
"@ultimat3/cache": "11.
|
|
37
|
-
"@ultimat3/core": "11.
|
|
38
|
-
"@ultimat3/db": "11.
|
|
39
|
-
"@ultimat3/jobs": "11.
|
|
40
|
-
"@ultimat3/money": "11.
|
|
41
|
-
"@ultimat3/policy": "11.
|
|
42
|
-
"@ultimat3/schema": "11.
|
|
43
|
-
"@ultimat3/time": "11.
|
|
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: {
|
|
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 {
|
|
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({
|
|
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
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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