@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.
@@ -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?: typeof 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(chatCompletionBody({ request, model, stream: false }), false);
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(chatCompletionBody({ request, model, stream: true }), true);
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(body: Record<string, unknown>, streaming: boolean): Promise<Response> {
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({
@@ -24,34 +24,46 @@ export interface ChatAnswer {
24
24
  readonly usage: TokenUsage | undefined;
25
25
  }
26
26
 
27
- const FINISH_REASONS: Readonly<Record<string, StopReason>> = {
28
- stop: 'end_turn',
29
- length: 'max_tokens',
30
- tool_calls: 'tool_use',
31
- // The legacy name for the same event; LiteLLM and older self-hosted servers still send it.
32
- function_call: 'tool_use',
33
- content_filter: 'refusal',
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
- const ERROR_STATUS: Readonly<Record<string, number>> = {
41
- invalid_request_error: 400,
42
- authentication_error: 401,
43
- permission_error: 403,
44
- not_found_error: 404,
45
- rate_limit_exceeded: 429,
46
- insufficient_quota: 429,
47
- server_error: 500,
48
- api_error: 500,
49
- overloaded_error: 503,
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[raw] : undefined;
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 = numberOf(record['prompt_tokens']);
68
- const completion = numberOf(record['completion_tokens']);
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 = numberOf(asRecord(record['prompt_tokens_details'])?.['cached_tokens']) ?? 0;
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
- return this.done || this.finished;
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: ERROR_STATUS[type] ?? (code === undefined ? undefined : ERROR_STATUS[code]) ?? 500,
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
- const value = vars[name];
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?: typeof 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(body: Record<string, unknown>): Promise<Response> {
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.config.replies?.[prompt] ?? this.config.fallback?.(prompt) ?? prompt;
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
- const size = input.size ?? 512;
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 >= 0 && carriedTokens < overlap; i -= 1) {
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;
@@ -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?: typeof 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
- const existing = caches.get(scope);
96
- if (existing !== undefined) return existing;
97
- const created = runtime?.semanticCache(scope) ?? createMemorySemanticCache();
98
- caches.set(scope, created);
99
- return created;
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(body: ReadableStream<Uint8Array>): AsyncGenerator<SseFrame> {
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
+ }