@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/src/llm.ts ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * `llm()` — a model call declared as an `action`, NOT a ninth primitive.
3
+ *
4
+ * The eight primitives are the whole vocabulary. A model call is a server-authoritative
5
+ * operation with an input schema, an output schema and a policy, which is the definition of
6
+ * an `action` — so this file is a FACTORY over `action()`, never a new kind of thing. That is
7
+ * why `summarize.tool()`, `.openapi()`, `.client()`, `.job()` and `.contract()` all exist
8
+ * without a line here: the value is an action, so it projects like one, and an app gains an
9
+ * MCP tool backed by a model the moment it declares one.
10
+ *
11
+ * What the factory adds is the model half: the prompt is rendered from the parsed input, the
12
+ * `output` schema is projected into a tool the model must answer through, a per-call budget is
13
+ * reserved before a token is spent, and a near-duplicate prompt hits the semantic cache.
14
+ */
15
+
16
+ import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
17
+ import { action } from '@ultimat3/action';
18
+ import type { Ctx } from '@ultimat3/core';
19
+ import { withSpan } from '@ultimat3/core';
20
+ import type { Money } from '@ultimat3/money';
21
+ import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
22
+ import { formatIssues, toMcpInputSchema, validateAsync } from '@ultimat3/schema';
23
+ import { parseDuration } from '@ultimat3/time';
24
+ import type { BudgetLimits } from './budget';
25
+ import { BudgetLedger, currentBudget, withBudget } from './budget';
26
+ import { embedOne, fnv1a } from './embeddings';
27
+ import { LlmOutputInvalidError, LlmRefusedError, LlmTruncatedError } from './errors';
28
+ import type { ModelId } from './models';
29
+ import { DEFAULT_MODEL, MODEL_IDS } from './models';
30
+ import type { Prompt, PromptVars } from './prompt';
31
+ import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
32
+ import { aiEmbedder, aiGateway, semanticCacheFor } from './runtime';
33
+ import type { LlmTool } from './tools';
34
+
35
+ /** The tool the model answers through. One name, so the reader never has to guess. */
36
+ const RESPOND = 'respond';
37
+
38
+ /** Two attempts total: the answer, then one repair turn. See `LlmOutputInvalidError`. */
39
+ const ATTEMPTS = 2;
40
+
41
+ /**
42
+ * Output ceiling when the declaration omits one. Not the model's maximum — a 128k ceiling
43
+ * makes the worst-case cost estimate so large that every `costPerCall` budget refuses.
44
+ */
45
+ const DEFAULT_MAX_TOKENS = 4_096;
46
+
47
+ export interface LlmSemanticCache<TParsed> {
48
+ /** Cosine floor. Below ~0.9 unrelated prompts collide and the cache answers the wrong one. */
49
+ readonly threshold?: number;
50
+ /** Entry lifetime as a duration string — `'7d'`, `'12h'`. `@ultimat3/time` owns the grammar. */
51
+ readonly ttl?: string;
52
+ /**
53
+ * Partition key, from the parsed input. Each scope is a separate cache: cosine similarity
54
+ * has no notion of a tenant, so a shared cache answers one tenant with another's data.
55
+ */
56
+ readonly scope?: (input: TParsed) => string;
57
+ }
58
+
59
+ export interface LlmCache<TParsed> {
60
+ readonly semantic: LlmSemanticCache<TParsed>;
61
+ // `05-caching.md` also declares `invalidates: [tag.post]` here. It is deliberately absent
62
+ // until `@ultimat3/cache`'s fan-out can reach something that is not a `CacheTier`: storing
63
+ // tags that the ONE invalidation path never visits would read as wired and silently not be.
64
+ // Today the invalidation story is the prompt version (a bump reaches a different store) and
65
+ // `ttl`.
66
+ }
67
+
68
+ /** Per-call ceilings, checked before the provider is reached. Never truncates — refuses. */
69
+ export interface LlmBudget {
70
+ /** Prompt tokens. */
71
+ readonly tokensIn?: number;
72
+ /** Worst-case price of one call, integer minor units. */
73
+ readonly costPerCall?: Money;
74
+ }
75
+
76
+ export interface LlmVarsArgs<TInput extends StandardSchemaV1> {
77
+ readonly input: InferOutput<TInput>;
78
+ readonly ctx: Ctx;
79
+ }
80
+
81
+ export interface LlmDef<
82
+ TInput extends StandardSchemaV1,
83
+ TOutput extends StandardSchemaV1,
84
+ V extends PromptVars,
85
+ > {
86
+ /**
87
+ * Overrides the prompt artifact's own model. The prompt's is part of its content hash and
88
+ * travels with it; this one is the deployment decision, so it wins.
89
+ */
90
+ readonly model?: ModelId;
91
+ readonly input: TInput;
92
+ readonly output: TOutput;
93
+ readonly prompt: Prompt<V>;
94
+ /**
95
+ * Everything the prompt is allowed to see, derived from the parsed input. Required, and
96
+ * async, because the input is usually an id and the prompt needs the row behind it — this
97
+ * is the one declared place a model call loads data, so a reader can see what was sent.
98
+ */
99
+ vars(args: LlmVarsArgs<TInput>): V | Promise<V>;
100
+ readonly cache?: LlmCache<InferOutput<TInput>>;
101
+ readonly budget?: LlmBudget;
102
+ readonly policy: ActionPolicy;
103
+ readonly mcp?: ActionMcp;
104
+ /** Enforced completion ceiling. The model never sees it, so it can be cut off mid-answer. */
105
+ readonly maxTokens?: number;
106
+ }
107
+
108
+ export function llm<
109
+ TInput extends StandardSchemaV1,
110
+ TOutput extends StandardSchemaV1,
111
+ V extends PromptVars,
112
+ >(def: LlmDef<TInput, TOutput, V>): Action<TInput, TOutput> {
113
+ const respond = respondToolFor(def.output);
114
+ return action<TInput, TOutput>({
115
+ input: def.input,
116
+ output: def.output,
117
+ policy: def.policy,
118
+ ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
119
+ handle: (args) => generate(def, respond, args),
120
+ });
121
+ }
122
+
123
+ async function generate<
124
+ TInput extends StandardSchemaV1,
125
+ TOutput extends StandardSchemaV1,
126
+ V extends PromptVars,
127
+ >(
128
+ def: LlmDef<TInput, TOutput, V>,
129
+ respond: LlmTool,
130
+ args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
131
+ ): Promise<InferOutput<TOutput>> {
132
+ const { prompt } = def;
133
+ // The prompt ref is the identity every failure here is about, and unlike the action's
134
+ // export name it exists before registration and can never twin.
135
+ const name = prompt.ref;
136
+ const model = def.model ?? prompt.model ?? DEFAULT_MODEL;
137
+ const rendered = prompt.render(await def.vars({ input: args.input, ctx: args.ctx }));
138
+
139
+ return withSpan('ai.llm', async (span) => {
140
+ span.setAttributes({
141
+ 'llm.model': model,
142
+ 'llm.prompt': prompt.ref,
143
+ 'llm.prompt.hash': prompt.hash,
144
+ });
145
+
146
+ // A cached answer is still data of unknown provenance, so it goes through the schema like
147
+ // any other. One that no longer fits — the schema moved under it — is a miss, not a
148
+ // failure: the model can produce a fresh answer, and refusing would be worse than paying.
149
+ const cache = await openCache(def, args.input, rendered);
150
+ const hit = await accept(def.output, await cache?.lookup());
151
+ span.setAttribute('llm.cache.hit', hit !== undefined);
152
+ if (hit !== undefined) return hit.value;
153
+
154
+ const request: GenerateRequest = {
155
+ model,
156
+ ...(prompt.system === undefined ? {} : { system: prompt.system }),
157
+ messages: [{ role: 'user', content: rendered }],
158
+ maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
159
+ ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
160
+ ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
161
+ tools: [respond],
162
+ };
163
+
164
+ // A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
165
+ // and org ceilings this call runs inside, never widen them. The gateway reserves against
166
+ // it before the provider is touched — that is where `X_AI_BUDGET_EXCEEDED` comes from.
167
+ const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(
168
+ limitsOf(def.budget),
169
+ );
170
+ const gateway = aiGateway(name);
171
+
172
+ return withBudget(ledger, async () => {
173
+ let messages: readonly AiMessage[] = request.messages;
174
+ let issues = 'no output';
175
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
176
+ const result = await gateway.generate({ ...request, messages });
177
+ span.setAttributes({
178
+ 'llm.attempts': attempt,
179
+ 'llm.stop': result.stopReason,
180
+ 'llm.tokens': result.usage.inputTokens + result.usage.outputTokens,
181
+ 'llm.cost.minor': result.cost.minor,
182
+ });
183
+ // Branch on the stop reason BEFORE reading the answer. A refusal carries empty or partial
184
+ // content, so parsing it first reports a schema disagreement — a cause that is wrong, a
185
+ // fix that does not apply, and a repair turn spent buying the same refusal again.
186
+ if (result.stopReason === 'refusal') {
187
+ throw new LlmRefusedError({
188
+ prompt: name,
189
+ model: result.model,
190
+ // The fix names a model the caller can paste. `<another model>` is not one, and a
191
+ // refusal is exactly the moment nobody wants to go read the catalogue.
192
+ alternative: MODEL_IDS.find((id) => id !== result.model) ?? DEFAULT_MODEL,
193
+ category: result.stopDetails?.category,
194
+ explanation: result.stopDetails?.explanation,
195
+ });
196
+ }
197
+ const parsed = await validateAsync(def.output, structuredOutputOf(result));
198
+ if (parsed.issues === undefined) {
199
+ await cache?.remember(parsed.value);
200
+ return parsed.value;
201
+ }
202
+ // A cut-off answer that also fails its schema is not a disagreement about the shape: the
203
+ // ceiling is the same on the next attempt, so the repair turn is a second truncation.
204
+ if (result.stopReason === 'max_tokens') {
205
+ throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
206
+ }
207
+ issues = formatIssues(parsed.issues).join('; ');
208
+ messages = [...messages, { role: 'assistant', content: result.text }, repair(issues)];
209
+ }
210
+ throw new LlmOutputInvalidError({ prompt: name, attempts: ATTEMPTS, issues });
211
+ });
212
+ });
213
+ }
214
+
215
+ /** `undefined` for "does not fit". Wrapped so a legitimately falsy value is still a hit. */
216
+ async function accept<TOutput extends StandardSchemaV1>(
217
+ schema: TOutput,
218
+ value: unknown,
219
+ ): Promise<{ readonly value: InferOutput<TOutput> } | undefined> {
220
+ if (value === undefined) return undefined;
221
+ const parsed = await validateAsync(schema, value);
222
+ return parsed.issues === undefined ? { value: parsed.value } : undefined;
223
+ }
224
+
225
+ function repair(issues: string): AiMessage {
226
+ return {
227
+ role: 'user',
228
+ content: `That answer failed its schema: ${issues}. Call the "${RESPOND}" tool with a value that satisfies it. Answer only through the tool.`,
229
+ };
230
+ }
231
+
232
+ function limitsOf(budget: LlmBudget | undefined): BudgetLimits {
233
+ return {
234
+ ...(budget?.tokensIn === undefined ? {} : { tokensIn: budget.tokensIn }),
235
+ ...(budget?.costPerCall === undefined ? {} : { costPerCall: budget.costPerCall }),
236
+ };
237
+ }
238
+
239
+ /**
240
+ * The output schema as the only tool the model may answer through — the spec's "structured
241
+ * output drives tool use". `toMcpInputSchema` is the same projection an MCP client sees, so
242
+ * a model and an agent are shown one shape, and a schema it cannot express throws HERE, at
243
+ * declaration time, rather than degrading into a permissive node the model cannot satisfy.
244
+ */
245
+ function respondToolFor(output: StandardSchemaV1): LlmTool {
246
+ return {
247
+ name: RESPOND,
248
+ description: 'Return the result. Call this exactly once; do not answer in prose.',
249
+ input_schema: toMcpInputSchema(output),
250
+ strict: true,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * The tool call if the model made one, otherwise the text parsed as JSON — a model that
256
+ * answers in prose is a schema failure, not a crash, so it flows into the repair turn.
257
+ */
258
+ function structuredOutputOf(result: GenerateResult): unknown {
259
+ const call = result.toolCalls.find((c) => c.name === RESPOND);
260
+ if (call !== undefined) return call.input;
261
+ return parseJsonish(result.text);
262
+ }
263
+
264
+ function parseJsonish(text: string): unknown {
265
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
266
+ const body = (fenced?.[1] ?? text).trim();
267
+ try {
268
+ return JSON.parse(body);
269
+ } catch {
270
+ return undefined;
271
+ }
272
+ }
273
+
274
+ interface PromptCache {
275
+ lookup(): Promise<unknown>;
276
+ remember(value: unknown): Promise<void>;
277
+ }
278
+
279
+ /**
280
+ * The semantic cache for one declaration, or `undefined` when none was declared. The instance
281
+ * is partitioned by prompt VERSION as well as scope, which is what makes "editing a prompt
282
+ * requires a version bump" invalidate the cache: a bumped version reaches a different store,
283
+ * so an old answer cannot survive a prompt edit no matter how similar the text.
284
+ */
285
+ async function openCache<
286
+ TInput extends StandardSchemaV1,
287
+ TOutput extends StandardSchemaV1,
288
+ V extends PromptVars,
289
+ >(
290
+ def: LlmDef<TInput, TOutput, V>,
291
+ input: InferOutput<TInput>,
292
+ rendered: string,
293
+ ): Promise<PromptCache | undefined> {
294
+ const semantic = def.cache?.semantic;
295
+ if (semantic === undefined) return undefined;
296
+ const scope = semantic.scope?.(input) ?? 'global';
297
+ const store = semanticCacheFor(`${def.prompt.ref}#${def.prompt.hash}::${scope}`);
298
+ const embedding = Array.from(await embedOne(aiEmbedder(), rendered));
299
+ const ttlMs = semantic.ttl === undefined ? undefined : parseDuration(semantic.ttl);
300
+ return {
301
+ async lookup(): Promise<unknown> {
302
+ return (await store.lookup(embedding, semantic.threshold))?.value;
303
+ },
304
+ remember(value: unknown): Promise<void> {
305
+ return store.remember(
306
+ `${def.prompt.hash}:${fnv1a(rendered).toString(16)}`,
307
+ embedding,
308
+ value,
309
+ ttlMs === undefined ? {} : { ttlMs },
310
+ );
311
+ },
312
+ };
313
+ }
package/src/models.ts ADDED
@@ -0,0 +1,163 @@
1
+ // The blessed models: limits, prices, and the reasoning controls each one's request surface
2
+ // actually accepts. Apart from ./provider because those controls are NOT uniform across the
3
+ // catalogue — one body sent to all three is a guaranteed 400 on the oldest, and a downgrade for
4
+ // price is not a licence to send a body the provider rejects. As of 2026-08.
5
+
6
+ import type { Money } from '@ultimat3/money';
7
+ import { AiRequestInvalidError } from './errors';
8
+
9
+ /**
10
+ * Blessed models. Opus 5 is the default; the others are explicit downgrades. IDs are exact alias
11
+ * strings — never append a date suffix.
12
+ */
13
+ export const MODEL_IDS = ['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5'] as const;
14
+ export type ModelId = (typeof MODEL_IDS)[number];
15
+
16
+ export const DEFAULT_MODEL: ModelId = 'claude-opus-5';
17
+
18
+ /**
19
+ * Reasoning depth, shallowest first — the order is load-bearing, because a model that caps where
20
+ * thinking may be switched off compares against it. `xhigh` is the best setting for coding and
21
+ * agentic work; `high` is the API default. Distinct from `maxTokens`, which is an enforced
22
+ * ceiling the model cannot see.
23
+ */
24
+ export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
25
+ export type Effort = (typeof EFFORTS)[number];
26
+
27
+ /**
28
+ * Thinking mode. Adaptive lets the model decide depth per request and is the default on every
29
+ * model that has it. There is no token budget to tune — `effort` replaced it.
30
+ */
31
+ export type ThinkingMode = 'adaptive' | 'disabled';
32
+
33
+ /**
34
+ * What one model's request surface accepts. Every field here is a 400 when it is sent to a model
35
+ * that does not take it, which is why it is data on the spec rather than a rule in the request
36
+ * builder: adding a fourth model is a row, not an `if`.
37
+ */
38
+ export interface ModelReasoning {
39
+ /** `output_config.effort`. Models older than 4.6 reject it outright. */
40
+ readonly effort: boolean;
41
+ /** `thinking: {type:'adaptive'}`. Older models take a token budget this package never sends. */
42
+ readonly adaptive: boolean;
43
+ /** Deepest effort that still accepts `thinking: 'disabled'`; `undefined` = every effort does. */
44
+ readonly disableThinkingUpTo: Effort | undefined;
45
+ }
46
+
47
+ export interface ModelSpec {
48
+ readonly id: ModelId;
49
+ readonly contextWindow: number;
50
+ readonly maxOutput: number;
51
+ /** Cost of one million input tokens, in minor units. */
52
+ readonly inputPerMillion: Money;
53
+ /** Cost of one million output tokens, in minor units. */
54
+ readonly outputPerMillion: Money;
55
+ /** Minimum cacheable prefix; a shorter prefix silently does not cache. */
56
+ readonly cacheMinimumTokens: number;
57
+ readonly reasoning: ModelReasoning;
58
+ }
59
+
60
+ /**
61
+ * A price per million tokens, in INTEGER MINOR UNITS. Token spend is money, and the house rule
62
+ * applies to money wherever it comes from: never a float.
63
+ */
64
+ const usd = (minor: number): Money => ({ minor, currency: 'USD' });
65
+
66
+ export const MODELS: Readonly<Record<ModelId, ModelSpec>> = {
67
+ // $5 / $25 per MTok.
68
+ 'claude-opus-5': {
69
+ id: 'claude-opus-5',
70
+ contextWindow: 1_000_000,
71
+ maxOutput: 128_000,
72
+ inputPerMillion: usd(500),
73
+ outputPerMillion: usd(2_500),
74
+ cacheMinimumTokens: 512,
75
+ // Thinking is on by default here, and switching it OFF is legal only at `high` or below.
76
+ reasoning: { effort: true, adaptive: true, disableThinkingUpTo: 'high' },
77
+ },
78
+ // $3 / $15 per MTok. The introductory rate is deliberately NOT modelled: a price that lapses on
79
+ // a date makes every recorded cost depend on when it was read, and a budget that under-reports
80
+ // spend after the lapse is a budget that is not one. List price over-reserves, which is safe.
81
+ 'claude-sonnet-5': {
82
+ id: 'claude-sonnet-5',
83
+ contextWindow: 1_000_000,
84
+ maxOutput: 128_000,
85
+ inputPerMillion: usd(300),
86
+ outputPerMillion: usd(1_500),
87
+ cacheMinimumTokens: 1_024,
88
+ reasoning: { effort: true, adaptive: true, disableThinkingUpTo: undefined },
89
+ },
90
+ // $1 / $5 per MTok. Pre-4.6, so it has neither knob: an `output_config.effort` or an adaptive
91
+ // `thinking` block sent here is a 400 on every request, which is what made the cheap tier
92
+ // uncallable while the request body was one shape for the whole catalogue.
93
+ 'claude-haiku-4-5': {
94
+ id: 'claude-haiku-4-5',
95
+ contextWindow: 200_000,
96
+ maxOutput: 64_000,
97
+ inputPerMillion: usd(100),
98
+ outputPerMillion: usd(500),
99
+ cacheMinimumTokens: 4_096,
100
+ reasoning: { effort: false, adaptive: false, disableThinkingUpTo: undefined },
101
+ },
102
+ };
103
+
104
+ const rankOf = (effort: Effort): number => EFFORTS.indexOf(effort);
105
+
106
+ /**
107
+ * The reasoning half of a Messages body, shaped for one model. Everything it refuses, it refuses
108
+ * LOCALLY with a real code — a round trip to learn a rule this file already states costs latency
109
+ * and teaches nothing, and the provider's own message names the field rather than the fix.
110
+ *
111
+ * A control the caller never asked for is OMITTED rather than defaulted, so a model without the
112
+ * knob stays callable. A control the caller did ask for is never silently dropped: a declaration
113
+ * that reads `effort: 'max'` and quietly runs at the default is the failure nobody can see.
114
+ */
115
+ export function reasoningBody(
116
+ model: ModelId,
117
+ effort: Effort | undefined,
118
+ thinking: ThinkingMode | undefined,
119
+ ): Record<string, unknown> {
120
+ const rules = MODELS[model].reasoning;
121
+ const body: Record<string, unknown> = {};
122
+
123
+ if (effort !== undefined && !rules.effort) {
124
+ throw new AiRequestInvalidError({
125
+ detail: `model "${model}" has no effort control; output_config.effort is a 400 on it`,
126
+ fix: `drop effort from definePrompt, or set model: '${DEFAULT_MODEL}' on the llm() declaration`,
127
+ });
128
+ }
129
+ // Only what the caller asked for. `output_config`, not a top-level `effort` — a top-level one
130
+ // is silently ignored — and no block at all when nothing was requested, because a default sent
131
+ // as a request is indistinguishable on the wire from a declaration that asked for it.
132
+ if (effort !== undefined) body['output_config'] = { effort };
133
+
134
+ if (!rules.adaptive) {
135
+ if (thinking === 'adaptive') {
136
+ throw new AiRequestInvalidError({
137
+ detail: `model "${model}" predates adaptive thinking; a thinking block is a 400 on it`,
138
+ fix: `set model: '${DEFAULT_MODEL}' on the llm() declaration, or drop thinking from definePrompt`,
139
+ });
140
+ }
141
+ // No `thinking` field at all is exactly "no thinking" on a pre-4.6 model, so `disabled`
142
+ // needs nothing sent — and sending a block it may not parse would be a 400 for no gain.
143
+ return body;
144
+ }
145
+
146
+ if ((thinking ?? 'adaptive') === 'disabled') {
147
+ assertDisableAllowed(model, rules, effort ?? 'high');
148
+ body['thinking'] = { type: 'disabled' };
149
+ return body;
150
+ }
151
+ body['thinking'] = { type: 'adaptive', display: 'summarized' };
152
+ return body;
153
+ }
154
+
155
+ /** Some models cap the effort at which thinking may be switched off. Above the cap it is a 400. */
156
+ function assertDisableAllowed(model: ModelId, rules: ModelReasoning, effort: Effort): void {
157
+ const cap = rules.disableThinkingUpTo;
158
+ if (cap === undefined || rankOf(effort) <= rankOf(cap)) return;
159
+ throw new AiRequestInvalidError({
160
+ detail: `model "${model}" allows thinking: 'disabled' only at effort '${cap}' or below, not '${effort}'`,
161
+ fix: `set effort: '${cap}' in definePrompt alongside thinking: 'disabled', or drop thinking from it`,
162
+ });
163
+ }
@@ -0,0 +1,198 @@
1
+ // Single responsibility: compile every pgvector statement. It lives apart from the store for
2
+ // the same reason `@ultimat3/entity` splits `pg-sql.ts` from `pg-driver.ts` — the SQL an agent
3
+ // has to read when a search is slow or wrong should be one file it can open, and every scalar
4
+ // here goes through `sql` so a metadata key or a tenant id can never become syntax.
5
+
6
+ import { identifier, join, literal, type SqlFragment, sql } from '@ultimat3/db';
7
+ import type { MetadataFilter } from './vector';
8
+ import type { VectorScope } from './vector-scope';
9
+
10
+ export interface PgVectorTable {
11
+ readonly table: string;
12
+ readonly dimension: number;
13
+ /** The `regconfig` the FTS column and every query share. Both sides must agree or `@@` lies. */
14
+ readonly language: string;
15
+ }
16
+
17
+ /** Nothing matches. `in ()` is a syntax error, so an empty allow-list needs a constant. */
18
+ const NEVER = sql`1 = 0`;
19
+ const ALWAYS = sql`true`;
20
+
21
+ /** pgvector's text input form. Float32 widens to double exactly, so no precision is invented. */
22
+ export function vectorLiteral(vector: Float32Array): string {
23
+ return `[${Array.from(vector).join(',')}]`;
24
+ }
25
+
26
+ /**
27
+ * The scope and the per-call filter as one `where` body. Every read, write and delete is built
28
+ * on top of this, which is what makes "tenant filters are applied in SQL" structural rather
29
+ * than a convention someone has to remember at each call site.
30
+ */
31
+ export function conditionsSql(scope: VectorScope, filter?: MetadataFilter): SqlFragment {
32
+ const parts: SqlFragment[] = [];
33
+ if (scope.tenant !== undefined) parts.push(sql`"tenant" = ${scope.tenant}`);
34
+ for (const [key, values] of Object.entries(scope.allow ?? {})) {
35
+ parts.push(
36
+ values.length === 0
37
+ ? NEVER
38
+ : sql`"metadata" ->> ${key} in (${join(values.map((value) => sql`${value}`))})`,
39
+ );
40
+ }
41
+ for (const [key, value] of Object.entries(filter ?? {})) {
42
+ parts.push(sql`"metadata" ->> ${key} = ${value}`);
43
+ }
44
+ return parts.length === 0 ? ALWAYS : join(parts, ' and ');
45
+ }
46
+
47
+ /**
48
+ * `x db gen` emits this. The primary key is `(tenant, id)`, not `id`: it makes a cross-tenant
49
+ * overwrite impossible at the storage layer instead of relying on every upsert remembering to
50
+ * check. An unscoped store writes the empty tenant, which is a tenant like any other.
51
+ */
52
+ export function ddlSql(target: PgVectorTable): string {
53
+ const table = identifier(target.table).text;
54
+ const language = literal(target.language).text;
55
+ return [
56
+ `create extension if not exists vector;`,
57
+ `create table if not exists ${table} (`,
58
+ ` tenant text not null default '',`,
59
+ ` id text not null,`,
60
+ ` embedding vector(${target.dimension}) not null,`,
61
+ ` content text not null,`,
62
+ ` metadata jsonb not null default '{}',`,
63
+ ` tsv tsvector generated always as (to_tsvector(${language}, content)) stored,`,
64
+ ` primary key (tenant, id)`,
65
+ `);`,
66
+ `create index if not exists ${indexName(target.table, 'embedding')}`,
67
+ ` on ${table} using hnsw (embedding vector_cosine_ops);`,
68
+ `create index if not exists ${indexName(target.table, 'tsv')} on ${table} using gin (tsv);`,
69
+ `create index if not exists ${indexName(target.table, 'metadata')}`,
70
+ ` on ${table} using gin (metadata jsonb_path_ops);`,
71
+ ].join('\n');
72
+ }
73
+
74
+ const indexName = (table: string, column: string): string =>
75
+ identifier(`${table}_${column}_idx`).text;
76
+
77
+ export interface PgVectorRowInput {
78
+ readonly id: string;
79
+ readonly vector: Float32Array;
80
+ readonly text: string;
81
+ readonly metadata: Readonly<Record<string, string>>;
82
+ }
83
+
84
+ export function upsertSql(
85
+ target: PgVectorTable,
86
+ tenant: string,
87
+ records: readonly PgVectorRowInput[],
88
+ ): SqlFragment {
89
+ // `::text::jsonb`, not `::jsonb`: a bound string cast straight to jsonb is JSON-encoded a
90
+ // SECOND time and lands as a jsonb *string*, which reads back fine and makes every
91
+ // `metadata ->> key` filter match nothing. Found live, invisible to a unit test of the reads.
92
+ const rows = records.map(
93
+ (record) =>
94
+ sql`(${tenant}, ${record.id}, ${vectorLiteral(record.vector)}::vector, ${record.text}, ${JSON.stringify(record.metadata)}::text::jsonb)`,
95
+ );
96
+ return sql`insert into ${identifier(target.table)} ("tenant", "id", "embedding", "content", "metadata")
97
+ values ${join(rows)}
98
+ on conflict ("tenant", "id") do update set
99
+ "embedding" = excluded."embedding",
100
+ "content" = excluded."content",
101
+ "metadata" = excluded."metadata"`;
102
+ }
103
+
104
+ export interface PgSearchArgs {
105
+ readonly scope: VectorScope;
106
+ readonly filter?: MetadataFilter | undefined;
107
+ readonly k: number;
108
+ }
109
+
110
+ /**
111
+ * `<=>` is cosine DISTANCE, so the score is `1 - distance` — the same scale the memory store
112
+ * returns. The ordering stays on the raw distance, ascending, inside a subquery: HNSW answers
113
+ * `order by embedding <=> $1` and nothing else, and `order by 1 - (...) desc` is a seq scan.
114
+ */
115
+ export function searchSql(
116
+ target: PgVectorTable,
117
+ vector: Float32Array,
118
+ args: PgSearchArgs,
119
+ ): SqlFragment {
120
+ return sql`select "id", "content", "metadata", 1 - distance as score
121
+ from (
122
+ select "id", "content", "metadata", "embedding" <=> ${vectorLiteral(vector)}::vector as distance
123
+ from ${identifier(target.table)}
124
+ where ${conditionsSql(args.scope, args.filter)}
125
+ order by distance
126
+ limit ${args.k}
127
+ ) top
128
+ order by distance`;
129
+ }
130
+
131
+ export function textSql(target: PgVectorTable, query: string, args: PgSearchArgs): SqlFragment {
132
+ return sql`select "id", "content", "metadata", ts_rank_cd("tsv", q) as score
133
+ from ${identifier(target.table)}, websearch_to_tsquery(${target.language}::regconfig, ${query}) q
134
+ where "tsv" @@ q and ${conditionsSql(args.scope, args.filter)}
135
+ order by score desc
136
+ limit ${args.k}`;
137
+ }
138
+
139
+ export interface PgHybridArgs extends PgSearchArgs {
140
+ readonly candidates: number;
141
+ readonly rrfK: number;
142
+ }
143
+
144
+ /**
145
+ * Reciprocal-rank fusion, done in SQL. Two candidate sets are ranked independently and fused by
146
+ * `1 / (rrfK + rank)` — identical to `fuse()` in `vector.ts`, so dev and production order hits
147
+ * the same way. Both CTEs carry the SAME scope conditions: fusing an unfiltered lexical ranking
148
+ * into a filtered dense one would leak the other tenant's rows through the back door.
149
+ */
150
+ export function hybridSql(
151
+ target: PgVectorTable,
152
+ query: string,
153
+ vector: Float32Array,
154
+ args: PgHybridArgs,
155
+ ): SqlFragment {
156
+ const table = identifier(target.table);
157
+ const where = conditionsSql(args.scope, args.filter);
158
+ return sql`with dense as (
159
+ select "tenant", "id", row_number() over (order by distance) as rank
160
+ from (
161
+ select "tenant", "id", "embedding" <=> ${vectorLiteral(vector)}::vector as distance
162
+ from ${table}
163
+ where ${where}
164
+ order by distance
165
+ limit ${args.candidates}
166
+ ) top
167
+ ), lexical as (
168
+ select "tenant", "id", row_number() over (order by relevance desc) as rank
169
+ from (
170
+ select "tenant", "id", ts_rank_cd("tsv", q) as relevance
171
+ from ${table}, websearch_to_tsquery(${target.language}::regconfig, ${query}) q
172
+ where "tsv" @@ q and ${where}
173
+ order by relevance desc
174
+ limit ${args.candidates}
175
+ ) top
176
+ ), fused as (
177
+ select "tenant", "id", sum(1.0 / (${args.rrfK} + rank))::double precision as score
178
+ from (
179
+ select "tenant", "id", rank from dense
180
+ union all
181
+ select "tenant", "id", rank from lexical
182
+ ) ranked
183
+ group by "tenant", "id"
184
+ )
185
+ select d."id", d."content", d."metadata", f.score
186
+ from fused f join ${table} d on d."tenant" = f."tenant" and d."id" = f."id"
187
+ order by f.score desc, d."id" asc
188
+ limit ${args.k}`;
189
+ }
190
+
191
+ export function deleteSql(
192
+ target: PgVectorTable,
193
+ scope: VectorScope,
194
+ ids: readonly string[],
195
+ ): SqlFragment {
196
+ return sql`delete from ${identifier(target.table)}
197
+ where "id" in (${join(ids.map((id) => sql`${id}`))}) and ${conditionsSql(scope)}`;
198
+ }