@ultimat3/ai 1.2.0 → 3.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/provider.ts CHANGED
@@ -4,16 +4,63 @@
4
4
  // catalogue and the per-model rules, ./wire owns the response half.
5
5
 
6
6
  import type { Money } from '@ultimat3/money';
7
+ import { detailOf, withoutKey } from './error-body';
7
8
  import { AiKeyMissingError, AiTransportError } from './errors';
8
9
  import type { Effort, ModelId, ThinkingMode } from './models';
9
- import { DEFAULT_MODEL, MODEL_IDS, MODELS, reasoningBody } from './models';
10
+ import { ANTHROPIC_MODEL_IDS, DEFAULT_MODEL, modelIds, modelSpec, reasoningBody } from './models';
10
11
  import { readSse } from './sse';
11
12
  import type { LlmTool, LlmToolCall } from './tools';
12
- import { MessageStream, parseStopDetails, parseStopReason, parseUsage } from './wire';
13
+ import {
14
+ asToolInput,
15
+ MessageStream,
16
+ parseStopDetails,
17
+ parseStopReason,
18
+ parseUsage,
19
+ throwInBandError,
20
+ } from './wire';
21
+
22
+ /**
23
+ * One block of a structured message. A plain string message is still the common case and still
24
+ * legal; blocks exist because a tool loop cannot be expressed without them — a `tool_result` has
25
+ * to name the `tool_use` it answers, and a string has nowhere to put the id.
26
+ *
27
+ * The field names are the Messages API's, not a translated set, so `body()` passes a block
28
+ * through untouched. A second vocabulary here would be a mapping table to keep in step with a
29
+ * wire format we do not own.
30
+ */
31
+ export type AiContentBlock =
32
+ | { readonly type: 'text'; readonly text: string }
33
+ | {
34
+ readonly type: 'tool_use';
35
+ readonly id: string;
36
+ readonly name: string;
37
+ readonly input: Record<string, unknown>;
38
+ }
39
+ | {
40
+ readonly type: 'tool_result';
41
+ readonly tool_use_id: string;
42
+ readonly content: string;
43
+ readonly is_error?: boolean;
44
+ };
13
45
 
14
46
  export interface AiMessage {
15
47
  readonly role: 'user' | 'assistant';
16
- readonly content: string;
48
+ readonly content: string | readonly AiContentBlock[];
49
+ }
50
+
51
+ /**
52
+ * The readable text of a message, blocks flattened. For ESTIMATING and for the echo provider —
53
+ * never for building a request, which sends `content` as it stands.
54
+ */
55
+ export function messageText(message: AiMessage): string {
56
+ if (typeof message.content === 'string') return message.content;
57
+ return message.content
58
+ .map((block) => {
59
+ if (block.type === 'text') return block.text;
60
+ if (block.type === 'tool_result') return block.content;
61
+ return JSON.stringify(block.input);
62
+ })
63
+ .join(' ');
17
64
  }
18
65
 
