@ultimat3/ai 11.1.0 → 11.3.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 +34 -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/src/llm-cache.ts +19 -1
package/CLAUDE.md
CHANGED
|
@@ -80,6 +80,17 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
80
80
|
- Semantic scopes are separate cache INSTANCES, never a filter over a shared one — cosine
|
|
81
81
|
similarity has no notion of a tenant. The instance key carries the prompt hash too, which is
|
|
82
82
|
what makes a version bump invalidate the cache.
|
|
83
|
+
- **The instance key carries `ctx.locale` too, `As of 2026-08-24`, and it sits with the prompt hash
|
|
84
|
+
rather than with the scope.** A prompt taking `locale` as a var — the reference app's `summarize`
|
|
85
|
+
does, and the model obeys it — differs by ONE token between languages while carrying a whole
|
|
86
|
+
document, so the two rendered prompts are neighbours: measured with this package's own
|
|
87
|
+
`HashEmbedder` over that template, **0.9986**, against the declaration's `threshold: 0.97`. The
|
|
88
|
+
Spanish summary was a hit for an English reader, and the model was never wrong — the cache was.
|
|
89
|
+
No threshold repairs it, because the same number has to keep an honest repeat above it. It is in
|
|
90
|
+
the UNCONDITIONAL half of the key because a `scope` answers "who may share this answer" and a
|
|
91
|
+
locale is part of what the answer IS: an app writing `scope: () => 'global'` is saying its
|
|
92
|
+
callers may read one another's summaries, never that a Spanish one will do. Same rule
|
|
93
|
+
`@ultimat3/render` applies to ISR.
|
|
83
94
|
- **The default scope is the calling ACTOR, and `scope` receives `{ input, ctx }`** (`As of
|
|
84
95
|
2026-08`). It defaulted to the literal string `'global'` and took the bare `input`, so the rule
|
|
85
96
|
in the bullet above was contradicted by the very default that shipped: `cache: { semantic: { ttl:
|
|
@@ -145,6 +156,29 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
145
156
|
`renderThrowable` rather than `error.message` / `String(error)` — a renderer that throws replaces
|
|
146
157
|
`X_AI_PROVIDER_UNAVAILABLE` with a bare `TypeError` nothing catches by code, and it bounds a
|
|
147
158
|
provider's 1MB body out of the `cause`.
|
|
159
|
+
- **`X_AI_PROVIDER_UNAVAILABLE` is classified `retryable`, and it is the only one, `As of
|
|
160
|
+
2026-08-23`.** `AI_ERROR_RETRY`. It rendered `retry: "terminal"` in every problem document while
|
|
161
|
+
the gateway three lines away was backing off and trying again. `retryable`, not `retry-after`:
|
|
162
|
+
`statedDelayMs` and `@ultimat3/http`'s `retryAfterOf` read one field, `meta.retryAfterSeconds`, and
|
|
163
|
+
neither wire format here parses `Retry-After` off a 429 — so there is no time to name. The code's
|
|
164
|
+
other half, "no configured provider serves this model", is an `app.config.ts` edit and carries a
|
|
165
|
+
per-instance `retry: 'terminal'` from `AiProviderUnavailableError({ unserved: true })`; registering
|
|
166
|
+
the code is what makes that override honoured, because core reads an instance `terminal` on an
|
|
167
|
+
unregistered code as unclassified. Every other code is left UNREGISTERED rather than registered as
|
|
168
|
+
terminal: `@ultimat3/jobs` dead-letters a registered `terminal` on attempt 1, which is very likely
|
|
169
|
+
right for `X_LLM_REFUSED` and `X_AI_BUDGET_EXCEEDED` — a re-run is a second identical bill — and is
|
|
170
|
+
a change to how every app's jobs fail, so it belongs to whoever makes it on purpose.
|
|
171
|
+
- **The retry SCHEDULE is core's, the retry LOOP is this package's, `As of 2026-08-23`.**
|
|
172
|
+
`backoffMs` is `@ultimat3/core`'s `backoffDelay` with `baseDelayMs`/`maxDelayMs` mapped onto
|
|
173
|
+
`base`/`max`, and `isRetryable`'s status half is core's `isRetryableStatus`. Two behaviour changes
|
|
174
|
+
came with it and both are pinned in `gateway-backoff.test.ts`: a delay is **rounded** where it was
|
|
175
|
+
floored (<=1ms), and **408, 409 and 425 are now retried** where only `429 || >= 500` was. What did
|
|
176
|
+
NOT move is `retry()`, core's executor, and the reason is the direction it fails: it stops on a
|
|
177
|
+
`terminal` classification and retries everything else, so an UNCLASSIFIED throw is retried. Every
|
|
178
|
+
value in this loop is an app `Provider`'s — a plain object with a `status`, never an
|
|
179
|
+
`UltimateError` — so adopting it would have retried a 400 three times per provider, which is the
|
|
180
|
+
one thing the loop's own comment says it must not do. `RetryPolicy` and `DEFAULT_RETRY` stay
|
|
181
|
+
declared here for a second reason: their field names are what an app writes.
|
|
148
182
|
- Every non-2xx and every in-band `error` frame becomes `AiTransportError`, which carries a real
|
|
149
183
|
`status` field — that field IS the gateway's retry rule. A body parsed as a message would read
|
|
150
184
|
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.3.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.3.0",
|
|
36
|
+
"@ultimat3/cache": "11.3.0",
|
|
37
|
+
"@ultimat3/core": "11.3.0",
|
|
38
|
+
"@ultimat3/db": "11.3.0",
|
|
39
|
+
"@ultimat3/jobs": "11.3.0",
|
|
40
|
+
"@ultimat3/money": "11.3.0",
|
|
41
|
+
"@ultimat3/policy": "11.3.0",
|
|
42
|
+
"@ultimat3/schema": "11.3.0",
|
|
43
|
+
"@ultimat3/time": "11.3.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
package/src/llm-cache.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* carries the prompt VERSION as well — which is what makes "editing a prompt requires a version
|
|
7
7
|
* bump" invalidate the cache: a bumped version reaches a different store, so an old answer cannot
|
|
8
8
|
* survive a prompt edit no matter how similar the text.
|
|
9
|
+
*
|
|
10
|
+
* It carries the request LOCALE for the same structural reason, `As of 2026-08-24`. A prompt that
|
|
11
|
+
* takes `locale` as a var — the reference app's `summarize` does, and the model obeys it — differs
|
|
12
|
+
* by one token between languages while carrying a whole document, so the two renderings are
|
|
13
|
+
* neighbours: measured with this package's own `HashEmbedder` over that template, **0.9986**
|
|
14
|
+
* against a declared threshold of `0.97`. The Spanish answer was therefore a hit for an English
|
|
15
|
+
* reader. No threshold fixes it, because the same number has to keep an honest repeat above it.
|
|
9
16
|
*/
|
|
10
17
|
|
|
11
18
|
import type { Ctx } from '@ultimat3/core';
|
|
@@ -62,6 +69,10 @@ export interface PromptCache {
|
|
|
62
69
|
* same reason it is JSON rather than a joined string — an actor id is app data and may carry any
|
|
63
70
|
* separator, and a value that can spell a boundary can spell somebody else's.
|
|
64
71
|
*
|
|
72
|
+
* The locale is deliberately NOT here. This function is the DEFAULT a declaration replaces by
|
|
73
|
+
* writing its own `scope`, and the locale has to survive that — so it lives in the unconditional
|
|
74
|
+
* half of the store key, beside the prompt hash.
|
|
75
|
+
*
|
|
65
76
|
* `ctx.actor` is never absent (`createContext` defaults it to `anonymousActor()`), so every
|
|
66
77
|
* anonymous caller shares one partition — which is what the anonymous actor already means
|
|
67
78
|
* everywhere else in the framework.
|
|
@@ -92,7 +103,14 @@ export async function openCache<TParsed>(
|
|
|
92
103
|
const semantic = args.cache?.semantic;
|
|
93
104
|
if (semantic === undefined) return undefined;
|
|
94
105
|
const scope = semantic.scope?.({ input: args.input, ctx: args.ctx }) ?? actorScope(args.ctx);
|
|
95
|
-
|
|
106
|
+
// The locale sits with the prompt HASH and not with the scope, on purpose: a scope answers "who
|
|
107
|
+
// may share this answer" and the locale is part of what the answer IS — so a written-down
|
|
108
|
+
// `scope: () => 'global'` is still partitioned by it. An app declaring a shared store is saying
|
|
109
|
+
// its callers may read one another's summaries, never that a Spanish one will do for an English
|
|
110
|
+
// reader. `@ultimat3/render`'s ISR keys by locale for exactly this reason.
|
|
111
|
+
const store = semanticCacheFor(
|
|
112
|
+
`${args.prompt.ref}#${args.prompt.hash}@${args.ctx.locale}::${scope}`,
|
|
113
|
+
);
|
|
96
114
|
const embedding = Array.from(await embedOne(aiEmbedder(), args.rendered));
|
|
97
115
|
const ttlMs = semantic.ttl === undefined ? undefined : parseDuration(semantic.ttl);
|
|
98
116
|
return {
|