@ultimat3/ai 24.0.0 → 25.1.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/CLAUDE.md +72 -34
- package/README.md +271 -69
- package/package.json +12 -12
- package/src/agent-facts.ts +9 -2
- package/src/agent-job.ts +41 -2
- package/src/agent.ts +30 -13
- package/src/budget.ts +41 -6
- package/src/content-blocks.ts +227 -0
- package/src/content-errors.ts +67 -0
- package/src/echo-provider.ts +102 -0
- package/src/embeddings.ts +30 -10
- package/src/errors.ts +17 -11
- package/src/eval-errors.ts +3 -1
- package/src/evals.ts +1 -1
- package/src/failure-disclosure.ts +90 -0
- package/src/gateway.ts +40 -16
- package/src/hive-pool.ts +24 -16
- package/src/hive-result.ts +4 -1
- package/src/hive.ts +6 -4
- package/src/index.ts +63 -24
- package/src/llm-cache.ts +1 -1
- package/src/llm.ts +15 -11
- package/src/model-resolve.ts +79 -0
- package/src/models.ts +125 -111
- package/src/openai-body.ts +13 -2
- package/src/openai-messages.ts +86 -15
- package/src/openai-provider.ts +28 -31
- package/src/openai-wire.ts +4 -1
- package/src/pg-vector-sql.ts +11 -7
- package/src/pg-vector.ts +14 -6
- package/src/prompt-fence.ts +128 -0
- package/src/prompt.ts +43 -13
- package/src/provider.ts +75 -112
- package/src/rag.ts +7 -6
- package/src/remote-embedder.ts +10 -5
- package/src/runtime.ts +7 -14
- package/src/scorers.ts +1 -1
- package/src/tool-idempotency.ts +79 -0
- package/src/tools.ts +38 -36
- package/src/vector-scope.ts +2 -2
- package/src/vector.ts +103 -37
- package/src/wire.ts +7 -2
- package/src/fix-line.evals.ts +0 -35
- package/src/fix-line.ts +0 -27
- package/src/openai-models.ts +0 -84
package/src/provider.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
// The `Provider` interface,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
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 `
|
|
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
|
-
|
|
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 *
|
|
169
|
-
const inputMinor = divideCeil(inputUnits
|
|
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
|
|
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
|
-
/**
|
|
219
|
-
readonly models: readonly ModelId[]
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 (`
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
package/src/remote-embedder.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { finiteCount, readWithinLimit, renderThrowable } from '@ultimat3/core';
|
|
10
10
|
import type { Embedder } from './embeddings';
|
|
11
|
-
import {
|
|
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
|
|
186
|
-
*
|
|
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
|
|
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 {
|
|
12
|
-
import { cachedFormatter
|
|
11
|
+
import { memorySemanticCache } from '@ultimat3/cache';
|
|
12
|
+
import { cachedFormatter } from '@ultimat3/core';
|
|
13
13
|
import type { Embedder } from './embeddings';
|
|
14
|
-
import {
|
|
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 ??
|
|
66
|
-
semanticCache: input.semanticCache ?? (() =>
|
|
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 ??
|
|
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) ??
|
|
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 `
|
|
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
|
+
}
|