19
66
  export interface GenerateRequest {
@@ -26,6 +73,15 @@ export interface GenerateRequest {
26
73
  readonly thinking?: ThinkingMode;
27
74
  readonly tools?: readonly LlmTool[];
28
75
  readonly stopSequences?: readonly string[];
76
+ /**
77
+ * The caller's abort signal, forwarded to the socket by every provider in this package.
78
+ *
79
+ * Deliberately absent from `cacheKeyFor` and from every estimate: it says whether a request was
80
+ * ABANDONED, never what it asked for, so two calls that differ only in it are the same call and
81
+ * must share a cache entry. Omitted means the call runs to completion — there is no ambient
82
+ * default, because a timeout the caller did not ask for is a truncated answer nothing reports.
83
+ */
84
+ readonly signal?: AbortSignal;
29
85
  }
30
86
 
31
87
  export interface TokenUsage {
@@ -61,6 +117,14 @@ export interface StopDetails {
61
117
 
62
118
  export interface GenerateResult {
63
119
  readonly model: ModelId;
120
+ /**
121
+ * Which provider actually answered. Stamped by the GATEWAY, not by the provider: falling back
122
+ * from one provider to the next is a gateway concept, and a `Provider` implementation an app
123
+ * wrote cannot be asked to report on a decision it did not make. Optional for exactly that
124
+ * reason — a provider's own result has not been through the router yet. `llm()` puts it on the
125
+ * span as `llm.provider`, which is what makes a fallback visible rather than silent.
126
+ */
127
+ readonly provider?: string;
64
128
  readonly text: string;
65
129
  readonly toolCalls: readonly LlmToolCall[];
66
130
  readonly stopReason: StopReason;
@@ -91,7 +155,7 @@ export interface Provider {
91
155
  * away is money the framework silently absorbs, and under-reporting spend defeats a budget.
92
156
  */
93
157
  export function costOf(model: ModelId, usage: TokenUsage): Money {
94
- const spec = MODELS[model];
158
+ const spec = modelSpec(model);
95
159
  // Cache reads are ~0.1x input; cache writes ~1.25x. Scaled by 10 to stay integral.
96
160
  const inputUnits =
97
161
  usage.inputTokens * 10 + usage.cacheReadTokens * 1 + Math.ceil(usage.cacheWriteTokens * 12.5);
@@ -121,8 +185,6 @@ export interface AnthropicProviderInput {
121
185
 
122
186
  const ANTHROPIC_VERSION = '2023-06-01';
123
187
  const API_KEY_ENV = 'ANTHROPIC_API_KEY';
124
- /** Enough of an error body to name the field that was wrong, not enough to fill a log. */
125
- const DETAIL_LIMIT = 300;
126
188
 
127
189
  /**
128
190
  * Above this ceiling a non-streaming request sits on an open socket past the HTTP timeout and
@@ -134,7 +196,7 @@ export const STREAM_ONLY_MAX_TOKENS = 16_000;
134
196
  /** Whether this request has to go over the streaming transport to arrive at all. */
135
197
  export function requiresStreaming(request: GenerateRequest): boolean {
136
198
  const model = request.model ?? DEFAULT_MODEL;
137
- return Math.min(request.maxTokens, MODELS[model].maxOutput) > STREAM_ONLY_MAX_TOKENS;
199
+ return Math.min(request.maxTokens, modelSpec(model).maxOutput) > STREAM_ONLY_MAX_TOKENS;
138
200
  }
139
201
 
140
202
  /**
@@ -146,7 +208,8 @@ export function requiresStreaming(request: GenerateRequest): boolean {
146
208
  */
147
209
  export class AnthropicProvider implements Provider {
148
210
  readonly name = 'anthropic';
149
- readonly models = MODEL_IDS;
211
+ /** Its own list, never the registry's: an app's internal model must not be routed here. */
212
+ readonly models: readonly ModelId[] = ANTHROPIC_MODEL_IDS;
150
213
  private readonly config: AnthropicProviderInput;
151
214
 
152
215
  constructor(config: AnthropicProviderInput = {}) {
@@ -162,7 +225,7 @@ export class AnthropicProvider implements Provider {
162
225
  */
163
226
  async generate(request: GenerateRequest): Promise<GenerateResult> {
164
227
  if (requiresStreaming(request)) return this.assemble(request);
165
- const response = await this.send({ ...this.body(request), stream: false });
228
+ const response = await this.send({ ...this.body(request), stream: false }, request.signal);
166
229
  const raw = (await response.json()) as Record<string, unknown>;
167
230
  return parseMessage(request.model ?? DEFAULT_MODEL, raw);
168
231
  }
@@ -185,7 +248,7 @@ export class AnthropicProvider implements Provider {
185
248
  */
186
249
  async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
187
250
  const model = request.model ?? DEFAULT_MODEL;
188
- const response = await this.send({ ...this.body(request), stream: true });
251
+ const response = await this.send({ ...this.body(request), stream: true }, request.signal);
189
252
  if (response.body === null) {
190
253
  throw new AiTransportError({
191
254
  provider: this.name,
@@ -217,7 +280,7 @@ export class AnthropicProvider implements Provider {
217
280
  const model = request.model ?? DEFAULT_MODEL;
218
281
  const body: Record<string, unknown> = {
219
282
  model,
220
- max_tokens: Math.min(request.maxTokens, MODELS[model].maxOutput),
283
+ max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
221
284
  messages: request.messages.map((m) => ({ role: m.role, content: m.content })),
222
285
  ...reasoningBody(model, request.effort, request.thinking),
223
286
  };
@@ -232,7 +295,10 @@ export class AnthropicProvider implements Provider {
232
295
  * carrying its status, because the gateway decides whether to retry from that status and a
233
296
  * body parsed as if it were a message would read as an empty, successful answer.
234
297
  */
235
- private async send(body: Record<string, unknown>): Promise<Response> {
298
+ private async send(
299
+ body: Record<string, unknown>,
300
+ signal: AbortSignal | undefined,
301
+ ): Promise<Response> {
236
302
  const apiKey = this.config.apiKey ?? Bun.env[API_KEY_ENV];
237
303
  if (apiKey === undefined || apiKey === '') {
238
304
  throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
@@ -248,38 +314,32 @@ export class AnthropicProvider implements Provider {
248
314
  accept: body['stream'] === true ? 'text/event-stream' : 'application/json',
249
315
  },
250
316
  body: JSON.stringify(body),
317
+ // Attached only when the caller has one: `exactOptionalPropertyTypes`, and an explicit
318
+ // `signal: undefined` is a different value to some fetch implementations.
319
+ ...(signal === undefined ? {} : { signal }),
251
320
  });
252
321
  if (!response.ok) {
253
322
  throw new AiTransportError({
254
323
  provider: this.name,
255
324
  status: response.status,
256
- detail: await detailOf(response),
325
+ // The endpoint's own message, with the credential scrubbed out of it — the same rule the
326
+ // OpenAI-format provider follows, and for the same reason: a proxy echoing the request
327
+ // headers into its 4xx body is the one path by which `x-api-key` reaches an error.
328
+ detail: withoutKey(await detailOf(response), apiKey),
329
+ envVar: API_KEY_ENV,
257
330
  });
258
331
  }
259
332
  return response;
260
333
  }
261
334
  }
262
335
 
263
- /** The provider's own message, when it sent one — it names the offending field, we name the fix. */
264
- async function detailOf(response: Response): Promise<string> {
265
- const body = await response.text().catch(() => '');
266
- try {
267
- const parsed: unknown = JSON.parse(body);
268
- if (typeof parsed === 'object' && parsed !== null) {
269
- const error = (parsed as Record<string, unknown>)['error'];
270
- if (typeof error === 'object' && error !== null) {
271
- const message = (error as Record<string, unknown>)['message'];
272
- if (typeof message === 'string') return message.slice(0, DETAIL_LIMIT);
273
- }
274
- }
275
- } catch {
276
- // Not JSON — a proxy or a gateway timeout page. The raw text is still the best evidence.
277
- }
278
- return body === '' ? response.statusText : body.slice(0, DETAIL_LIMIT);
279
- }
280
-
281
336
  /** Map a Messages API response onto `GenerateResult`. Exported so tests can drive it. */
282
337
  export function parseMessage(model: ModelId, raw: Record<string, unknown>): GenerateResult {
338
+ // A 200 carrying an `error` object instead of an answer — how a gateway in front of a model
339
+ // reports a fault it noticed after the headers were sent. Read as a message it is an empty,
340
+ // successful answer, which is the one outcome nothing downstream can detect; the STREAMED half
341
+ // of this same provider has always refused it.
342
+ throwInBandError(raw);
283
343
  const content = Array.isArray(raw['content']) ? raw['content'] : [];
284
344
  let text = '';
285
345
  const toolCalls: LlmToolCall[] = [];
@@ -291,17 +351,24 @@ export function parseMessage(model: ModelId, raw: Record<string, unknown>): Gene
291
351
  toolCalls.push({
292
352
  id: b['id'],
293
353
  name: b['name'],
294
- input: (b['input'] ?? {}) as Record<string, unknown>,
354
+ // Parsed, never cast: `input` is untrusted, and a string under `Record<string, unknown>`
355
+ // is a lie every later reader indexes into. The streamed half already parses it.
356
+ input: asToolInput(b['input']),
295
357
  });
296
358
  }
297
359
  }
298
360
  const usage = parseUsage(raw['usage']);
361
+ const stopDetails = parseStopDetails(raw['stop_details']);
299
362
  return {
300
363
  model,
301
364
  text,
302
365
  toolCalls,
303
- stopReason: parseStopReason(raw['stop_reason']),
304
- stopDetails: parseStopDetails(raw['stop_details']),
366
+ // A refusal detail is a refusal whatever the stop field says: `parseStopReason` answers
367
+ // `end_turn` for a spelling this build has never seen, and `llm()` branches on the REASON —
368
+ // `stopDetails` has no reader that can refuse — so the pair would arrive as a complete answer
369
+ // that happens to be empty. The OpenAI-format read has always forced it.
370
+ stopReason: stopDetails === undefined ? parseStopReason(raw['stop_reason']) : 'refusal',
371
+ stopDetails,
305
372
  usage,
306
373
  cost: costOf(model, usage),
307
374
  };
@@ -324,7 +391,14 @@ export interface EchoProviderInput {
324
391
  */
325
392
  export class EchoProvider implements Provider {
326
393
  readonly name = 'echo';
327
- readonly models = MODEL_IDS;
394
+ /**
395
+ * A getter over the whole registry, not a snapshot: the test double has to serve whatever the
396
+ * test registered, and a field read at construction time would miss a model registered after.
397
+ */
398
+ get models(): readonly ModelId[] {
399
+ return modelIds();
400
+ }
401
+
328
402
  private readonly config: EchoProviderInput;
329
403
 
330
404
  constructor(config: EchoProviderInput = {}) {
@@ -365,7 +439,7 @@ export class EchoProvider implements Provider {
365
439
  function lastUserMessage(messages: readonly AiMessage[]): string {
366
440
  for (let i = messages.length - 1; i >= 0; i -= 1) {
367
441
  const message = messages[i];
368
- if (message !== undefined && message.role === 'user') return message.content;
442
+ if (message !== undefined && message.role === 'user') return messageText(message);
369
443
  }
370
444
  return '';
371
445
  }
@@ -381,7 +455,7 @@ export function estimateTokens(request: GenerateRequest): number {
381
455
 
382
456
  /** The prompt half alone — what the provider bills at the input rate. */
383
457
  export function estimateInputTokens(request: GenerateRequest): number {
384
- const body = request.messages.map((m) => m.content).join(' ');
458
+ const body = request.messages.map(messageText).join(' ');
385
459
  return estimateTextTokens(body) + estimateTextTokens(request.system ?? '');
386
460
  }
387
461
 
@@ -394,7 +468,7 @@ export function estimateCost(request: GenerateRequest): Money {
394
468
  const model = request.model ?? DEFAULT_MODEL;
395
469
  return costOf(model, {
396
470
  inputTokens: estimateInputTokens(request),
397
- outputTokens: Math.min(request.maxTokens, MODELS[model].maxOutput),
471
+ outputTokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
398
472
  cacheReadTokens: 0,
399
473
  cacheWriteTokens: 0,
400
474
  });
package/src/rag.ts CHANGED
@@ -156,6 +156,28 @@ export interface AssembledContext {
156
156
  readonly dropped: readonly string[];
157
157
  }
158
158
 
159
+ const BLOCK_OPEN = '<document id=';
160
+ const BLOCK_CLOSE = '</document>';
161
+
162
+ /**
163
+ * One retrieved document, fenced and labelled with the id it came from. A bare separator was not
164
+ * a boundary: it is a string a document can simply contain, and the assembled text lands in the
165
+ * `user` message indistinguishable from the author's own instructions — while a tool RESULT
166
+ * carries provenance and this carried none. So the fence is neutralised inside the payload, the
167
+ * shape `cdata()` uses in `@ultimat3/seo`'s `xml.ts`: the marker is broken, never deleted, so the
168
+ * model still reads every word the document actually said.
169
+ *
170
+ * Influence only, and deliberately not sold as more: the actor is `ctx.actor` and tool dispatch is
171
+ * matched against `def.tools`, so retrieved text can persuade a model and can never authorise it.
172
+ */
173
+ function documentBlock(id: string, text: string): string {
174
+ const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
175
+ const body = text
176
+ .replaceAll(BLOCK_CLOSE, '<\\/document>')
177
+ .replaceAll(BLOCK_OPEN, '<\\document id=');
178
+ return `${BLOCK_OPEN}"${label}">\n${body}\n${BLOCK_CLOSE}`;
179
+ }
180
+
159
181
  /**
160
182
  * Fill a context window in rank order until the budget is reached. Skips oversized hits
161
183
  * rather than stopping, so one long chunk does not starve every shorter one behind it.
@@ -163,9 +185,10 @@ export interface AssembledContext {
163
185
  export function assembleContext(input: {
164
186
  readonly hits: readonly SearchHit[];
165
187
  readonly maxTokens: number;
188
+ /** Between BLOCKS, never inside one — the block fence is what separates documents. */
166
189
  readonly separator?: string;
167
190
  }): AssembledContext {
168
- const separator = input.separator ?? '\n\n---\n\n';
191
+ const separator = input.separator ?? '\n\n';
169
192
  const separatorTokens = estimateChunkTokens(separator);
170
193
  const parts: string[] = [];
171
194
  const used: string[] = [];
@@ -173,12 +196,13 @@ export function assembleContext(input: {
173
196
  let tokens = 0;
174
197
 
175
198
  for (const hit of input.hits) {
176
- const cost = estimateChunkTokens(hit.text) + (parts.length === 0 ? 0 : separatorTokens);
199
+ const block = documentBlock(hit.id, hit.text);
200
+ const cost = estimateChunkTokens(block) + (parts.length === 0 ? 0 : separatorTokens);
177
201
  if (tokens + cost > input.maxTokens) {
178
202
  dropped.push(hit.id);
179
203
  continue;
180
204
  }
181
- parts.push(hit.text);
205
+ parts.push(block);
182
206
  used.push(hit.id);
183
207
  tokens += cost;
184
208
  }
@@ -0,0 +1,22 @@
1
+ // The one gate between `vars()` and the provider: a `Secret` never reaches a prompt.
2
+ //
3
+ // Not a leak check — `Secret` redacts by value, so the string would have arrived as `[redacted]`.
4
+ // It is a CORRECTNESS check, and the same one `render()` already makes for an unfilled `{{slot}}`:
5
+ // a prompt that reads fine and means something else is the failure nobody sees, and here it is
6
+ // also a token bill for an answer about a placeholder.
7
+
8
+ import { isSecret } from '@ultimat3/core';
9
+ import { AiPromptSecretError } from './errors';
10
+
11
+ /**
12
+ * Refuse a `Secret` among a prompt's variables, naming every key that carries one. Runs whether
13
+ * or not an app installed a redactor: the redactor is the app's policy, this is the framework's
14
+ * invariant, and an invariant that only holds when something optional is configured is not one.
15
+ *
16
+ * Structural (`isSecret` reads the shared brand), so two copies of `@ultimat3/core` in one tree
17
+ * still recognise each other's secrets.
18
+ */
19
+ export function assertNoSecrets(ref: string, vars: Readonly<Record<string, unknown>>): void {
20
+ const keys = Object.keys(vars).filter((key) => isSecret(vars[key]));
21
+ if (keys.length > 0) throw new AiPromptSecretError({ ref, keys: keys.sort() });
22
+ }
@@ -6,6 +6,7 @@
6
6
  // vendor: `baseUrl` selects the provider and nothing else changes. A second class per vendor
7
7
  // would be a second thing to learn for a difference that does not exist on the wire.
8
8
 
9
+ import { readWithinLimit } from '@ultimat3/core';
9
10
  import type { Embedder } from './embeddings';
10
11
  import { normalize } from './embeddings';
11
12
  import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors';
@@ -15,6 +16,14 @@ const DEFAULT_BASE_URL = 'https://api.voyageai.com/v1';
15
16
  /** Providers cap a batch around 128 inputs; 96 leaves headroom for long texts. */
16
17
  const DEFAULT_BATCH_SIZE = 96;
17
18
  const DETAIL_LIMIT = 300;
19
+ /**
20
+ * A hosted endpoint is a third party and `baseUrl` is app config, so neither its latency nor its
21
+ * response size is ours to assume. 30s is longer than any healthy embedding batch and shorter than
22
+ * a job lease; 32 MiB is an order of magnitude past the worst legitimate batch (96 inputs x 3072
23
+ * dimensions of JSON floats is under 4 MiB), so nothing real hits it and nothing unreal is held.
24
+ */
25
+ const DEFAULT_TIMEOUT_MS = 30_000;
26
+ const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
18
27
 
19
28
  export interface RemoteEmbedderInput {
20
29
  /** The provider's model id. Doubles as the embedder name, so a store records what wrote it. */
@@ -30,6 +39,10 @@ export interface RemoteEmbedderInput {
30
39
  readonly baseUrl?: string;
31
40
  /** Inputs per request. Larger calls are split; the provider's own cap is not the caller's. */
32
41
  readonly batchSize?: number;
42
+ /** Deadline for ONE batch request. Defaults to 30s; `AbortSignal.timeout` enforces it. */
43
+ readonly timeoutMs?: number;
44
+ /** Bytes this process will hold of one response. Defaults to 32 MiB. */
45
+ readonly maxResponseBytes?: number;
33
46
  /** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
34
47
  readonly fetch?: typeof fetch;
35
48
  }
@@ -65,19 +78,53 @@ export class RemoteEmbedder implements Embedder {
65
78
  throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
66
79
  }
67
80
  const doFetch = this.config.fetch ?? fetch;
68
- const response = await doFetch(`${this.config.baseUrl ?? DEFAULT_BASE_URL}/embeddings`, {
69
- method: 'POST',
70
- headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
71
- body: JSON.stringify({ model: this.name, input: texts }),
72
- });
81
+ const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
82
+ const url = `${this.config.baseUrl ?? DEFAULT_BASE_URL}/embeddings`;
83
+ let response: Response;
84
+ try {
85
+ response = await doFetch(url, {
86
+ method: 'POST',
87
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
88
+ body: JSON.stringify({ model: this.name, input: texts }),
89
+ // Without this the call has no deadline at all: the per-request budget produces a
90
+ // `ctx.signal` that never reaches here, so a provider that accepts the connection and
91
+ // never answers holds a worker for as long as the socket stays open.
92
+ signal: AbortSignal.timeout(timeoutMs),
93
+ });
94
+ } catch (error) {
95
+ throw new AiTransportError({
96
+ provider: this.name,
97
+ detail: `${error instanceof Error ? error.message : 'the request failed before a response'} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
98
+ envVar: API_KEY_ENV,
99
+ });
100
+ }
73
101
  if (!response.ok) {
74
102
  throw new AiTransportError({
75
103
  provider: this.name,
76
104
  status: response.status,
77
105
  detail: (await response.text().catch(() => '')).slice(0, DETAIL_LIMIT),
106
+ envVar: API_KEY_ENV,
78
107
  });
79
108
  }
80
- return this.decode((await response.json()) as unknown, texts.length);
109
+ // Read through core's counting reader rather than `response.json()`: a body is buffered whole
110
+ // before anything measures it otherwise, and a `content-length` a remote wrote is not a bound.
111
+ const maxBytes = this.config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
112
+ const read = await readWithinLimit(response.body, maxBytes);
113
+ if ('over' in read) {
114
+ throw new AiTransportError({
115
+ provider: this.name,
116
+ status: response.status,
117
+ detail: `response body is at least ${read.over} bytes, limit is ${maxBytes}`,
118
+ envVar: API_KEY_ENV,
119
+ });
120
+ }
121
+ let payload: unknown;
122
+ try {
123
+ payload = JSON.parse(new TextDecoder().decode(read.bytes));
124
+ } catch {
125
+ throw this.malformed('the response body is not valid JSON');
126
+ }
127
+ return this.decode(payload, texts.length);
81
128
  }
82
129
 
83
130
  private decode(payload: unknown, expected: number): readonly Float32Array[] {
package/src/runtime.ts CHANGED
@@ -24,12 +24,35 @@ export interface AiRuntimeInput {
24
24
  readonly embedder?: Embedder;
25
25
  /** One cache per scope key. Defaults to the in-memory driver; pgvector in production. */
26
26
  readonly semanticCache?: (scope: string) => SemanticCache;
27
+ /**
28
+ * The last thing that runs over a prompt before it leaves the process. Declared here, in the
29
+ * one place the framework already owns, because `vars()` is the one declared place a model call
30
+ * loads data — the framework knows exactly where the row enters the prompt, and until now
31
+ * nothing sat between that and a third-party endpoint.
32
+ *
33
+ * WHAT to remove is the app's decision, not the framework's: a PII classifier is a model choice
34
+ * and ships in no framework (axiom 8). Ultimate ships the seam, the span attribute
35
+ * (`llm.redacted`) and the one rule it can enforce structurally — a `Secret` reaching `vars()`
36
+ * is `X_AI_PROMPT_SECRET`, whether or not a redactor is installed.
37
+ */
38
+ readonly redact?: Redactor;
27
39
  }
28
40
 
41
+ /**
42
+ * Rewrites a prompt on its way to the provider. Sees the whole RENDERED text, system prompt
43
+ * included — template as well as values, because a redactor shown only the values cannot tell
44
+ * a name in a data slot from the same name in an instruction.
45
+ */
46
+ export type Redactor = (text: string) => string;
47
+
48
+ /** No app redactor installed. Named rather than inline so `llm.redacted` has one thing to mean. */
49
+ const noRedaction: Redactor = (text) => text;
50
+
29
51
  interface AiRuntime {
30
52
  readonly gateway: Gateway;
31
53
  readonly embedder: Embedder;
32
54
  readonly semanticCache: (scope: string) => SemanticCache;
55
+ readonly redact: Redactor;
33
56
  }
34
57
 
35
58
  let runtime: AiRuntime | undefined;
@@ -40,6 +63,7 @@ export function configureAi(input: AiRuntimeInput): void {
40
63
  gateway: input.gateway,
41
64
  embedder: input.embedder ?? new HashEmbedder(),
42
65
  semanticCache: input.semanticCache ?? (() => createMemorySemanticCache()),
66
+ redact: input.redact ?? noRedaction,
43
67
  };
44
68
  // A new runtime means a new embedder and a new gateway; vectors from the old one are not
45
69
  // comparable to vectors from the new one, and a stale hit would answer the wrong question.
@@ -56,6 +80,11 @@ export function aiEmbedder(): Embedder {
56
80
  return runtime?.embedder ?? new HashEmbedder();
57
81
  }
58
82
 
83
+ /** The installed redactor, or the identity. Never absent, so the call site has no branch. */
84
+ export function aiRedactor(): Redactor {
85
+ return runtime?.redact ?? noRedaction;
86
+ }
87
+
59
88
  /**
60
89
  * The cache for one scope. Scopes are separate CACHE INSTANCES, never a filter over a shared
61
90
  * one: cosine similarity has no notion of a tenant, so two tenants asking near-identical