@ultimat3/ai 23.0.0 → 24.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 +34 -6
- package/README.md +45 -12
- package/package.json +11 -10
- package/src/agent-transcript.ts +1 -1
- package/src/agent.ts +14 -13
- package/src/budget.ts +138 -46
- package/src/embeddings.ts +10 -11
- package/src/error-body.ts +38 -3
- package/src/errors.ts +2 -0
- package/src/eval-baseline.ts +5 -1
- package/src/gateway.ts +33 -21
- package/src/hive.ts +10 -2
- package/src/index.ts +4 -2
- package/src/llm-cache.ts +3 -2
- package/src/llm.ts +13 -55
- package/src/openai-wire.ts +7 -2
- package/src/pg-vector.ts +7 -4
- package/src/provider.ts +9 -7
- package/src/rag.ts +10 -3
- package/src/remote-embedder.ts +5 -36
- package/src/respond.ts +86 -0
- package/src/runtime.ts +5 -0
- package/src/scorers.ts +13 -2
- package/src/tools.ts +30 -19
- package/src/vector-scope.ts +44 -2
- package/src/vector.ts +16 -6
package/CLAUDE.md
CHANGED
|
@@ -4,7 +4,8 @@ Tier 4. May import tier 0–3: `core schema i18n money time cache seo entity pol
|
|
|
4
4
|
query jobs realtime`. **Never** `mcp manifest render pwa ui admin testing cli`.
|
|
5
5
|
|
|
6
6
|
Declared today: `action` (the primitive `llm()` returns), `cache` (semantic cache), `core`,
|
|
7
|
-
`db` (pgvector), `
|
|
7
|
+
`db` (pgvector), `http` (`statusFor`, so a tool result redacts a 5xx cause as a problem document
|
|
8
|
+
does), `jobs` (`agentJob()`), `money`, `policy`, `schema`, `time`.
|
|
8
9
|
|
|
9
10
|
`jobs` is declared for `agentJob()`: the action→job bridge has to live at tier 4+.
|
|
10
11
|
|
|
@@ -46,6 +47,7 @@ local `=== true`. An in-app agent and an external one must be offered exactly th
|
|
|
46
47
|
| `rag.ts` | chunker, retriever, reranker, budgeted context assembler |
|
|
47
48
|
| `tools.ts` | action → LLM tool definition; the `AgentTool` union and `asProjectableAction`; `runLlmToolCall` |
|
|
48
49
|
| `llm.ts` | `llm()` — the model call, declared as an `action`; and what a streamed answer must satisfy |
|
|
50
|
+
| `respond.ts` | the `respond` tool for one `output` (a non-object one wrapped in `{ value }`), its reader, and `parseJsonish` |
|
|
49
51
|
| `llm-stream.ts` | `.stream()`'s plumbing: the sink, the ambient mark, the one-turn drive |
|
|
50
52
|
| `agent.ts` | `agent()` — the tool loop, declared as an `action` |
|
|
51
53
|
| `agent-transcript.ts` | what one turn leaves in the transcript: the assistant replay, the tool results, the correction |
|
|
@@ -75,7 +77,18 @@ local `=== true`. An in-app agent and an external one must be offered exactly th
|
|
|
75
77
|
before the ledger's `request`; `retrieve()`'s own `k`; `RemoteEmbedder`'s `maxResponseBytes`).
|
|
76
78
|
- A per-call budget `derive`s from the ambient ledger and only TIGHTENS. **A derived ledger reports
|
|
77
79
|
back up the chain** (every debit on every ancestor; `reserve` checks each `request` scope); the
|
|
78
|
-
STORE is written once, by the ledger the call was made on
|
|
80
|
+
STORE is written once, by the ledger the call was made on.
|
|
81
|
+
- **`reserve` debits, then checks — never a read-then-write across an `await`.** The in-memory scopes
|
|
82
|
+
check and debit synchronously; the store's go through `BudgetStore.take(key, tokens, limit)`, which
|
|
83
|
+
is REQUIRED and must be atomic (no default over `spent` + `add` — that pair is the race). A refusal
|
|
84
|
+
at a later scope gives back what the earlier ones took.
|
|
85
|
+
- **The store is touched only for a scope whose ceiling is DECLARED** (`takeScope`, `meteredKeys`):
|
|
86
|
+
every call carries keys, so a write per key grew the default store by one entry per caller.
|
|
87
|
+
`MemoryBudgetStore` has no window and no eviction (eviction is a ceiling bypass); a counter back
|
|
88
|
+
at zero is deleted.
|
|
89
|
+
- **`llm()`, `agent()`, `hive()` root as `currentBudget() ?? gateway.callLedger(budgetKeysFor(ctx.actor))`**
|
|
90
|
+
— the gateway's ceilings, keyed `actor:<kind>:<id>` / `org:<orgId>`. `callLedger` is required on
|
|
91
|
+
`Gateway`; `hive()` asks `installedGateway()` so a hive of plain actions needs none.
|
|
79
92
|
- **`BudgetStore` is where `actor` and `org` live; the default is per PROCESS**
|
|
80
93
|
(`createGateway({ budgetStore })`). `add` takes a negative `tokens` for a release — a store that
|
|
81
94
|
clamps at zero leaks the ceiling.
|
|
@@ -88,6 +101,9 @@ local `=== true`. An in-app agent and an external one must be offered exactly th
|
|
|
88
101
|
|
|
89
102
|
- **`llm()` returns an `action`**, never a ninth primitive; it adds only the model half: render the
|
|
90
103
|
prompt, project `output` into the one `respond` tool, reserve the budget, consult the semantic cache.
|
|
104
|
+
- **A non-object `output` is wrapped in `{ value }` and unwrapped** (`respond.ts`) — a tool's input is
|
|
105
|
+
an object; never refused, because `.stream()` serves `output: t.string`. `parseJsonish` is two
|
|
106
|
+
`indexOf` scans, never a backtracking pattern.
|
|
91
107
|
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts`).
|
|
92
108
|
- One repair turn, then `X_LLM_OUTPUT_INVALID`. A refusal (a 200 with no answer) is `X_LLM_REFUSED`
|
|
93
109
|
before the parse, carrying `stopDetails.category`; a truncated invalid answer is `X_LLM_TRUNCATED`.
|
|
@@ -116,6 +132,11 @@ local `=== true`. An in-app agent and an external one must be offered exactly th
|
|
|
116
132
|
|
|
117
133
|
## Invariants — providers and the gateway
|
|
118
134
|
|
|
135
|
+
- **The response cache key is core's `fingerprint` over the request, each tool WHOLE** (every `llm()`
|
|
136
|
+
tool is `respond`); a throwing `cache.set` is logged (`ai.cache.write_failed`), never a failure.
|
|
137
|
+
- **An unknown stop/finish reason FAILS CLOSED as `max_tokens` on both formats**, streamed or not.
|
|
138
|
+
- **One failure-body reader** (`error-body.ts`'s `detailOf`, bounded at `READ_LIMIT`) and one
|
|
139
|
+
`withoutKey` scrub — the embedder included.
|
|
119
140
|
- **`attempt` collects TRANSPORT failures only**: an `UltimateError` other than
|
|
120
141
|
`X_AI_PROVIDER_UNAVAILABLE` is rethrown on the spot (the same misconfiguration everywhere). Read
|
|
121
142
|
with `stringField(error, 'code')`.
|
|
@@ -188,9 +209,12 @@ local `=== true`. An in-app agent and an external one must be offered exactly th
|
|
|
188
209
|
("superseded").
|
|
189
210
|
- A tool result is rendered totally (`'null'` for `undefined`; a non-JSON result reported as such,
|
|
190
211
|
never as a failure); the throw is read with `stringField`.
|
|
212
|
+
- **A 5xx code's cause reaches the model only when core's `hasPublicCause` allows it** (status from
|
|
213
|
+
`@ultimat3/http`'s `statusFor`) — else `CODE: HIDDEN_TOOL_CAUSE`, plus a branded `callerFix`. The
|
|
214
|
+
same verdict a problem document and `@ultimat3/mcp` give.
|
|
191
215
|
- **`hive()` is a fan-out action** (`PRIMITIVE_FACTORIES`): `HiveResult` is a SCHEMA from the
|
|
192
216
|
member's `output`; three arms (`ok`/`failed`/`skipped`) and three counters; `members` in SPLIT
|
|
193
|
-
order with `index`; the hive never names an actor; no hive budget code (the
|
|
217
|
+
order with `index`; the hive never names an actor; no hive budget code (the reservation holds
|
|
194
218
|
it); a member's throw is recorded via `isThrownError`/`stringField`; `skipped` reasons
|
|
195
219
|
`SKIPPED_ABORTED` / `SKIPPED_NO_INPUT`; `onMemberError` is required; `concurrency` 4, `minMembers`
|
|
196
220
|
2 (a below-floor split still runs serially); an empty split is `X_HIVE_EMPTY`; an aborted `ctx`
|
|
@@ -206,13 +230,17 @@ local `=== true`. An in-app agent and an external one must be offered exactly th
|
|
|
206
230
|
- **No fix line may name `x ai`** (planned). An eval is selected with `x test eval --filter <name>`;
|
|
207
231
|
`eval-errors.test.ts` asserts every `x test <word>` is one of the six types.
|
|
208
232
|
- Every eval result carries the prompt hash. An eval gates on the DROP from its recorded baseline,
|
|
209
|
-
mean AND per case. Never-recorded is `X_EVAL_BASELINE_MISSING`, corrupt is `X_EVAL_BASELINE_INVALID`.
|
|
233
|
+
mean AND per case, both sides rounded as the baseline is written. Never-recorded is `X_EVAL_BASELINE_MISSING`, corrupt is `X_EVAL_BASELINE_INVALID`.
|
|
210
234
|
`baseline` is `import.meta.resolve('./…')`. Every prompt is named by an eval (`X_EVAL_MISSING`, by
|
|
211
235
|
prompt ID). `ULTIMATE_EVAL_RECORD=1` records; a deliberately-worse test calls `run`, never
|
|
212
236
|
`assert`; `x verify` with the flag is `X_EVAL_RECORDING` and runs no eval. The gate asks for a
|
|
213
237
|
recorded baseline, not only a declaration.
|
|
214
|
-
- Retrieval is hybrid by default; no vector-only path. **`chunk()` hard-wraps**,
|
|
215
|
-
carry stops at `buffer.length - 1
|
|
238
|
+
- Retrieval is hybrid by default; no vector-only path. **`chunk()` hard-wraps**, the overlap
|
|
239
|
+
carry stops at `buffer.length - 1` and yields to a next unit it does not fit beside (no chunk over
|
|
240
|
+
`size`); `metadata.source` is written AFTER the caller's spread. `VectorStore.prune` is required.
|
|
241
|
+
- **A store with no tenant bound, read inside a request whose actor has an `orgId`, is
|
|
242
|
+
`X_VECTOR_UNSCOPED`** (`assertTenantRead`, both stores, every read). `scope: UNSCOPED`
|
|
243
|
+
(`crossTenant: true`) is the named opt-in; an omitted scope is `UNBOUND`.
|
|
216
244
|
- `PgVectorStore` is the only production vector path (pgvector + FTS in the app's Postgres);
|
|
217
245
|
`MemoryVectorStore` enforces the same envelope. **`pg-vector.live.test.ts` refuses to skip** on a
|
|
218
246
|
Postgres without the extension (CI uses `pgvector/pgvector:pg17`).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The LLM gateway primitive. Every model call in an Ultimate app goes through it,
|
|
|
4
4
|
and cost accounting cannot be bypassed by a stray `fetch`.
|
|
5
5
|
|
|
6
6
|
```ts
|
|
7
|
-
import { createGateway, AnthropicProvider, EchoProvider } from '@ultimat3/ai';
|
|
7
|
+
import { budgetKeysFor, createGateway, AnthropicProvider, EchoProvider } from '@ultimat3/ai';
|
|
8
8
|
|
|
9
9
|
export const ai = createGateway({
|
|
10
10
|
providers: [new AnthropicProvider(), new EchoProvider()], // ANTHROPIC_API_KEY, or { apiKey }
|
|
@@ -12,8 +12,9 @@ export const ai = createGateway({
|
|
|
12
12
|
cache: memoCache,
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
// Budgets are scoped, and every nested call inside the scope shares one ledger.
|
|
16
|
-
|
|
15
|
+
// Budgets are scoped, and every nested call inside the scope shares one ledger. `llm()`, `agent()`
|
|
16
|
+
// and `hive()` open one keyed on their caller (`budgetKeysFor(ctx.actor)`) without being asked.
|
|
17
|
+
const answer = await ai.scope(budgetKeysFor(actor), async () => {
|
|
17
18
|
const { text } = await ai.generate({
|
|
18
19
|
model: 'claude-opus-5',
|
|
19
20
|
system: 'You summarise support tickets.',
|
|
@@ -27,8 +28,8 @@ const answer = await ai.scope({ actorKey: actor.id, orgKey: actor.orgId }, async
|
|
|
27
28
|
|
|
28
29
|
## Budgets — and which of the three is fleet-wide
|
|
29
30
|
|
|
30
|
-
`request` is one call chain. `actor` and `
|
|
31
|
-
decides what they mean:
|
|
31
|
+
`request` is one call chain. `actor` and `org` are counters across calls, keyed
|
|
32
|
+
`actor:<kind>:<id>` and `org:<orgId>` (`budgetKeysFor`), so where they live decides what they mean:
|
|
32
33
|
|
|
33
34
|
| `budgetStore` | `actor` / `org` counts | Right for |
|
|
34
35
|
|---|---|---|
|
|
@@ -40,15 +41,25 @@ import { AnthropicProvider, type BudgetStore, createGateway } from '@ultimat3/ai
|
|
|
40
41
|
|
|
41
42
|
declare const redis: {
|
|
42
43
|
incrby(key: string, by: number): Promise<number>;
|
|
44
|
+
eval(script: string, keys: readonly string[], args: readonly string[]): Promise<number>;
|
|
43
45
|
del(key: string): Promise<unknown>;
|
|
44
46
|
flushdb(): Promise<unknown>;
|
|
45
47
|
};
|
|
46
48
|
|
|
49
|
+
// Add only if the total stays within the limit, in ONE step on the server; answers what was spent before.
|
|
50
|
+
const TAKE = `local s = tonumber(redis.call('get', KEYS[1]) or '0')
|
|
51
|
+
if s + tonumber(ARGV[1]) > tonumber(ARGV[2]) then return -1 - s end
|
|
52
|
+
redis.call('incrby', KEYS[1], ARGV[1]) return s`;
|
|
53
|
+
|
|
47
54
|
const sharedBudget: BudgetStore = {
|
|
48
55
|
spent: (key) => redis.incrby(key, 0),
|
|
49
56
|
add: async (key, tokens) => {
|
|
50
57
|
await redis.incrby(key, tokens);
|
|
51
58
|
},
|
|
59
|
+
take: async (key, tokens, limit) => {
|
|
60
|
+
const answer = await redis.eval(TAKE, [key], [String(tokens), String(limit)]);
|
|
61
|
+
return answer < 0 ? { taken: false, spent: -1 - answer } : { taken: true, spent: answer };
|
|
62
|
+
},
|
|
52
63
|
reset: async (key) => {
|
|
53
64
|
await (key === undefined ? redis.flushdb() : redis.del(key));
|
|
54
65
|
},
|
|
@@ -61,8 +72,14 @@ export const sharedGateway = createGateway({
|
|
|
61
72
|
});
|
|
62
73
|
```
|
|
63
74
|
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
The store is touched only for a scope whose ceiling is **declared** — no `actor` / `org` in
|
|
76
|
+
`budget`, no counter and no write, however many callers. There is no window: a counter lives until
|
|
77
|
+
`reset()` or (on the default store) a restart, and is never evicted, because forgetting a counter
|
|
78
|
+
hands that caller its ceiling back. A per-day or per-month window is a shared store whose keys expire.
|
|
79
|
+
|
|
80
|
+
Four methods. `take` is the reservation and must be **atomic** — a `spent` then an `add` is the
|
|
81
|
+
read-then-write two concurrent requests both pass. `add` takes a **negative** `tokens` — releasing a
|
|
82
|
+
reservation the call never spent is a credit, so a store that clamps at zero leaks the ceiling. `org: 20_000_000` on the
|
|
66
83
|
default store at `replicas: 6` is six ledgers of twenty million, which is a budget that is not one.
|
|
67
84
|
|
|
68
85
|
## Rules the gateway enforces
|
|
@@ -82,7 +99,8 @@ default store at `replicas: 6` is six ledgers of twenty million, which is a budg
|
|
|
82
99
|
| 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 |
|
|
83
100
|
| 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 |
|
|
84
101
|
| 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
|
-
| `reserve()` **debits** the
|
|
102
|
+
| `reserve()` **debits, then checks** | the in-memory scopes check and debit with no `await` between, the store's through its atomic `take`; a read-then-write let concurrent calls — one request per scope racing one org key — all read the same `spent()` and all pass. `record` reconciles and `release` gives it back |
|
|
103
|
+
| The cache key is every tool **whole**, through core's `fingerprint` | every `llm()` tool is named `respond`, so a key over names served an answer shaped for an old `output` schema; a cache `set` that throws is logged, never turned into a failure of a call already paid for |
|
|
86
104
|
| A refusal is never cached | a cached one keeps serving a classifier decision after the prompt was fixed |
|
|
87
105
|
| 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
106
|
| 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 |
|
|
@@ -265,9 +283,9 @@ summarize.contract(); // the contract tests
|
|
|
265
283
|
|
|
266
284
|
| Declared | Behaviour |
|
|
267
285
|
|---|---|
|
|
268
|
-
| `output` | projected into the one tool the model may answer through; prose with a fenced JSON block still parses |
|
|
286
|
+
| `output` | projected into the one tool the model may answer through; a non-object `output` (`t.string`, `t.number`, an array) travels wrapped in `{ value }` and comes back unwrapped, because a tool's input must be an object; prose with a fenced JSON block still parses |
|
|
269
287
|
| a schema failure | **one** repair turn naming the issues, then `X_LLM_OUTPUT_INVALID` |
|
|
270
|
-
| `budget` | reserved against the worst case **before** the provider is reached — nothing spent, nothing truncated |
|
|
288
|
+
| `budget` | reserved against the worst case **before** the provider is reached — nothing spent, nothing truncated. The gateway's `actor` / `org` ceilings count the CALLER (`budgetKeysFor(ctx.actor)`) |
|
|
271
289
|
| `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation. `scope` receives `{ input, ctx }` and **defaults to the calling actor** — the narrowest key, `@ultimat3/query`'s `readAuthority` rule; a shared store is `scope: () => 'global'`, written down |
|
|
272
290
|
| `policy` | the same object every surface evaluates — an MCP call and an HTTP call are denied identically |
|
|
273
291
|
| `vars` | the declared place an `llm()` call loads data, so a reader can see what was sent — the redactor sees it (and every `agent()` tool result), and a `Secret` is refused here |
|
|
@@ -373,7 +391,7 @@ sub-agents** with no supervisor primitive anywhere.
|
|
|
373
391
|
| three arms — `ok`, `failed`, `skipped` — never two | *ran and threw* and *never ran* are different facts, and an aborted sibling is the second. Collapsing them makes "the hive stopped early" read as "every remaining item is bad data" |
|
|
374
392
|
| `onMemberError` is **required** | `'abort'` stops and leaves the rest `skipped`; `'collect'` harvests the rest. Both are right for somebody, so neither is a default |
|
|
375
393
|
| the hive **never names an actor** | `split` derives member inputs from `input` and `ctx` and from nothing a model emitted; each member runs through its own callable, so `invoke` applies the member's own policy with `ctx.actor` untouched |
|
|
376
|
-
| `concurrency` bounds the fan-out; one derived ledger bounds the spend |
|
|
394
|
+
| `concurrency` bounds the fan-out; one derived ledger bounds the spend | rooted as `llm()` and `agent()` root theirs — the gateway's ceilings, keyed on the caller — and the reservation debits before it checks, so three members against a ceiling only one fits leave exactly one `ok` — no hive-specific budget code exists |
|
|
377
395
|
| an empty split is `X_HIVE_EMPTY` | "0 ok, 0 failed" cannot be told apart from a query that returned no rows and nobody noticed |
|
|
378
396
|
| `minMembers` (default 2) stops fanning out, and **drops nothing** | a member's fixed cost dominates trivial work; below the floor every input still runs, serially |
|
|
379
397
|
| an aborted `ctx` unwinds the whole hive with `X_ABORTED` | distinct from `onMemberError: 'abort'`, which is a completed run with a partial harvest worth returning — here there is nobody left to hand it to |
|
|
@@ -609,13 +627,20 @@ Allow-lists are default deny: a row missing the key is invisible, and an empty l
|
|
|
609
627
|
nothing. `scoped()` only ever **tightens** — re-scoping to a different tenant is
|
|
610
628
|
`X_VECTOR_SCOPE_WIDENED`, never a silent widening.
|
|
611
629
|
|
|
630
|
+
A store opened without a scope binds no tenant, and **reading it inside a request acting for an
|
|
631
|
+
org is `X_VECTOR_UNSCOPED`** — the forgotten `.scoped()` that searched every tenant's rows. Outside a
|
|
632
|
+
request, or for an actor with no org, it reads as before. The backfill path opts in by name:
|
|
633
|
+
`new PgVectorStore({ name, dimension, scope: UNSCOPED })`, or `store.scoped(UNSCOPED)`.
|
|
634
|
+
|
|
612
635
|
`chunk()` is token-aware with overlap and splits at paragraph, then sentence, then hard wrap
|
|
613
636
|
— a fact split across a boundary with no overlap is retrievable by neither chunk. All three
|
|
614
637
|
splits are load-bearing: the wrap is what bounds a UNIT (a base64 blob, a minified line, a CJK
|
|
615
638
|
paragraph the sentence alphabet cannot see), and a unit larger than `size` is one the size check
|
|
616
639
|
can never flush, so it rode every chunk after it — `As of 2026-08`, a ~1,000-token document
|
|
617
640
|
indexed as nine chunks of the same sentence. The overlap carries a tail forward and never the
|
|
618
|
-
whole buffer, for the same reason
|
|
641
|
+
whole buffer, for the same reason — and it yields, oldest unit first, to a next unit it does not fit
|
|
642
|
+
beside: no chunk exceeds `size`. Every chunk's `metadata.source` is the document id, written over
|
|
643
|
+
any `source` the caller passed, because `indexDocument` prunes a re-index by it.
|
|
619
644
|
|
|
620
645
|
## Tools: the same projection as MCP
|
|
621
646
|
|
|
@@ -629,6 +654,12 @@ An in-app agent and an external MCP agent both end at the same `invoke` — `run
|
|
|
629
654
|
carries it, and an action facade has no `.run` of its own. So they authorize identically. The
|
|
630
655
|
actor comes from the request context, never from the model.
|
|
631
656
|
|
|
657
|
+
A failed tool reads back as `CODE: cause (fix: …)` — except a 5xx code whose cause core's
|
|
658
|
+
`hasPublicCause` does not declare public, which reads `CODE: ` plus a fixed sentence (and a
|
|
659
|
+
`callerFix`, when the `UltimateError` declared one): the rule a production problem document follows,
|
|
660
|
+
and the one `@ultimat3/mcp` applies. The model's provider keeps every request it is sent, so a
|
|
661
|
+
database message in a tool result is a disclosure nothing can recall.
|
|
662
|
+
|
|
632
663
|
## Errors
|
|
633
664
|
|
|
634
665
|
**`X_AI_PROVIDER_UNAVAILABLE` carries `retry: "retryable"` in `--json`, `As of 2026-08-23`**
|
|
@@ -654,6 +685,7 @@ fail-closed `terminal` default.
|
|
|
654
685
|
| `X_EVAL_THRESHOLD` | an eval scored below its bar |
|
|
655
686
|
| `X_VECTOR_DIM_MISMATCH` | a vector's length disagrees with the store |
|
|
656
687
|
| `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave the tenant it was bound to |
|
|
688
|
+
| `X_VECTOR_UNSCOPED` | a store with no tenant bound was read inside a request acting for an org; `scope: UNSCOPED` is the named opt-out |
|
|
657
689
|
| `X_NOT_IMPLEMENTED` | a remote driver with no key or transport; the fix names the env var |
|
|
658
690
|
|
|
659
691
|
### Error classes
|
|
@@ -688,3 +720,4 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
688
720
|
| `LlmTruncatedError` | `X_LLM_TRUNCATED` | `src/errors.ts` |
|
|
689
721
|
| `VectorDimMismatchError` | `X_VECTOR_DIM_MISMATCH` | `src/errors.ts` |
|
|
690
722
|
| `VectorScopeWidenedError` | `X_VECTOR_SCOPE_WIDENED` | `src/errors.ts` |
|
|
723
|
+
| `VectorUnscopedError` | `X_VECTOR_UNSCOPED` | `src/vector-scope.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.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,15 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/action": "
|
|
36
|
-
"@ultimat3/cache": "
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/db": "
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
42
|
-
"@ultimat3/
|
|
43
|
-
"@ultimat3/
|
|
35
|
+
"@ultimat3/action": "24.0.0",
|
|
36
|
+
"@ultimat3/cache": "24.0.0",
|
|
37
|
+
"@ultimat3/core": "24.0.0",
|
|
38
|
+
"@ultimat3/db": "24.0.0",
|
|
39
|
+
"@ultimat3/http": "24.0.0",
|
|
40
|
+
"@ultimat3/jobs": "24.0.0",
|
|
41
|
+
"@ultimat3/money": "24.0.0",
|
|
42
|
+
"@ultimat3/policy": "24.0.0",
|
|
43
|
+
"@ultimat3/schema": "24.0.0",
|
|
44
|
+
"@ultimat3/time": "24.0.0"
|
|
44
45
|
}
|
|
45
46
|
}
|
package/src/agent-transcript.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* next message — and a rule split across two call sites is a rule one of them will miss.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { RESPOND } from './llm';
|
|
9
8
|
import type { AiContentBlock, AiMessage, GenerateResult } from './provider';
|
|
9
|
+
import { RESPOND } from './respond';
|
|
10
10
|
import type { LlmToolCall, LlmToolResult } from './tools';
|
|
11
11
|
|
|
12
12
|
/**
|
package/src/agent.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type { AgentFact } from './agent-facts';
|
|
|
31
31
|
import { registerAgentFact } from './agent-facts';
|
|
32
32
|
import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
|
|
33
33
|
import type { BudgetLimits } from './budget';
|
|
34
|
-
import {
|
|
34
|
+
import { budgetKeysFor, currentBudget, withBudget } from './budget';
|
|
35
35
|
import {
|
|
36
36
|
AgentMaxTurnsError,
|
|
37
37
|
AgentToolUnexposedError,
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
LlmTruncatedError,
|
|
41
41
|
} from './errors';
|
|
42
42
|
import type { LlmBudget } from './llm';
|
|
43
|
-
import { answerAttributes
|
|
43
|
+
import { answerAttributes } from './llm';
|
|
44
44
|
import type { ModelId } from './models';
|
|
45
45
|
import { DEFAULT_MODEL, moreCapableThan } from './models';
|
|
46
46
|
import type { Prompt, PromptVars } from './prompt';
|
|
@@ -53,6 +53,8 @@ import type {
|
|
|
53
53
|
} from './provider';
|
|
54
54
|
import { isTruncated } from './provider';
|
|
55
55
|
import { assertNoSecrets } from './redaction';
|
|
56
|
+
import type { Respond } from './respond';
|
|
57
|
+
import { RESPOND, respondFor } from './respond';
|
|
56
58
|
import { aiGateway, aiRedactor } from './runtime';
|
|
57
59
|
import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
|
|
58
60
|
import { asProjectableAction, runLlmToolCall, toLlmTools, toolLabel } from './tools';
|
|
@@ -153,7 +155,7 @@ export function agent<
|
|
|
153
155
|
TOutput extends StandardSchemaV1,
|
|
154
156
|
V extends PromptVars,
|
|
155
157
|
>(def: AgentDef<TInput, TOutput, V>): Action<TInput, TOutput> {
|
|
156
|
-
const respond =
|
|
158
|
+
const respond = respondFor(def.output);
|
|
157
159
|
// At declaration, because the tools are values by then and a run that discovers this at the
|
|
158
160
|
// first request has already been declared, registered and projected as if it worked. Asked of
|
|
159
161
|
// the DECLARATION rather than of the projection: `isMcpExposed` needs no name, and a real
|
|
@@ -241,7 +243,7 @@ async function run<
|
|
|
241
243
|
V extends PromptVars,
|
|
242
244
|
>(
|
|
243
245
|
def: AgentDef<TInput, TOutput, V>,
|
|
244
|
-
respond:
|
|
246
|
+
respond: Respond,
|
|
245
247
|
adapted: Adapted,
|
|
246
248
|
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
247
249
|
): Promise<InferOutput<TOutput>> {
|
|
@@ -271,11 +273,10 @@ async function run<
|
|
|
271
273
|
const limits = limitsOf(def);
|
|
272
274
|
// Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
|
|
273
275
|
const gateway = aiGateway(name);
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
).derive(limits);
|
|
276
|
+
// Keyed on the CALLER, so the gateway's `actor` / `org` ceilings count this call against them.
|
|
277
|
+
const ledger = (currentBudget() ?? gateway.callLedger(budgetKeysFor(args.ctx.actor))).derive(
|
|
278
|
+
limits,
|
|
279
|
+
);
|
|
279
280
|
const base: GenerateRequest = {
|
|
280
281
|
model,
|
|
281
282
|
...(system === undefined ? {} : { system }),
|
|
@@ -283,7 +284,7 @@ async function run<
|
|
|
283
284
|
maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
284
285
|
...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
|
|
285
286
|
...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
|
|
286
|
-
tools: [...adapted.offered, respond],
|
|
287
|
+
tools: [...adapted.offered, respond.tool],
|
|
287
288
|
// The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
|
|
288
289
|
// not just the top of the next turn: a provider call already in flight is the expensive one.
|
|
289
290
|
signal: args.ctx.signal,
|
|
@@ -328,8 +329,8 @@ async function run<
|
|
|
328
329
|
// Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
|
|
329
330
|
// model turn asked for, each entry is an ordinary `action` carrying its own policy and
|
|
330
331
|
// its own `rateLimit`, and a second ceiling here would be a throttle competing with
|
|
331
|
-
// those. A tool that calls a model still
|
|
332
|
-
// budget holds. Serial cost 5x wall clock for a turn that asked for 5 tools, and nothing
|
|
332
|
+
// those. A tool that calls a model still reserves on the same ledger chain, which
|
|
333
|
+
// debits before it checks, so the budget holds. Serial cost 5x wall clock for a turn that asked for 5 tools, and nothing
|
|
333
334
|
// in the types or the docs ever said so.
|
|
334
335
|
//
|
|
335
336
|
// Order is by INDEX, not by completion: `Promise.all` resolves positionally and each
|
|
@@ -353,7 +354,7 @@ async function run<
|
|
|
353
354
|
continue;
|
|
354
355
|
}
|
|
355
356
|
|
|
356
|
-
const parsed = await validateAsync(def.output,
|
|
357
|
+
const parsed = await validateAsync(def.output, respond.read(result));
|
|
357
358
|
if (parsed.issues === undefined) return parsed.value;
|
|
358
359
|
// A wrong shape gets another turn like any other, because unlike `llm()` this loop has
|
|
359
360
|
// turns left by construction — and unlike a tool result, the correction is the message.
|
package/src/budget.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// module-scope `new` threw at EVALUATION in a browser bundle, where the bundler stubs
|
|
11
11
|
// `node:async_hooks` to `{}`, and took every importer of `@ultimat3/ai` with it.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import type { Actor } from '@ultimat3/core';
|
|
14
|
+
import { assert, asyncContext, finiteCount } from '@ultimat3/core';
|
|
14
15
|
import type { Money } from '@ultimat3/money';
|
|
15
16
|
import { assertSameCurrency } from '@ultimat3/money';
|
|
16
17
|
import { AiBudgetExceededError } from './errors';
|
|
@@ -64,14 +65,48 @@ export function estimateSpend(request: GenerateRequest): SpendEstimate {
|
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* What one `take` found. `spent` is the counter as the store read it, BEFORE this take — so a
|
|
70
|
+
* refusal can say how much was left.
|
|
71
|
+
*/
|
|
72
|
+
export interface BudgetTake {
|
|
73
|
+
readonly taken: boolean;
|
|
74
|
+
readonly spent: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
/** Where cross-request counters live. Swap for Redis in a multi-process deployment. */
|
|
68
78
|
export interface BudgetStore {
|
|
69
79
|
spent(key: string): Promise<number> | number;
|
|
70
80
|
/** `tokens` may be NEGATIVE: releasing a reservation the call never spent is a credit. */
|
|
71
81
|
add(key: string, tokens: number): Promise<void> | void;
|
|
82
|
+
/**
|
|
83
|
+
* Add `tokens` to `key` only if the total stays at or under `limit`, in ONE atomic step — a
|
|
84
|
+
* Redis `EVAL`, a SQL `update … set spent = spent + $2 where key = $1 and spent + $2 <= $3`.
|
|
85
|
+
* Required, with no default built on `spent` + `add`: that pair is the read-then-write two
|
|
86
|
+
* concurrent requests both pass, which is the overspend this member exists to close.
|
|
87
|
+
*/
|
|
88
|
+
take(key: string, tokens: number, limit: number): Promise<BudgetTake> | BudgetTake;
|
|
72
89
|
reset(key?: string): Promise<void> | void;
|
|
73
90
|
}
|
|
74
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The store keys a call's identity is counted under: the acting identity (kind and id — one window
|
|
94
|
+
* per actor, whichever org it acts in) and its org, when it has one. Derived, never taken from a
|
|
95
|
+
* declaration, so every `llm()`, `agent()` and `hive()` binds the gateway's `actor` / `org` ceilings
|
|
96
|
+
* to whoever called it. An anonymous caller is one identity: every anonymous call shares its window.
|
|
97
|
+
*/
|
|
98
|
+
export interface BudgetKeys {
|
|
99
|
+
readonly actorKey?: string;
|
|
100
|
+
readonly orgKey?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function budgetKeysFor(actor: Actor): BudgetKeys {
|
|
104
|
+
return {
|
|
105
|
+
actorKey: `actor:${actor.kind}:${actor.id}`,
|
|
106
|
+
...(actor.orgId === undefined ? {} : { orgKey: `org:${actor.orgId}` }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
75
110
|
/**
|
|
76
111
|
* What `reserve` debited, so `record` can reconcile it against the provider's real counts and
|
|
77
112
|
* `release` can give it back. Held by the caller rather than the ledger because one ledger serves
|
|
@@ -81,6 +116,13 @@ export interface BudgetReservation {
|
|
|
81
116
|
readonly tokens: number;
|
|
82
117
|
}
|
|
83
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The per-process default. There is NO window: a counter lives until `reset()` or a restart, so an
|
|
121
|
+
* `actor` / `org` ceiling here is "per process lifetime". It holds one entry per caller that has
|
|
122
|
+
* spent under a DECLARED ceiling (none at all without one), and deliberately no eviction: dropping
|
|
123
|
+
* a counter hands that caller its whole ceiling back, which is the bypass the ceiling exists to
|
|
124
|
+
* stop. A window — per day, per month — is a shared store whose keys expire.
|
|
125
|
+
*/
|
|
84
126
|
export class MemoryBudgetStore implements BudgetStore {
|
|
85
127
|
private readonly counters = new Map<string, number>();
|
|
86
128
|
|
|
@@ -88,8 +130,27 @@ export class MemoryBudgetStore implements BudgetStore {
|
|
|
88
130
|
return this.counters.get(key) ?? 0;
|
|
89
131
|
}
|
|
90
132
|
|
|
133
|
+
/** A counter back at zero is deleted: absent and zero are the same answer, and an entry costs. */
|
|
91
134
|
add(key: string, tokens: number): void {
|
|
92
|
-
this.
|
|
135
|
+
this.write(key, this.spent(key) + tokens);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Atomic because it is synchronous: nothing else runs between the read and the write. */
|
|
139
|
+
take(key: string, tokens: number, limit: number): BudgetTake {
|
|
140
|
+
const spent = this.spent(key);
|
|
141
|
+
if (spent + tokens > limit) return { taken: false, spent };
|
|
142
|
+
this.write(key, spent + tokens);
|
|
143
|
+
return { taken: true, spent };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How many counters are held — one per caller that has spent under a declared ceiling. */
|
|
147
|
+
size(): number {
|
|
148
|
+
return this.counters.size;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private write(key: string, total: number): void {
|
|
152
|
+
if (total === 0) this.counters.delete(key);
|
|
153
|
+
else this.counters.set(key, total);
|
|
93
154
|
}
|
|
94
155
|
|
|
95
156
|
reset(key?: string): void {
|
|
@@ -133,13 +194,6 @@ export class BudgetLedger {
|
|
|
133
194
|
* inside it, and its `request` ceiling was re-granted in full to every one of them.
|
|
134
195
|
*/
|
|
135
196
|
private parent: BudgetLedger | undefined;
|
|
136
|
-
/**
|
|
137
|
-
* Reservations take turns. Check-then-debit spans an `await store.spent()`, and three callers
|
|
138
|
-
* interleaving inside it is the bypass this ledger exists to close — one event loop, so a
|
|
139
|
-
* promise chain IS the lock. A store shared across PROCESSES needs an atomic increment of its
|
|
140
|
-
* own; this closes the parallelism inside one.
|
|
141
|
-
*/
|
|
142
|
-
private turnstile: Promise<unknown> = Promise.resolve();
|
|
143
197
|
|
|
144
198
|
constructor(input: BudgetLedgerInput) {
|
|
145
199
|
// The ceilings are screened where they LAND, because `assertScope` compares with `>`: a `NaN`
|
|
@@ -156,35 +210,19 @@ export class BudgetLedger {
|
|
|
156
210
|
}
|
|
157
211
|
|
|
158
212
|
/**
|
|
159
|
-
* Check an estimate against every applicable scope BEFORE the call,
|
|
160
|
-
* the first scope that cannot cover it, naming that scope, so the fix line points
|
|
161
|
-
* rather than four.
|
|
213
|
+
* Check an estimate against every applicable scope BEFORE the call, and DEBIT it in the same
|
|
214
|
+
* step. Throws on the first scope that cannot cover it, naming that scope, so the fix line points
|
|
215
|
+
* at one knob rather than four.
|
|
162
216
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
217
|
+
* Debit-then-check, never check-then-debit: the in-memory scopes are checked and debited with no
|
|
218
|
+
* `await` between (one event loop, so that IS atomic), and the store's scopes go through its
|
|
219
|
+
* atomic `take`. Any read-then-write across an `await` lets concurrent calls — `Promise.all` of
|
|
220
|
+
* derived ledgers, or one request per scope racing one org key — all read the same `spent`, all
|
|
221
|
+
* pass, and all debit a ceiling only one fitted. A refusal at a later scope gives back what the
|
|
222
|
+
* earlier ones took. `record` replaces the estimate with the real counts; `release` gives it back
|
|
223
|
+
* when the call never happened.
|
|
168
224
|
*/
|
|
169
225
|
async reserve(estimate: SpendEstimate): Promise<BudgetReservation> {
|
|
170
|
-
// The ROOT's turnstile, not this ledger's: reservations under one scope take turns even when
|
|
171
|
-
// each call derived its own ledger, which is every `llm()` call. A per-ledger queue serialised
|
|
172
|
-
// nothing once `derive` existed — `Promise.all` of three derived ledgers all read the chain
|
|
173
|
-
// before any of them debited it.
|
|
174
|
-
const gate = this.rootLedger();
|
|
175
|
-
const turn = gate.turnstile.then(() => this.reserveNow(estimate));
|
|
176
|
-
// Chained on a settled shadow: one refusal must not reject every reservation queued behind it.
|
|
177
|
-
gate.turnstile = turn.catch(() => undefined);
|
|
178
|
-
return await turn;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private rootLedger(): BudgetLedger {
|
|
182
|
-
let ledger: BudgetLedger = this;
|
|
183
|
-
while (ledger.parent !== undefined) ledger = ledger.parent;
|
|
184
|
-
return ledger;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
private async reserveNow(estimate: SpendEstimate): Promise<BudgetReservation> {
|
|
188
226
|
// Every ledger in the chain, because each keeps its own counter and the tightest limit is not
|
|
189
227
|
// always the one with the most spent against it.
|
|
190
228
|
for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
|
|
@@ -192,19 +230,53 @@ export class BudgetLedger {
|
|
|
192
230
|
}
|
|
193
231
|
// Per call, so nothing is "already spent" against it.
|
|
194
232
|
this.assertScope('tokensIn', this.limits.tokensIn, 0, estimate.inputTokens);
|
|
195
|
-
if (this.limits.actor !== undefined && this.actorKey !== undefined) {
|
|
196
|
-
const spent = await this.store.spent(this.actorKey);
|
|
197
|
-
this.assertScope(`actor:${this.actorKey}`, this.limits.actor, spent, estimate.tokens);
|
|
198
|
-
}
|
|
199
|
-
if (this.limits.org !== undefined && this.orgKey !== undefined) {
|
|
200
|
-
const spent = await this.store.spent(this.orgKey);
|
|
201
|
-
this.assertScope(`org:${this.orgKey}`, this.limits.org, spent, estimate.tokens);
|
|
202
|
-
}
|
|
203
233
|
this.assertCost(estimate.cost);
|
|
204
|
-
|
|
234
|
+
this.debitChain(estimate.tokens);
|
|
235
|
+
const taken: string[] = [];
|
|
236
|
+
try {
|
|
237
|
+
await this.takeScope('actor', this.limits.actor, this.actorKey, estimate.tokens, taken);
|
|
238
|
+
await this.takeScope('org', this.limits.org, this.orgKey, estimate.tokens, taken);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
this.debitChain(-estimate.tokens);
|
|
241
|
+
for (const key of taken) await this.store.add(key, -estimate.tokens);
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
205
244
|
return { tokens: estimate.tokens };
|
|
206
245
|
}
|
|
207
246
|
|
|
247
|
+
/**
|
|
248
|
+
* One store scope, and only when its ceiling is DECLARED: every `llm()` / `agent()` / `hive()`
|
|
249
|
+
* carries its caller's keys, so writing per key grew `MemoryBudgetStore` by one entry per caller
|
|
250
|
+
* forever — and cost a shared store two writes a call — for counters no ceiling reads. Records
|
|
251
|
+
* the key it took, so a refusal further on can give it back.
|
|
252
|
+
*/
|
|
253
|
+
private async takeScope(
|
|
254
|
+
scope: 'actor' | 'org',
|
|
255
|
+
limit: number | undefined,
|
|
256
|
+
key: string | undefined,
|
|
257
|
+
tokens: number,
|
|
258
|
+
taken: string[],
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
if (key === undefined || limit === undefined) return;
|
|
261
|
+
const outcome: unknown = await this.store.take(key, tokens, limit);
|
|
262
|
+
// The store is the app's: a shape it did not promise must be a coded refusal, never a
|
|
263
|
+
// `TypeError` from reading `.taken` off `undefined` inside the reservation.
|
|
264
|
+
assert(
|
|
265
|
+
isBudgetTake(outcome),
|
|
266
|
+
`BudgetStore.take("${key}") answered ${typeof outcome}, not { taken: boolean, spent: number }`,
|
|
267
|
+
'return { taken, spent } from take(key, tokens, limit), spent being the counter before this take',
|
|
268
|
+
);
|
|
269
|
+
if (!outcome.taken) {
|
|
270
|
+
throw new AiBudgetExceededError({
|
|
271
|
+
scope: `${scope}:${key}`,
|
|
272
|
+
requested: tokens,
|
|
273
|
+
remaining: limit - outcome.spent,
|
|
274
|
+
limit,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
taken.push(key);
|
|
278
|
+
}
|
|
279
|
+
|
|
208
280
|
/** Give a reservation back: a provider that threw, a stream abandoned before `done`. */
|
|
209
281
|
async release(reservation: BudgetReservation | undefined): Promise<void> {
|
|
210
282
|
if (reservation === undefined) return;
|
|
@@ -258,11 +330,25 @@ export class BudgetLedger {
|
|
|
258
330
|
*/
|
|
259
331
|
private async debit(tokens: number): Promise<void> {
|
|
260
332
|
if (tokens === 0) return;
|
|
333
|
+
this.debitChain(tokens);
|
|
334
|
+
// The keys `reserve` took — a scope with a declared ceiling — and no others, so a reconcile
|
|
335
|
+
// never creates the counter `takeScope` declined to.
|
|
336
|
+
for (const key of this.meteredKeys()) await this.store.add(key, tokens);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The store keys this ledger meters: a key whose scope declares a ceiling. */
|
|
340
|
+
private meteredKeys(): readonly string[] {
|
|
341
|
+
const keys: string[] = [];
|
|
342
|
+
if (this.actorKey !== undefined && this.limits.actor !== undefined) keys.push(this.actorKey);
|
|
343
|
+
if (this.orgKey !== undefined && this.limits.org !== undefined) keys.push(this.orgKey);
|
|
344
|
+
return keys;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The in-memory half: this ledger's `request` counter and every ancestor's. */
|
|
348
|
+
private debitChain(tokens: number): void {
|
|
261
349
|
for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
|
|
262
350
|
l.requestTokens += tokens;
|
|
263
351
|
}
|
|
264
|
-
if (this.actorKey !== undefined) await this.store.add(this.actorKey, tokens);
|
|
265
|
-
if (this.orgKey !== undefined) await this.store.add(this.orgKey, tokens);
|
|
266
352
|
}
|
|
267
353
|
|
|
268
354
|
async report(): Promise<BudgetReport> {
|
|
@@ -313,6 +399,12 @@ function assertFiniteLimits(limits: BudgetLimits): BudgetLimits {
|
|
|
313
399
|
return limits;
|
|
314
400
|
}
|
|
315
401
|
|
|
402
|
+
function isBudgetTake(value: unknown): value is BudgetTake {
|
|
403
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
404
|
+
const record = value as Record<string, unknown>;
|
|
405
|
+
return typeof record['taken'] === 'boolean' && Number.isFinite(record['spent']);
|
|
406
|
+
}
|
|
407
|
+
|
|
316
408
|
/** Spreadable single-key record, so an absent limit stays absent under exactOptionalPropertyTypes. */
|
|
317
409
|
function pick<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
|
|
318
410
|
return value === undefined ? {} : ({ [key]: value } as Record<K, V>);
|