@ultimat3/ai 24.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.
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,87 +424,6 @@ 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
428
  * ~4 characters per token. Deliberately an ESTIMATE and never used for billing — the gateway's
467
429
  * pre-flight budget check needs a number before the call exists, and `usage` replaces it after.
@@ -472,12 +434,13 @@ export function estimateTokens(request: GenerateRequest): number {
472
434
  }
473
435
 
474
436
  const completionCeiling = (request: GenerateRequest): number =>
475
- Math.min(request.maxTokens, modelSpec(request.model ?? DEFAULT_MODEL).maxOutput);
437
+ Math.min(request.maxTokens, modelSpec(resolveModel('provider', request.model)).maxOutput);
476
438
 
477
439
  /** The prompt half alone — what the provider bills at the input rate. */
478
440
  export function estimateInputTokens(request: GenerateRequest): number {
479
441
  const body = request.messages.map(messageText).join(' ');
480
- return estimateTextTokens(body) + estimateTextTokens(request.system ?? '');
442
+ const text = estimateTextTokens(body) + estimateTextTokens(request.system ?? '');
443
+ return text + mediaTokenEstimate(request.messages);
481
444
  }
482
445
 
483
446
  /**
@@ -486,7 +449,7 @@ export function estimateInputTokens(request: GenerateRequest): number {
486
449
  * optimistic estimate is a ceiling one long completion walks through.
487
450
  */
