@ultimat3/ai 3.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 CHANGED
@@ -61,6 +61,8 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
61
61
  | `hive-errors.ts` | the `X_HIVE_*` class; its code and title stay in `errors.ts` |
62
62
  | `redaction.ts` | the one gate between `vars()` and the provider: a `Secret` never reaches a prompt |
63
63
  | `eval-errors.ts` | the five `X_EVAL_*` classes; their codes and titles stay in `errors.ts` |
64
+ | `llm-cache.ts` | the semantic cache half of `llm()`: what a declaration may partition on, and the store it reaches |
65
+ | `llm-fixture.ts` | the harness `llm.test.ts` and `llm-cache.test.ts` share. Not shipped (`!src/**/*-fixture.ts`) |
64
66
  | `runtime.ts` | the ambient gateway / embedder / semantic caches an `llm()` reaches |
65
67
  | `fix-line.ts` / `fix-line.evals.ts` / `fix-line.v1.baseline.json` / `fix-line.eval.test.ts` | the package's own dogfood eval — the first framework-level `*.eval.test.ts`, proving the `defineEval`/baseline convention actually fails a build |
66
68
 
@@ -78,6 +80,21 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
78
80
  - Semantic scopes are separate cache INSTANCES, never a filter over a shared one — cosine
79
81
  similarity has no notion of a tenant. The instance key carries the prompt hash too, which is
80
82
  what makes a version bump invalidate the cache.
83
+ - **The default scope is the calling ACTOR, and `scope` receives `{ input, ctx }`** (`As of
84
+ 2026-08`). It defaulted to the literal string `'global'` and took the bare `input`, so the rule
85
+ in the bullet above was contradicted by the very default that shipped: `cache: { semantic: { ttl:
86
+ '1h' } }` put every tenant in one store, and `lookup` is a nearest neighbour with no tenant
87
+ predicate — proven by execution, tenant B asking a prompt within 0.92 cosine of tenant A's
88
+ received A's completion verbatim. And with only `input` to decide from, the one thing a partition
89
+ may never be chosen by (a value the caller sends) was the only thing it could be chosen by, while
90
+ `vars()` on the same declaration already took the pair. The default is
91
+ `JSON.stringify([actor.kind, actor.id, actor.orgId ?? null])` — `@ultimat3/query`'s
92
+ `readAuthority` rule, verbatim: a declaration that says nothing gets the NARROWEST key, and
93
+ widening is a written statement about what the answers are (`scope: () => 'global'`). **Breaking**
94
+ in both halves. `semanticCacheFor`'s instance map is bounded as a consequence
95
+ (`MAX_SEMANTIC_CACHE_SCOPES`, which IS core's `MAX_CACHED_FORMATTERS` — one bounded FIFO map in
96
+ the framework, whose name is about its first caller and not its contract): one entry per actor in
97
+ a process that never restarts is a leak where one entry per process was not.
81
98
  - A per-call budget `derive`s from the ambient ledger, so it can only TIGHTEN the actor and org
82
99
  ceilings it runs inside. Widening them from a declaration would be a budget that is not one.
83
100
  **A derived ledger reports back up the chain**: every debit and every recorded cost lands on it
@@ -135,6 +152,15 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
135
152
  *as* differences rather than flattening them.
136
153
  - A stream that ends without `message_stop` throws. A truncated answer that returns `end_turn`
137
154
  is a confidently wrong answer with no signal, which the budget rule already forbids.
155
+ - **`readSse` caps the unterminated buffer** (`MAX_FRAME_CHARS`, `As of 2026-08`) and refuses with
156
+ `AiTransportError`. A peer that never sends a frame boundary — an HTML error page, a proxy on the
157
+ model's port — grew it without limit and no read deadline interrupted it, because every read
158
+ SUCCEEDED. Same call `@ultimat3/mail`'s `createReplyParser` makes: coded failure > OOM. `provider`
159
+ is a required argument for that reason — a transport error names the endpoint it is about.
160
+ - **`llm()` forwards `ctx.signal` onto `GenerateRequest`**, `As of 2026-08`, the way `agent()`
161
+ always did. Without it a model call had no cancellation and no deadline: a caller that hung up
162
+ left the provider call in flight, billed and unread, and the repair turn bought a second one.
163
+ `.stream()` inherits it from the same `base`, which is where it matters most.
138
164
  - A tool call is emitted whole. `input_json_delta` fragments are not arguments until the block
