@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 +44 -1
- package/README.md +10 -3
- package/package.json +11 -10
- package/src/fetch-seam.ts +15 -0
- package/src/gateway.ts +8 -2
- package/src/index.ts +4 -8
- package/src/llm-cache.ts +111 -0
- package/src/llm.ts +14 -66
- package/src/openai-provider.ts +4 -3
- package/src/openai-wire.ts +59 -25
- package/src/prompt.ts +6 -1
- package/src/provider.ts +16 -4
- 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/wire.ts +23 -13
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
|
|
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
|
+
"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": "
|
|
35
|
-
"@ultimat3/cache": "
|
|
36
|
-
"@ultimat3/core": "
|
|
37
|
-
"@ultimat3/db": "
|
|
38
|
-
"@ultimat3/jobs": "
|
|
39
|
-
"@ultimat3/money": "
|
|
40
|
-
"@ultimat3/policy": "
|
|
41
|
-
"@ultimat3/schema": "
|
|
42
|
-
"@ultimat3/time": "
|
|
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
|
-
//
|
|
131
|
-
//
|
|
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';
|
package/src/llm-cache.ts
ADDED
|
@@ -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 {
|
|
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(
|
|
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
|
-
}
|
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
|
/**
|
|
@@ -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: {
|
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';
|
|
@@ -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?:
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
311
|
+
status: ERROR_STATUS.get(type),
|
|
302
312
|
detail: message,
|
|
303
313
|
});
|
|
304
314
|
}
|