@ultimat3/ai 6.0.0 → 8.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 +19 -5
- package/package.json +10 -10
- package/src/agent.ts +4 -2
- package/src/budget.ts +8 -7
- package/src/hive.ts +6 -4
- package/src/llm-stream.ts +5 -3
- package/src/openai-wire.ts +3 -6
- package/src/prompt.ts +15 -17
- package/src/remote-embedder.ts +6 -2
- package/src/wire.ts +3 -6
package/CLAUDE.md
CHANGED
|
@@ -302,8 +302,9 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
302
302
|
- `.stream()` is LAZY. Nothing is authorised, budgeted or sent until the first pull. `named()` is
|
|
303
303
|
re-narrowed for the same reason `stream` is assigned in place: `action()`'s `named` builds a
|
|
304
304
|
fresh twin that would silently not stream.
|
|
305
|
-
- **`agent()` is a job for the tool loop, and
|
|
306
|
-
`
|
|
305
|
+
- **`agent()` is a job for the tool loop, and one of the factory rule's instances** — the list is
|
|
306
|
+
`PRIMITIVE_FACTORIES` in `@ultimat3/core`, never an ordinal in a header — it returns an `action`,
|
|
307
|
+
never a ninth primitive. It exists because
|
|
307
308
|
the alternative is a hand-rolled loop, and a hand-rolled loop is where the dangerous mistake
|
|
308
309
|
lives: **taking the actor from the model's output.** `ctx.actor` is read once and is the only
|
|
309
310
|
identity any tool runs as; nothing the model emits can reach it. Bounded by `maxTurns`
|
|
@@ -379,8 +380,8 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
379
380
|
- `AiMessage.content` widened to `string | readonly AiContentBlock[]` for this: a `tool_result`
|
|
380
381
|
has to name the `tool_use` it answers and a string has nowhere to put the id. The block field
|
|
381
382
|
names are the Messages API's, so `body()` passes them through untouched.
|
|
382
|
-
- **`hive()` is a fan-out, and
|
|
383
|
-
|
|
383
|
+
- **`hive()` is a fan-out, and another of the factory rule's instances** (`PRIMITIVE_FACTORIES`
|
|
384
|
+
again) — it returns an `action`, never a ninth primitive. It exists because
|
|
384
385
|
the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that loop gets four
|
|
385
386
|
things wrong every time: the actor, the order, the difference between ran-and-failed and
|
|
386
387
|
never-ran, and the ceiling.
|
|
@@ -487,7 +488,20 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
487
488
|
- Server-side `fallbacks` (beta) are deliberately NOT sent. The provider speaks the stable
|
|
488
489
|
`2023-06-01` surface, and a 1.0 package that promises semver cannot pin a beta wire contract;
|
|
489
490
|
the typed refusal plus the gateway's own model routing is the framework's answer instead.
|
|
490
|
-
- `definePrompt` refuses a re-registered version whose hash moved
|
|
491
|
+
- `definePrompt` refuses a re-registered version whose hash moved, and the hash is taken over
|
|
492
|
+
`@ultimat3/core`'s `canonicalJson` (`As of 2026-08-22`). It was a local sorted-key
|
|
493
|
+
`JSON.stringify` — the framework's third copy of one canonical form — which spells `-0` as `0`
|
|
494
|
+
and every non-finite number as `null`: a schema `default` the model is told about could move
|
|
495
|
+
while the ref did not, and every score already filed goes on claiming to describe the new prompt.
|
|
496
|
+
Ordinary JSON hashes byte-identically between the two, so **no committed baseline is
|
|
497
|
+
invalidated**; `prompt.test.ts` pins one hash literally to keep it that way. An ABSENT schema
|
|
498
|
+
stays the empty string rather than `canonicalJson(undefined)`'s `null`, for the same reason.
|
|
499
|
+
- **A caught value is read with `renderThrowable`, never `error instanceof Error ? error.message :
|
|
500
|
+
String(error)`.** `instanceof` RUNS a `Proxy`'s `getPrototypeOf` trap, and a throw there escapes
|
|
501
|
+
the very `catch` that exists to produce a coded refusal — proven against `RemoteEmbedder`, whose
|
|
502
|
+
injected `fetch` made it reachable from app config. Five sites in this package
|
|
503
|
+
(`remote-embedder.ts`, `wire.ts` x2, `openai-wire.ts` x2); `scripts/error-render.ts` cannot see
|
|
504
|
+
any of them, because it reads PARAMETERS typed `unknown` and these are `catch` bindings.
|
|
491
505
|
- Every eval result carries the prompt hash. A score without one is not a measurement.
|
|
492
506
|
- An eval gates on the DROP from its recorded baseline, never on an absolute score. An absolute
|
|
493
507
|
floor fails every eval at once the day a provider ships a slightly different model, which
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.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",
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/action": "
|
|
36
|
-
"@ultimat3/cache": "
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/db": "
|
|
39
|
-
"@ultimat3/jobs": "
|
|
40
|
-
"@ultimat3/money": "
|
|
41
|
-
"@ultimat3/policy": "
|
|
42
|
-
"@ultimat3/schema": "
|
|
43
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/action": "8.0.0",
|
|
36
|
+
"@ultimat3/cache": "8.0.0",
|
|
37
|
+
"@ultimat3/core": "8.0.0",
|
|
38
|
+
"@ultimat3/db": "8.0.0",
|
|
39
|
+
"@ultimat3/jobs": "8.0.0",
|
|
40
|
+
"@ultimat3/money": "8.0.0",
|
|
41
|
+
"@ultimat3/policy": "8.0.0",
|
|
42
|
+
"@ultimat3/schema": "8.0.0",
|
|
43
|
+
"@ultimat3/time": "8.0.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/agent.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `agent()` — a multi-turn tool-using model call, declared as an `action`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* arrives as a FACTORY over an existing primitive, never as a
|
|
4
|
+
* One more instance of the framework's rule — the whole list is `PRIMITIVE_FACTORIES` in
|
|
5
|
+
* `@ultimat3/core`: a new capability arrives as a FACTORY over an existing primitive, never as a
|
|
6
|
+
* ninth kind, and the list is what a reader counts instead of a sentence in one of them that
|
|
7
|
+
* cannot see the rest. A tool-using run is
|
|
6
8
|
* still one server-authoritative operation with an input schema, an output schema and a policy —
|
|
7
9
|
* so this returns an `action`, and inherits `.tool()`, `.openapi()`, `.client()`, `.job()`,
|
|
8
10
|
* `.contract()` and its manifest row without a line here.
|
package/src/budget.ts
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// wrong answer that looks like a real one, and the caller has no signal anything happened.
|
|
5
5
|
// A thrown X_AI_BUDGET_EXCEEDED with the remaining count is strictly more useful.
|
|
6
6
|
//
|
|
7
|
-
// The carrier is an
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// The carrier is an async context so nested calls (a RAG retrieval, a tool call that generates,
|
|
8
|
+
// an eval judge) all debit the same ledger without threading it through every signature. It opens
|
|
9
|
+
// through `@ultimat3/core`'s one lazy seam rather than constructing an `AsyncLocalStorage` here: a
|
|
10
|
+
// module-scope `new` threw at EVALUATION in a browser bundle, where the bundler stubs
|
|
11
|
+
// `node:async_hooks` to `{}`, and took every importer of `@ultimat3/ai` with it.
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
+
import { asyncContext } from '@ultimat3/core';
|
|
13
14
|
import type { Money } from '@ultimat3/money';
|
|
14
15
|
import { assertSameCurrency } from '@ultimat3/money';
|
|
15
16
|
import { AiBudgetExceededError } from './errors';
|
|
@@ -307,7 +308,7 @@ function tighterMoney(a: Money | undefined, b: Money | undefined): Money | undef
|
|
|
307
308
|
return a.minor <= b.minor ? a : b;
|
|
308
309
|
}
|
|
309
310
|
|
|
310
|
-
const storage =
|
|
311
|
+
const storage = asyncContext<BudgetLedger>('an AI budget');
|
|
311
312
|
|
|
312
313
|
/** Run `fn` with `ledger` as the ambient budget for everything it awaits. */
|
|
313
314
|
export function withBudget<T>(ledger: BudgetLedger, fn: () => Promise<T>): Promise<T> {
|
|
@@ -316,5 +317,5 @@ export function withBudget<T>(ledger: BudgetLedger, fn: () => Promise<T>): Promi
|
|
|
316
317
|
|
|
317
318
|
/** The ambient ledger, or `undefined` outside a budget scope (spend is then unmetered). */
|
|
318
319
|
export function currentBudget(): BudgetLedger | undefined {
|
|
319
|
-
return storage.
|
|
320
|
+
return storage.get();
|
|
320
321
|
}
|
package/src/hive.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `hive()` — one action fanned out over many inputs, declared as an `action`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* One more instance of the framework's factory rule — the whole list is `PRIMITIVE_FACTORIES` in
|
|
5
|
+
* `@ultimat3/core`, and it is a list rather than a sentence because the ordinal a file writes for
|
|
6
|
+
* itself is wrong the day the next one lands and no file can see the others. A fan-out is still
|
|
7
|
+
* one server-authoritative operation with an input schema, an output schema and a policy, so this
|
|
8
|
+
* returns an `action` and inherits `.tool()`, `.openapi()`, `.client()`, `.job()`, `.contract()`
|
|
9
|
+
* and its manifest row without a line here.
|
|
8
10
|
*
|
|
9
11
|
* It exists because the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that
|
|
10
12
|
* loop gets four things wrong every time: it takes the actor from somewhere other than the request,
|
package/src/llm-stream.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* async chain — abandoning the iterator stops delivery, never the accounting.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import { asyncContext } from '@ultimat3/core';
|
|
25
25
|
import { AiTransportError } from './errors';
|
|
26
26
|
import type { Gateway } from './gateway';
|
|
27
27
|
import type { GenerateRequest, GenerateResult } from './provider';
|
|
@@ -102,7 +102,9 @@ export class LlmSink {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
// Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a
|
|
106
|
+
// browser bundle, where the bundler stubs `node:async_hooks` to `{}`.
|
|
107
|
+
const sinks = asyncContext<LlmSink>('an LLM stream sink');
|
|
106
108
|
|
|
107
109
|
/** Mark everything `fn` awaits as a streamed invocation. */
|
|
108
110
|
export function withLlmSink<T>(sink: LlmSink, fn: () => Promise<T>): Promise<T> {
|
|
@@ -111,7 +113,7 @@ export function withLlmSink<T>(sink: LlmSink, fn: () => Promise<T>): Promise<T>
|
|
|
111
113
|
|
|
112
114
|
/** The sink of the streamed invocation this call belongs to, or `undefined` for a plain one. */
|
|
113
115
|
export function currentLlmSink(): LlmSink | undefined {
|
|
114
|
-
return sinks.
|
|
116
|
+
return sinks.get();
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
/**
|
package/src/openai-wire.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// order, or stops half way. An LLM response is untrusted input, so every field is parsed and
|
|
7
7
|
// nothing is cast.
|
|
8
8
|
|
|
9
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
9
10
|
import { AiTransportError } from './errors';
|
|
10
11
|
import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
|
|
11
12
|
import type { SseFrame } from './sse';
|
|
@@ -148,9 +149,7 @@ function parseArguments(raw: unknown, name: string, provider: string): Record<st
|
|
|
148
149
|
} catch (error) {
|
|
149
150
|
throw malformed(
|
|
150
151
|
provider,
|
|
151
|
-
`tool "${name}" returned arguments that are not JSON: ${
|
|
152
|
-
error instanceof Error ? error.message : 'unreadable'
|
|
153
|
-
}`,
|
|
152
|
+
`tool "${name}" returned arguments that are not JSON: ${renderThrowable(error)}`,
|
|
154
153
|
);
|
|
155
154
|
}
|
|
156
155
|
}
|
|
@@ -321,9 +320,7 @@ export class ChatCompletionStream {
|
|
|
321
320
|
} catch (error) {
|
|
322
321
|
throw malformed(
|
|
323
322
|
this.provider,
|
|
324
|
-
`unreadable "${frame.event}" frame: ${
|
|
325
|
-
error instanceof Error ? error.message : 'unreadable'
|
|
326
|
-
}`,
|
|
323
|
+
`unreadable "${frame.event}" frame: ${renderThrowable(error)}`,
|
|
327
324
|
);
|
|
328
325
|
}
|
|
329
326
|
}
|
package/src/prompt.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// So: edit the template, bump the version. Re-registering a version whose hash moved is a
|
|
10
10
|
// build error, not a warning.
|
|
11
11
|
|
|
12
|
+
import { canonicalJson } from '@ultimat3/core';
|
|
12
13
|
import { AiPromptRenderError, AiPromptVersionError } from './errors';
|
|
13
14
|
import type { Effort, ModelId, ThinkingMode } from './models';
|
|
14
15
|
import type { JsonSchema } from './tools';
|
|
@@ -141,34 +142,31 @@ function render(template: string, vars: PromptVars, ref: string): string {
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
/**
|
|
144
|
-
* Canonical serialisation then sha256
|
|
145
|
-
*
|
|
145
|
+
* Canonical serialisation then sha256, over `@ultimat3/core`'s `canonicalJson` — the framework's
|
|
146
|
+
* one INJECTIVE form, so a schema that changed cannot hash as the schema it replaced. A local
|
|
147
|
+
* sorted-key `JSON.stringify` was here and spelled `-0` as `0` and every non-finite number as
|
|
148
|
+
* `null`, which is a `default` the model is told about moving under a ref that did not: every eval
|
|
149
|
+
* score already filed goes on claiming to describe the new prompt.
|
|
150
|
+
*
|
|
151
|
+
* An absent schema stays the empty string rather than becoming `canonicalJson(undefined)`'s
|
|
152
|
+
* `null` — that is what keeps every hash already recorded in a baseline the same value.
|
|
146
153
|
*/
|
|
147
154
|
export function contentHash<V extends PromptVars>(input: DefinePromptInput<V>): string {
|
|
148
|
-
const
|
|
155
|
+
const fields = [
|
|
149
156
|
`id:${input.id}`,
|
|
150
157
|
`version:${input.version}`,
|
|
151
158
|
`system:${input.system ?? ''}`,
|
|
152
159
|
`template:${input.template}`,
|
|
153
|
-
`input:${
|
|
154
|
-
`output:${
|
|
160
|
+
`input:${schemaField(input.input)}`,
|
|
161
|
+
`output:${schemaField(input.output)}`,
|
|
155
162
|
`model:${input.model ?? ''}`,
|
|
156
163
|
`effort:${input.effort ?? ''}`,
|
|
157
164
|
`thinking:${input.thinking ?? ''}`,
|
|
158
165
|
].join('\n');
|
|
159
166
|
const hasher = new Bun.CryptoHasher('sha256');
|
|
160
|
-
hasher.update(
|
|
167
|
+
hasher.update(fields);
|
|
161
168
|
return hasher.digest('hex').slice(0, 32);
|
|
162
169
|
}
|
|
163
170
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (value === undefined) return '';
|
|
167
|
-
return JSON.stringify(value, (_key, val: unknown) => {
|
|
168
|
-
if (typeof val !== 'object' || val === null || Array.isArray(val)) return val;
|
|
169
|
-
const record = val as Record<string, unknown>;
|
|
170
|
-
const sorted: Record<string, unknown> = {};
|
|
171
|
-
for (const key of Object.keys(record).sort()) sorted[key] = record[key];
|
|
172
|
-
return sorted;
|
|
173
|
-
});
|
|
174
|
-
}
|
|
171
|
+
const schemaField = (schema: JsonSchema | undefined): string =>
|
|
172
|
+
schema === undefined ? '' : canonicalJson(schema);
|
package/src/remote-embedder.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// vendor: `baseUrl` selects the provider and nothing else changes. A second class per vendor
|
|
7
7
|
// would be a second thing to learn for a difference that does not exist on the wire.
|
|
8
8
|
|
|
9
|
-
import { readWithinLimit } from '@ultimat3/core';
|
|
9
|
+
import { readWithinLimit, renderThrowable } from '@ultimat3/core';
|
|
10
10
|
import type { Embedder } from './embeddings';
|
|
11
11
|
import { normalize } from './embeddings';
|
|
12
12
|
import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors';
|
|
@@ -95,7 +95,11 @@ export class RemoteEmbedder implements Embedder {
|
|
|
95
95
|
} catch (error) {
|
|
96
96
|
throw new AiTransportError({
|
|
97
97
|
provider: this.name,
|
|
98
|
-
|
|
98
|
+
// `renderThrowable`, never `error instanceof Error` and `.message`: `fetch` is injected
|
|
99
|
+
// and the endpoint is app config, so the rejection is a value this package did not build —
|
|
100
|
+
// and `instanceof` RUNS a `Proxy`'s `getPrototypeOf` trap, whose throw escapes this very
|
|
101
|
+
// `catch` and replaces a coded refusal with an uncoded crash.
|
|
102
|
+
detail: `${renderThrowable(error)} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
|
|
99
103
|
envVar: API_KEY_ENV,
|
|
100
104
|
});
|
|
101
105
|
}
|
package/src/wire.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// with no socket, which is the only way to cover a stream that arrives out of order or stops
|
|
8
8
|
// half way. Imports from provider.ts are types only — the dependency runs one way.
|
|
9
9
|
|
|
10
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
10
11
|
import { AiTransportError } from './errors';
|
|
11
12
|
import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
|
|
12
13
|
import type { SseFrame } from './sse';
|
|
@@ -205,9 +206,7 @@ export class MessageStream {
|
|
|
205
206
|
} catch (error) {
|
|
206
207
|
throw new AiTransportError({
|
|
207
208
|
provider: 'anthropic',
|
|
208
|
-
detail: `unreadable "${frame.event}" frame: ${
|
|
209
|
-
error instanceof Error ? error.message : String(error)
|
|
210
|
-
}`,
|
|
209
|
+
detail: `unreadable "${frame.event}" frame: ${renderThrowable(error)}`,
|
|
211
210
|
});
|
|
212
211
|
}
|
|
213
212
|
}
|
|
@@ -275,9 +274,7 @@ export class MessageStream {
|
|
|
275
274
|
} catch (error) {
|
|
276
275
|
throw new AiTransportError({
|
|
277
276
|
provider: 'anthropic',
|
|
278
|
-
detail: `tool "${tool.name}" streamed arguments that are not JSON: ${
|
|
279
|
-
error instanceof Error ? error.message : String(error)
|
|
280
|
-
}`,
|
|
277
|
+
detail: `tool "${tool.name}" streamed arguments that are not JSON: ${renderThrowable(error)}`,
|
|
281
278
|
});
|
|
282
279
|
}
|
|
283
280
|
}
|