139
165
  closes, so nothing partial reaches a caller.
140
166
  - Thinking chunks are never appended to `text`. A consumer concatenating every chunk must not
@@ -217,7 +243,13 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
217
243
  per-block stop event in this format.
218
244
  - `isComplete()` accepts `[DONE]` **or** a finish reason: plenty of servers in the family close
219
245
  the socket straight after the finish chunk, and a finish reason is the model saying why it
220
- stopped, which a cut connection cannot produce.
246
+ stopped, which a cut connection cannot produce. **With one exception, `As of 2026-08`:
247
+ `[DONE]` while tool-call fragments are still pending is NOT complete.** `onFinish` is the only
248
+ drain of `pending` and the finish reason is the only close this format has, so the sentinel
249
+ alone cannot tell "the model finished asking" from "the connection died mid-arguments" —
250
+ reporting complete discarded a whole tool call and answered an empty, successful `end_turn`.
251
+ Refused rather than flushed, exactly as the Anthropic half refuses a missing `message_stop`:
252
+ emitting the fragments would run a tool's side effects from half a JSON object.
221
253
  - `role: 'system'`, not `developer` — the newer role is OpenAI's alone and every other server in
222
254
  the family knows only `system`.
223
255
  - **Only three models are priced** (`gpt-5.6-sol` / `-terra` / `-luna`, list price read
@@ -481,6 +513,17 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
481
513
  recorded — one no test asserts, one whose `baseline:` is a cwd-relative string — would otherwise
482
514
  satisfy `X_EVAL_MISSING` while gating on nothing.
483
515
  - Retrieval is hybrid by default. Do not add a vector-only convenience path.
516
+ - **`chunk()` performs all three splits its header names — paragraph, sentence, HARD WRAP** (`As of
517
+ 2026-08`). The wrap is what bounds a unit, and an oversized unit is one the size check can never
518
+ flush (it only fires when something is already in the buffer), so it re-seeded every following
519
+ chunk: a ~1,000-token document indexed as nine chunks totalling ~9,000, each carrying the same
520
+ sentence. The overlap carry stops at `buffer.length - 1` for the same reason — a flushed unit may
521
+ never become the whole of the next buffer.
522
+ - **A caller's string is never used as an object KEY.** A `Record` lookup on one answers
523
+ `constructor`, `toString` and `valueOf` off the prototype chain, and every read here is of a
524
+ string a provider, a template author or a test fixture chose: the two wire tables are `Map`s,
525
+ `prompt.render` uses `Object.hasOwn` (a `{{constructor}}` slot rendered JS source into a billed
526
+ prompt and hashed it into the cache key), and so does `EchoProvider`'s `replies`.
484
527
  - `PgVectorStore` is the ONLY production vector path — pgvector and Postgres FTS in the app's own
485
528
  Postgres, never a second datastore. `MemoryVectorStore` is the dev twin and enforces the same
486
529
  envelope; a leak that only reproduces against real Postgres is a leak nobody finds.
package/README.md CHANGED
@@ -108,6 +108,8 @@ for await (const chunk of ai.stream({ messages, maxTokens: 64_000 })) {
108
108
  | A `tool-call` chunk arrives whole | `input_json_delta` fragments are not arguments until the block closes |
109
109
  | `thinking` chunks never join `text` | concatenating every chunk must not ship the reasoning to the user |
110
110
  | A stream cut before `message_stop` **throws** | a truncated answer reporting `end_turn` is wrong with no signal |
111
+ | `[DONE]` with a tool call still open **throws** | the OpenAI format has no per-call stop event, so the finish reason is the only close there is; the sentinel alone cannot tell "finished asking" from "cut mid-arguments" |
112
+ | A body with no frame boundary in it **throws** | an SSE peer that never completes a frame is an unbounded allocation no read deadline interrupts |
111
113
  | An in-band `error` frame carries a status | `overloaded_error` mid-stream retries like a 529 on the handshake |
112
114
 
113
115
  ## Embeddings
@@ -249,7 +251,7 @@ export const summarize = llm({
249
251
  output: t.object({ summary: t.string, tags: t.array(t.string) }),
250
252
  prompt: summarizePrompt, // versioned artifact
251
253
  vars: async ({ input, ctx }) => ({ body: await ctx.posts.body(input.postId) }),
252
- cache: { semantic: { threshold: 0.97, ttl: '7d', scope: ({ orgId }) => orgId } },
254
+ cache: { semantic: { threshold: 0.97, ttl: '7d' } }, // scope defaults to the ACTOR
253
255
  budget: { tokensIn: 8_000, costPerCall: { minor: 5, currency: 'USD' } },
254
256
  policy: can('post:read'),
255
257
  });
@@ -265,7 +267,7 @@ summarize.contract(); // the contract tests
265
267
  | `output` | projected into the one tool the model may answer through; prose with a fenced JSON block still parses |
266
268
  | a schema failure | **one** repair turn naming the issues, then `X_LLM_OUTPUT_INVALID` |
267
269
  | `budget` | reserved against the worst case **before** the provider is reached — nothing spent, nothing truncated |
268
- | `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation |
270
+ | `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation. `scope` receives `{ input, ctx }` and **defaults to the calling actor** — the narrowest key, `@ultimat3/query`'s `readAuthority` rule; a shared store is `scope: () => 'global'`, written down |
269
271
  | `policy` | the same object every surface evaluates — an MCP call and an HTTP call are denied identically |
270
272
  | `vars` | the one declared place a model call loads data, so a reader can see what was sent — and the one place a redactor sees it, and where a `Secret` is refused |
271
273
 
@@ -600,7 +602,12 @@ nothing. `scoped()` only ever **tightens** — re-scoping to a different tenant
600
602
  `X_VECTOR_SCOPE_WIDENED`, never a silent widening.
601
603
 
602
604
  `chunk()` is token-aware with overlap and splits at paragraph, then sentence, then hard wrap
603
- — a fact split across a boundary with no overlap is retrievable by neither chunk.
605
+ — a fact split across a boundary with no overlap is retrievable by neither chunk. All three
606
+ splits are load-bearing: the wrap is what bounds a UNIT (a base64 blob, a minified line, a CJK
607
+ paragraph the sentence alphabet cannot see), and a unit larger than `size` is one the size check
608
+ can never flush, so it rode every chunk after it — `As of 2026-08`, a ~1,000-token document
609
+ indexed as nine chunks of the same sentence. The overlap carries a tail forward and never the
610
+ whole buffer, for the same reason.
604
611
 
605
612
  ## Tools: the same projection as MCP
606
613
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
22
23
  "CLAUDE.md",
23
24
  "README.md",
24
25
  "LICENSE"
@@ -31,14 +32,14 @@
31
32
  "test": "bun test"
32
33
  },
33
34
  "dependencies": {
34
- "@ultimat3/action": "3.0.0",
35
- "@ultimat3/cache": "3.0.0",
36
- "@ultimat3/core": "3.0.0",
37
- "@ultimat3/db": "3.0.0",
38
- "@ultimat3/jobs": "3.0.0",
39
- "@ultimat3/money": "3.0.0",
40
- "@ultimat3/policy": "3.0.0",
41
- "@ultimat3/schema": "3.0.0",
42
- "@ultimat3/time": "3.0.0"
35
+ "@ultimat3/action": "4.0.0",
36
+ "@ultimat3/cache": "4.0.0",
37
+ "@ultimat3/core": "4.0.0",
38
+ "@ultimat3/db": "4.0.0",
39
+ "@ultimat3/jobs": "4.0.0",
40
+ "@ultimat3/money": "4.0.0",
41
+ "@ultimat3/policy": "4.0.0",
42
+ "@ultimat3/schema": "4.0.0",
43
+ "@ultimat3/time": "4.0.0"
43
44
  }
44
45
  }
