@ultimat3/ai 2.0.0 → 4.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 +205 -4
- package/README.md +149 -4
- package/package.json +11 -9
- package/src/agent-facts.ts +70 -0
- package/src/agent-job.ts +97 -0
- package/src/agent-transcript.ts +94 -0
- package/src/agent.ts +170 -61
- package/src/errors.ts +2 -0
- package/src/fetch-seam.ts +15 -0
- package/src/gateway.ts +25 -11
- package/src/hive-errors.ts +30 -0
- package/src/hive-pool.ts +90 -0
- package/src/hive-result.ts +96 -0
- package/src/hive.ts +177 -0
- package/src/index.ts +15 -10
- package/src/llm-cache.ts +111 -0
- package/src/llm.ts +14 -66
- package/src/openai-provider.ts +21 -6
- package/src/openai-wire.ts +59 -25
- package/src/prompt.ts +6 -1
- package/src/provider.ts +34 -7
- package/src/rag.ts +66 -6
- package/src/remote-embedder.ts +3 -2
- package/src/runtime.ts +19 -5
- package/src/sse.ts +32 -1
- package/src/tools.ts +95 -8
- package/src/wire.ts +23 -13
package/src/openai-provider.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { Secret } from '@ultimat3/core';
|
|
|
11
11
|
import { isSecret, revealSecret } from '@ultimat3/core';
|
|
12
12
|
import { detailOf, withoutKey } from './error-body';
|
|
13
13
|
import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
|
|
14
|
+
import type { AiFetch } from './fetch-seam';
|
|
14
15
|
import type { ModelId } from './models';
|
|
15
16
|
import { chatCompletionBody } from './openai-body';
|
|
16
17
|
// Imported for its registration side effect: a provider that cannot price what it serves throws
|
|
@@ -66,7 +67,7 @@ export interface OpenAiProviderInput {
|
|
|
66
67
|
*/
|
|
67
68
|
readonly name?: string;
|
|
68
69
|
/** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
|
|
69
|
-
readonly fetch?:
|
|
70
|
+
readonly fetch?: AiFetch;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
/**
|
|
@@ -114,14 +115,22 @@ class OpenAiProvider implements Provider {
|
|
|
114
115
|
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
115
116
|
if (requiresStreaming(request)) return this.assemble(request);
|
|
116
117
|
const model = this.modelOf(request);
|
|
117
|
-
const response = await this.send(
|
|
118
|
+
const response = await this.send(
|
|
119
|
+
chatCompletionBody({ request, model, stream: false }),
|
|
120
|
+
false,
|
|
121
|
+
request.signal,
|
|
122
|
+
);
|
|
118
123
|
const answer = parseChatCompletion((await response.json()) as unknown, this.name);
|
|
119
124
|
return this.result(request, model, answer);
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
123
128
|
const model = this.modelOf(request);
|
|
124
|
-
const response = await this.send(
|
|
129
|
+
const response = await this.send(
|
|
130
|
+
chatCompletionBody({ request, model, stream: true }),
|
|
131
|
+
true,
|
|
132
|
+
request.signal,
|
|
133
|
+
);
|
|
125
134
|
if (response.body === null) {
|
|
126
135
|
throw new AiTransportError({
|
|
127
136
|
provider: this.name,
|
|
@@ -130,7 +139,7 @@ class OpenAiProvider implements Provider {
|
|
|
130
139
|
});
|
|
131
140
|
}
|
|
132
141
|
const completion = new ChatCompletionStream(this.name);
|
|
133
|
-
for await (const frame of readSse(response.body)) {
|
|
142
|
+
for await (const frame of readSse(response.body, this.name)) {
|
|
134
143
|
for (const chunk of completion.push(frame)) yield chunk;
|
|
135
144
|
}
|
|
136
145
|
// A connection cut mid-answer must fail, not resolve: partial text reads as a complete answer,
|
|
@@ -183,9 +192,13 @@ class OpenAiProvider implements Provider {
|
|
|
183
192
|
* its status, because the gateway decides whether to retry from that status and a body parsed as
|
|
184
193
|
* if it were a message would read as an empty, successful answer.
|
|
185
194
|
*/
|
|
186
|
-
private async send(
|
|
195
|
+
private async send(
|
|
196
|
+
body: Record<string, unknown>,
|
|
197
|
+
streaming: boolean,
|
|
198
|
+
signal: AbortSignal | undefined,
|
|
199
|
+
): Promise<Response> {
|
|
187
200
|
const apiKey = this.apiKey();
|
|
188
|
-
const doFetch = this.config.fetch ?? fetch;
|
|
201
|
+
const doFetch: AiFetch = this.config.fetch ?? fetch;
|
|
189
202
|
const response = await doFetch(this.url(), {
|
|
190
203
|
method: 'POST',
|
|
191
204
|
headers: {
|
|
@@ -199,6 +212,8 @@ class OpenAiProvider implements Provider {
|
|
|
199
212
|
...this.config.headers,
|
|
200
213
|
},
|
|
201
214
|
body: JSON.stringify(body),
|
|
215
|
+
// Attached only when the caller has one — same rule, same reason, as the Anthropic half.
|
|
216
|
+
...(signal === undefined ? {} : { signal }),
|
|
202
217
|
});
|
|
203
218
|
if (!response.ok) {
|
|
204
219
|
throw new AiTransportError({
|
package/src/openai-wire.ts
CHANGED
|
@@ -24,34 +24,46 @@ export interface ChatAnswer {
|
|
|
24
24
|
readonly usage: TokenUsage | undefined;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
// A `Map`, not an object literal: `raw` is the PROVIDER's string on every read, and
|
|
28
|
+
// `FINISH_REASONS['constructor']` on an object answers the `Object` FUNCTION — which
|
|
29
|
+
// `parseFinishReason` returned as a `StopReason`, and which the stream reader below then treated
|
|
30
|
+
// as a finish, so `isComplete()` answered true for a stream that never finished. Same fix core
|
|
31
|
+
// made in `error-retry.ts` for the same shape.
|
|
32
|
+
const FINISH_REASONS: ReadonlyMap<string, StopReason> = new Map(
|
|
33
|
+
Object.entries({
|
|
34
|
+
stop: 'end_turn',
|
|
35
|
+
length: 'max_tokens',
|
|
36
|
+
tool_calls: 'tool_use',
|
|
37
|
+
// The legacy name for the same event; LiteLLM and older self-hosted servers still send it.
|
|
38
|
+
function_call: 'tool_use',
|
|
39
|
+
content_filter: 'refusal',
|
|
40
|
+
} as const),
|
|
41
|
+
);
|
|
35
42
|
|
|
36
43
|
/**
|
|
37
44
|
* In-band error frames carry a type, not a status, and the gateway's retry rule reads a status.
|
|
38
45
|
* Same mapping job as wire.ts's, over this format's own vocabulary.
|
|
39
46
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
// A `Map`, for the reason `FINISH_REASONS` above is one: `type` is the provider's string, and a
|
|
48
|
+
// function where `AiTransportError.status` is declared `number | undefined` makes `isRetryable`
|
|
49
|
+
// answer false for a 429-class frame and interpolates JS source into the operator-facing `cause`.
|
|
50
|
+
const ERROR_STATUS: ReadonlyMap<string, number> = new Map(
|
|
51
|
+
Object.entries({
|
|
52
|
+
invalid_request_error: 400,
|
|
53
|
+
authentication_error: 401,
|
|
54
|
+
permission_error: 403,
|
|
55
|
+
not_found_error: 404,
|
|
56
|
+
rate_limit_exceeded: 429,
|
|
57
|
+
insufficient_quota: 429,
|
|
58
|
+
server_error: 500,
|
|
59
|
+
api_error: 500,
|
|
60
|
+
overloaded_error: 503,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
51
63
|
|
|
52
64
|
/** A finish reason this format knows, or `undefined` for `null` — which means "still going". */
|
|
53
65
|
export function parseFinishReason(raw: unknown): StopReason | undefined {
|
|
54
|
-
return typeof raw === 'string' ? FINISH_REASONS
|
|
66
|
+
return typeof raw === 'string' ? FINISH_REASONS.get(raw) : undefined;
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
/**
|
|
@@ -64,10 +76,10 @@ export function parseFinishReason(raw: unknown): StopReason | undefined {
|
|
|
64
76
|
export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
|
|
65
77
|
const record = asRecord(raw);
|
|
66
78
|
if (record === undefined) return undefined;
|
|
67
|
-
const prompt =
|
|
68
|
-
const completion =
|
|
79
|
+
const prompt = countOf(record['prompt_tokens']);
|
|
80
|
+
const completion = countOf(record['completion_tokens']);
|
|
69
81
|
if (prompt === undefined && completion === undefined) return undefined;
|
|
70
|
-
const cached =
|
|
82
|
+
const cached = countOf(asRecord(record['prompt_tokens_details'])?.['cached_tokens']) ?? 0;
|
|
71
83
|
return {
|
|
72
84
|
inputTokens: Math.max((prompt ?? 0) - cached, 0),
|
|
73
85
|
// `completion_tokens` already contains `reasoning_tokens`; adding them is a double count.
|
|
@@ -194,7 +206,17 @@ export class ChatCompletionStream {
|
|
|
194
206
|
// Either sentinel counts. `[DONE]` is the format's own end marker, but plenty of servers in
|
|
195
207
|
// the family close the socket straight after the finish-reason chunk — and a finish reason IS
|
|
196
208
|
// the model saying why it stopped, which is the fact a truncated stream cannot produce.
|
|
197
|
-
|
|
209
|
+
//
|
|
210
|
+
// One exception, and it is REFUSAL rather than a flush: `[DONE]` while tool-call fragments are
|
|
211
|
+
// still open. This format has no per-call stop event, so the finish reason is the only thing
|
|
212
|
+
// that ever closes a call and `onFinish` is the only drain of `pending` — which means `[DONE]`
|
|
213
|
+
// alone cannot tell "the model finished asking" from "the connection died mid-arguments".
|
|
214
|
+
// Reporting complete discarded a whole tool call and answered an empty, successful `end_turn`;
|
|
215
|
+
// emitting the fragments anyway would run a tool's side effects from arguments that may be
|
|
216
|
+
// half a JSON object, and would report `end_turn` for a turn that stopped to call one. So the
|
|
217
|
+
// stream is refused exactly as the Anthropic half refuses a missing `message_stop`
|
|
218
|
+
// (`provider.ts`), and `openai-provider.ts`'s existing truncation guard is what raises it.
|
|
219
|
+
return this.finished || (this.done && this.pending.size === 0);
|
|
198
220
|
}
|
|
199
221
|
|
|
200
222
|
/** What the stream accumulated. `cost` is applied by the provider, which owns prices. */
|
|
@@ -320,7 +342,8 @@ function throwInBandError(payload: Record<string, unknown>, provider: string): v
|
|
|
320
342
|
const message = typeof error['message'] === 'string' ? error['message'] : type;
|
|
321
343
|
throw new AiTransportError({
|
|
322
344
|
provider,
|
|
323
|
-
status:
|
|
345
|
+
status:
|
|
346
|
+
ERROR_STATUS.get(type) ?? (code === undefined ? undefined : ERROR_STATUS.get(code)) ?? 500,
|
|
324
347
|
detail: message,
|
|
325
348
|
});
|
|
326
349
|
}
|
|
@@ -337,3 +360,14 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
337
360
|
function numberOf(value: unknown): number | undefined {
|
|
338
361
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
339
362
|
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A token count, floored at zero. Usage is the PROVIDER's number and a proxy in front of one can
|
|
366
|
+
* send anything: a negative count becomes a negative `cost`, and `MemoryBudgetStore.add` takes a
|
|
367
|
+
* negative debit as a credit deliberately (releasing an unspent reservation IS one) — so an
|
|
368
|
+
* unclamped `-1` here does not under-report spend, it TOPS THE LEDGER UP. Twin of `wire.ts`'s.
|
|
369
|
+
*/
|
|
370
|
+
function countOf(value: unknown): number | undefined {
|
|
371
|
+
const count = numberOf(value);
|
|
372
|
+
return count === undefined ? undefined : Math.max(0, count);
|
|
373
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -120,7 +120,12 @@ const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
|
120
120
|
function render(template: string, vars: PromptVars, ref: string): string {
|
|
121
121
|
const missing: string[] = [];
|
|
122
122
|
const out = template.replace(PLACEHOLDER, (_match, name: string) => {
|
|
123
|
-
|
|
123
|
+
// `Object.hasOwn`, never `vars[name] === undefined`: a plain object inherits `constructor`,
|
|
124
|
+
// `toString` and `valueOf`, so `{{constructor}}` in a template rendered JS SOURCE into the
|
|
125
|
+
// prompt instead of raising the unfilled-slot error this file promises — and that source was
|
|
126
|
+
// then hashed into the semantic cache key and paid for at the input rate. The discriminator
|
|
127
|
+
// `@ultimat3/flags`' `subject.ts` already uses, for the same reason.
|
|
128
|
+
const value = Object.hasOwn(vars, name) ? vars[name] : undefined;
|
|
124
129
|
if (value === undefined) {
|
|
125
130
|
missing.push(name);
|
|
126
131
|
return '';
|
package/src/provider.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { Money } from '@ultimat3/money';
|
|
7
7
|
import { detailOf, withoutKey } from './error-body';
|
|
8
8
|
import { AiKeyMissingError, AiTransportError } from './errors';
|
|
9
|
+
import type { AiFetch } from './fetch-seam';
|
|
9
10
|
import type { Effort, ModelId, ThinkingMode } from './models';
|
|
10
11
|
import { ANTHROPIC_MODEL_IDS, DEFAULT_MODEL, modelIds, modelSpec, reasoningBody } from './models';
|
|
11
12
|
import { readSse } from './sse';
|
|
@@ -73,6 +74,15 @@ export interface GenerateRequest {
|
|
|
73
74
|
readonly thinking?: ThinkingMode;
|
|
74
75
|
readonly tools?: readonly LlmTool[];
|
|
75
76
|
readonly stopSequences?: readonly string[];
|
|
77
|
+
/**
|
|
78
|
+
* The caller's abort signal, forwarded to the socket by every provider in this package.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately absent from `cacheKeyFor` and from every estimate: it says whether a request was
|
|
81
|
+
* ABANDONED, never what it asked for, so two calls that differ only in it are the same call and
|
|
82
|
+
* must share a cache entry. Omitted means the call runs to completion — there is no ambient
|
|
83
|
+
* default, because a timeout the caller did not ask for is a truncated answer nothing reports.
|
|
84
|
+
*/
|
|
85
|
+
readonly signal?: AbortSignal;
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
export interface TokenUsage {
|
|
@@ -171,7 +181,7 @@ export interface AnthropicProviderInput {
|
|
|
171
181
|
readonly apiKey?: string;
|
|
172
182
|
readonly baseUrl?: string;
|
|
173
183
|
/** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
|
|
174
|
-
readonly fetch?:
|
|
184
|
+
readonly fetch?: AiFetch;
|
|
175
185
|
}
|
|
176
186
|
|
|
177
187
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
@@ -216,7 +226,7 @@ export class AnthropicProvider implements Provider {
|
|
|
216
226
|
*/
|
|
217
227
|
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
218
228
|
if (requiresStreaming(request)) return this.assemble(request);
|
|
219
|
-
const response = await this.send({ ...this.body(request), stream: false });
|
|
229
|
+
const response = await this.send({ ...this.body(request), stream: false }, request.signal);
|
|
220
230
|
const raw = (await response.json()) as Record<string, unknown>;
|
|
221
231
|
return parseMessage(request.model ?? DEFAULT_MODEL, raw);
|
|
222
232
|
}
|
|
@@ -239,7 +249,7 @@ export class AnthropicProvider implements Provider {
|
|
|
239
249
|
*/
|
|
240
250
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
241
251
|
const model = request.model ?? DEFAULT_MODEL;
|
|
242
|
-
const response = await this.send({ ...this.body(request), stream: true });
|
|
252
|
+
const response = await this.send({ ...this.body(request), stream: true }, request.signal);
|
|
243
253
|
if (response.body === null) {
|
|
244
254
|
throw new AiTransportError({
|
|
245
255
|
provider: this.name,
|
|
@@ -248,7 +258,7 @@ export class AnthropicProvider implements Provider {
|
|
|
248
258
|
});
|
|
249
259
|
}
|
|
250
260
|
const message = new MessageStream();
|
|
251
|
-
for await (const frame of readSse(response.body)) {
|
|
261
|
+
for await (const frame of readSse(response.body, this.name)) {
|
|
252
262
|
for (const chunk of message.push(frame)) yield chunk;
|
|
253
263
|
}
|
|
254
264
|
// A connection cut mid-answer must fail, not resolve: the partial text reads as a complete
|
|
@@ -286,12 +296,15 @@ export class AnthropicProvider implements Provider {
|
|
|
286
296
|
* carrying its status, because the gateway decides whether to retry from that status and a
|
|
287
297
|
* body parsed as if it were a message would read as an empty, successful answer.
|
|
288
298
|
*/
|
|
289
|
-
private async send(
|
|
299
|
+
private async send(
|
|
300
|
+
body: Record<string, unknown>,
|
|
301
|
+
signal: AbortSignal | undefined,
|
|
302
|
+
): Promise<Response> {
|
|
290
303
|
const apiKey = this.config.apiKey ?? Bun.env[API_KEY_ENV];
|
|
291
304
|
if (apiKey === undefined || apiKey === '') {
|
|
292
305
|
throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
|
|
293
306
|
}
|
|
294
|
-
const doFetch = this.config.fetch ?? fetch;
|
|
307
|
+
const doFetch: AiFetch = this.config.fetch ?? fetch;
|
|
295
308
|
const url = `${this.config.baseUrl ?? 'https://api.anthropic.com'}/v1/messages`;
|
|
296
309
|
const response = await doFetch(url, {
|
|
297
310
|
method: 'POST',
|
|
@@ -302,6 +315,9 @@ export class AnthropicProvider implements Provider {
|
|
|
302
315
|
accept: body['stream'] === true ? 'text/event-stream' : 'application/json',
|
|
303
316
|
},
|
|
304
317
|
body: JSON.stringify(body),
|
|
318
|
+
// Attached only when the caller has one: `exactOptionalPropertyTypes`, and an explicit
|
|
319
|
+
// `signal: undefined` is a different value to some fetch implementations.
|
|
320
|
+
...(signal === undefined ? {} : { signal }),
|
|
305
321
|
});
|
|
306
322
|
if (!response.ok) {
|
|
307
323
|
throw new AiTransportError({
|
|
@@ -393,7 +409,7 @@ export class EchoProvider implements Provider {
|
|
|
393
409
|
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
394
410
|
const model = request.model ?? DEFAULT_MODEL;
|
|
395
411
|
const prompt = lastUserMessage(request.messages);
|
|
396
|
-
const text = this.
|
|
412
|
+
const text = this.fixedReply(prompt) ?? this.config.fallback?.(prompt) ?? prompt;
|
|
397
413
|
const usage: TokenUsage = {
|
|
398
414
|
inputTokens: this.config.tokensPerCall ?? estimateTokens(request),
|
|
399
415
|
outputTokens: estimateTextTokens(text),
|
|
@@ -411,6 +427,17 @@ export class EchoProvider implements Provider {
|
|
|
411
427
|
};
|
|
412
428
|
}
|
|
413
429
|
|
|
430
|
+
/**
|
|
431
|
+
* The fixture reply for this prompt. `Object.hasOwn`, never `replies?.[prompt]`: the key is
|
|
432
|
+
* MESSAGE TEXT, so a prompt of `toString` read a function off the prototype chain and returned
|
|
433
|
+
* it as the model's answer — a double that answers with JS source is worse than one that cannot.
|
|
434
|
+
*/
|
|
435
|
+
private fixedReply(prompt: string): string | undefined {
|
|
436
|
+
const { replies } = this.config;
|
|
437
|
+
if (replies === undefined || !Object.hasOwn(replies, prompt)) return undefined;
|
|
438
|
+
return replies[prompt];
|
|
439
|
+
}
|
|
440
|
+
|
|
414
441
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
415
442
|
const result = await this.generate(request);
|
|
416
443
|
// One word per chunk: enough to exercise a consumer's assembly logic.
|
package/src/rag.ts
CHANGED
|
@@ -31,9 +31,12 @@ export interface ChunkInput {
|
|
|
31
31
|
* boundary lands at a meaning boundary whenever one is available within the budget.
|
|
32
32
|
*/
|
|
33
33
|
export function chunk(input: ChunkInput): readonly Chunk[] {
|
|
34
|
-
|
|
34
|
+
// Floored at one token: `size: 0` makes every comparison below meaningless and the wrap's cut
|
|
35
|
+
// point zero-width, which is a loop that never advances rather than a chunker that produces
|
|
36
|
+
// nothing. A budget under one token is not a budget.
|
|
37
|
+
const size = Math.max(1, Math.floor(input.size ?? 512));
|
|
35
38
|
const overlap = Math.min(input.overlap ?? 64, size - 1);
|
|
36
|
-
const units = splitUnits(input.text);
|
|
39
|
+
const units = splitUnits(input.text, size);
|
|
37
40
|
const chunks: Chunk[] = [];
|
|
38
41
|
let buffer: string[] = [];
|
|
39
42
|
let tokens = 0;
|
|
@@ -49,10 +52,14 @@ export function chunk(input: ChunkInput): readonly Chunk[] {
|
|
|
49
52
|
metadata: { source: input.id, ...(input.metadata ?? {}) },
|
|
50
53
|
});
|
|
51
54
|
}
|
|
52
|
-
// Carry the tail forward as the overlap for the next chunk
|
|
55
|
+
// Carry the tail forward as the overlap for the next chunk — at most every unit BUT THE
|
|
56
|
+
// FIRST. Walking back to index 0 re-seeded the next buffer with everything just flushed, so
|
|
57
|
+
// a single unit whose own size exceeds `overlap` became the whole of the next chunk, and the
|
|
58
|
+
// next, and the next: a ~1,000-token document indexed as nine chunks of the same sentence.
|
|
59
|
+
// The wrap above bounds a unit; this bounds the loop, and both are needed.
|
|
53
60
|
const carried: string[] = [];
|
|
54
61
|
let carriedTokens = 0;
|
|
55
|
-
for (let i = buffer.length - 1; i >=
|
|
62
|
+
for (let i = buffer.length - 1; i >= 1 && carriedTokens < overlap; i -= 1) {
|
|
56
63
|
const unit = buffer[i] ?? '';
|
|
57
64
|
carried.unshift(unit);
|
|
58
65
|
carriedTokens += estimateChunkTokens(unit);
|
|
@@ -72,7 +79,7 @@ export function chunk(input: ChunkInput): readonly Chunk[] {
|
|
|
72
79
|
return chunks;
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
function splitUnits(text: string): readonly string[] {
|
|
82
|
+
function splitUnits(text: string, size: number): readonly string[] {
|
|
76
83
|
const units: string[] = [];
|
|
77
84
|
for (const paragraph of text.split(/\n{2,}/)) {
|
|
78
85
|
const trimmed = paragraph.trim();
|
|
@@ -81,12 +88,65 @@ function splitUnits(text: string): readonly string[] {
|
|
|
81
88
|
const sentences = trimmed.match(/[^.!?]+[.!?]*\s*/g) ?? [trimmed];
|
|
82
89
|
for (const sentence of sentences) {
|
|
83
90
|
const s = sentence.trim();
|
|
84
|
-
if (s !== '') units.push(s);
|
|
91
|
+
if (s !== '') units.push(...hardWrap(s, size));
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
return units;
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The third split the header promises, and the one that was missing: a unit no larger than the
|
|
99
|
+
* budget. Neither split above can guarantee it — a base64 blob, a minified line, a CJK paragraph
|
|
100
|
+
* this splitter's `[.!?]` alphabet cannot see and a legal 400-word sentence all survive both — and
|
|
101
|
+
* an oversized unit is a chunk the size check can never flush, because the check only fires when
|
|
102
|
+
* something is ALREADY in the buffer.
|
|
103
|
+
*
|
|
104
|
+
* Word boundaries first, so a chunk edge still lands at a meaning boundary when one exists within
|
|
105
|
+
* the budget; a run with no boundary in it is cut mid-word, because the alternative is no boundary
|
|
106
|
+
* at all.
|
|
107
|
+
*/
|
|
108
|
+
function hardWrap(unit: string, size: number): readonly string[] {
|
|
109
|
+
if (estimateChunkTokens(unit) <= size) return [unit];
|
|
110
|
+
const pieces: string[] = [];
|
|
111
|
+
let piece = '';
|
|
112
|
+
const flushPiece = (): void => {
|
|
113
|
+
if (piece !== '') pieces.push(piece);
|
|
114
|
+
piece = '';
|
|
115
|
+
};
|
|
116
|
+
for (const word of unit.split(/\s+/)) {
|
|
117
|
+
if (word === '') continue;
|
|
118
|
+
const candidate = piece === '' ? word : `${piece} ${word}`;
|
|
119
|
+
if (estimateChunkTokens(candidate) <= size) {
|
|
120
|
+
piece = candidate;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
flushPiece();
|
|
124
|
+
if (estimateChunkTokens(word) <= size) {
|
|
125
|
+
piece = word;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
pieces.push(...cutToBudget(word, size));
|
|
129
|
+
}
|
|
130
|
+
flushPiece();
|
|
131
|
+
return pieces;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One word longer than the whole budget, cut into pieces that fit. The estimator is linear in
|
|
136
|
+
* length, so the ratio over what is left gives the cut point directly — `Math.max(1, …)` is what
|
|
137
|
+
* keeps a pathological ratio from producing a zero-width cut and a loop that never advances.
|
|
138
|
+
*/
|
|
139
|
+
function cutToBudget(run: string, size: number): readonly string[] {
|
|
140
|
+
const pieces: string[] = [];
|
|
141
|
+
let rest = run;
|
|
142
|
+
while (rest !== '') {
|
|
143
|
+
const fit = Math.max(1, Math.floor((rest.length * size) / estimateChunkTokens(rest)));
|
|
144
|
+
pieces.push(rest.slice(0, fit));
|
|
145
|
+
rest = rest.slice(fit);
|
|
146
|
+
}
|
|
147
|
+
return pieces;
|
|
148
|
+
}
|
|
149
|
+
|
|
90
150
|
/** Index a document: chunk, embed, upsert. One call so no step is skipped by accident. */
|
|
91
151
|
export async function indexDocument(input: {
|
|
92
152
|
readonly store: VectorStore;
|
package/src/remote-embedder.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { readWithinLimit } from '@ultimat3/core';
|
|
|
10
10
|
import type { Embedder } from './embeddings';
|
|
11
11
|
import { normalize } from './embeddings';
|
|
12
12
|
import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors';
|
|
13
|
+
import type { AiFetch } from './fetch-seam';
|
|
13
14
|
|
|
14
15
|
const API_KEY_ENV = 'EMBEDDINGS_API_KEY';
|
|
15
16
|
const DEFAULT_BASE_URL = 'https://api.voyageai.com/v1';
|
|
@@ -44,7 +45,7 @@ export interface RemoteEmbedderInput {
|
|
|
44
45
|
/** Bytes this process will hold of one response. Defaults to 32 MiB. */
|
|
45
46
|
readonly maxResponseBytes?: number;
|
|
46
47
|
/** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
|
|
47
|
-
readonly fetch?:
|
|
48
|
+
readonly fetch?: AiFetch;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
export class RemoteEmbedder implements Embedder {
|
|
@@ -77,7 +78,7 @@ export class RemoteEmbedder implements Embedder {
|
|
|
77
78
|
if (apiKey === undefined || apiKey === '') {
|
|
78
79
|
throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
|
|
79
80
|
}
|
|
80
|
-
const doFetch = this.config.fetch ?? fetch;
|
|
81
|
+
const doFetch: AiFetch = this.config.fetch ?? fetch;
|
|
81
82
|
const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
82
83
|
const url = `${this.config.baseUrl ?? DEFAULT_BASE_URL}/embeddings`;
|
|
83
84
|
let response: Response;
|
package/src/runtime.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { SemanticCache } from '@ultimat3/cache';
|
|
11
11
|
import { createMemorySemanticCache } from '@ultimat3/cache';
|
|
12
|
+
import { cachedFormatter, MAX_CACHED_FORMATTERS } from '@ultimat3/core';
|
|
12
13
|
import type { Embedder } from './embeddings';
|
|
13
14
|
import { HashEmbedder } from './embeddings';
|
|
14
15
|
import { AiGatewayMissingError } from './errors';
|
|
@@ -85,18 +86,31 @@ export function aiRedactor(): Redactor {
|
|
|
85
86
|
return runtime?.redact ?? noRedaction;
|
|
86
87
|
}
|
|
87
88
|
|
|
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
|
+
|
|
88
96
|
/**
|
|
89
97
|
* The cache for one scope. Scopes are separate CACHE INSTANCES, never a filter over a shared
|
|
90
98
|
* one: cosine similarity has no notion of a tenant, so two tenants asking near-identical
|
|
91
99
|
* questions of a shared cache is one tenant reading the other's answer. Partitioning is the
|
|
92
100
|
* only thing that makes that structurally impossible.
|
|
101
|
+
*
|
|
102
|
+
* BOUNDED, and that is new with `llm()`'s actor-derived default scope: the default was the single
|
|
103
|
+
* string `'global'`, so this map held one entry no matter how many callers there were, and the
|
|
104
|
+
* narrowest-key default makes it one entry per ACTOR in a process that never restarts. Eviction
|
|
105
|
+
* costs nothing but a rebuild — a `SemanticCache` is a handle, so the durable ones (pgvector) lose
|
|
106
|
+
* no entries at all and the memory one loses a cache.
|
|
93
107
|
*/
|
|
94
108
|
export function semanticCacheFor(scope: string): SemanticCache {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
return cachedFormatter(
|
|
110
|
+
caches,
|
|
111
|
+
scope,
|
|
112
|
+
() => runtime?.semanticCache(scope) ?? createMemorySemanticCache(),
|
|
113
|
+
);
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
/** Test-only reset. Module-level state otherwise leaks between test files. */
|
package/src/sse.ts
CHANGED
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
// stream is a boundary search and a field split, and the only interesting property — that a
|
|
6
6
|
// frame may arrive split at any byte offset — is exactly what a library would hide.
|
|
7
7
|
|
|
8
|
+
import { AiTransportError } from './errors';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The most one unterminated frame may buffer. A peer that sends a body with no frame boundary in
|
|
12
|
+
* it — an HTML error page, a proxy answering on the model's port, a hung gateway — grows `buffer`
|
|
13
|
+
* without limit, and no read deadline interrupts it because every individual read SUCCEEDS. Coded
|
|
14
|
+
* failure > OOM, the same call `@ultimat3/mail`'s `createReplyParser` makes for the same shape.
|
|
15
|
+
*
|
|
16
|
+
* Counted in UTF-16 code units rather than bytes: `buffer` holds decoded text, and a decoded unit
|
|
17
|
+
* is never more than one byte of input, so the cap is conservative in the direction that matters.
|
|
18
|
+
* A megabyte is orders of magnitude above the largest single delta any provider in this package
|
|
19
|
+
* sends, so a legitimate stream can never reach it.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_FRAME_CHARS = 1024 * 1024;
|
|
22
|
+
|
|
8
23
|
export interface SseFrame {
|
|
9
24
|
/** The `event:` field, or `message` when the frame omits one — the spec's default. */
|
|
10
25
|
readonly event: string;
|
|
@@ -57,7 +72,12 @@ function frameOf(block: string): SseFrame | undefined {
|
|
|
57
72
|
* a stream cut mid-message must fail loudly at the consumer that parses it, and dropping the
|
|
58
73
|
* tail silently would turn a truncated answer into a complete-looking one.
|
|
59
74
|
*/
|
|
60
|
-
export async function* readSse(
|
|
75
|
+
export async function* readSse(
|
|
76
|
+
body: ReadableStream<Uint8Array>,
|
|
77
|
+
/** Named, never defaulted: the cap below fails as a transport error, and a transport error the
|
|
78
|
+
* caller reads has to say which endpoint it is about. */
|
|
79
|
+
provider: string,
|
|
80
|
+
): AsyncGenerator<SseFrame> {
|
|
61
81
|
const reader = body.getReader();
|
|
62
82
|
const decoder = new TextDecoder();
|
|
63
83
|
let buffer = '';
|
|
@@ -66,6 +86,7 @@ export async function* readSse(body: ReadableStream<Uint8Array>): AsyncGenerator
|
|
|
66
86
|
const { done, value } = await reader.read();
|
|
67
87
|
if (done) break;
|
|
68
88
|
buffer += decoder.decode(value, { stream: true });
|
|
89
|
+
guard(buffer, provider);
|
|
69
90
|
const decoded = decodeSse(buffer);
|
|
70
91
|
buffer = decoded.rest;
|
|
71
92
|
for (const frame of decoded.frames) yield frame;
|
|
@@ -79,3 +100,13 @@ export async function* readSse(body: ReadableStream<Uint8Array>): AsyncGenerator
|
|
|
79
100
|
await reader.cancel().catch(() => undefined);
|
|
80
101
|
}
|
|
81
102
|
}
|
|
103
|
+
|
|
104
|
+
function guard(buffer: string, provider: string): void {
|
|
105
|
+
if (buffer.length <= MAX_FRAME_CHARS) return;
|
|
106
|
+
throw new AiTransportError({
|
|
107
|
+
provider,
|
|
108
|
+
detail:
|
|
109
|
+
`the stream sent more than ${MAX_FRAME_CHARS} characters without completing one SSE ` +
|
|
110
|
+
'frame — the endpoint is answering with something that is not an event stream',
|
|
111
|
+
});
|
|
112
|
+
}
|