@ultimat3/ai 23.0.0 → 25.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.
Files changed (48) hide show
  1. package/CLAUDE.md +96 -32
  2. package/README.md +249 -75
  3. package/package.json +12 -11
  4. package/src/agent-facts.ts +9 -2
  5. package/src/agent-transcript.ts +1 -1
  6. package/src/agent.ts +43 -25
  7. package/src/budget.ts +178 -51
  8. package/src/content-blocks.ts +227 -0
  9. package/src/content-errors.ts +67 -0
  10. package/src/echo-provider.ts +102 -0
  11. package/src/embeddings.ts +40 -21
  12. package/src/error-body.ts +38 -3
  13. package/src/errors.ts +19 -11
  14. package/src/eval-baseline.ts +5 -1
  15. package/src/eval-errors.ts +3 -1
  16. package/src/evals.ts +1 -1
  17. package/src/failure-disclosure.ts +90 -0
  18. package/src/gateway.ts +73 -37
  19. package/src/hive-pool.ts +24 -16
  20. package/src/hive-result.ts +4 -1
  21. package/src/hive.ts +15 -5
  22. package/src/index.ts +66 -25
  23. package/src/llm-cache.ts +4 -3
  24. package/src/llm.ts +27 -65
  25. package/src/model-resolve.ts +79 -0
  26. package/src/models.ts +125 -111
  27. package/src/openai-body.ts +13 -2
  28. package/src/openai-messages.ts +86 -15
  29. package/src/openai-provider.ts +28 -31
  30. package/src/openai-wire.ts +11 -3
  31. package/src/pg-vector-sql.ts +11 -7
  32. package/src/pg-vector.ts +21 -10
  33. package/src/prompt-fence.ts +128 -0
  34. package/src/prompt.ts +43 -13
  35. package/src/provider.ts +82 -117
  36. package/src/rag.ts +17 -9
  37. package/src/remote-embedder.ts +15 -41
  38. package/src/respond.ts +86 -0
  39. package/src/runtime.ts +12 -14
  40. package/src/scorers.ts +14 -3
  41. package/src/tool-idempotency.ts +79 -0
  42. package/src/tools.ts +52 -39
  43. package/src/vector-scope.ts +46 -4
  44. package/src/vector.ts +117 -41
  45. package/src/wire.ts +7 -2
  46. package/src/fix-line.evals.ts +0 -35
  47. package/src/fix-line.ts +0 -27
  48. package/src/openai-models.ts +0 -84
package/src/provider.ts CHANGED
@@ -1,14 +1,17 @@
1
- // The `Provider` interface, its two implementations — `AnthropicProvider` (the real Messages API,
2
- // streaming and not) and `EchoProvider` (deterministic, for tests and `x dev` without a key) —
3
- // and the money arithmetic over a call's reported usage. The REQUEST half only: ./models owns the
4
- // catalogue and the per-model rules, ./wire owns the response half.
1
+ // The `Provider` interface, `AnthropicProvider` (the real Messages API, streaming and not) and
2
+ // the money arithmetic over a call's reported usage — `EchoProvider`, the double, is
3
+ // ./echo-provider's. The REQUEST half only: ./models owns the catalogue and the per-model rules,
4
+ // ./wire owns the response half.
5
5
 
6
6
  import type { Money } from '@ultimat3/money';
7
+ import type { AiMediaBlock } from './content-blocks';
8
+ import { assertMediaContent, blockText, mediaTokenEstimate } from './content-blocks';
7
9
  import { detailOf, withoutKey } from './error-body';
8
- import { AiKeyMissingError, AiTransportError } from './errors';
10
+ import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
9
11
  import type { AiFetch } from './fetch-seam';
12
+ import { resolveModel } from './model-resolve';
10
13
  import type { Effort, ModelId, ThinkingMode } from './models';
11
- import { ANTHROPIC_MODEL_IDS, DEFAULT_MODEL, modelIds, modelSpec, reasoningBody } from './models';
14
+ import { modelSpec, reasoningBody } from './models';
12
15
  import { readSse } from './sse';
13
16
  import type { LlmTool, LlmToolCall } from './tools';
