@ultimat3/ai 22.15.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 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), `jobs` (`agentJob()`), `money`, `policy`, `schema`, `time`.
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; reservations queue on the ROOT's turnstile.
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 root turnstile holds
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**, and the overlap
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
- const answer = await ai.scope({ actorKey: actor.id, orgKey: actor.orgId }, async () => {
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 `orgs` are counters across calls, so where they live
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
- Three methods, and `add` takes a **negative** `tokens` — releasing a reservation the call never
65
- spent is a credit, so a store that clamps at zero leaks the ceiling. `org: 20_000_000` on the
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 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 |
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 | the ceiling holds under parallelism because the budget's root turnstile debits before the call, so three members against a ceiling only one fits leave exactly one `ok` — no hive-specific budget code exists |
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": "22.15.0",
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": "22.15.0",
36
- "@ultimat3/cache": "22.15.0",
37
- "@ultimat3/core": "22.15.0",
38
- "@ultimat3/db": "22.15.0",
39
- "@ultimat3/jobs": "22.15.0",
40
- "@ultimat3/money": "22.15.0",
41
- "@ultimat3/policy": "22.15.0",
42
- "@ultimat3/schema": "22.15.0",
43
- "@ultimat3/time": "22.15.0"
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
  }
@@ -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 { BudgetLedger, currentBudget, withBudget } from './budget';
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, RESPOND, respondToolFor, structuredOutputOf } from './llm';
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 = respondToolFor(def.output);
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: LlmTool,
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
- const ledger = (
275
- currentBudget() ??
276
- gateway.callLedger?.() ??
277
- new BudgetLedger({ limits: {} })
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 queues on the ledger's root turnstile, so the
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, structuredOutputOf(result));
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 { asyncContext, finiteCount } from '@ultimat3/core';
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.counters.set(key, this.spent(key) + tokens);
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, then DEBIT it. Throws on
160
- * the first scope that cannot cover it, naming that scope, so the fix line points at one knob
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
- * The debit is what makes the ceiling hold under parallelism. Checking without debiting meant
164
- * three concurrent calls under one ledger all read `spent() === 0`, all passed, and all three
165
- * recorded against a ceiling only one of them fitted — an "un-bypassable" org budget bypassed
166
- * by `Promise.all`. `record` replaces the estimate with the real counts; `release` gives it
167
- * back when the call never happened.
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
- await this.debit(estimate.tokens);
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>);