@@ -0,0 +1,15 @@
1
+ // Single responsibility: the one injectable HTTP call every transport in this package takes.
2
+ //
3
+ // Shared by all three rather than declared three times: both chat providers and the embedder hand
4
+ // a URL and a `RequestInit` to something that answers a `Response`, and three separate spellings
5
+ // of that is three places a test double has to be kept assignable to.
6
+
7
+ /**
8
+ * Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to —
9
+ * and none can supply, so every fake written against `typeof fetch` here needed
10
+ * `as unknown as typeof fetch` to compile: an option no caller could fill without a double cast.
11
+ *
12
+ * The same seam `@ultimat3/cache` (`PurgeFetch`), `@ultimat3/auth` (`OAuthFetch`),
13
+ * `@ultimat3/mail` (`MailFetch`) and `@ultimat3/scraping` (`ScrapeFetch`) already name.
14
+ */
15
+ export type AiFetch = (input: string, init: RequestInit) => Promise<Response>;
package/src/gateway.ts CHANGED
@@ -127,8 +127,14 @@ class GatewayImpl implements Gateway {
127
127
  const ledger = currentBudget();
128
128
  const reservation = await ledger?.reserve(estimateSpend(resolved));
129
129
 
130
- // A stream is not retried mid-flight: the consumer has already seen tokens, and
131
- // replaying from the top would duplicate them. Only the handshake retries.
130
+ // The streaming path does not retry AT ALL — not mid-flight, and not on the handshake either.
131
+ // Mid-flight is the obvious one: the consumer has already been handed tokens, and replaying
132
+ // from the top would duplicate them. The handshake is not separable from it here, because
133
+ // `provider.stream()` is one call that yields — there is no point at which the connection is
134
+ // open and no chunk has been delivered for a retry to hide behind. So `providerFor` picks the
135
+ // single provider serving this model and that call stands or throws; `attempt`'s backoff and
136
+ // its fallback across providers belong to `generate` alone. A caller that wants either uses
137
+ // `generate`, or reconnects itself and knows what it has already shown.
132
138
  const provider = this.providerFor(model);
133
139
  let settled = false;
134
140
  try {
package/src/index.ts CHANGED
@@ -91,21 +91,16 @@ export {
91
91
  promptsWithoutEvals,
92
92
  resetEvals,
93
93
  } from './evals';
94
+ export type { AiFetch } from './fetch-seam';
94
95
  export type { CreateGatewayInput, Gateway, GatewayCache, RetryPolicy } from './gateway';
95
96
  export { backoffMs, cacheKeyFor, createGateway, DEFAULT_RETRY, isRetryable } from './gateway';
96
97
  export type { HiveDef, HiveSplitArgs } from './hive';
97
98
  export { hive } from './hive';
98
99
  export { HiveEmptyError } from './hive-errors';
99
100
  export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
100
- export type {
101
- LlmAction,
102
- LlmBudget,
103
- LlmCache,
104
- LlmDef,
105
- LlmSemanticCache,
106
- LlmVarsArgs,
107
- } from './llm';
101
+ export type { LlmAction, LlmBudget, LlmDef, LlmVarsArgs } from './llm';
108
102
  export { llm } from './llm';
103
+ export type { LlmCache, LlmScopeArgs, LlmSemanticCache } from './llm-cache';
109
104
  export type { LlmStreamChunk } from './llm-stream';
110
105
  export type { Effort, ModelId, ModelReasoning, ModelSpec, ThinkingMode } from './models';
111
106
  export {
@@ -196,6 +191,7 @@ export {
196
191
  aiGateway,
197
192
  aiRedactor,
198
193
  configureAi,
194
+ MAX_SEMANTIC_CACHE_SCOPES,
199
195
  resetAiRuntime,
200
196
  semanticCacheFor,
201
197
  } from './runtime';
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The semantic cache half of `llm()`: what a declaration may partition on, and the store one
3
+ * declaration reaches. Split from `llm.ts` so that file stays the model call itself.
4
+ *
5
+ * A scope is a separate cache INSTANCE, never a filter over a shared one, and the instance key
6
+ * carries the prompt VERSION as well — which is what makes "editing a prompt requires a version
7
+ * bump" invalidate the cache: a bumped version reaches a different store, so an old answer cannot
8
+ * survive a prompt edit no matter how similar the text.
9
+ */
10
+
11
+ import type { Ctx } from '@ultimat3/core';
12
+ import { parseDuration } from '@ultimat3/time';
13
+ import { embedOne, fnv1a } from './embeddings';
14
+ import { aiEmbedder, semanticCacheFor } from './runtime';
15
+
16
+ /** What a `scope` may decide from. The same pair `vars()` receives, and for the same reason. */
17
+ export interface LlmScopeArgs<TParsed> {
18
+ readonly input: TParsed;
19
+ readonly ctx: Ctx;
20
+ }
21
+
22
+ export interface LlmSemanticCache<TParsed> {
23
+ /** Cosine floor. Below ~0.9 unrelated prompts collide and the cache answers the wrong one. */
24
+ readonly threshold?: number;
25
+ /** Entry lifetime as a duration string — `'7d'`, `'12h'`. `@ultimat3/time` owns the grammar. */
26
+ readonly ttl?: string;
27
+ /**
28
+ * Partition key. Each scope is a separate cache instance, never a filter over a shared one:
29
+ * cosine similarity has no notion of a tenant, so a shared cache answers one tenant with
30
+ * another's data — by construction, since `lookup` is a nearest neighbour with no predicate.
31
+ *
32
+ * **Omitting it is safe.** The default is the narrowest key the ctx can supply — the actor,
33
+ * its kind and its org — which is `@ultimat3/query`'s `readAuthority` rule applied here: a
34
+ * declaration that says nothing gets the narrowest partition, which is always correct, and
35
+ * WIDENING is a written statement about what the answers are. It defaulted to `'global'`, so a
36
+ * `cache: { semantic: { ttl: '1h' } }` put every tenant in one store.
37
+ *
38
+ * **Breaking: it receives `{ input, ctx }`, not the bare `input`.** Taking `input` alone meant
39
+ * the partition could only be chosen from a value the caller sends, which is the one thing a
40
+ * partition may never be chosen by. `vars()` on the same declaration already takes the pair.
41
+ */
42
+ readonly scope?: (args: LlmScopeArgs<TParsed>) => string;
43
+ }
44
+
45
+ export interface LlmCache<TParsed> {
46
+ readonly semantic: LlmSemanticCache<TParsed>;
47
+ // `05-caching.md` also declares `invalidates: [tag.post]` here. It is deliberately absent
48
+ // until `@ultimat3/cache`'s fan-out can reach something that is not a `CacheTier`: storing
49
+ // tags that the ONE invalidation path never visits would read as wired and silently not be.
50
+ // Today the invalidation story is the prompt version (a bump reaches a different store) and
51
+ // `ttl`.
52
+ }
53
+
54
+ export interface PromptCache {
55
+ lookup(): Promise<unknown>;
56
+ remember(value: unknown): Promise<void>;
57
+ }
58
+
59
+ /**
60
+ * The partition a declaration that named none gets: the narrowest key this call can prove, which
61
+ * is the actor itself. Verbatim the shape `@ultimat3/query`'s `actorAuthority` builds, for the
62
+ * same reason it is JSON rather than a joined string — an actor id is app data and may carry any
63
+ * separator, and a value that can spell a boundary can spell somebody else's.
64
+ *
65
+ * `ctx.actor` is never absent (`createContext` defaults it to `anonymousActor()`), so every
66
+ * anonymous caller shares one partition — which is what the anonymous actor already means
67
+ * everywhere else in the framework.
68
+ */
69
+ function actorScope(ctx: Ctx): string {
70
+ return JSON.stringify([ctx.actor.kind, ctx.actor.id, ctx.actor.orgId ?? null]);
71
+ }
72
+
73
+ /**
74
+ * The semantic cache for one declaration, or `undefined` when none was declared. The instance
75
+ * is partitioned by prompt VERSION as well as scope, which is what makes "editing a prompt
76
+ * requires a version bump" invalidate the cache: a bumped version reaches a different store,
77
+ * so an old answer cannot survive a prompt edit no matter how similar the text.
78
+ */
79
+ export interface OpenCacheArgs<TParsed> {
80
+ readonly cache: LlmCache<TParsed> | undefined;
81
+ /** Identity and content hash of the prompt artifact — the version half of the partition. */
82
+ readonly prompt: { readonly ref: string; readonly hash: string };
83
+ readonly input: TParsed;
84
+ readonly ctx: Ctx;
85
+ /** The rendered, redacted prompt text: what is embedded and what the entry is keyed on. */
86
+ readonly rendered: string;
87
+ }
88
+
89
+ export async function openCache<TParsed>(
90
+ args: OpenCacheArgs<TParsed>,
91
+ ): Promise<PromptCache | undefined> {
92
+ const semantic = args.cache?.semantic;
93
+ if (semantic === undefined) return undefined;
94
+ const scope = semantic.scope?.({ input: args.input, ctx: args.ctx }) ?? actorScope(args.ctx);
95
+ const store = semanticCacheFor(`${args.prompt.ref}#${args.prompt.hash}::${scope}`);
96
+ const embedding = Array.from(await embedOne(aiEmbedder(), args.rendered));
97
+ const ttlMs = semantic.ttl === undefined ? undefined : parseDuration(semantic.ttl);
98
+ return {
99
+ async lookup(): Promise<unknown> {
100
+ return (await store.lookup(embedding, semantic.threshold))?.value;
101
+ },
102
+ remember(value: unknown): Promise<void> {
103
+ return store.remember(
104
+ `${args.prompt.hash}:${fnv1a(args.rendered).toString(16)}`,
105
+ embedding,
106
+ value,
107
+ ttlMs === undefined ? {} : { ttlMs },
108
+ );
109
+ },
110
+ };
111
+ }
package/src/llm.ts CHANGED
@@ -28,10 +28,8 @@ import { withSpan } from '@ultimat3/core';
28
28
  import type { Money } from '@ultimat3/money';
29
29
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
30
30
  import { formatIssues, toMcpInputSchema, validateAsync } from '@ultimat3/schema';
31
- import { parseDuration } from '@ultimat3/time';
32
31
  import type { BudgetLimits } from './budget';
33
32
  import { BudgetLedger, currentBudget, withBudget } from './budget';
34
- import { embedOne, fnv1a } from './embeddings';
35
33
  import {
36
34
  LlmOutputInvalidError,
37
35
  LlmRefusedError,
@@ -39,6 +37,8 @@ import {
39
37
  LlmTruncatedError,
40
38
  } from './errors';
41
39
  import type { Gateway } from './gateway';
40
+ import type { LlmCache } from './llm-cache';
41
+ import { openCache } from './llm-cache';
42
42
  import type { LlmSink, LlmStreamChunk } from './llm-stream';
43
43
  import { currentLlmSink, llmStream, streamOneTurn, withLlmSink } from './llm-stream';
44
44
  import type { ModelId } from './models';
@@ -46,7 +46,7 @@ import { DEFAULT_MODEL, moreCapableThan } from './models';
46
46
  import type { Prompt, PromptVars } from './prompt';
47
47
  import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
48
48
  import { assertNoSecrets } from './redaction';
49
- import { aiEmbedder, aiGateway, aiRedactor, semanticCacheFor } from './runtime';
49
+ import { aiGateway, aiRedactor } from './runtime';
50
50
  import type { LlmTool } from './tools';
51
51
 
52
52
  /**
@@ -65,27 +65,6 @@ const ATTEMPTS = 2;
65
65
  */
66
66
  const DEFAULT_MAX_TOKENS = 4_096;
67
67
 
68
- export interface LlmSemanticCache<TParsed> {
69
- /** Cosine floor. Below ~0.9 unrelated prompts collide and the cache answers the wrong one. */
70
- readonly threshold?: number;
71
- /** Entry lifetime as a duration string — `'7d'`, `'12h'`. `@ultimat3/time` owns the grammar. */
72
- readonly ttl?: string;
73
- /**
74
- * Partition key, from the parsed input. Each scope is a separate cache: cosine similarity
75
- * has no notion of a tenant, so a shared cache answers one tenant with another's data.
76
- */
77
- readonly scope?: (input: TParsed) => string;
78
- }
79
-
80
- export interface LlmCache<TParsed> {
81
- readonly semantic: LlmSemanticCache<TParsed>;
82
- // `05-caching.md` also declares `invalidates: [tag.post]` here. It is deliberately absent
83
- // until `@ultimat3/cache`'s fan-out can reach something that is not a `CacheTier`: storing
84
- // tags that the ONE invalidation path never visits would read as wired and silently not be.
85
- // Today the invalidation story is the prompt version (a bump reaches a different store) and
86
- // `ttl`.
87
- }
88
-
89
68
  /** Per-call ceilings, checked before the provider is reached. Never truncates — refuses. */
90
69
  export interface LlmBudget {
91
70
  /** Prompt tokens. */
@@ -235,7 +214,13 @@ async function generate<
235
214
  // A cached answer is still data of unknown provenance, so it goes through the schema like
236
215
  // any other. One that no longer fits — the schema moved under it — is a miss, not a
237
216
  // failure: the model can produce a fresh answer, and refusing would be worse than paying.
238
- const cache = await openCache(def, args.input, rendered);
217
+ const cache = await openCache({
218
+ cache: def.cache,
219
+ prompt: { ref: prompt.ref, hash: prompt.hash },
220
+ input: args.input,
221
+ ctx: args.ctx,
222
+ rendered,
223
+ });
239
224
  const hit = await accept(def.output, await cache?.lookup());
240
225
  span.setAttribute('llm.cache.hit', hit !== undefined);
241
226
  if (hit !== undefined) return hit.value;
@@ -250,6 +235,10 @@ async function generate<
250
235
  maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
251
236
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
252
237
  ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
238
+ // The caller's own signal, forwarded to the transport exactly as `agent()` does — and
239
+ // inherited by `streamedAnswer` from this same `base`. Without it a disconnected caller left
240
+ // the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
241
+ signal: args.ctx.signal,
253
242
  };
254
243
  const request: GenerateRequest = { ...base, tools: [respond] };
255
244
 
@@ -447,44 +436,3 @@ function parseJsonish(text: string): unknown {
447
436
  return undefined;
448
437
  }
449
438
  }
450
-
451
- interface PromptCache {
452
- lookup(): Promise<unknown>;
453
- remember(value: unknown): Promise<void>;
454
- }
455
-
456
- /**
457
- * The semantic cache for one declaration, or `undefined` when none was declared. The instance
458
- * is partitioned by prompt VERSION as well as scope, which is what makes "editing a prompt
459
- * requires a version bump" invalidate the cache: a bumped version reaches a different store,
460
- * so an old answer cannot survive a prompt edit no matter how similar the text.
461
- */
462
- async function openCache<
463
- TInput extends StandardSchemaV1,
464
- TOutput extends StandardSchemaV1,
465
- V extends PromptVars,
466
- >(
467
- def: LlmDef<TInput, TOutput, V>,
468
- input: InferOutput<TInput>,
469
- rendered: string,
470
- ): Promise<PromptCache | undefined> {
471
- const semantic = def.cache?.semantic;
472
- if (semantic === undefined) return undefined;
473
- const scope = semantic.scope?.(input) ?? 'global';
474
- const store = semanticCacheFor(`${def.prompt.ref}#${def.prompt.hash}::${scope}`);
475
- const embedding = Array.from(await embedOne(aiEmbedder(), rendered));
476
- const ttlMs = semantic.ttl === undefined ? undefined : parseDuration(semantic.ttl);
477
- return {
478
- async lookup(): Promise<unknown> {
479
- return (await store.lookup(embedding, semantic.threshold))?.value;
480
- },
481
- remember(value: unknown): Promise<void> {
482
- return store.remember(
483
- `${def.prompt.hash}:${fnv1a(rendered).toString(16)}`,
484
- embedding,
485
- value,
486
- ttlMs === undefined ? {} : { ttlMs },
487
- );
488
- },
489
- };
490
- }
@@ -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
  /**
@@ -138,7 +139,7 @@ class OpenAiProvider implements Provider {
138
139
  });
139
140
  }
140
141
  const completion = new ChatCompletionStream(this.name);
141
- for await (const frame of readSse(response.body)) {
142
+ for await (const frame of readSse(response.body, this.name)) {
142
143
  for (const chunk of completion.push(frame)) yield chunk;
143
144
  }
144
145
  // A connection cut mid-answer must fail, not resolve: partial text reads as a complete answer,
@@ -197,7 +198,7 @@ class OpenAiProvider implements Provider {
197
198
  signal: AbortSignal | undefined,
198
199
  ): Promise<Response> {
199
200
  const apiKey = this.apiKey();
200
- const doFetch = this.config.fetch ?? fetch;
201
+ const doFetch: AiFetch = this.config.fetch ?? fetch;
201
202
  const response = await doFetch(this.url(), {
202
203
  method: 'POST',
203
204
  headers: {
@@ -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';
@@ -180,7 +181,7 @@ export interface AnthropicProviderInput {
180
181
  readonly apiKey?: string;
181
182
  readonly baseUrl?: string;
182
183
  /** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
183
- readonly fetch?: typeof fetch;
184
+ readonly fetch?: AiFetch;
184
185
  }
185
186
 
186
187
  const ANTHROPIC_VERSION = '2023-06-01';
@@ -257,7 +258,7 @@ export class AnthropicProvider implements Provider {
257
258
  });
258
259
  }
259
260
  const message = new MessageStream();
260
- for await (const frame of readSse(response.body)) {
261
+ for await (const frame of readSse(response.body, this.name)) {
261
262
  for (const chunk of message.push(frame)) yield chunk;
262
263
  }
263
264
  // A connection cut mid-answer must fail, not resolve: the partial text reads as a complete
@@ -303,7 +304,7 @@ export class AnthropicProvider implements Provider {
303
304
  if (apiKey === undefined || apiKey === '') {
304
305
  throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
305
306
  }
306
- const doFetch = this.config.fetch ?? fetch;
307
+ const doFetch: AiFetch = this.config.fetch ?? fetch;
307
308
  const url = `${this.config.baseUrl ?? 'https://api.anthropic.com'}/v1/messages`;
308
309
  const response = await doFetch(url, {
309
310
  method: 'POST',
@@ -408,7 +409,7 @@ export class EchoProvider implements Provider {
408
409
  async generate(request: GenerateRequest): Promise<GenerateResult> {
409
410
  const model = request.model ?? DEFAULT_MODEL;
410
411
  const prompt = lastUserMessage(request.messages);
411
- const text = this.config.replies?.[prompt] ?? this.config.fallback?.(prompt) ?? prompt;
412
+ const text = this.fixedReply(prompt) ?? this.config.fallback?.(prompt) ?? prompt;
412
413
  const usage: TokenUsage = {
413
414
  inputTokens: this.config.tokensPerCall ?? estimateTokens(request),
414
415
  outputTokens: estimateTextTokens(text),
@@ -426,6 +427,17 @@ export class EchoProvider implements Provider {
426
427
  };
427
428
  }
428
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
+
429
441
  async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
430
442
  const result = await this.generate(request);
431
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
+ }
package/src/wire.ts CHANGED
@@ -42,7 +42,11 @@ export function parsePartialUsage(raw: unknown): Partial<TokenUsage> {
42
42
  const usage: Partial<Record<keyof TokenUsage, number>> = {};
43
43
  for (const [field, wire] of Object.entries(USAGE_FIELDS) as [keyof TokenUsage, string][]) {
44
44
  const value = record[wire];
45
- if (typeof value === 'number') usage[field] = value;
45
+ // Floored at zero, and finite: usage is the provider's number, a negative one becomes a
46
+ // negative `cost`, and `MemoryBudgetStore.add` reads a negative debit as a CREDIT — releasing
47
+ // an unspent reservation is one — so an unclamped `-1` tops the ledger up instead of
48
+ // under-reporting it. `NaN` propagates the same way through every later sum.
49
+ if (typeof value === 'number' && Number.isFinite(value)) usage[field] = Math.max(0, value);
46
50
  }
47
51
  return usage;
48
52
  }
@@ -81,17 +85,23 @@ export function parseStopDetails(raw: unknown): StopDetails | undefined {
81
85
  * retry rule in the gateway: an overloaded provider is retryable whether it says so with a
82
86
  * 529 on the handshake or with an `overloaded_error` frame ten tokens in.
83
87
  */
84
- const ERROR_STATUS: Readonly<Record<string, number>> = {
85
- invalid_request_error: 400,
86
- authentication_error: 401,
87
- permission_error: 403,
88
- not_found_error: 404,
89
- request_too_large: 413,
90
- rate_limit_error: 429,
91
- api_error: 500,
92
- timeout_error: 504,
93
- overloaded_error: 529,
94
- };
88
+ // A `Map`, not an object literal: `type` is the PROVIDER's string on the one read below, and
89
+ // `ERROR_STATUS['constructor']` on an object answers the `Object` FUNCTION where
90
+ // `AiTransportError.status` is declared `number | undefined`. Same fix, same reason, as
91
+ // `openai-wire.ts`'s twin and `core`'s `error-retry.ts`.
92
+ const ERROR_STATUS: ReadonlyMap<string, number> = new Map(
93
+ Object.entries({
94
+ invalid_request_error: 400,
95
+ authentication_error: 401,
96
+ permission_error: 403,
97
+ not_found_error: 404,
98
+ request_too_large: 413,
99
+ rate_limit_error: 429,
100
+ api_error: 500,
101
+ timeout_error: 504,
102
+ overloaded_error: 529,
103
+ }),
104
+ );
95
105
 
96
106
  /**
97
107
  * A 200 whose body carries an `error` object instead of an answer, refused — how a gateway in
@@ -298,7 +308,7 @@ function inBandFailure(error: Record<string, unknown>): AiTransportError {
298
308
  const message = typeof error['message'] === 'string' ? error['message'] : type;
299
309
  return new AiTransportError({
300
310
  provider: 'anthropic',
301
- status: ERROR_STATUS[type],
311
+ status: ERROR_STATUS.get(type),
302
312
  detail: message,
303
313
  });
304
314
  }