14
17
  import {
@@ -23,7 +26,8 @@ import {
23
26
  /**
24
27
  * One block of a structured message. A plain string message is still the common case and still
25
28
  * legal; blocks exist because a tool loop cannot be expressed without them — a `tool_result` has
26
- * to name the `tool_use` it answers, and a string has nowhere to put the id.
29
+ * to name the `tool_use` it answers, and a string has nowhere to put the id — and because an image
30
+ * or a PDF is not a string at all (`./content-blocks` owns those two and screens them).
27
31
  *
28
32
  * The field names are the Messages API's, not a translated set, so `body()` passes a block
29
33
  * through untouched. A second vocabulary here would be a mapping table to keep in step with a
@@ -42,7 +46,8 @@ export type AiContentBlock =
42
46
  readonly tool_use_id: string;
43
47
  readonly content: string;
44
48
  readonly is_error?: boolean;
45
- };
49
+ }
50
+ | AiMediaBlock;
46
51
 
47
52
  export interface AiMessage {
48
53
  readonly role: 'user' | 'assistant';
@@ -55,13 +60,7 @@ export interface AiMessage {
55
60
  */
56
61
  export function messageText(message: AiMessage): string {
57
62
  if (typeof message.content === 'string') return message.content;
58
- return message.content
59
- .map((block) => {
60
- if (block.type === 'text') return block.text;
61
- if (block.type === 'tool_result') return block.content;
62
- return JSON.stringify(block.input);
63
- })
64
- .join(' ');
63
+ return message.content.map(blockText).join(' ');
65
64
  }
66
65
 
67
66
  export interface GenerateRequest {
@@ -77,7 +76,7 @@ export interface GenerateRequest {
77
76
  /**
78
77
  * The caller's abort signal, forwarded to the socket by every provider in this package.
79
78
  *
80
- * Deliberately absent from `cacheKeyFor` and from every estimate: it says whether a request was
79
+ * Deliberately absent from `promptCacheKey` and from every estimate: it says whether a request was
81
80
  * ABANDONED, never what it asked for, so two calls that differ only in it are the same call and
82
81
  * must share a cache entry. Omitted means the call runs to completion — there is no ambient
83
82
  * default, because a timeout the caller did not ask for is a truncated answer nothing reports.
@@ -162,11 +161,20 @@ export interface Provider {
162
161
  * away is money the framework silently absorbs, and under-reporting spend defeats a budget.
163
162
  */
164
163
  export function costOf(model: ModelId, usage: TokenUsage): Money {
164
+ // The one price lookup: a model the app never `registerModel`-ed is `X_AI_MODEL_UNKNOWN` here,
165
+ // so no call is priced — and no budget reserved — against a number nobody chose.
165
166
  const spec = modelSpec(model);
166
- // Cache reads are ~0.1x input; cache writes ~1.25x. Scaled by 10 to stay integral.
167
+ const input = spec.inputPerMillion.minor;
168
+ // In TWENTIETHS of a minor unit, so the standard multipliers stay integral on any whole price:
169
+ // a cache read is 0.1x input (2/20) and a 5-minute write 1.25x (25/20) unless the row states
170
+ // its own — Opus 5.5 reads at 0.05x and Fable 5.1 at 0.025x, which a fixed 0.1x overstated.
171
+ const read =
172
+ spec.cacheReadPerMillion === undefined ? input * 2 : spec.cacheReadPerMillion.minor * 20;
173
+ const write =
174
+ spec.cacheWritePerMillion === undefined ? input * 25 : spec.cacheWritePerMillion.minor * 20;
167
175
  const inputUnits =
168
- usage.inputTokens * 10 + usage.cacheReadTokens * 1 + Math.ceil(usage.cacheWriteTokens * 12.5);
169
- const inputMinor = divideCeil(inputUnits * spec.inputPerMillion.minor, 10_000_000);
176
+ usage.inputTokens * input * 20 + usage.cacheReadTokens * read + usage.cacheWriteTokens * write;
177
+ const inputMinor = divideCeil(inputUnits, 20_000_000);
170
178
  const outputMinor = divideCeil(usage.outputTokens * spec.outputPerMillion.minor, 1_000_000);
171
179
  return { minor: inputMinor + outputMinor, currency: spec.inputPerMillion.currency };
172
180
  }
@@ -183,6 +191,12 @@ export function totalTokens(usage: TokenUsage): number {
183
191
  // ── Anthropic ────────────────────────────────────────────────────────────────
184
192
 
185
193
  export interface AnthropicProviderInput {
194
+ /**
195
+ * The ids this endpoint serves — REQUIRED, the app's list, never a built-in one: the gateway
196
+ * routes by membership, and each id is one the app `registerModel`-ed. A direct call names its
197
+ * model or is refused (`X_AI_MODEL_UNRESOLVED`), as `openAiProvider({ models })`'s is.
198
+ */
199
+ readonly models: readonly ModelId[];
186
200
  /** Reads `ANTHROPIC_API_KEY` when omitted. Absent at call time is a labelled throw. */
187
201
  readonly apiKey?: string;
188
202
  readonly baseUrl?: string;
@@ -202,10 +216,15 @@ export const STREAM_ONLY_MAX_TOKENS = 16_000;
202
216
 
203
217
  /** Whether this request has to go over the streaming transport to arrive at all. */
204
218
  export function requiresStreaming(request: GenerateRequest): boolean {
205
- const model = request.model ?? DEFAULT_MODEL;
219
+ const model = resolveModel('provider', request.model);
206
220
  return Math.min(request.maxTokens, modelSpec(model).maxOutput) > STREAM_ONLY_MAX_TOKENS;
207
221
  }
208
222
 
223
+ /** The Anthropic Messages API provider — the one way to build one; the class is a type only. */
224
+ export function anthropicProvider(input: AnthropicProviderInput): AnthropicProvider {
225
+ return new AnthropicProvider(input);
226
+ }
227
+
209
228
  /**
210
229
  * The real Messages API shape. The request-surface rules are encoded rather than documented,
211
230
  * because getting them wrong is a 400:
@@ -215,14 +234,34 @@ export function requiresStreaming(request: GenerateRequest): boolean {
215
234
  */
216
235
  export class AnthropicProvider implements Provider {
217
236
  readonly name = 'anthropic';
218
- /** Its own list, never the registry's: an app's internal model must not be routed here. */
219
- readonly models: readonly ModelId[] = ANTHROPIC_MODEL_IDS;
237
+ /** The app's list, never the registry's: a model served elsewhere must not be routed here. */
238
+ readonly models: readonly ModelId[];
220
239
  private readonly config: AnthropicProviderInput;
221
240
 
222
- constructor(config: AnthropicProviderInput = {}) {
241
+ // `| undefined`: plain JS (or a cast) reaches here with no argument, and that is the same boot
242
+ // mistake as an empty list — it gets the coded refusal, never a TypeError on `.models`.
243
+ constructor(config: AnthropicProviderInput | undefined) {
244
+ if (config === undefined || (config.models ?? []).length === 0) {
245
+ // A provider serving nothing can never be routed to — the same boot mistake, refused the
246
+ // same way, as an empty `openAiProvider({ models })`.
247
+ throw new AiRequestInvalidError({
248
+ detail:
249
+ 'anthropicProvider() was given an empty models list, so the gateway can never route to it',
250
+ fix: "anthropicProvider({ models: ['<id>'] }) # the ids this endpoint serves, each one your app registerModel-ed",
251
+ });
252
+ }
253
+ this.models = config.models;
223
254
  this.config = config;
224
255
  }
225
256
 
257
+ /**
258
+ * The model this request is for. The gateway resolves one before it routes; a direct call names
259
+ * one or is refused — this provider's own list is never a fourth place a model comes from.
260
+ */
261
+ private modelOf(request: GenerateRequest): ModelId {
262
+ return resolveModel('provider', request.model);
263
+ }
264
+
226
265
  /**
227
266
  * Above `STREAM_ONLY_MAX_TOKENS` this runs the STREAMING transport and returns the assembled
228
267
  * result, rather than refusing. Refusing would leak a transport limit into the API: `llm()`
@@ -231,10 +270,11 @@ export class AnthropicProvider implements Provider {
231
270
  * the same caller either way.
232
271
  */
233
272
  async generate(request: GenerateRequest): Promise<GenerateResult> {
234
- if (requiresStreaming(request)) return this.assemble(request);
273
+ const model = this.modelOf(request);
274
+ if (requiresStreaming({ ...request, model })) return this.assemble(request);
235
275
  const response = await this.send({ ...this.body(request), stream: false }, request.signal);
236
276
  const raw = (await response.json()) as Record<string, unknown>;
237
- return parseMessage(request.model ?? DEFAULT_MODEL, raw);
277
+ return parseMessage(model, raw);
238
278
  }
239
279
 
240
280
  /** Drive `stream()` to its `done` chunk. It throws on a cut stream, so a partial never lands. */
@@ -254,7 +294,7 @@ export class AnthropicProvider implements Provider {
254
294
  * result, so a consumer that only wants the answer can ignore every chunk before it.
255
295
  */
256
296
  async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
257
- const model = request.model ?? DEFAULT_MODEL;
297
+ const model = this.modelOf(request);
258
298
  const response = await this.send({ ...this.body(request), stream: true }, request.signal);
259
299
  if (response.body === null) {
260
300
  throw new AiTransportError({
@@ -272,7 +312,8 @@ export class AnthropicProvider implements Provider {
272
312
  if (!message.isComplete()) {
273
313
  throw new AiTransportError({
274
314
  provider: this.name,
275
- detail: 'the stream ended before message_stop — the answer is truncated',
315
+ detail:
316
+ 'the stream ended before message_stop, or with a tool call still open — the answer is truncated',
276
317
  });
277
318
  }
278
319
  const state = message.state();
@@ -284,7 +325,9 @@ export class AnthropicProvider implements Provider {
284
325
 
285
326
  /** The request body. Pure and side-effect free so a test can assert it directly. */
286
327
  body(request: GenerateRequest): Record<string, unknown> {
287
- const model = request.model ?? DEFAULT_MODEL;
328
+ const model = this.modelOf(request);
329
+ // The blocks pass through untouched below — they ARE this wire's shapes — so this is the screen.
330
+ assertMediaContent(request.messages, { provider: this.name, model });
288
331
  const body: Record<string, unknown> = {
289
332
  model,
290
333
  max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
@@ -381,100 +424,23 @@ export function parseMessage(model: ModelId, raw: Record<string, unknown>): Gene
381
424
  };
382
425
  }
383
426
 
384
- // ── Echo ─────────────────────────────────────────────────────────────────────
385
-
386
- export interface EchoProviderInput {
387
- /** Fixed replies keyed by the last user message, for eval fixtures. */
388
- readonly replies?: Readonly<Record<string, string>>;
389
- /** Fallback when no key matches. Defaults to echoing the last user message. */
390
- readonly fallback?: (prompt: string) => string;
391
- readonly tokensPerCall?: number;
392
- }
393
-
394
- /**
395
- * Deterministic provider. Same input, same output, same usage — which is what makes an eval
396
- * suite a test rather than a sample. Token counts are derived from length, so a budget test
397
- * can assert a refusal without a network.
398
- */
399
- export class EchoProvider implements Provider {
400
- readonly name = 'echo';
401
- /**
402
- * A getter over the whole registry, not a snapshot: the test double has to serve whatever the
403
- * test registered, and a field read at construction time would miss a model registered after.
404
- */
405
- get models(): readonly ModelId[] {
406
- return modelIds();
407
- }
408
-
409
- private readonly config: EchoProviderInput;
410
-
411
- constructor(config: EchoProviderInput = {}) {
412
- this.config = config;
413
- }
414
-
415
- async generate(request: GenerateRequest): Promise<GenerateResult> {
416
- const model = request.model ?? DEFAULT_MODEL;
417
- const prompt = lastUserMessage(request.messages);
418
- const text = this.fixedReply(prompt) ?? this.config.fallback?.(prompt) ?? prompt;
419
- const usage: TokenUsage = {
420
- inputTokens: this.config.tokensPerCall ?? estimateTokens(request),
421
- outputTokens: estimateTextTokens(text),
422
- cacheReadTokens: 0,
423
- cacheWriteTokens: 0,
424
- };
425
- return {
426
- model,
427
- text,
428
- toolCalls: [],
429
- stopReason: 'end_turn',
430
- stopDetails: undefined,
431
- usage,
432
- cost: costOf(model, usage),
433
- };
434
- }
435
-
436
- /**
437
- * The fixture reply for this prompt. `Object.hasOwn`, never `replies?.[prompt]`: the key is
438
- * MESSAGE TEXT, so a prompt of `toString` read a function off the prototype chain and returned
439
- * it as the model's answer — a double that answers with JS source is worse than one that cannot.
440
- */
441
- private fixedReply(prompt: string): string | undefined {
442
- const { replies } = this.config;
443
- if (replies === undefined || !Object.hasOwn(replies, prompt)) return undefined;
444
- return replies[prompt];
445
- }
446
-
447
- async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
448
- const result = await this.generate(request);
449
- // One word per chunk: enough to exercise a consumer's assembly logic.
450
- for (const word of result.text.split(' ')) {
451
- if (word !== '') yield { type: 'text', text: `${word} ` };
452
- }
453
- yield { type: 'done', result };
454
- }
455
- }
456
-
457
- function lastUserMessage(messages: readonly AiMessage[]): string {
458
- for (let i = messages.length - 1; i >= 0; i -= 1) {
459
- const message = messages[i];
460
- if (message !== undefined && message.role === 'user') return messageText(message);
461
- }
462
- return '';
463
- }
464
-
465
427
  /**
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.
428
+ * ~4 characters per token. Deliberately an ESTIMATE and never used for billing — the gateway's
429
+ * pre-flight budget check needs a number before the call exists, and `usage` replaces it after.
430
+ * The completion is clamped to what the model can write, exactly as `estimateCost` clamps it.
469
431
  */
470
432
  export function estimateTokens(request: GenerateRequest): number {
471
- return estimateInputTokens(request) + request.maxTokens;
433
+ return estimateInputTokens(request) + completionCeiling(request);
472
434
  }
473
435
 
436
+ const completionCeiling = (request: GenerateRequest): number =>
437
+ Math.min(request.maxTokens, modelSpec(resolveModel('provider', request.model)).maxOutput);
438
+
474
439
  /** The prompt half alone — what the provider bills at the input rate. */
475
440
  export function estimateInputTokens(request: GenerateRequest): number {
476
441
  const body = request.messages.map(messageText).join(' ');
477
- return estimateTextTokens(body) + estimateTextTokens(request.system ?? '');
442
+ const text = estimateTextTokens(body) + estimateTextTokens(request.system ?? '');
443
+ return text + mediaTokenEstimate(request.messages);
478
444
  }
479
445
 
480
446
  /**
@@ -483,10 +449,9 @@ export function estimateInputTokens(request: GenerateRequest): number {
483
449
  * optimistic estimate is a ceiling one long completion walks through.
484
450
  */
485
451
  export function estimateCost(request: GenerateRequest): Money {
486
- const model = request.model ?? DEFAULT_MODEL;
487
- return costOf(model, {
452
+ return costOf(resolveModel('provider', request.model), {
488
453
  inputTokens: estimateInputTokens(request),
489
- outputTokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
454
+ outputTokens: completionCeiling(request),
490
455
  cacheReadTokens: 0,
491
456
  cacheWriteTokens: 0,
492
457
  });
package/src/rag.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { finiteCount, finiteOption } from '@ultimat3/core';
8
8
  import type { Embedder } from './embeddings';
9
9
  import { embedBatched, embedOne } from './embeddings';
10
+ import { neutraliseFences } from './prompt-fence';
10
11
  import { estimateTextTokens as estimateChunkTokens } from './provider';
11
12
  import type { SearchHit, VectorStore } from './vector';
12
13
 
@@ -31,7 +32,7 @@ export interface ChunkInput {
31
32
  * Token-aware chunker. Splits on paragraph, then sentence, then hard-wraps — so a chunk
32
33
  * boundary lands at a meaning boundary whenever one is available within the budget.
33
34
  */
34
- export function chunk(input: ChunkInput): readonly Chunk[] {
35
+ export function chunkDocument(input: ChunkInput): readonly Chunk[] {
35
36
  // Floored at one token: `size: 0` makes every comparison below meaningless and the wrap's cut
36
37
  // point zero-width, which is a loop that never advances rather than a chunker that produces
37
38
  // nothing. A budget under one token is not a budget.
@@ -56,7 +57,9 @@ export function chunk(input: ChunkInput): readonly Chunk[] {
56
57
  id: `${input.id}#${chunks.length}`,
57
58
  text,
58
59
  tokens: estimateChunkTokens(text),
59
- metadata: { source: input.id, ...(input.metadata ?? {}) },
60
+ // Written AFTER the spread: `source` is the framework's stamp, and `indexDocument` prunes by
61
+ // it — a caller's own `source` replaced it, and a re-index pruned the wrong rows or none.
62
+ metadata: { ...(input.metadata ?? {}), source: input.id },
60
63
  });
61
64
  }
62
65
  // Carry the tail forward as the overlap for the next chunk — at most every unit BUT THE
@@ -78,6 +81,11 @@ export function chunk(input: ChunkInput): readonly Chunk[] {
78
81
  for (const unit of units) {
79
82
  const unitTokens = estimateChunkTokens(unit);
80
83
  if (tokens + unitTokens > size && buffer.length > 0) flush();
84
+ // The carried overlap yields to the unit, oldest first: a carry the unit does not fit beside
85
+ // made a chunk past `size` — the one promise this function makes.
86
+ while (buffer.length > 0 && tokens + unitTokens > size) {
87
+ tokens -= estimateChunkTokens(buffer.shift() ?? '');
88
+ }
81
89
  buffer.push(unit);
82
90
  tokens += unitTokens;
83
91
  }
@@ -158,14 +166,14 @@ function cutToBudget(run: string, size: number): readonly string[] {
158
166
  * Index a document: chunk, embed, upsert, prune. One call so no step is skipped by accident. The
159
167
  * prune is what re-indexing a SHORTER document needs: chunk ids are `<doc>#<n>`, so the upsert
160
168
  * 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.
169
+ * document is never absent from a search in between.
162
170
  */
163
171
  export async function indexDocument(input: {
164
172
  readonly store: VectorStore;
165
173
  readonly embedder: Embedder;
166
174
  readonly document: ChunkInput;
167
175
  }): Promise<readonly Chunk[]> {
168
- const chunks = chunk(input.document);
176
+ const chunks = chunkDocument(input.document);
169
177
  const vectors = await embedBatched(
170
178
  input.embedder,
171
179
  chunks.map((c) => c.text),
@@ -181,8 +189,8 @@ export async function indexDocument(input: {
181
189
  metadata: c.metadata,
182
190
  })),
183
191
  );
184
- // `source` is the chunker's own stamp (`chunk()`), so only THIS document's rows are touched.
185
- await input.store.prune?.(
192
+ // `source` is the chunker's own stamp (`chunkDocument()`), so only THIS document's rows are touched.
193
+ await input.store.prune(
186
194
  { source: input.document.id },
187
195
  chunks.map((c) => c.id),
188
196
  );
@@ -255,9 +263,9 @@ const BLOCK_CLOSE = '</document>';
255
263
  */
256
264
  function documentBlock(id: string, text: string): string {
257
265
  const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
258
- const body = text
259
- .replaceAll(BLOCK_CLOSE, '<\\/document>')
260
- .replaceAll(BLOCK_OPEN, '<\\document id=');
266
+ // The closer through the one neutraliser `render` uses (any case, any whitespace, whatever
267
+ // follows the name); the opener is broken here, because a forged label is this fence's alone.
268
+ const body = neutraliseFences(text, ['document']).replaceAll(BLOCK_OPEN, '<\\document id=');
261
269
  return `${BLOCK_OPEN}"${label}">\n${body}\n${BLOCK_CLOSE}`;
262
270
  }
263
271
 
@@ -8,7 +8,8 @@
8
8
 
9
9
  import { finiteCount, readWithinLimit, renderThrowable } from '@ultimat3/core';
10
10
  import type { Embedder } from './embeddings';
11
- import { normalize } from './embeddings';
11
+ import { normalizeVector } from './embeddings';
12
+ import { detailOf, withoutKey } from './error-body';
12
13
  import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors';
13
14
  import type { AiFetch } from './fetch-seam';
14
15
 
@@ -16,7 +17,6 @@ const API_KEY_ENV = 'EMBEDDINGS_API_KEY';
16
17
  const DEFAULT_BASE_URL = 'https://api.voyageai.com/v1';
17
18
  /** Providers cap a batch around 128 inputs; 96 leaves headroom for long texts. */
18
19
  const DEFAULT_BATCH_SIZE = 96;
19
- const DETAIL_LIMIT = 300;
20
20
  /**
21
21
  * A hosted endpoint is a third party and `baseUrl` is app config, so neither its latency nor its
22
22
  * response size is ours to assume. 30s is longer than any healthy embedding batch and shorter than
@@ -50,6 +50,11 @@ export interface RemoteEmbedderInput {
50
50
  readonly fetch?: AiFetch;
51
51
  }
52
52
 
53
+ /** An HTTP embeddings endpoint — the one way to build one; the class is a type only. */
54
+ export function remoteEmbedder(input: RemoteEmbedderInput): RemoteEmbedder {
55
+ return new RemoteEmbedder(input);
56
+ }
57
+
53
58
  export class RemoteEmbedder implements Embedder {
54
59
  readonly name: string;
55
60
  readonly dimension: number;
@@ -124,7 +129,7 @@ export class RemoteEmbedder implements Embedder {
124
129
  // and the endpoint is app config, so the rejection is a value this package did not build —
125
130
  // and `instanceof` RUNS a `Proxy`'s `getPrototypeOf` trap, whose throw escapes this very
126
131
  // `catch` and replaces a coded refusal with an uncoded crash.
127
- detail: `${renderThrowable(error)} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
132
+ detail: `${withoutKey(renderThrowable(error), apiKey)} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
128
133
  envVar: API_KEY_ENV,
129
134
  });
130
135
  }
@@ -132,7 +137,9 @@ export class RemoteEmbedder implements Embedder {
132
137
  throw new AiTransportError({
133
138
  provider: this.name,
134
139
  status: response.status,
135
- detail: await detailOf(response.body),
140
+ // The shared reader and the shared scrub: a proxy echoing the bearer header into its 4xx
141
+ // body put the key into an error that reaches a log, a span and a problem document.
142
+ detail: withoutKey(await detailOf(response), apiKey),
136
143
  envVar: API_KEY_ENV,
137
144
  });
138
145
  }
@@ -180,9 +187,9 @@ export class RemoteEmbedder implements Embedder {
180
187
  }
181
188
 
182
189
  /**
183
- * L2 normalised on the way in, like every other embedder here, so `cosine` stays a dot
184
- * product. Providers already return unit vectors, which makes this a no-op — but "already"
185
- * is a property of today's provider, not of the interface.
190
+ * L2 normalised on the way in, like every other embedder here, so a vector's scale is never the
191
+ * provider's choice. Providers already return unit vectors, which makes this a no-op — but
192
+ * "already" is a property of today's provider, not of the interface.
186
193
  */
187
194
  private vectorOf(raw: unknown, position: number): Float32Array {
188
195
  if (!Array.isArray(raw))
@@ -202,7 +209,7 @@ export class RemoteEmbedder implements Embedder {
202
209
  }
203
210
  vector[i] = value;
204
211
  }
205
- return normalize(vector);
212
+ return normalizeVector(vector);
206
213
  }
207
214
 
208
215
  private malformed(detail: string): AiTransportError {
@@ -218,36 +225,3 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
218
225
  function countOf(data: unknown): string {
219
226
  return Array.isArray(data) ? String(data.length) : 'no data array';
220
227
  }
221
-
222
- /**
223
- * The start of an error body, and never more. `response.text()` read it WHOLE — outside
224
- * `maxResponseBytes`, which bounds a success only — so a provider streaming an endless 5xx body
225
- * held the call and its memory forever. Capped through the same counting reader, and decoded from
226
- * the bytes it kept: `over` still has a detail worth showing.
227
- */
228
- async function detailOf(body: ReadableStream<Uint8Array> | null): Promise<string> {
229
- if (body === null) return '';
230
- const reader = body.getReader();
231
- const kept: Uint8Array[] = [];
232
- let total = 0;
233
- try {
234
- while (total < DETAIL_LIMIT) {
235
- const { done, value } = await reader.read();
236
- if (done) break;
237
- kept.push(value);
238
- total += value.byteLength;
239
- }
240
- } catch {
241
- // A body that fails mid-read still has whatever arrived; the status is the error's substance.
242
- } finally {
243
- await reader.cancel().catch(() => undefined);
244
- reader.releaseLock();
245
- }
246
- const bytes = new Uint8Array(total);
247
- let offset = 0;
248
- for (const chunk of kept) {
249
- bytes.set(chunk, offset);
250
- offset += chunk.byteLength;
251
- }
252
- return new TextDecoder().decode(bytes).slice(0, DETAIL_LIMIT);
253
- }
package/src/respond.ts ADDED
@@ -0,0 +1,86 @@
1
+ // The `respond` tool an `llm()` / `agent()` answers through, and the reader that gets the answer
2
+ // back out of a turn: the tool call's arguments, or the prose parsed as JSON. One file so the
3
+ // envelope a non-object `output` is wrapped in and its unwrapping cannot drift apart.
4
+
5
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
6
+ import { toWireSchema } from '@ultimat3/schema';
7
+ import type { GenerateResult } from './provider';
8
+ import type { LlmTool } from './tools';
9
+
10
+ /**
11
+ * The tool the model answers through. One name, so the reader never has to guess — and shared
12
+ * with `agent()`, which offers the app's tools alongside it and needs the same name to tell an
13
+ * answer from a tool call.
14
+ */
15
+ export const RESPOND = 'respond';
16
+
17
+ /** The one property a non-object `output` travels under. */
18
+ const ENVELOPE = 'value';
19
+
20
+ /** The tool for one `output`, and the reader that undoes whatever the tool did to it. */
21
+ export interface Respond {
22
+ readonly tool: LlmTool;
23
+ /** The answer in `result`, unwrapped — or `undefined` when there is none to validate. */
24
+ read(result: GenerateResult): unknown;
25
+ }
26
+
27
+ /**
28
+ * The output schema as the only tool the model may answer through — the spec's "structured
29
+ * output drives tool use". `toWireSchema` is the projection an MCP client is served, so a model
30
+ * and an agent are shown one shape, and a schema it cannot express throws HERE, at declaration
31
+ * time.
32
+ *
33
+ * A tool's `input_schema` must be an OBJECT, so any other `output` (`t.string`, `t.number`, an
34
+ * array) is wrapped in `{ value }` and unwrapped on the way back — never refused: `.stream()` is
35
+ * documented for `output: t.string`, and the same declaration must work called plainly.
36
+ */
37
+ export function respondFor(output: StandardSchemaV1): Respond {
38
+ const schema = toWireSchema(output);
39
+ const wrapped = schema.type !== 'object';
40
+ return {
41
+ tool: {
42
+ name: RESPOND,
43
+ description: 'Return the result. Call this exactly once; do not answer in prose.',
44
+ input_schema: wrapped
45
+ ? {
46
+ type: 'object',
47
+ properties: { [ENVELOPE]: schema },
48
+ required: [ENVELOPE],
49
+ additionalProperties: false,
50
+ }
51
+ : schema,
52
+ strict: true,
53
+ },
54
+ read(result) {
55
+ const call = result.toolCalls.find((c) => c.name === RESPOND);
56
+ if (call !== undefined) return wrapped ? call.input[ENVELOPE] : call.input;
57
+ // A model that answers in prose is a schema failure, not a crash, so it flows into the
58
+ // repair turn. For a wrapped output the prose itself may BE the answer (`t.string`), the
59
+ // rule `.stream()` already applies.
60
+ const parsed = parseJsonish(result.text);
61
+ return parsed === undefined && wrapped && result.text !== '' ? result.text : parsed;
62
+ },
63
+ };
64
+ }
65
+
66
+ const FENCE = '```';
67
+
68
+ /**
69
+ * The JSON in a prose answer: the first fenced block when there is a closed one, else the whole
70
+ * text. Two `indexOf` scans, never a backtracking pattern: the regex this replaced re-scanned the
71
+ * tail from every whitespace position after an unterminated fence — quadratic on model output.
72
+ */
73
+ export function parseJsonish(text: string): unknown {
74
+ const open = text.indexOf(FENCE);
75
+ const close = open === -1 ? -1 : text.indexOf(FENCE, open + FENCE.length);
76
+ let body = text;
77
+ if (close !== -1) {
78
+ body = text.slice(open + FENCE.length, close);
79
+ if (body.startsWith('json')) body = body.slice('json'.length);
80
+ }
81
+ try {
82
+ return JSON.parse(body.trim());
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
package/src/runtime.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  // un-bypassable rather than merely recommended.
9
9
 
10
10
  import type { SemanticCache } from '@ultimat3/cache';
11
- import { createMemorySemanticCache } from '@ultimat3/cache';
12
- import { cachedFormatter, MAX_CACHED_FORMATTERS } from '@ultimat3/core';
11
+ import { memorySemanticCache } from '@ultimat3/cache';
12
+ import { cachedFormatter } from '@ultimat3/core';
13
13
  import type { Embedder } from './embeddings';
14
- import { HashEmbedder } from './embeddings';
14
+ import { hashEmbedder } from './embeddings';
15
15
  import { AiGatewayMissingError } from './errors';
16
16
  import type { Gateway } from './gateway';
17
17
 
@@ -62,8 +62,8 @@ const caches = new Map<string, SemanticCache>();
62
62
  export function configureAi(input: AiRuntimeInput): void {
63
63
  runtime = {
64
64
  gateway: input.gateway,
65
- embedder: input.embedder ?? new HashEmbedder(),
66
- semanticCache: input.semanticCache ?? (() => createMemorySemanticCache()),
65
+ embedder: input.embedder ?? hashEmbedder(),
66
+ semanticCache: input.semanticCache ?? (() => memorySemanticCache()),
67
67
  redact: input.redact ?? noRedaction,
68
68
  };
69
69
  // A new runtime means a new embedder and a new gateway; vectors from the old one are not
@@ -77,8 +77,13 @@ export function aiGateway(prompt: string): Gateway {
77
77
  return runtime.gateway;
78
78
  }
79
79
 
80
+ /** The installed gateway, or `undefined` — for a caller that may run without one (`hive()`). */
81
+ export function installedGateway(): Gateway | undefined {
82
+ return runtime?.gateway;
83
+ }
84
+
80
85
  export function aiEmbedder(): Embedder {
81
- return runtime?.embedder ?? new HashEmbedder();
86
+ return runtime?.embedder ?? hashEmbedder();
82
87
  }
83
88
 
84
89
  /** The installed redactor, or the identity. Never absent, so the call site has no branch. */
@@ -86,13 +91,6 @@ export function aiRedactor(): Redactor {
86
91
  return runtime?.redact ?? noRedaction;
87
92
  }
88
93
 
89
- /**
90
- * How many scopes hold a live cache instance at once. Core's bound, not a second one — the name
91
- * `MAX_CACHED_FORMATTERS` is about `cachedFormatter`'s first caller, never about its contract,
92
- * and a second FIFO map written here would be two answers to one question (axiom 1).
93
- */
94
- export const MAX_SEMANTIC_CACHE_SCOPES = MAX_CACHED_FORMATTERS;
95
-
96
94
  /**
97
95
  * The cache for one scope. Scopes are separate CACHE INSTANCES, never a filter over a shared
98
96
  * one: cosine similarity has no notion of a tenant, so two tenants asking near-identical
@@ -109,7 +107,7 @@ export function semanticCacheFor(scope: string): SemanticCache {
109
107
  return cachedFormatter(
110
108
  caches,
111
109
  scope,
112
- () => runtime?.semanticCache(scope) ?? createMemorySemanticCache(),
110
+ () => runtime?.semanticCache(scope) ?? memorySemanticCache(),
113
111
  );
114
112
  }
115
113