@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/src/embeddings.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // queried with another is a silent relevance collapse, and the only place to catch it is
6
6
  // where the two meet. `VectorStore` compares the declared dimension and refuses.
7
7
 
8
- import { finiteCount } from '@ultimat3/core';
8
+ import { fingerprint, finiteCount } from '@ultimat3/core';
9
9
  import { AiEmbedderInvalidError } from './errors';
10
10
 
11
11
  export interface Embedder {
@@ -88,10 +88,10 @@ export class HashEmbedder implements Embedder {
88
88
  private one(text: string): Float32Array {
89
89
  const vector = new Float32Array(this.dimension);
90
90
  for (const token of tokenize(text)) {
91
- const slot = fnv1a(token) % this.dimension;
91
+ const slot = hashOf(token) % this.dimension;
92
92
  // Signed accumulation: without it every vector is non-negative and cosine
93
93
  // similarity compresses into a narrow band where nothing ranks apart.
94
- const sign = fnv1a(`${token}#sign`) % 2 === 0 ? 1 : -1;
94
+ const sign = hashOf(`${token}#sign`) % 2 === 0 ? 1 : -1;
95
95
  vector[slot] = (vector[slot] ?? 0) + sign;
96
96
  }
97
97
  return normalize(vector);
@@ -103,14 +103,13 @@ export function tokenize(text: string): readonly string[] {
103
103
  return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
104
104
  }
105
105
 
106
- /** FNV-1a 32-bit. Chosen for stability across runtimes, not for cryptographic strength. */
107
- export function fnv1a(text: string): number {
108
- let hash = 0x811c9dc5;
109
- for (let i = 0; i < text.length; i += 1) {
110
- hash ^= text.charCodeAt(i);
111
- hash = Math.imul(hash, 0x01000193) >>> 0;
112
- }
113
- return hash;
106
+ /**
107
+ * A token's slot hash: 32 bits of core's `fingerprint`, never a local FNV-1a. FNV-1a/32 collides on
108
+ * ordinary words (`costarring` / `liquid`), and two colliding words embed IDENTICALLY here — cosine
109
+ * 1, so a semantic lookup answered one with the other's entry.
110
+ */
111
+ function hashOf(text: string): number {
112
+ return Number.parseInt(fingerprint(text).slice(0, 8), 16);
114
113
  }
115
114
 
116
115
  /** L2 normalise in place so cosine similarity reduces to a dot product. */
package/src/error-body.ts CHANGED
@@ -11,13 +11,20 @@ import { REDACTED } from '@ultimat3/core';
11
11
  /** Enough of an error body to name the field that was wrong, not enough to fill a log. */
12
12
  const DETAIL_LIMIT = 300;
13
13
 
14
+ /**
15
+ * How much of a failure body is READ. Larger than what is shown, because the message sits inside a
16
+ * JSON envelope that has to parse; bounded, because a provider streaming an endless 5xx body held
17
+ * the call and its memory for as long as it kept sending.
18
+ */
19
+ const READ_LIMIT = 4 * 1024;
20
+
14
21
  /**
15
22
  * The provider's own message, when it sent one — it names the offending field, we name the fix.
16
23
  * Falls back to the raw text because a proxy or a gateway timeout page is not JSON and is still
17
- * the best evidence there is.
24
+ * the best evidence there is. One reader for every transport, the embedder included.
18
25
  */
19
26
  export async function detailOf(response: Response): Promise<string> {
20
- const body = await response.text().catch(() => '');
27
+ const body = await boundedText(response.body);
21
28
  try {
22
29
  const parsed: unknown = JSON.parse(body);
23
30
  if (typeof parsed === 'object' && parsed !== null) {
@@ -28,11 +35,39 @@ export async function detailOf(response: Response): Promise<string> {
28
35
  }
29
36
  }
30
37
  } catch {
31
- // Not JSON — a proxy or a gateway timeout page. The raw text is still the best evidence.
38
+ // Not JSON — a proxy or a gateway timeout page, or a body cut at the read limit.
32
39
  }
33
40
  return body === '' ? response.statusText : body.slice(0, DETAIL_LIMIT);
34
41
  }
35
42
 
43
+ /** The start of a body, never more than `READ_LIMIT` bytes of it; the rest is cancelled. */
44
+ async function boundedText(body: ReadableStream<Uint8Array> | null): Promise<string> {
45
+ if (body === null) return '';
46
+ const reader = body.getReader();
47
+ const kept: Uint8Array[] = [];
48
+ let total = 0;
49
+ try {
50
+ while (total < READ_LIMIT) {
51
+ const { done, value } = await reader.read();
52
+ if (done) break;
53
+ kept.push(value);
54
+ total += value.byteLength;
55
+ }
56
+ } catch {
57
+ // A body that fails mid-read still has whatever arrived; the status is the error's substance.
58
+ } finally {
59
+ await reader.cancel().catch(() => undefined);
60
+ reader.releaseLock();
61
+ }
62
+ const bytes = new Uint8Array(total);
63
+ let offset = 0;
64
+ for (const chunk of kept) {
65
+ bytes.set(chunk, offset);
66
+ offset += chunk.byteLength;
67
+ }
68
+ return new TextDecoder().decode(bytes);
69
+ }
70
+
36
71
  /**
37
72
  * Every occurrence of the credential replaced with `[redacted]`. Cheap, and the one leak path:
38
73
  * a proxy that echoes the request headers into its own 400 body puts the key in an error, and an
package/src/errors.ts CHANGED
@@ -27,6 +27,7 @@ export const AI_ERROR_CODES = [
27
27
  'X_VECTOR_DIM_MISMATCH',
28
28
  'X_VECTOR_SCOPE_WIDENED',
29
29
  'X_AI_EMBEDDER_INVALID',
30
+ 'X_VECTOR_UNSCOPED',
30
31
  ] as const;
31
32
 
32
33
  export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
@@ -55,6 +56,7 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
55
56
  X_VECTOR_DIM_MISMATCH: 'embedding dimensions differ from the store',
56
57
  X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
57
58
  X_AI_EMBEDDER_INVALID: 'an Embedder returned fewer vectors than texts it was given',
59
+ X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
58
60
  };
59
61
 
60
62
  // Titles must be registered for `format()` to render the contract's first line. Unconditional and
@@ -91,7 +91,11 @@ export function regressionsAgainst(input: {
91
91
  readonly tolerance: number;
92
92
  }): readonly Regression[] {
93
93
  const found: Regression[] = [];
94
- const fell = (was: number, now: number): boolean => now < was - input.tolerance;
94
+ // Both sides at the precision the baseline is WRITTEN at, and the drop rounded too: a raw 2/3
95
+ // against its own recorded 0.667 fell at `tolerance: 0`, and `0.9 - 0.05` is not `0.85` in
96
+ // binary — a drop of exactly the tolerance must read as within it.
97
+ const fell = (was: number, now: number): boolean =>
98
+ round(round(was) - round(now)) > input.tolerance;
95
99
  if (fell(input.baseline.score, input.score)) {
96
100
  found.push({ case: OVERALL, baseline: input.baseline.score, score: input.score });
97
101
  }
package/src/gateway.ts CHANGED
@@ -7,14 +7,16 @@
7
7
  import type { Random } from '@ultimat3/core';
8
8
  import {
9
9
  backoffDelay,
10
+ fingerprint,
10
11
  finiteCount,
11
12
  isRetryableStatus,
12
13
  isUltimateError,
14
+ logger,
13
15
  renderThrowable,
14
16
  stringField,
15
17
  } from '@ultimat3/core';
16
18
  import type { Money } from '@ultimat3/money';
17
- import type { BudgetLimits, BudgetStore } from './budget';
19
+ import type { BudgetKeys, BudgetLimits, BudgetStore } from './budget';
18
20
  import { BudgetLedger, currentBudget, estimateSpend, withBudget } from './budget';
19
21
  import { AiProviderUnavailableError } from './errors';
20
22
  import type { ModelId } from './models';
@@ -78,10 +80,11 @@ export interface Gateway {
78
80
  spent(): Promise<Money>;
79
81
  /**
80
82
  * A ledger over this gateway's own `budget`, for a call made with no `scope()` open — which is
81
- * every call `llm()` and `agent()` make. Optional so a hand-written gateway still satisfies the
82
- * interface; absent, such a call runs under the ceilings its own declaration sets and no others.
83
+ * every call `llm()`, `agent()` and `hive()` make, each passing `budgetKeysFor(ctx.actor)` so the
84
+ * `actor` and `org` ceilings count the caller. Required: a gateway without it ran every such call
85
+ * under no gateway ceiling at all.
83
86
  */
84
- callLedger?(): BudgetLedger;
87
+ callLedger(keys: BudgetKeys): BudgetLedger;
85
88
  }
86
89
 
87
90
  export function createGateway(input: CreateGatewayInput): Gateway {
@@ -114,23 +117,19 @@ class GatewayImpl implements Gateway {
114
117
  /**
115
118
  * `budget` held with no scope open. It was enforced ONLY inside `scope()`, which nothing in the
116
119
  * framework calls — so `createGateway({ budget: { request: 100 } })` capped no call any app made.
117
- * No actor or org key here: those scopes need `scope()` to name whose window they are.
120
+ * A raw `generate` / `stream` outside any scope passes no keys: it has no caller to count.
118
121
  */
119
- callLedger(): BudgetLedger {
122
+ callLedger(keys: BudgetKeys): BudgetLedger {
120
123
  return new BudgetLedger({
121
124
  limits: this.config.budget ?? {},
125
+ ...(keys.actorKey !== undefined ? { actorKey: keys.actorKey } : {}),
126
+ ...(keys.orgKey !== undefined ? { orgKey: keys.orgKey } : {}),
122
127
  ...(this.config.budgetStore !== undefined ? { store: this.config.budgetStore } : {}),
123
128
  });
124
129
  }
125
130
 
126
- scope<T>(input: { actorKey?: string; orgKey?: string }, fn: () => Promise<T>): Promise<T> {
127
- const ledger = new BudgetLedger({
128
- limits: this.config.budget ?? {},
129
- ...(input.actorKey !== undefined ? { actorKey: input.actorKey } : {}),
130
- ...(input.orgKey !== undefined ? { orgKey: input.orgKey } : {}),
131
- ...(this.config.budgetStore !== undefined ? { store: this.config.budgetStore } : {}),
132
- });
133
- return withBudget(ledger, fn);
131
+ scope<T>(input: BudgetKeys, fn: () => Promise<T>): Promise<T> {
132
+ return withBudget(this.callLedger(input), fn);
134
133
  }
135
134
 
136
135
  async spent(): Promise<Money> {
@@ -153,7 +152,7 @@ class GatewayImpl implements Gateway {
153
152
  // Reserve against the ESTIMATE before spending anything — tokens AND money, since a
154
153
  // cheap-in-tokens call on an expensive model is still a cost cap the app declared.
155
154
  // `record` below replaces the estimate with the provider's real counts.
156
- const ledger = currentBudget() ?? this.callLedger();
155
+ const ledger = currentBudget() ?? this.callLedger({});
157
156
  // The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
158
157
  // all read the same `spent()` otherwise, all pass, and all three record against a ceiling
159
158
  // only one of them fitted.
@@ -170,10 +169,20 @@ class GatewayImpl implements Gateway {
170
169
  await ledger?.record(result.usage, result.cost, reservation);
171
170
  // A refusal is not an answer, so it is not cached. Storing one would keep serving a decision
172
171
  // the classifier might not make twice, long after the prompt that provoked it was fixed.
173
- if (result.stopReason !== 'refusal') {
172
+ if (result.stopReason !== 'refusal') await this.remember(cacheKey, result);
173
+ return result;
174
+ }
175
+
176
+ /**
177
+ * The cache write, AFTER the call was paid for and recorded. A throw here must not turn that
178
+ * answer into a failure: the caller would retry and pay for it twice. Logged, and the answer kept.
179
+ */
180
+ private async remember(cacheKey: string, result: GenerateResult): Promise<void> {
181
+ try {
174
182
  await this.config.cache?.set(cacheKey, JSON.stringify(result));
183
+ } catch (error) {
184
+ logger.warn('ai.cache.write_failed', { cacheKey, error: renderThrowable(error) });
175
185
  }
176
- return result;
177
186
  }
178
187
 
179
188
  async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
@@ -196,7 +205,7 @@ class GatewayImpl implements Gateway {
196
205
  // `generate`, or reconnects itself and knows what it has already shown.
197
206
  const provider = this.providerFor(model);
198
207
 
199
- const ledger = currentBudget() ?? this.callLedger();
208
+ const ledger = currentBudget() ?? this.callLedger({});
200
209
  const reservation = await ledger?.reserve(estimateSpend(resolved));
201
210
  let settled = false;
202
211
  try {
@@ -368,17 +377,20 @@ export function isRetryable(error: unknown): boolean {
368
377
 
369
378
  /**
370
379
  * Cache key. Every field that changes the answer is in it — a key that ignores `effort` or
371
- * `system` would serve one prompt's answer for another.
380
+ * `system` would serve one prompt's answer for another — and each tool WHOLE: every `llm()` tool
381
+ * is named `respond`, so a key over names alone served an answer shaped for an old `output` schema
382
+ * after the schema changed. Core's `fingerprint`, so key order never splits one request in two and
383
+ * the width is the one every other sharing key in the framework has.
372
384
  */
373
385
  export function cacheKeyFor(request: GenerateRequest): string {
374
- return JSON.stringify({
386
+ return fingerprint({
375
387
  model: request.model,
376
388
  system: request.system ?? '',
377
389
  messages: request.messages,
378
390
  maxTokens: request.maxTokens,
379
391
  effort: request.effort ?? 'high',
380
392
  thinking: request.thinking ?? 'adaptive',
381
- tools: request.tools?.map((t) => t.name) ?? [],
393
+ tools: request.tools ?? [],
382
394
  stop: request.stopSequences ?? [],
383
395
  });
384
396
  }
package/src/hive.ts CHANGED
@@ -20,12 +20,13 @@ import type { Ctx } from '@ultimat3/core';
20
20
  import { finiteCount, throwIfAborted, withSpan } from '@ultimat3/core';
21
21
  import type { AnySchema, InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
22
22
  import type { BudgetLimits } from './budget';
23
- import { BudgetLedger, currentBudget, withBudget } from './budget';
23
+ import { BudgetLedger, budgetKeysFor, currentBudget, withBudget } from './budget';
24
24
  import { HiveEmptyError } from './hive-errors';
25
25
  import { runPool } from './hive-pool';
26
26
  import type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
27
27
  import { hiveResultSchema } from './hive-result';
28
28
  import type { LlmBudget } from './llm';
29
+ import { installedGateway } from './runtime';
29
30
 
30
31
  /**
31
32
  * Members in flight at once when the declaration omits one. Small and deliberately arbitrary — it
@@ -131,7 +132,14 @@ async function run<
131
132
  'hive.concurrency': width,
132
133
  'hive.on_member_error': def.onMemberError,
133
134
  });
134
- const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(limitsOf(def));
135
+ // Rooted as `llm()` and `agent()` root: the gateway's ceilings, keyed on the caller. An empty
136
+ // root dropped every gateway ceiling for every member. No gateway installed is a hive of plain
137
+ // actions, which spends nothing — a model member is refused `X_AI_GATEWAY_MISSING` on its own.
138
+ const root =
139
+ currentBudget() ??
140
+ installedGateway()?.callLedger(budgetKeysFor(args.ctx.actor)) ??
141
+ new BudgetLedger({ limits: {} });
142
+ const ledger = root.derive(limitsOf(def));
135
143
  const result = await withBudget(ledger, () =>
136
144
  runPool<InferInput<MIn>, InferOutput<MOut>>({
137
145
  inputs,
package/src/index.ts CHANGED
@@ -11,14 +11,17 @@ export { describeAgents, resetAgents } from './agent-facts';
11
11
  export type { AgentJobOptions } from './agent-job';
12
12
  export { agentJob } from './agent-job';
13
13
  export type {
14
+ BudgetKeys,
14
15
  BudgetLedgerInput,
15
16
  BudgetLimits,
16
17
  BudgetReport,
17
18
  BudgetStore,
19
+ BudgetTake,
18
20
  SpendEstimate,
19
21
  } from './budget';
20
22
  export {
21
23
  BudgetLedger,
24
+ budgetKeysFor,
22
25
  currentBudget,
23
26
  estimateSpend,
24
27
  MemoryBudgetStore,
@@ -29,7 +32,6 @@ export {
29
32
  cosine,
30
33
  embedBatched,
31
34
  embedOne,
32
- fnv1a,
33
35
  HashEmbedder,
34
36
  normalize,
35
37
  tokenize,
@@ -219,5 +221,5 @@ export type {
219
221
  } from './vector';
220
222
  export { fuse, MemoryVectorStore } from './vector';
221
223
  export type { VectorScope } from './vector-scope';
222
- export { NO_TENANT, tenantOf, UNSCOPED } from './vector-scope';
224
+ export { NO_TENANT, tenantOf, UNSCOPED, VectorUnscopedError } from './vector-scope';
223
225
  export type { StreamState } from './wire';
package/src/llm-cache.ts CHANGED
@@ -16,8 +16,9 @@
16
16
  */
17
17
 
18
18
  import type { Ctx } from '@ultimat3/core';
19
+ import { fingerprint } from '@ultimat3/core';
19
20
  import { parseDuration } from '@ultimat3/time';
20
- import { embedOne, fnv1a } from './embeddings';
21
+ import { embedOne } from './embeddings';
21
22
  import { aiEmbedder, semanticCacheFor } from './runtime';
22
23
 
23
24
  /** What a `scope` may decide from. The same pair `vars()` receives, and for the same reason. */
@@ -119,7 +120,7 @@ export async function openCache<TParsed>(
119
120
  },
120
121
  remember(value: unknown): Promise<void> {
121
122
  return store.remember(
122
- `${args.prompt.hash}:${fnv1a(args.rendered).toString(16)}`,
123
+ `${args.prompt.hash}:${fingerprint(args.rendered)}`,
123
124
  embedding,
124
125
  value,
125
126
  ttlMs === undefined ? {} : { ttlMs },
package/src/llm.ts CHANGED
@@ -27,9 +27,9 @@ import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
27
27
  import { finiteCount, withSpan } from '@ultimat3/core';
28
28
  import type { Money } from '@ultimat3/money';
29
29
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
30
- import { formatIssues, toMcpInputSchema, validateAsync } from '@ultimat3/schema';
30
+ import { formatIssues, validateAsync } from '@ultimat3/schema';
31
31
  import type { BudgetLimits } from './budget';
32
- import { BudgetLedger, currentBudget, withBudget } from './budget';
32
+ import { budgetKeysFor, currentBudget, withBudget } from './budget';
33
33
  import {
34
34
  LlmOutputInvalidError,
35
35
  LlmRefusedError,
@@ -47,15 +47,9 @@ import type { Prompt, PromptVars } from './prompt';
47
47
  import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
48
48
  import { isTruncated } from './provider';
49
49
  import { assertNoSecrets } from './redaction';
50
+ import type { Respond } from './respond';
51
+ import { parseJsonish, RESPOND, respondFor } from './respond';
50
52
  import { aiGateway, aiRedactor } from './runtime';
51
- import type { LlmTool } from './tools';
52
-
53
- /**
54
- * The tool the model answers through. One name, so the reader never has to guess — and shared
55
- * with `agent()`, which offers the app's tools alongside it and needs the same name to tell an
56
- * answer from a tool call.
57
- */
58
- export const RESPOND = 'respond';
59
53
 
60
54
  /** Two attempts total: the answer, then one repair turn. See `LlmOutputInvalidError`. */
61
55
  const ATTEMPTS = 2;
@@ -138,8 +132,8 @@ export function llm<
138
132
  TOutput extends StandardSchemaV1,
139
133
  V extends PromptVars,
140
134
  >(def: LlmDef<TInput, TOutput, V>): LlmAction<TInput, TOutput> {
141
- const respond = respondToolFor(def.output);
142
- // Screened at DECLARATION, beside `respondToolFor`'s own refusal and for the same reason: an
135
+ const respond = respondFor(def.output);
136
+ // Screened at DECLARATION, beside `respondFor`'s own refusal and for the same reason: an
143
137
  // `llm()` is evaluated at module scope, so a bound that is not one fails the boot rather than
144
138
  // the first request. It is the read at `generate` below that makes it urgent — `maxTokens`
145
139
  // becomes the pre-flight ESTIMATE, a `NaN` estimate passes every `want > remaining` check, and
@@ -190,7 +184,7 @@ async function generate<
190
184
  V extends PromptVars,
191
185
  >(
192
186
  def: LlmDef<TInput, TOutput, V>,
193
- respond: LlmTool,
187
+ respond: Respond,
194
188
  args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
195
189
  ): Promise<InferOutput<TOutput>> {
196
190
  const { prompt } = def;
@@ -250,7 +244,7 @@ async function generate<
250
244
  // the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
251
245
  signal: args.ctx.signal,
252
246
  };
253
- const request: GenerateRequest = { ...base, tools: [respond] };
247
+ const request: GenerateRequest = { ...base, tools: [respond.tool] };
254
248
 
255
249
  // A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
256
250
  // and org ceilings this call runs inside, never widen them. The gateway reserves against
@@ -259,11 +253,10 @@ async function generate<
259
253
  const limits = limitsOf(def.budget);
260
254
  // Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
261
255
  const gateway = aiGateway(name);
262
- const ledger = (
263
- currentBudget() ??
264
- gateway.callLedger?.() ??
265
- new BudgetLedger({ limits: {} })
266
- ).derive(limits);
256
+ // Keyed on the CALLER, so the gateway's `actor` / `org` ceilings count this call against them.
257
+ const ledger = (currentBudget() ?? gateway.callLedger(budgetKeysFor(args.ctx.actor))).derive(
258
+ limits,
259
+ );
267
260
  const sink = currentLlmSink();
268
261
 
269
262
  return withBudget(ledger, async () => {
@@ -292,7 +285,7 @@ async function generate<
292
285
  explanation: result.stopDetails?.explanation,
293
286
  });
294
287
  }
295
- const parsed = await validateAsync(def.output, structuredOutputOf(result));
288
+ const parsed = await validateAsync(def.output, respond.read(result));
296
289
  if (parsed.issues === undefined) {
297
290
  await cache?.remember(parsed.value);
298
291
  return parsed.value;
@@ -403,21 +396,6 @@ function limitsOf(budget: LlmBudget | undefined): BudgetLimits {
403
396
  };
404
397
  }
405
398
 
406
- /**
407
- * The output schema as the only tool the model may answer through — the spec's "structured
408
- * output drives tool use". `toMcpInputSchema` is the same projection an MCP client sees, so
409
- * a model and an agent are shown one shape, and a schema it cannot express throws HERE, at
410
- * declaration time, rather than degrading into a permissive node the model cannot satisfy.
411
- */
412
- export function respondToolFor(output: StandardSchemaV1): LlmTool {
413
- return {
414
- name: RESPOND,
415
- description: 'Return the result. Call this exactly once; do not answer in prose.',
416
- input_schema: toMcpInputSchema(output),
417
- strict: true,
418
- };
419
- }
420
-
421
399
  /**
422
400
  * What the model answered, as text the Messages API will accept — or nothing.
423
401
  *
@@ -436,23 +414,3 @@ function assistantEcho(result: GenerateResult): string | undefined {
436
414
  const replayed = JSON.stringify(call.input);
437
415
  return replayed === undefined || replayed === '' ? undefined : replayed;
438
416
  }
439
-
440
- /**
441
- * The tool call if the model made one, otherwise the text parsed as JSON — a model that
442
- * answers in prose is a schema failure, not a crash, so it flows into the repair turn.
443
- */
444
- export function structuredOutputOf(result: GenerateResult): unknown {
445
- const call = result.toolCalls.find((c) => c.name === RESPOND);
446
- if (call !== undefined) return call.input;
447
- return parseJsonish(result.text);
448
- }
449
-
450
- function parseJsonish(text: string): unknown {
451
- const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
452
- const body = (fenced?.[1] ?? text).trim();
453
- try {
454
- return JSON.parse(body);
455
- } catch {
456
- return undefined;
457
- }
458
- }
@@ -62,9 +62,14 @@ const ERROR_STATUS: ReadonlyMap<string, number> = new Map(
62
62
  }),
63
63
  );
64
64
 
65
- /** A finish reason this format knows, or `undefined` for `null` — which means "still going". */
65
+ /**
66
+ * The finish reason, or `undefined` for a non-string (`null` on a delta means "still going").
67
+ * A string this table does not know FAILS CLOSED as `max_tokens` — `wire.ts`'s `parseStopReason`
68
+ * rule, so one unknown reason reads the same on both formats: a provider's new reason is far
69
+ * likelier to be a way of stopping early than of finishing.
70
+ */
66
71
  export function parseFinishReason(raw: unknown): StopReason | undefined {
67
- return typeof raw === 'string' ? FINISH_REASONS.get(raw) : undefined;
72
+ return typeof raw === 'string' ? (FINISH_REASONS.get(raw) ?? 'max_tokens') : undefined;
68
73
  }
69
74
 
70
75
  /**
package/src/pg-vector.ts CHANGED
@@ -24,7 +24,7 @@ import type {
24
24
  VectorRecord,
25
25
  VectorStore,
26
26
  } from './vector';
27
- import { narrowScope, tenantOf, UNSCOPED, type VectorScope } from './vector-scope';
27
+ import { assertTenantRead, narrowScope, tenantOf, UNBOUND, type VectorScope } from './vector-scope';
28
28
 
29
29
  export interface PgVectorStoreInput {
30
30
  /** The table. Also the store's name in errors, and the stem of every index name. */
@@ -60,7 +60,7 @@ export class PgVectorStore implements VectorStore {
60
60
  this.input = input;
61
61
  this.name = input.name;
62
62
  this.dimension = input.dimension;
63
- this.scope = input.scope ?? UNSCOPED;
63
+ this.scope = input.scope ?? UNBOUND;
64
64
  this.target = {
65
65
  table: input.name,
66
66
  dimension: input.dimension,
@@ -75,8 +75,8 @@ export class PgVectorStore implements VectorStore {
75
75
  }
76
76
 
77
77
  /**
78
- * A view of the same table through a narrower envelope. The unscoped store is the backfill
79
- * path; a request handler derives from it and can never widen back out.
78
+ * A view of the same table through a narrower envelope. A store opened with `scope: UNSCOPED`
79
+ * is the backfill path; a request handler derives from it and can never widen back out.
80
80
  */
81
81
  scoped(scope: VectorScope): PgVectorStore {
82
82
  return new PgVectorStore({
@@ -111,6 +111,7 @@ export class PgVectorStore implements VectorStore {
111
111
  k: number,
112
112
  filter?: MetadataFilter,
113
113
  ): Promise<readonly SearchHit[]> {
114
+ assertTenantRead(this.name, this.scope);
114
115
  this.assertDimension(vector.length);
115
116
  // `k` is the statement's `limit` and reaches Postgres as a bound parameter, so a `NaN` is the
116
117
  // database's error to report rather than this store's — and a fractional one is an error there
@@ -125,12 +126,14 @@ export class PgVectorStore implements VectorStore {
125
126
  k: number,
126
127
  filter?: MetadataFilter,
127
128
  ): Promise<readonly SearchHit[]> {
129
+ assertTenantRead(this.name, this.scope);
128
130
  return this.run(
129
131
  textSql(this.target, query, { scope: this.scope, filter, k: finiteCount(HYBRID, 'k', k) }),
130
132
  );
131
133
  }
132
134
 
133
135
  async hybrid(input: HybridSearchInput): Promise<readonly SearchHit[]> {
136
+ assertTenantRead(this.name, this.scope);
134
137
  this.assertDimension(input.vector.length);
135
138
  // The same three bounds `MemoryVectorStore.hybrid` screens, and they have to be screened in
136
139
  // BOTH stores: `rrfK` lands in `1.0 / (rrfK + rank)`, Postgres has a float8 `NaN`, and it
package/src/provider.ts CHANGED
@@ -463,14 +463,17 @@ function lastUserMessage(messages: readonly AiMessage[]): string {
463
463
  }
464
464
 
465
465
  /**
466
- * ~4 characters per token. Deliberately an ESTIMATE and never used for billing — the
467
- * gateway's pre-flight budget check needs a number before the call exists, and the real
468
- * count from `usage` replaces it afterwards.
466
+ * ~4 characters per token. Deliberately an ESTIMATE and never used for billing — the gateway's
467
+ * pre-flight budget check needs a number before the call exists, and `usage` replaces it after.
468
+ * The completion is clamped to what the model can write, exactly as `estimateCost` clamps it.
469
469
  */
470
470
  export function estimateTokens(request: GenerateRequest): number {
471
- return estimateInputTokens(request) + request.maxTokens;
471
+ return estimateInputTokens(request) + completionCeiling(request);
472
472
  }
473
473
 
474
+ const completionCeiling = (request: GenerateRequest): number =>
475
+ Math.min(request.maxTokens, modelSpec(request.model ?? DEFAULT_MODEL).maxOutput);
476
+
474
477
  /** The prompt half alone — what the provider bills at the input rate. */
475
478
  export function estimateInputTokens(request: GenerateRequest): number {
476
479
  const body = request.messages.map(messageText).join(' ');
@@ -483,10 +486,9 @@ export function estimateInputTokens(request: GenerateRequest): number {
483
486
  * optimistic estimate is a ceiling one long completion walks through.
484
487
  */
485
488
  export function estimateCost(request: GenerateRequest): Money {
486
- const model = request.model ?? DEFAULT_MODEL;
487
- return costOf(model, {
489
+ return costOf(request.model ?? DEFAULT_MODEL, {
488
490
  inputTokens: estimateInputTokens(request),
489
- outputTokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
491
+ outputTokens: completionCeiling(request),
490
492
  cacheReadTokens: 0,
491
493
  cacheWriteTokens: 0,
492
494
  });
package/src/rag.ts CHANGED
@@ -56,7 +56,9 @@ export function chunk(input: ChunkInput): readonly Chunk[] {
56
56
  id: `${input.id}#${chunks.length}`,
57
57
  text,
58
58
  tokens: estimateChunkTokens(text),
59
- metadata: { source: input.id, ...(input.metadata ?? {}) },
59
+ // Written AFTER the spread: `source` is the framework's stamp, and `indexDocument` prunes by
60
+ // it — a caller's own `source` replaced it, and a re-index pruned the wrong rows or none.
61
+ metadata: { ...(input.metadata ?? {}), source: input.id },
60
62
  });
61
63
  }
62
64
  // Carry the tail forward as the overlap for the next chunk — at most every unit BUT THE
@@ -78,6 +80,11 @@ export function chunk(input: ChunkInput): readonly Chunk[] {
78
80
  for (const unit of units) {
79
81
  const unitTokens = estimateChunkTokens(unit);
80
82
  if (tokens + unitTokens > size && buffer.length > 0) flush();
83
+ // The carried overlap yields to the unit, oldest first: a carry the unit does not fit beside
84
+ // made a chunk past `size` — the one promise this function makes.
85
+ while (buffer.length > 0 && tokens + unitTokens > size) {
86
+ tokens -= estimateChunkTokens(buffer.shift() ?? '');
87
+ }
81
88
  buffer.push(unit);
82
89
  tokens += unitTokens;
83
90
  }
@@ -158,7 +165,7 @@ function cutToBudget(run: string, size: number): readonly string[] {
158
165
  * Index a document: chunk, embed, upsert, prune. One call so no step is skipped by accident. The
159
166
  * prune is what re-indexing a SHORTER document needs: chunk ids are `<doc>#<n>`, so the upsert
160
167
  * overwrote the first chunks and left the old tail retrievable. Upsert first, prune after, so the
161
- * document is never absent from a search in between. A store without `prune` cannot clean up.
168
+ * document is never absent from a search in between.
162
169
  */
163
170
  export async function indexDocument(input: {
164
171
  readonly store: VectorStore;
@@ -182,7 +189,7 @@ export async function indexDocument(input: {
182
189
  })),
183
190
  );
184
191
  // `source` is the chunker's own stamp (`chunk()`), so only THIS document's rows are touched.
185
- await input.store.prune?.(
192
+ await input.store.prune(
186
193
  { source: input.document.id },
187
194
  chunks.map((c) => c.id),
188
195
  );