488
451
  export function estimateCost(request: GenerateRequest): Money {
489
- return costOf(request.model ?? DEFAULT_MODEL, {
452
+ return costOf(resolveModel('provider', request.model), {
490
453
  inputTokens: estimateInputTokens(request),
491
454
  outputTokens: completionCeiling(request),
492
455
  cacheReadTokens: 0,
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.
@@ -172,7 +173,7 @@ export async function indexDocument(input: {
172
173
  readonly embedder: Embedder;
173
174
  readonly document: ChunkInput;
174
175
  }): Promise<readonly Chunk[]> {
175
- const chunks = chunk(input.document);
176
+ const chunks = chunkDocument(input.document);
176
177
  const vectors = await embedBatched(
177
178
  input.embedder,
178
179
  chunks.map((c) => c.text),
@@ -188,7 +189,7 @@ export async function indexDocument(input: {
188
189
  metadata: c.metadata,
189
190
  })),
190
191
  );
191
- // `source` is the chunker's own stamp (`chunk()`), so only THIS document's rows are touched.
192
+ // `source` is the chunker's own stamp (`chunkDocument()`), so only THIS document's rows are touched.
192
193
  await input.store.prune(
193
194
  { source: input.document.id },
194
195
  chunks.map((c) => c.id),
@@ -262,9 +263,9 @@ const BLOCK_CLOSE = '</document>';
262
263
  */
263
264
  function documentBlock(id: string, text: string): string {
264
265
  const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
265
- const body = text
266
- .replaceAll(BLOCK_CLOSE, '<\\/document>')
267
- .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=');
268
269
  return `${BLOCK_OPEN}"${label}">\n${body}\n${BLOCK_CLOSE}`;
269
270
  }
270
271
 
@@ -8,7 +8,7 @@
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
12
  import { detailOf, withoutKey } from './error-body';
13
13
  import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors';
14
14
  import type { AiFetch } from './fetch-seam';
@@ -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;
@@ -182,9 +187,9 @@ export class RemoteEmbedder implements Embedder {
182
187
  }
183
188
 
184
189
  /**
185
- * L2 normalised on the way in, like every other embedder here, so `cosine` stays a dot
186
- * product. Providers already return unit vectors, which makes this a no-op — but "already"
187
- * 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.
188
193
  */
189
194
  private vectorOf(raw: unknown, position: number): Float32Array {
190
195
  if (!Array.isArray(raw))
@@ -204,7 +209,7 @@ export class RemoteEmbedder implements Embedder {
204
209
  }
205
210
  vector[i] = value;
206
211
  }
207
- return normalize(vector);
212
+ return normalizeVector(vector);
208
213
  }
209
214
 
210
215
  private malformed(detail: string): AiTransportError {
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
@@ -83,7 +83,7 @@ export function installedGateway(): Gateway | undefined {
83
83
  }
84
84
 
85
85
  export function aiEmbedder(): Embedder {
86
- return runtime?.embedder ?? new HashEmbedder();
86
+ return runtime?.embedder ?? hashEmbedder();
87
87
  }
88
88
 
89
89
  /** The installed redactor, or the identity. Never absent, so the call site has no branch. */
@@ -91,13 +91,6 @@ export function aiRedactor(): Redactor {
91
91
  return runtime?.redact ?? noRedaction;
92
92
  }
93
93
 
94
- /**
95
- * How many scopes hold a live cache instance at once. Core's bound, not a second one — the name
96
- * `MAX_CACHED_FORMATTERS` is about `cachedFormatter`'s first caller, never about its contract,
97
- * and a second FIFO map written here would be two answers to one question (axiom 1).
98
- */
99
- export const MAX_SEMANTIC_CACHE_SCOPES = MAX_CACHED_FORMATTERS;
100
-
101
94
  /**
102
95
  * The cache for one scope. Scopes are separate CACHE INSTANCES, never a filter over a shared
103
96
  * one: cosine similarity has no notion of a tenant, so two tenants asking near-identical
@@ -114,7 +107,7 @@ export function semanticCacheFor(scope: string): SemanticCache {
114
107
  return cachedFormatter(
115
108
  caches,
116
109
  scope,
117
- () => runtime?.semanticCache(scope) ?? createMemorySemanticCache(),
110
+ () => runtime?.semanticCache(scope) ?? memorySemanticCache(),
118
111
  );
119
112
  }
120
113
 
package/src/scorers.ts CHANGED
@@ -132,7 +132,7 @@ export function llmJudge(input: {
132
132
  maxTokens: finiteCount('llmJudge', 'maxTokens', input.maxTokens ?? 256, 1),
133
133
  ...(input.judge.system !== undefined ? { system: input.judge.system } : {}),
134
134
  ...(input.judge.model !== undefined ? { model: input.judge.model } : {}),
135
- // The judge prompt's hash is this scorer's NAME, and `contentHash` covers `effort` and
135
+ // The judge prompt's hash is this scorer's NAME, and `promptHash` covers `effort` and
136
136
  // `thinking` — so dropping either measures with a judge the name does not describe.
137
137
  ...(input.judge.effort !== undefined ? { effort: input.judge.effort } : {}),
138
138
  ...(input.judge.thinking !== undefined ? { thinking: input.judge.thinking } : {}),
@@ -0,0 +1,79 @@
1
+ // The in-app half of `idempotent: true`: an agent's tool carries the reserved `idempotencyKey`
2
+ // argument exactly as `@ultimat3/mcp`'s does, so an action an external agent may retry safely is
3
+ // one an in-app agent may retry safely too. Restated, not imported — `mcp` is this package's own
4
+ // tier — and held equal to `mcp/src/idempotency-arg.ts` by `scripts/ai-mcp-tool-parity.test.ts`.
5
+
6
+ import { MAX_IDEMPOTENCY_KEY_LENGTH } from '@ultimat3/action';
7
+ import { UltimateError } from '@ultimat3/core';
8
+ import type { JsonSchema } from './tools';
9
+
10
+ /** The reserved argument name on every idempotent action's tool — the one MCP reserves. */
11
+ export const TOOL_IDEMPOTENCY_KEY_ARG = 'idempotencyKey';
12
+
13
+ /** Contract text, byte-identical to MCP's: it is what the model reads on either surface. */
14
+ const DESCRIPTION =
15
+ 'Idempotency key. Retrying this call with the same key and the same arguments returns the ' +
16
+ 'first result instead of running it again; the same key with different arguments is refused. ' +
17
+ 'Any unique string, e.g. a UUID — reuse it only to retry.';
18
+
19
+ const KEY_SCHEMA: JsonSchema = {
20
+ type: 'string',
21
+ description: DESCRIPTION,
22
+ minLength: 1,
23
+ maxLength: MAX_IDEMPOTENCY_KEY_LENGTH,
24
+ };
25
+
26
+ /**
27
+ * MCP's own refusal, raised for the same tool: an agent tool is an MCP-exposed action by
28
+ * construction (`X_AGENT_TOOL_UNEXPOSED`), so this is the projection `@ultimat3/mcp` refuses too,
29
+ * and one code for it on both surfaces is the parity. Never titled here — `mcp` registers it.
30
+ */
31
+ export class ToolIdempotencyKeyShadowedError extends UltimateError {
32
+ constructor(input: { readonly name: string; readonly argument: string }) {
33
+ super({
34
+ code: 'X_MCP_IDEMPOTENCY_KEY_SHADOWED',
35
+ cause: `${input.name} is idempotent and its input declares "${input.argument}", the MCP argument reserved for the idempotency key`,
36
+ fix: `rename ${input.name}'s "${input.argument}" input field — MCP clients send the Idempotency-Key as the reserved "${input.argument}" argument, and the action receives the rest`,
37
+ meta: { action: input.name, argument: input.argument },
38
+ });
39
+ }
40
+ }
41
+
42
+ /** `schema` with the optional key argument; a non-object input is left alone, a clash refused. */
43
+ export function withToolIdempotencyKey(name: string, schema: JsonSchema): JsonSchema {
44
+ if (schema.type !== 'object') return schema;
45
+ const properties = schema.properties ?? {};
46
+ if (Object.hasOwn(properties, TOOL_IDEMPOTENCY_KEY_ARG)) {
47
+ throw new ToolIdempotencyKeyShadowedError({ name, argument: TOOL_IDEMPOTENCY_KEY_ARG });
48
+ }
49
+ return { ...schema, properties: { ...properties, [TOOL_IDEMPOTENCY_KEY_ARG]: KEY_SCHEMA } };
50
+ }
51
+
52
+ /**
53
+ * The model's arguments split into the action's input and the key. No key is an un-keyed run, as
54
+ * HTTP without the header; a non-string is no key. A blank or over-long one goes to `invoke`, whose
55
+ * `X_IDEMPOTENCY_KEY_INVALID` the model reads as a tool error naming the bound.
56
+ */
57
+ export function takeToolIdempotencyKey(args: unknown): {
58
+ readonly input: unknown;
59
+ readonly idempotencyKey: string | null;
60
+ } {
61
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) {
62
+ return { input: args, idempotencyKey: null };
63
+ }
64
+ if (!Object.hasOwn(args, TOOL_IDEMPOTENCY_KEY_ARG)) return { input: args, idempotencyKey: null };
65
+ const input: Record<string, unknown> = {};
66
+ let key: unknown;
67
+ for (const [field, value] of Object.entries(args)) {
68
+ if (field === TOOL_IDEMPOTENCY_KEY_ARG) key = value;
69
+ // defineProperty, never `input[field] =`: a `__proto__` field would re-prototype the copy.
70
+ else
71
+ Object.defineProperty(input, field, {
72
+ value,
73
+ writable: true,
74
+ enumerable: true,
75
+ configurable: true,
76
+ });
77
+ }
78
+ return { input, idempotencyKey: typeof key === 'string' ? key : null };
79
+ }