@ultimat3/ai 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # @ultimat3/ai 🧠
2
+
3
+ The LLM gateway primitive. Every model call in an Ultimate app goes through it, so budgets
4
+ and cost accounting cannot be bypassed by a stray `fetch`.
5
+
6
+ ```ts
7
+ import { createGateway, AnthropicProvider, EchoProvider } from '@ultimat3/ai';
8
+
9
+ export const ai = createGateway({
10
+ providers: [new AnthropicProvider(), new EchoProvider()], // ANTHROPIC_API_KEY, or { apiKey }
11
+ budget: { request: 40_000, actor: 500_000, org: 20_000_000 }, // tokens
12
+ cache: memoCache,
13
+ });
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 () => {
17
+ const { text } = await ai.generate({
18
+ model: 'claude-opus-5',
19
+ system: 'You summarise support tickets.',
20
+ messages: [{ role: 'user', content: ticket.body }],
21
+ maxTokens: 1_024,
22
+ effort: 'high',
23
+ });
24
+ return text;
25
+ });
26
+ ```
27
+
28
+ ## Rules the gateway enforces
29
+
30
+ | Rule | Why |
31
+ |---|---|
32
+ | A budget **refuses**, never truncates | a shortened prompt yields a confidently wrong answer with no signal |
33
+ | Cost is **integer minor units** (`@ultimat3/money`) | token spend is money; the house rule has no exception |
34
+ | Cost rounds **up** | a rounded-away fraction is money the framework absorbs and a budget under-reports |
35
+ | `temperature` / `top_p` / `top_k` are never sent | rejected with a 400 on every current model — steer with the prompt |
36
+ | `effort` goes in `output_config` | a top-level `effort` is silently ignored |
37
+ | The reasoning half of the body is **per model** | `effort` and adaptive thinking arrived with 4.6; sending them to an older model is a 400 on every request |
38
+ | A control the model lacks is **refused**, never dropped | a declaration reading `effort: 'max'` that quietly runs at the default is the failure nobody can see |
39
+ | A control nobody asked for is **omitted**, never defaulted | a default sent as a request is indistinguishable on the wire from one that was declared |
40
+ | A refusal is `X_LLM_REFUSED`, not a schema failure | it is a 200 with no answer in it, and a repair turn buys the same refusal again |
41
+ | A refusal is never cached | a cached one keeps serving a classifier decision after the prompt was fixed |
42
+ | Retries use **full jitter** | synchronised retries from N workers reproduce the rate limit |
43
+ | A 4xx is never retried | the same body gets the same rejection and burns the budget |
44
+
45
+ ## Streaming
46
+
47
+ `stream()` yields as the model writes and ends with one `done` chunk carrying the assembled
48
+ result — so a consumer that only wants the answer can ignore everything before it. Required
49
+ above `STREAM_ONLY_MAX_TOKENS` (16k): a non-streaming request that large hits the HTTP timeout
50
+ after the completion has already been generated and billed. `generate()` switches to this
51
+ transport by itself above the ceiling and returns the assembled result — the limit belongs to
52
+ the transport, so it is not one the caller has to change API for.
53
+
54
+ ```ts
55
+ for await (const chunk of ai.stream({ messages, maxTokens: 64_000 })) {
56
+ if (chunk.type === 'text') process.stdout.write(chunk.text);
57
+ if (chunk.type === 'tool-call') await runLlmToolCall(tools, chunk.call, actor);
58
+ if (chunk.type === 'done') debit(chunk.result.cost); // real usage, not the estimate
59
+ }
60
+ ```
61
+
62
+ | Rule | Why |
63
+ |---|---|
64
+ | A `tool-call` chunk arrives whole | `input_json_delta` fragments are not arguments until the block closes |
65
+ | `thinking` chunks never join `text` | concatenating every chunk must not ship the reasoning to the user |
66
+ | A stream cut before `message_stop` **throws** | a truncated answer reporting `end_turn` is wrong with no signal |
67
+ | An in-band `error` frame carries a status | `overloaded_error` mid-stream retries like a 529 on the handshake |
68
+
69
+ ## Embeddings
70
+
71
+ `RemoteEmbedder` speaks the one `/v1/embeddings` shape every hosted and self-hosted embedder
72
+ uses; `baseUrl` selects the provider. `HashEmbedder` is the deterministic offline twin `x dev`
73
+ and the test suite run on.
74
+
75
+ ```ts
76
+ const embedder = new RemoteEmbedder({ name: 'voyage-3', dimension: 1_024 }); // EMBEDDINGS_API_KEY
77
+ ```
78
+
79
+ Vectors are L2-normalised on arrival, so `cosine` stays a dot product. A width other than the
80
+ declared `dimension` is `X_VECTOR_DIM_MISMATCH` **before** anything reaches a store — a store
81
+ half-written at the wrong width has no error to report, only worse answers.
82
+
83
+ Models, `As of 2026-08`:
84
+
85
+ | Model | Context | Max output | Input / MTok | Output / MTok | `effort` | adaptive thinking |
86
+ |---|---|---|---|---|---|---|
87
+ | `claude-opus-5` (default) | 1M | 128K | $5 | $25 | yes | yes, off only at `effort ≤ high` |
88
+ | `claude-sonnet-5` | 1M | 128K | $3 | $15 | yes | yes |
89
+ | `claude-haiku-4-5` | 200K | 64K | $1 | $5 | no — a 400 | no — a 400 |
90
+
91
+ The last two columns are data on the spec, not prose: `body()` builds the reasoning half from
92
+ them, so a downgrade for price cannot become a request the provider rejects.
93
+
94
+ ## `llm()` — a model call, declared as an action
95
+
96
+ Not a ninth primitive. A model call has an input schema, an output schema and a policy, which
97
+ is an `action` — so `llm()` returns one, and everything an action projects, it projects.
98
+
99
+ ```ts
100
+ import { llm, t } from '@ultimat3/ai';
101
+ import { can } from '@ultimat3/policy';
102
+
103
+ export const summarize = llm({
104
+ model: 'claude-sonnet-5',
105
+ input: t.object({ postId: t.uuid }),
106
+ output: t.object({ summary: t.string, tags: t.array(t.string) }),
107
+ prompt: summarizePrompt, // versioned artifact
108
+ vars: async ({ input, ctx }) => ({ body: await ctx.posts.body(input.postId) }),
109
+ cache: { semantic: { threshold: 0.97, ttl: '7d', scope: ({ orgId }) => orgId } },
110
+ budget: { tokensIn: 8_000, costPerCall: { minor: 5, currency: 'USD' } },
111
+ policy: can('post:read'),
112
+ });
113
+
114
+ summarize.tool(); // an MCP tool, gated by the same policy object
115
+ summarize.openapi(); // an HTTP operation
116
+ summarize.job(); // a job handle, for the long chains
117
+ summarize.contract(); // the contract tests
118
+ ```
119
+
120
+ | Declared | Behaviour |
121
+ |---|---|
122
+ | `output` | projected into the one tool the model may answer through; prose with a fenced JSON block still parses |
123
+ | a schema failure | **one** repair turn naming the issues, then `X_LLM_OUTPUT_INVALID` |
124
+ | `budget` | reserved against the worst case **before** the provider is reached — nothing spent, nothing truncated |
125
+ | `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation |
126
+ | `policy` | the same object every surface evaluates — an MCP call and an HTTP call are denied identically |
127
+ | `vars` | the one declared place a model call loads data, so a reader can see what was sent |
128
+
129
+ The gateway is ambient, installed once at boot — a declaration is evaluated at module scope,
130
+ long before a provider exists:
131
+
132
+ ```ts
133
+ configureAi({ gateway: createGateway({ providers: [new AnthropicProvider()] }) });
134
+ ```
135
+
136
+ Missing at call time is `X_AI_GATEWAY_MISSING`, never a silent default provider.
137
+
138
+ ## Evals are a test type
139
+
140
+ Not a notebook, not a weekly report — a `bun test` case that fails CI. **Every prompt has an
141
+ eval**; a `definePrompt` no `defineEval` names is `X_EVAL_MISSING` in `x verify`, because an
142
+ unevaluated prompt is untested code that costs money and answers users.
143
+
144
+ The gate is the **drop from a recorded baseline**, never an absolute score. An absolute floor
145
+ fails every eval at once the day a provider ships a slightly different model, which teaches
146
+ everyone to lower thresholds until they measure nothing.
147
+
148
+ ```ts
149
+ // app/support/summarize.evals.ts — the declaration the gate reads
150
+ import { defineEval, exact, jsonSchemaValid } from '@ultimat3/ai';
151
+ import { summarize } from './prompts';
152
+
153
+ export const summarizeEval = defineEval({
154
+ name: 'summarize',
155
+ prompt: summarize,
156
+ cases: [
157
+ { name: 'refund', vars: { ticket: 'I want my money back' }, expected: 'billing' },
158
+ { name: 'outage', vars: { ticket: 'the site is down' }, expected: 'incident' },
159
+ ],
160
+ scorers: [exact, jsonSchemaValid(['category', 'summary'])],
161
+ baseline: import.meta.resolve('./summarize.baseline.json'), // committed scores
162
+ tolerance: 0.05, // how far one may fall
163
+ });
164
+ ```
165
+
166
+ ```ts
167
+ // app/support/summarize.eval.test.ts — the suite `x verify` runs
168
+ test('summarize holds its recorded scores', async () => {
169
+ await summarizeEval.assert(ai); // throws X_EVAL_THRESHOLD on a drop past 0.05
170
+ });
171
+ ```
172
+
173
+ `ULTIMATE_EVAL_RECORD=1 x test eval` writes the baselines instead of gating on them, so
174
+ accepting a new number is a reviewable diff. An eval that has never been recorded fails with
175
+ `X_EVAL_BASELINE_MISSING` — gating on nothing is not passing — and `x verify` asks that question
176
+ itself, so an eval no test happens to assert is still red.
177
+
178
+ Recording and the gate are mutually exclusive: `x verify` with `ULTIMATE_EVAL_RECORD` set is
179
+ `X_EVAL_RECORDING` and runs no suite. Recording passes by definition, and a gate that inherited
180
+ the flag would report green over numbers it had just written over the committed ones.
181
+
182
+ A failure names the score, what it fell from, the exact prompt hash, and every case that moved:
183
+
184
+ ```
185
+ X_EVAL_THRESHOLD: an eval scored below its tolerance
186
+ cause: eval "summarize" scored 0.667 against a recorded baseline of 1.000
187
+ (tolerance 0.050) on prompt version summarize@1.0.0 (a3f1…);
188
+ regressed: overall 0.67 ← 1.00, refund 0.00 ← 1.00
189
+ fix: x test summarize to see per-case scores, then fix the prompt — or
190
+ ULTIMATE_EVAL_RECORD=1 x test eval to accept the new numbers as a reviewed diff
191
+ ```
192
+
193
+ Built-in scorers: `exact`, `contains`, `jsonValid`, `jsonSchemaValid(keys)`,
194
+ `numericTolerance(t)`, `llmJudge({ judge })` — the judge prompt is itself versioned, so a
195
+ judge that drifts is a measuring instrument that lies, and its hash is in the scorer name.
196
+
197
+ ## Prompts are versioned artifacts
198
+
199
+ ```ts
200
+ export const summarize = definePrompt<{ ticket: string }>({
201
+ id: 'summarize',
202
+ version: '1.0.0',
203
+ system: 'You classify support tickets.',
204
+ template: 'Classify and summarise:\n\n{{ticket}}',
205
+ output: { type: 'object', properties: { category: { type: 'string' } } },
206
+ });
207
+ ```
208
+
209
+ Content-hashed over id, version, system, template, schemas, model, effort, and thinking mode.
210
+ Edit the template without bumping the version and `definePrompt` throws — otherwise every
211
+ score ever recorded against that version is silently invalid. An unfilled `{{variable}}`
212
+ throws too, like an i18n miss.
213
+
214
+ ## Retrieval
215
+
216
+ ```ts
217
+ const store = new PgVectorStore({ name: 'doc_chunks', dimension: 256 }); // MemoryVectorStore in dev
218
+ await indexDocument({ store, embedder, document: { id: 'faq', text } });
219
+
220
+ const hits = await retrieve({ store, embedder, query, k: 8 });
221
+ const context = assembleContext({ hits, maxTokens: 8_000 });
222
+ context.dropped; // reported, never silent
223
+ ```
224
+
225
+ Retrieval is **hybrid by default** — vector + lexical, fused by reciprocal rank. Pure vector
226
+ search loses on exactly the queries users type: error codes, SKUs, identifiers, rare terms.
227
+ RRF fuses by *rank*, so the two score scales never have to be reconciled.
228
+
229
+ `PgVectorStore` is the production path: pgvector cosine (`<=>`, HNSW) and Postgres FTS
230
+ (`websearch_to_tsquery` + `ts_rank_cd`, GIN) in **the same Postgres**, fused by `1/(k+rank)` in
231
+ one statement. `MemoryVectorStore` is the dev twin — BM25 instead of `ts_rank_cd`, the same RRF,
232
+ the same envelope. `store.ddl()` prints the table and both indexes; `x db gen` emits it.
233
+
234
+ ### The scope is the leak-proofing
235
+
236
+ ```ts
237
+ const tenantStore = store.scoped({ tenant: orgId, allow: { visibility: ['public', 'internal'] } });
238
+ ```
239
+
240
+ Every read, write and delete a scoped store emits carries `tenant = $n` and the allow-list **in
241
+ SQL** — including *both* halves of the hybrid fusion, since an unfiltered lexical ranking fused
242
+ into a filtered dense one leaks through the back door. `(tenant, id)` is the primary key, so a
243
+ cross-tenant overwrite is impossible at the storage layer rather than by remembering to check.
244
+ Allow-lists are default deny: a row missing the key is invisible, and an empty list matches
245
+ nothing. `scoped()` only ever **tightens** — re-scoping to a different tenant is
246
+ `X_VECTOR_SCOPE_WIDENED`, never a silent widening.
247
+
248
+ `chunk()` is token-aware with overlap and splits at paragraph, then sentence, then hard wrap
249
+ — a fact split across a boundary with no overlap is retrievable by neither chunk.
250
+
251
+ ## Tools: the same projection as MCP
252
+
253
+ ```ts
254
+ const tools = toLlmTools([publishPost, suspendUser]); // only those with mcp.expose
255
+ const result = await runLlmToolCall(actions, call, actor);
256
+ ```
257
+
258
+ An in-app agent and an external MCP agent both end at `action.run`, so they authorize
259
+ identically. The actor comes from the request context, never from the model.
260
+
261
+ ## Errors
262
+
263
+ | Code | Meaning |
264
+ |---|---|
265
+ | `X_AI_PROVIDER_UNAVAILABLE` | every provider for the model failed; lists what each said |
266
+ | `X_AI_BUDGET_EXCEEDED` | refused pre-flight, naming the scope and what remains |
267
+ | `X_AI_GATEWAY_MISSING` | an `llm()` action ran before `configureAi` |
268
+ | `X_AI_PROMPT_VERSION` | version drift, or a render missing a declared variable |
269
+ | `X_LLM_OUTPUT_INVALID` | the model failed its `output` schema on the answer and on the repair turn |
270
+ | `X_EVAL_THRESHOLD` | an eval scored below its bar |
271
+ | `X_VECTOR_DIM_MISMATCH` | a vector's length disagrees with the store |
272
+ | `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave the tenant it was bound to |
273
+ | `X_NOT_IMPLEMENTED` | a remote driver with no key or transport; the fix names the env var |
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@ultimat3/ai",
3
+ "version": "1.0.0",
4
+ "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/ai"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@ultimat3/action": "1.0.0",
34
+ "@ultimat3/cache": "1.0.0",
35
+ "@ultimat3/core": "1.0.0",
36
+ "@ultimat3/db": "1.0.0",
37
+ "@ultimat3/money": "1.0.0",
38
+ "@ultimat3/policy": "1.0.0",
39
+ "@ultimat3/schema": "1.0.0",
40
+ "@ultimat3/time": "1.0.0"
41
+ }
42
+ }
package/src/budget.ts ADDED
@@ -0,0 +1,233 @@
1
+ // Token and cost budgets, per request / per actor / per org.
2
+ //
3
+ // A budget REFUSES; it never truncates. A silently shortened prompt produces a confidently
4
+ // wrong answer that looks like a real one, and the caller has no signal anything happened.
5
+ // A thrown X_AI_BUDGET_EXCEEDED with the remaining count is strictly more useful.
6
+ //
7
+ // The carrier is an AsyncLocalStorage so nested calls (a RAG retrieval, a tool call that
8
+ // generates, an eval judge) all debit the same ledger without threading it through every
9
+ // signature. `node:async_hooks` is used directly because Bun implements it natively and the
10
+ // framework's ALS context is established at the HTTP boundary, above this package.
11
+
12
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
+ import type { Money } from '@ultimat3/money';
14
+ import { assertSameCurrency } from '@ultimat3/money';
15
+ import { AiBudgetExceededError } from './errors';
16
+ import type { GenerateRequest, TokenUsage } from './provider';
17
+ import { estimateCost, estimateInputTokens, estimateTokens, totalTokens } from './provider';
18
+
19
+ /** Ceilings. An omitted scope is unlimited — declare the ones that matter. */
20
+ export interface BudgetLimits {
21
+ /** Token ceiling for one `generate`/`stream` call, including its pre-flight estimate. */
22
+ readonly request?: number;
23
+ /**
24
+ * Prompt-token ceiling for ONE call. Distinct from `request`, which counts the completion
25
+ * too: a prompt is what the caller assembles and can shorten, a completion is not.
26
+ */
27
+ readonly tokensIn?: number;
28
+ /** Token ceiling for the acting identity across its whole window. */
29
+ readonly actor?: number;
30
+ /** Token ceiling for the organisation across its whole window. */
31
+ readonly org?: number;
32
+ /**
33
+ * Money ceiling for ONE call, checked against the worst-case estimate before the call.
34
+ * Per call rather than accumulated, because that is the knob an app can reason about:
35
+ * "no single answer may cost more than this". Integer minor units, never a float.
36
+ */
37
+ readonly costPerCall?: Money;
38
+ }
39
+
40
+ /**
41
+ * What one call is about to cost, priced before it happens. One object rather than a growing
42
+ * argument list, so a new scope is a new field here and never a new call site.
43
+ */
44
+ export interface SpendEstimate {
45
+ /** Prompt tokens — what `tokensIn` caps. */
46
+ readonly inputTokens: number;
47
+ /** Prompt plus worst-case completion — what the request/actor/org scopes count. */
48
+ readonly tokens: number;
49
+ /** Worst-case price, in integer minor units. */
50
+ readonly cost: Money;
51
+ }
52
+
53
+ /** Price a request pre-flight. The pessimistic read on purpose — see `estimateCost`. */
54
+ export function estimateSpend(request: GenerateRequest): SpendEstimate {
55
+ return {
56
+ inputTokens: estimateInputTokens(request),
57
+ tokens: estimateTokens(request),
58
+ cost: estimateCost(request),
59
+ };
60
+ }
61
+
62
+ /** Where cross-request counters live. Swap for Redis in a multi-process deployment. */
63
+ export interface BudgetStore {
64
+ spent(key: string): Promise<number> | number;
65
+ add(key: string, tokens: number): Promise<void> | void;
66
+ reset(key?: string): Promise<void> | void;
67
+ }
68
+
69
+ export class MemoryBudgetStore implements BudgetStore {
70
+ private readonly counters = new Map<string, number>();
71
+
72
+ spent(key: string): number {
73
+ return this.counters.get(key) ?? 0;
74
+ }
75
+
76
+ add(key: string, tokens: number): void {
77
+ this.counters.set(key, this.spent(key) + tokens);
78
+ }
79
+
80
+ reset(key?: string): void {
81
+ if (key === undefined) this.counters.clear();
82
+ else this.counters.delete(key);
83
+ }
84
+ }
85
+
86
+ export interface BudgetLedgerInput {
87
+ readonly limits: BudgetLimits;
88
+ /** Stable identity keys. Omit a key to skip that scope even when a limit is set. */
89
+ readonly actorKey?: string;
90
+ readonly orgKey?: string;
91
+ readonly store?: BudgetStore;
92
+ readonly currency?: string;
93
+ }
94
+
95
+ export interface BudgetReport {
96
+ readonly requestTokens: number;
97
+ readonly cost: Money;
98
+ readonly limits: BudgetLimits;
99
+ readonly actorSpent: number;
100
+ readonly orgSpent: number;
101
+ }
102
+
103
+ export class BudgetLedger {
104
+ private readonly limits: BudgetLimits;
105
+ private readonly actorKey: string | undefined;
106
+ private readonly orgKey: string | undefined;
107
+ private readonly store: BudgetStore;
108
+ private requestTokens = 0;
109
+ private costMinor = 0;
110
+ private readonly currency: string;
111
+
112
+ constructor(input: BudgetLedgerInput) {
113
+ this.limits = input.limits;
114
+ this.actorKey = input.actorKey;
115
+ this.orgKey = input.orgKey;
116
+ this.store = input.store ?? new MemoryBudgetStore();
117
+ this.currency = input.currency ?? 'USD';
118
+ }
119
+
120
+ /**
121
+ * Check an estimate against every applicable scope BEFORE the call. Throws on the first
122
+ * scope that cannot cover it, naming that scope, so the fix line points at one knob rather
123
+ * than four. Nothing is debited here: `record` does that with the provider's real counts.
124
+ */
125
+ async reserve(estimate: SpendEstimate): Promise<void> {
126
+ this.assertScope('request', this.limits.request, this.requestTokens, estimate.tokens);
127
+ // Per call, so nothing is "already spent" against it.
128
+ this.assertScope('tokensIn', this.limits.tokensIn, 0, estimate.inputTokens);
129
+ if (this.limits.actor !== undefined && this.actorKey !== undefined) {
130
+ const spent = await this.store.spent(this.actorKey);
131
+ this.assertScope(`actor:${this.actorKey}`, this.limits.actor, spent, estimate.tokens);
132
+ }
133
+ if (this.limits.org !== undefined && this.orgKey !== undefined) {
134
+ const spent = await this.store.spent(this.orgKey);
135
+ this.assertScope(`org:${this.orgKey}`, this.limits.org, spent, estimate.tokens);
136
+ }
137
+ this.assertCost(estimate.cost);
138
+ }
139
+
140
+ /**
141
+ * A nested ledger for one call: the TIGHTER of each limit, the same identity keys and the
142
+ * same store. Tightening rather than replacing is the point — a per-call budget declared on
143
+ * an `llm()` action must not be able to widen the actor or org ceiling it runs inside.
144
+ */
145
+ derive(limits: BudgetLimits): BudgetLedger {
146
+ return new BudgetLedger({
147
+ limits: {
148
+ ...pick('request', tighterNumber(this.limits.request, limits.request)),
149
+ ...pick('tokensIn', tighterNumber(this.limits.tokensIn, limits.tokensIn)),
150
+ ...pick('actor', tighterNumber(this.limits.actor, limits.actor)),
151
+ ...pick('org', tighterNumber(this.limits.org, limits.org)),
152
+ ...pick('costPerCall', tighterMoney(this.limits.costPerCall, limits.costPerCall)),
153
+ },
154
+ ...(this.actorKey !== undefined ? { actorKey: this.actorKey } : {}),
155
+ ...(this.orgKey !== undefined ? { orgKey: this.orgKey } : {}),
156
+ store: this.store,
157
+ currency: this.currency,
158
+ });
159
+ }
160
+
161
+ /** Debit ACTUAL usage after the call, replacing the estimate `reserve` worked from. */
162
+ async record(usage: TokenUsage, cost: Money): Promise<void> {
163
+ const tokens = totalTokens(usage);
164
+ this.requestTokens += tokens;
165
+ this.costMinor += cost.minor;
166
+ if (this.actorKey !== undefined) await this.store.add(this.actorKey, tokens);
167
+ if (this.orgKey !== undefined) await this.store.add(this.orgKey, tokens);
168
+ }
169
+
170
+ async report(): Promise<BudgetReport> {
171
+ return {
172
+ requestTokens: this.requestTokens,
173
+ cost: { minor: this.costMinor, currency: this.currency },
174
+ limits: this.limits,
175
+ actorSpent: this.actorKey === undefined ? 0 : await this.store.spent(this.actorKey),
176
+ orgSpent: this.orgKey === undefined ? 0 : await this.store.spent(this.orgKey),
177
+ };
178
+ }
179
+
180
+ private assertScope(scope: string, limit: number | undefined, spent: number, want: number): void {
181
+ if (limit === undefined) return;
182
+ const remaining = limit - spent;
183
+ if (want > remaining) {
184
+ throw new AiBudgetExceededError({ scope, requested: want, remaining, limit });
185
+ }
186
+ }
187
+
188
+ /** Per-call, so `remaining` IS the limit. Currencies must match; a mismatch is a config bug. */
189
+ private assertCost(cost: Money): void {
190
+ const limit = this.limits.costPerCall;
191
+ if (limit === undefined) return;
192
+ assertSameCurrency(limit, cost);
193
+ if (cost.minor > limit.minor) {
194
+ throw new AiBudgetExceededError({
195
+ scope: 'costPerCall',
196
+ requested: cost.minor,
197
+ remaining: limit.minor,
198
+ limit: limit.minor,
199
+ unit: `${limit.currency} minor units`,
200
+ });
201
+ }
202
+ }
203
+ }
204
+
205
+ /** Spreadable single-key record, so an absent limit stays absent under exactOptionalPropertyTypes. */
206
+ function pick<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
207
+ return value === undefined ? {} : ({ [key]: value } as Record<K, V>);
208
+ }
209
+
210
+ function tighterNumber(a: number | undefined, b: number | undefined): number | undefined {
211
+ if (a === undefined) return b;
212
+ if (b === undefined) return a;
213
+ return Math.min(a, b);
214
+ }
215
+
216
+ function tighterMoney(a: Money | undefined, b: Money | undefined): Money | undefined {
217
+ if (a === undefined) return b;
218
+ if (b === undefined) return a;
219
+ assertSameCurrency(a, b);
220
+ return a.minor <= b.minor ? a : b;
221
+ }
222
+
223
+ const storage = new AsyncLocalStorage<BudgetLedger>();
224
+
225
+ /** Run `fn` with `ledger` as the ambient budget for everything it awaits. */
226
+ export function withBudget<T>(ledger: BudgetLedger, fn: () => Promise<T>): Promise<T> {
227
+ return storage.run(ledger, fn);
228
+ }
229
+
230
+ /** The ambient ledger, or `undefined` outside a budget scope (spend is then unmetered). */
231
+ export function currentBudget(): BudgetLedger | undefined {
232
+ return storage.getStore();
233
+ }
@@ -0,0 +1,107 @@
1
+ // The embedding interface, the vector maths every store shares, and a deterministic hash
2
+ // embedder for tests and `x dev`. The remote one lives in ./remote-embedder.
3
+ //
4
+ // The dimension lives in the TYPE, not in a config file: a store built with one embedder and
5
+ // queried with another is a silent relevance collapse, and the only place to catch it is
6
+ // where the two meet. `VectorStore` compares the declared dimension and refuses.
7
+
8
+ import { AiEmbedderInvalidError } from './errors';
9
+
10
+ export interface Embedder {
11
+ readonly name: string;
12
+ /** Declared once, checked everywhere. */
13
+ readonly dimension: number;
14
+ /** Batched: an embedder that only does one text at a time is a per-chunk round trip. */
15
+ embed(texts: readonly string[]): Promise<readonly Float32Array[]>;
16
+ }
17
+
18
+ /** Embed one text without building an array at the call site. */
19
+ export async function embedOne(embedder: Embedder, text: string): Promise<Float32Array> {
20
+ const [vector] = await embedder.embed([text]);
21
+ if (vector === undefined) throw new AiEmbedderInvalidError({ embedder: embedder.name });
22
+ return vector;
23
+ }
24
+
25
+ /**
26
+ * Split a batch into chunks of `size`, preserving order. Providers cap batch size, and a
27
+ * caller that embeds a 50k-chunk corpus should not have to know each provider's cap.
28
+ */
29
+ export async function embedBatched(
30
+ embedder: Embedder,
31
+ texts: readonly string[],
32
+ size = 96,
33
+ ): Promise<readonly Float32Array[]> {
34
+ const out: Float32Array[] = [];
35
+ for (let i = 0; i < texts.length; i += size) {
36
+ out.push(...(await embedder.embed(texts.slice(i, i + size))));
37
+ }
38
+ return out;
39
+ }
40
+
41
+ export interface HashEmbedderInput {
42
+ readonly dimension?: number;
43
+ }
44
+
45
+ /**
46
+ * Deterministic bag-of-words hashing embedder. Not semantic — two paraphrases share no
47
+ * vocabulary and land far apart — but it IS stable, dependency-free, and fast, which is
48
+ * what a test fixture and a `x dev` boot without an API key actually need. Shared words
49
+ * produce genuine similarity, so relevance tests are meaningful rather than tautological.
50
+ */
51
+ export class HashEmbedder implements Embedder {
52
+ readonly name = 'hash';
53
+ readonly dimension: number;
54
+
55
+ constructor(input: HashEmbedderInput = {}) {
56
+ this.dimension = input.dimension ?? 256;
57
+ }
58
+
59
+ async embed(texts: readonly string[]): Promise<readonly Float32Array[]> {
60
+ return texts.map((text) => this.one(text));
61
+ }
62
+
63
+ private one(text: string): Float32Array {
64
+ const vector = new Float32Array(this.dimension);
65
+ for (const token of tokenize(text)) {
66
+ const slot = fnv1a(token) % this.dimension;
67
+ // Signed accumulation: without it every vector is non-negative and cosine
68
+ // similarity compresses into a narrow band where nothing ranks apart.
69
+ const sign = fnv1a(`${token}#sign`) % 2 === 0 ? 1 : -1;
70
+ vector[slot] = (vector[slot] ?? 0) + sign;
71
+ }
72
+ return normalize(vector);
73
+ }
74
+ }
75
+
76
+ /** Lowercased word tokens. Punctuation is dropped; digits are kept (versions, ids). */
77
+ export function tokenize(text: string): readonly string[] {
78
+ return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
79
+ }
80
+
81
+ /** FNV-1a 32-bit. Chosen for stability across runtimes, not for cryptographic strength. */
82
+ export function fnv1a(text: string): number {
83
+ let hash = 0x811c9dc5;
84
+ for (let i = 0; i < text.length; i += 1) {
85
+ hash ^= text.charCodeAt(i);
86
+ hash = Math.imul(hash, 0x01000193) >>> 0;
87
+ }
88
+ return hash;
89
+ }
90
+
91
+ /** L2 normalise in place so cosine similarity reduces to a dot product. */
92
+ export function normalize(vector: Float32Array): Float32Array {
93
+ let sum = 0;
94
+ for (const value of vector) sum += value * value;
95
+ if (sum === 0) return vector;
96
+ const inverse = 1 / Math.sqrt(sum);
97
+ for (let i = 0; i < vector.length; i += 1) vector[i] = (vector[i] ?? 0) * inverse;
98
+ return vector;
99
+ }
100
+
101
+ /** Dot product. Correct as cosine only for normalised vectors — which `normalize` ensures. */
102
+ export function cosine(a: Float32Array, b: Float32Array): number {
103
+ let sum = 0;
104
+ const length = Math.min(a.length, b.length);
105
+ for (let i = 0; i < length; i += 1) sum += (a[i] ?? 0) * (b[i] ?? 0);
106
+ return sum;
107
+ }