@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.
- package/CLAUDE.md +96 -32
- package/README.md +249 -75
- package/package.json +12 -11
- package/src/agent-facts.ts +9 -2
- package/src/agent-transcript.ts +1 -1
- package/src/agent.ts +43 -25
- package/src/budget.ts +178 -51
- 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 +40 -21
- package/src/error-body.ts +38 -3
- package/src/errors.ts +19 -11
- package/src/eval-baseline.ts +5 -1
- package/src/eval-errors.ts +3 -1
- package/src/evals.ts +1 -1
- package/src/failure-disclosure.ts +90 -0
- package/src/gateway.ts +73 -37
- package/src/hive-pool.ts +24 -16
- package/src/hive-result.ts +4 -1
- package/src/hive.ts +15 -5
- package/src/index.ts +66 -25
- package/src/llm-cache.ts +4 -3
- package/src/llm.ts +27 -65
- 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 +11 -3
- package/src/pg-vector-sql.ts +11 -7
- package/src/pg-vector.ts +21 -10
- package/src/prompt-fence.ts +128 -0
- package/src/prompt.ts +43 -13
- package/src/provider.ts +82 -117
- package/src/rag.ts +17 -9
- package/src/remote-embedder.ts +15 -41
- package/src/respond.ts +86 -0
- package/src/runtime.ts +12 -14
- package/src/scorers.ts +14 -3
- package/src/tool-idempotency.ts +79 -0
- package/src/tools.ts +52 -39
- package/src/vector-scope.ts +46 -4
- package/src/vector.ts +117 -41
- 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,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
|
-
*
|
|
468
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
return costOf(model, {
|
|
452
|
+
return costOf(resolveModel('provider', request.model), {
|
|
488
453
|
inputTokens: estimateInputTokens(request),
|
|
489
|
-
outputTokens:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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 (`
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
package/src/remote-embedder.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
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
|
+
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
|
-
|
|
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
|
|
184
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
@@ -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 ??
|
|
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) ??
|
|
110
|
+
() => runtime?.semanticCache(scope) ?? memorySemanticCache(),
|
|
113
111
|
);
|
|
114
112
|
}
|
|
115
113
|
|