@ultimat3/ai 23.0.0 → 25.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.
Files changed (48) hide show
  1. package/CLAUDE.md +96 -32
  2. package/README.md +249 -75
  3. package/package.json +12 -11
  4. package/src/agent-facts.ts +9 -2
  5. package/src/agent-transcript.ts +1 -1
  6. package/src/agent.ts +43 -25
  7. package/src/budget.ts +178 -51
  8. package/src/content-blocks.ts +227 -0
  9. package/src/content-errors.ts +67 -0
  10. package/src/echo-provider.ts +102 -0
  11. package/src/embeddings.ts +40 -21
  12. package/src/error-body.ts +38 -3
  13. package/src/errors.ts +19 -11
  14. package/src/eval-baseline.ts +5 -1
  15. package/src/eval-errors.ts +3 -1
  16. package/src/evals.ts +1 -1
  17. package/src/failure-disclosure.ts +90 -0
  18. package/src/gateway.ts +73 -37
  19. package/src/hive-pool.ts +24 -16
  20. package/src/hive-result.ts +4 -1
  21. package/src/hive.ts +15 -5
  22. package/src/index.ts +66 -25
  23. package/src/llm-cache.ts +4 -3
  24. package/src/llm.ts +27 -65
  25. package/src/model-resolve.ts +79 -0
  26. package/src/models.ts +125 -111
  27. package/src/openai-body.ts +13 -2
  28. package/src/openai-messages.ts +86 -15
  29. package/src/openai-provider.ts +28 -31
  30. package/src/openai-wire.ts +11 -3
  31. package/src/pg-vector-sql.ts +11 -7
  32. package/src/pg-vector.ts +21 -10
  33. package/src/prompt-fence.ts +128 -0
  34. package/src/prompt.ts +43 -13
  35. package/src/provider.ts +82 -117
  36. package/src/rag.ts +17 -9
  37. package/src/remote-embedder.ts +15 -41
  38. package/src/respond.ts +86 -0
  39. package/src/runtime.ts +12 -14
  40. package/src/scorers.ts +14 -3
  41. package/src/tool-idempotency.ts +79 -0
  42. package/src/tools.ts +52 -39
  43. package/src/vector-scope.ts +46 -4
  44. package/src/vector.ts +117 -41
  45. package/src/wire.ts +7 -2
  46. package/src/fix-line.evals.ts +0 -35
  47. package/src/fix-line.ts +0 -27
  48. package/src/openai-models.ts +0 -84
@@ -0,0 +1,227 @@
1
+ // The image and document blocks a user turn may carry: their shapes (the Messages API's own field
2
+ // names, so the Anthropic body passes them through untouched), the screen every request runs them
3
+ // through before it leaves, and what they add to the pre-flight token estimate.
4
+
5
+ import { renderCauseValue } from '@ultimat3/core';
6
+ import { AiContentUnsupportedError } from './content-errors';
7
+ import { AiRequestInvalidError } from './errors';
8
+ import type { ContentKind, ModelId } from './models';
9
+ import { modelSpec } from './models';
10
+ import type { AiContentBlock } from './provider';
11
+
12
+ /** "JPEG, PNG, GIF, and WebP" — the one list both wire formats publish. */
13
+ export const IMAGE_MEDIA_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] as const;
14
+ export type ImageMediaType = (typeof IMAGE_MEDIA_TYPES)[number];
15
+
16
+ /**
17
+ * Ceilings on a base64 `data` string, in CHARACTERS — the encoded size, which is what the limits
18
+ * are stated in. Image: the Claude API's 10 MB per image (MiB, as its own refusal counts them).
19
+ * Document: 32 MiB, the Messages API's whole-request ceiling, so no single PDF can be legal on
20
+ * its own and still be a request the endpoint refuses. The OpenAI format allows more for a file
21
+ * (50 MB); one table for both, the stricter, so which provider answers never changes the verdict.
22
+ */
23
+ export const MAX_IMAGE_BASE64_CHARS = 10 * 1024 * 1024;
24
+ export const MAX_DOCUMENT_BASE64_CHARS = 32 * 1024 * 1024;
25
+
26
+ /**
27
+ * An image's pre-flight token estimate: the most visual tokens Claude 4.7+ spends on one image
28
+ * after resizing (4,784 — vision guide, high-resolution tier). Pessimistic on purpose, like the
29
+ * rest of the estimate: the budget reserves it and `record` gives back what the call did not use.
30
+ */
31
+ export const IMAGE_TOKEN_ESTIMATE = 4_784;
32
+
33
+ export type AiImageSource =
34
+ | { readonly type: 'base64'; readonly media_type: ImageMediaType; readonly data: string }
35
+ | { readonly type: 'url'; readonly url: string };
36
+
37
+ export type AiDocumentSource =
38
+ | { readonly type: 'base64'; readonly media_type: 'application/pdf'; readonly data: string }
39
+ | { readonly type: 'url'; readonly url: string }
40
+ | { readonly type: 'text'; readonly media_type: 'text/plain'; readonly data: string };
41
+
42
+ export interface AiImageBlock {
43
+ readonly type: 'image';
44
+ readonly source: AiImageSource;
45
+ }
46
+
47
+ /** `title` is the Messages API's; on the OpenAI format it becomes the file's `filename`. */
48
+ export interface AiDocumentBlock {
49
+ readonly type: 'document';
50
+ readonly source: AiDocumentSource;
51
+ readonly title?: string;
52
+ }
53
+
54
+ export type AiMediaBlock = AiImageBlock | AiDocumentBlock;
55
+
56
+ /** The two kinds this module screens; a narrowing every consumer of `AiContentBlock` can reuse. */
57
+ export function isMediaBlock(block: { readonly type: string }): block is AiMediaBlock {
58
+ return block.type === 'image' || block.type === 'document';
59
+ }
60
+
61
+ /** The fields of a message this screen reads — structural, so `provider.ts` stays the owner. */
62
+ interface ScreenedMessage {
63
+ readonly role: string;
64
+ readonly content: string | readonly { readonly type: string }[];
65
+ }
66
+
67
+ /**
68
+ * Refuse every media block the target cannot take, and every one that is malformed, BEFORE the
69
+ * request is built. Blocks reach here from typed code and from replayed JSON alike, so the screen
70
+ * is at runtime: a media type outside the list, a URL that is not `https:`, a base64 string over
71
+ * its ceiling or outside the alphabet. A malformed block is `X_AI_REQUEST_INVALID`; a well-formed
72
+ * one the role or model cannot take is `X_AI_CONTENT_UNSUPPORTED`.
73
+ */
74
+ export function assertMediaContent(
75
+ messages: readonly ScreenedMessage[],
76
+ target: { readonly provider: string; readonly model: ModelId },
77
+ ): void {
78
+ messages.forEach((message, m) => {
79
+ if (typeof message.content === 'string') return;
80
+ message.content.forEach((block, b) => {
81
+ if (!isMediaBlock(block)) return;
82
+ const at = `messages[${m}].content[${b}]`;
83
+ const kind: ContentKind = block.type;
84
+ if (message.role !== 'user') {
85
+ const refusal = { by: 'role', ...target, role: message.role } as const;
86
+ throw new AiContentUnsupportedError({ kind, at, refusal });
87
+ }
88
+ if (!(modelSpec(target.model).input ?? []).includes(kind)) {
89
+ throw new AiContentUnsupportedError({ kind, at, refusal: { by: 'model', ...target } });
90
+ }
91
+ assertSource(block, at);
92
+ });
93
+ });
94
+ }
95
+
96
+ function assertSource(block: AiMediaBlock, at: string): void {
97
+ const fields = fieldsOf(block.source);
98
+ const type = fields['type'];
99
+ // Read as untrusted: a replayed block's title is whatever the JSON held, on either kind.
100
+ const title: unknown = (block as { readonly title?: unknown }).title;
101
+ if (title !== undefined && typeof title !== 'string') {
102
+ throw invalid(
103
+ at,
104
+ `has a title of ${renderCauseValue(title)}`,
105
+ "title: 'q3.pdf' # a string, or omit the field",
106
+ );
107
+ }
108
+ if (type === 'url') {
109
+ assertUrl(fields['url'], at);
110
+ return;
111
+ }
112
+ if (type === 'base64') {
113
+ assertBase64(block.type, fields, at);
114
+ return;
115
+ }
116
+ if (type === 'text' && block.type === 'document') {
117
+ assertText(fields, at);
118
+ return;
119
+ }
120
+ const legal = block.type === 'image' ? "'base64' or 'url'" : "'base64', 'url' or 'text'";
121
+ throw invalid(
122
+ at,
123
+ `has source.type ${renderCauseValue(type)}`,
124
+ `source: { type: 'base64', media_type, data } # source.type is ${legal}`,
125
+ );
126
+ }
127
+
128
+ function assertUrl(url: unknown, at: string): void {
129
+ // `https:` only: a `data:` URL is the base64 source spelled a second way, and plain `http:` is a
130
+ // fetch the vendor makes over a channel anyone on the path can rewrite.
131
+ if (typeof url === 'string' && URL.canParse(url) && new URL(url).protocol === 'https:') return;
132
+ throw invalid(
133
+ at,
134
+ `has source.url ${renderCauseValue(url)}, which is not an https: URL`,
135
+ "source: { type: 'url', url: 'https://…' } # or send the bytes as { type: 'base64', media_type, data }",
136
+ );
137
+ }
138
+
139
+ /** The standard alphabet with its padding, whole: no whitespace, no `data:` prefix, no URL-safe. */
140
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
141
+
142
+ function assertBase64(kind: ContentKind, fields: Record<string, unknown>, at: string): void {
143
+ const mediaType = fields['media_type'];
144
+ const legal: readonly unknown[] = kind === 'image' ? IMAGE_MEDIA_TYPES : ['application/pdf'];
145
+ if (!legal.includes(mediaType)) {
146
+ throw invalid(
147
+ at,
148
+ `has media_type ${renderCauseValue(mediaType)}, which a ${kind} block does not take`,
149
+ `media_type: '${String(legal[0])}' # one of ${legal.join(', ')}${kind === 'document' ? "; text goes as { type: 'text', media_type: 'text/plain', data }" : ''}`,
150
+ );
151
+ }
152
+ const data = fields['data'];
153
+ const cap = kind === 'image' ? MAX_IMAGE_BASE64_CHARS : MAX_DOCUMENT_BASE64_CHARS;
154
+ if (
155
+ typeof data !== 'string' ||
156
+ data.length > cap ||
157
+ data.length % 4 !== 0 ||
158
+ !BASE64.test(data)
159
+ ) {
160
+ const size = typeof data === 'string' ? `${data.length} characters` : renderCauseValue(data);
161
+ throw invalid(
162
+ at,
163
+ `has source.data of ${size}: not one non-empty, padded, standard-alphabet base64 string of at most ${cap} characters`,
164
+ `data: Buffer.from(bytes).toString('base64') # no data: prefix, at most ${cap} characters; a larger ${kind} goes as an https:// url source`,
165
+ );
166
+ }
167
+ }
168
+
169
+ function assertText(fields: Record<string, unknown>, at: string): void {
170
+ const data = fields['data'];
171
+ if (fields['media_type'] === 'text/plain' && typeof data === 'string' && data !== '') return;
172
+ throw invalid(
173
+ at,
174
+ 'has a text source that is not { media_type: "text/plain", data: <non-empty string> }',
175
+ "source: { type: 'text', media_type: 'text/plain', data: text } # the document's text, non-empty",
176
+ );
177
+ }
178
+
179
+ const invalid = (at: string, what: string, fix: string): AiRequestInvalidError =>
180
+ new AiRequestInvalidError({ detail: `${at} ${what}`, fix });
181
+
182
+ /**
183
+ * The readable text of one block, for `messageText`. An image and a PDF have none — their cost is
184
+ * `mediaTokenEstimate`'s, never their base64 read as prose — and a text document's is its data.
185
+ */
186
+ export function blockText(block: AiContentBlock): string {
187
+ if (block.type === 'text') return block.text;
188
+ if (block.type === 'tool_result') return block.content;
189
+ if (block.type === 'tool_use') return JSON.stringify(block.input);
190
+ if (block.type !== 'document') return '';
191
+ const source = fieldsOf(block.source);
192
+ return source['type'] === 'text' ? stringOr(source['data']) : '';
193
+ }
194
+
195
+ /**
196
+ * What the media blocks add to the pre-flight estimate. An image is `IMAGE_TOKEN_ESTIMATE`, its
197
+ * worst case. A base64 PDF is its encoded length over four — the same 4-characters-per-token rule
198
+ * as text, and in the published per-page range (text plus one page image) for an ordinary PDF. A
199
+ * text document's `data` is already counted by `messageText`. A PDF by URL is UNKNOWABLE before
200
+ * the vendor fetches it and adds nothing: `record` reconciles to the real usage afterwards.
201
+ *
202
+ * Runs BEFORE the screen (the gateway reserves, then the provider builds), so every field is read
203
+ * as untrusted — a malformed block is estimated as nothing and refused a moment later.
204
+ */
205
+ export function mediaTokenEstimate(messages: readonly ScreenedMessage[]): number {
206
+ let tokens = 0;
207
+ for (const message of messages) {
208
+ if (typeof message.content === 'string') continue;
209
+ for (const block of message.content) {
210
+ if (!isMediaBlock(block)) continue;
211
+ if (block.type === 'image') {
212
+ tokens += IMAGE_TOKEN_ESTIMATE;
213
+ continue;
214
+ }
215
+ const source = fieldsOf(block.source);
216
+ if (source['type'] === 'base64') tokens += Math.ceil(stringOr(source['data']).length / 4);
217
+ }
218
+ }
219
+ return tokens;
220
+ }
221
+
222
+ const stringOr = (value: unknown): string => (typeof value === 'string' ? value : '');
223
+
224
+ /** A block's `source` as untrusted fields: a replayed block may carry none, or `null`. */
225
+ function fieldsOf(source: unknown): Readonly<Record<string, unknown>> {
226
+ return typeof source === 'object' && source !== null ? (source as Record<string, unknown>) : {};
227
+ }
@@ -0,0 +1,67 @@
1
+ // The X_AI_CONTENT_UNSUPPORTED class, apart from ./errors because that catalogue is at its ceiling —
2
+ // the split `hive-errors.ts` and `eval-errors.ts` made. Its code and title are registered in
3
+ // ./errors with the rest: one owner, one registration, one place a duplicate can surface.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+
7
+ /** Where a block was refused: a capability of the WIRE format, or of the model's own row. */
8
+ export type ContentRefusal =
9
+ | { readonly by: 'model'; readonly model: string; readonly provider: string }
10
+ | {
11
+ readonly by: 'provider';
12
+ readonly model: string;
13
+ readonly provider: string;
14
+ /** What the wire format lacks — and, in `fix`, the block this provider DOES take instead. */
15
+ readonly why: string;
16
+ readonly fix: string;
17
+ }
18
+ | {
19
+ readonly by: 'role';
20
+ readonly model: string;
21
+ readonly provider: string;
22
+ readonly role: string;
23
+ };
24
+
25
+ /**
26
+ * A message carries an image or document block the target cannot take. Refused before the request
27
+ * leaves, because the alternative is a 400 whose message names a JSON path, not the fix — or, on
28
+ * the endpoints that skip what they cannot parse, an answer about a document the model never saw.
29
+ *
30
+ * Never collected into `X_AI_PROVIDER_UNAVAILABLE`: the same blocks get the same refusal from
31
+ * every provider serving that model, so retrying burns attempts and discards this fix line.
32
+ */
33
+ export class AiContentUnsupportedError extends UltimateError {
34
+ constructor(input: {
35
+ readonly kind: string;
36
+ readonly at: string;
37
+ readonly refusal: ContentRefusal;
38
+ }) {
39
+ const { kind, at, refusal } = input;
40
+ super({
41
+ code: 'X_AI_CONTENT_UNSUPPORTED',
42
+ cause: `${at} is a ${kind} block, and ${causeOf(kind, refusal)}`,
43
+ fix: fixOf(kind, refusal),
44
+ meta: { kind, at, by: refusal.by, model: refusal.model, provider: refusal.provider },
45
+ });
46
+ }
47
+ }
48
+
49
+ function causeOf(kind: string, refusal: ContentRefusal): string {
50
+ if (refusal.by === 'model') {
51
+ return `model "${refusal.model}" is registered without '${kind}' in its input list`;
52
+ }
53
+ if (refusal.by === 'role') {
54
+ return `a ${kind} block may only travel in a user turn, not a ${refusal.role} turn (provider "${refusal.provider}", model "${refusal.model}")`;
55
+ }
56
+ return `provider "${refusal.provider}" cannot send it to model "${refusal.model}": ${refusal.why}`;
57
+ }
58
+
59
+ function fixOf(kind: string, refusal: ContentRefusal): string {
60
+ if (refusal.by === 'model') {
61
+ return `registerModel({ ...modelSpec('${refusal.model}'), input: ['image', 'document'] }) # if its endpoint accepts ${kind}s; or route the request to a model that does`;
62
+ }
63
+ if (refusal.by === 'role') {
64
+ return `{ role: 'user', content: [/* the ${kind} block */] } # move the block out of the ${refusal.role} turn`;
65
+ }
66
+ return refusal.fix;
67
+ }
@@ -0,0 +1,102 @@
1
+ // `EchoProvider`: the deterministic `Provider` tests, evals and `x dev` without a key run on. Apart
2
+ // from ./provider, which owns the real Messages API, because a double and the socket it stands in
3
+ // for are two jobs — and the request half had reached its line ceiling holding both.
4
+
5
+ import { resolveModel } from './model-resolve';
6
+ import type { ModelId } from './models';
7
+ import { modelIds } from './models';
8
+ import type {
9
+ AiMessage,
10
+ GenerateRequest,
11
+ GenerateResult,
12
+ Provider,
13
+ StreamChunk,
14
+ TokenUsage,
15
+ } from './provider';
16
+ import { costOf, estimateTextTokens, estimateTokens, messageText } from './provider';
17
+
18
+ export interface EchoProviderInput {
19
+ /** Fixed replies keyed by the last user message, for eval fixtures. */
20
+ readonly replies?: Readonly<Record<string, string>>;
21
+ /** Fallback when no key matches. Defaults to echoing the last user message. */
22
+ readonly fallback?: (prompt: string) => string;
23
+ readonly tokensPerCall?: number;
24
+ }
25
+
26
+ /** The deterministic test provider — the one way to build one; the class is a type only. */
27
+ export function echoProvider(config: EchoProviderInput = {}): EchoProvider {
28
+ return new EchoProvider(config);
29
+ }
30
+
31
+ /**
32
+ * Deterministic provider. Same input, same output, same usage — which is what makes an eval
33
+ * suite a test rather than a sample. Token counts are derived from length, so a budget test
34
+ * can assert a refusal without a network.
35
+ */
36
+ export class EchoProvider implements Provider {
37
+ readonly name = 'echo';
38
+ /**
39
+ * A getter over the whole registry, not a snapshot: the test double has to serve whatever the
40
+ * test registered, and a field read at construction time would miss a model registered after.
41
+ */
42
+ get models(): readonly ModelId[] {
43
+ return modelIds();
44
+ }
45
+
46
+ private readonly config: EchoProviderInput;
47
+
48
+ constructor(config: EchoProviderInput = {}) {
49
+ this.config = config;
50
+ }
51
+
52
+ async generate(request: GenerateRequest): Promise<GenerateResult> {
53
+ // No model of its own: the double must not choose one for an app any more than the real
54
+ // providers do. The gateway resolves before it routes; a direct call names one or is refused.
55
+ const model = resolveModel('echo-provider', request.model);
56
+ const prompt = lastUserMessage(request.messages);
57
+ const text = this.fixedReply(prompt) ?? this.config.fallback?.(prompt) ?? prompt;
58
+ const usage: TokenUsage = {
59
+ inputTokens: this.config.tokensPerCall ?? estimateTokens({ ...request, model }),
60
+ outputTokens: estimateTextTokens(text),
61
+ cacheReadTokens: 0,
62
+ cacheWriteTokens: 0,
63
+ };
64
+ return {
65
+ model,
66
+ text,
67
+ toolCalls: [],
68
+ stopReason: 'end_turn',
69
+ stopDetails: undefined,
70
+ usage,
71
+ cost: costOf(model, usage),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The fixture reply for this prompt. `Object.hasOwn`, never `replies?.[prompt]`: the key is
77
+ * MESSAGE TEXT, so a prompt of `toString` read a function off the prototype chain and returned
78
+ * it as the model's answer — a double that answers with JS source is worse than one that cannot.
79
+ */
80
+ private fixedReply(prompt: string): string | undefined {
81
+ const { replies } = this.config;
82
+ if (replies === undefined || !Object.hasOwn(replies, prompt)) return undefined;
83
+ return replies[prompt];
84
+ }
85
+
86
+ async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
87
+ const result = await this.generate(request);
88
+ // One word per chunk: enough to exercise a consumer's assembly logic.
89
+ for (const word of result.text.split(' ')) {
90
+ if (word !== '') yield { type: 'text', text: `${word} ` };
91
+ }
92
+ yield { type: 'done', result };
93
+ }
94
+ }
95
+
96
+ function lastUserMessage(messages: readonly AiMessage[]): string {
97
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
98
+ const message = messages[i];
99
+ if (message !== undefined && message.role === 'user') return messageText(message);
100
+ }
101
+ return '';
102
+ }
package/src/embeddings.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // queried with another is a silent relevance collapse, and the only place to catch it is
6
6
  // where the two meet. `VectorStore` compares the declared dimension and refuses.
7
7
 
8
- import { finiteCount } from '@ultimat3/core';
8
+ import { fingerprint, finiteCount } from '@ultimat3/core';
9
9
  import { AiEmbedderInvalidError } from './errors';
10
10
 
11
11
  export interface Embedder {
@@ -64,6 +64,11 @@ export interface HashEmbedderInput {
64
64
  readonly dimension?: number;
65
65
  }
66
66
 
67
+ /** The hashing embedder — the one way to build one; the class is a type only. */
68
+ export function hashEmbedder(input: HashEmbedderInput = {}): HashEmbedder {
69
+ return new HashEmbedder(input);
70
+ }
71
+
67
72
  /**
68
73
  * Deterministic bag-of-words hashing embedder. Not semantic — two paraphrases share no
69
74
  * vocabulary and land far apart — but it IS stable, dependency-free, and fast, which is
@@ -76,7 +81,7 @@ export class HashEmbedder implements Embedder {
76
81
 
77
82
  constructor(input: HashEmbedderInput = {}) {
78
83
  // Floored at 1: `new Float32Array(NaN)` is a vector of LENGTH ZERO, so every embedding is
79
- // empty, `cosine` answers 0 for every pair, and the ranking collapses with nothing thrown —
84
+ // empty, `cosine` answers `NaN` for every pair, and the ranking collapses with nothing thrown —
80
85
  // the silent relevance collapse this file's own header says the dimension exists to catch.
81
86
  this.dimension = finiteCount('HashEmbedder', 'dimension', input.dimension ?? 256, 1);
82
87
  }
@@ -87,34 +92,33 @@ export class HashEmbedder implements Embedder {
87
92
 
88
93
  private one(text: string): Float32Array {
89
94
  const vector = new Float32Array(this.dimension);
90
- for (const token of tokenize(text)) {
91
- const slot = fnv1a(token) % this.dimension;
95
+ for (const token of wordTokens(text)) {
96
+ const slot = hashOf(token) % this.dimension;
92
97
  // Signed accumulation: without it every vector is non-negative and cosine
93
98
  // similarity compresses into a narrow band where nothing ranks apart.
94
- const sign = fnv1a(`${token}#sign`) % 2 === 0 ? 1 : -1;
99
+ const sign = hashOf(`${token}#sign`) % 2 === 0 ? 1 : -1;
95
100
  vector[slot] = (vector[slot] ?? 0) + sign;
96
101
  }
97
- return normalize(vector);
102
+ return normalizeVector(vector);
98
103
  }
99
104
  }
100
105
 
101
106
  /** Lowercased word tokens. Punctuation is dropped; digits are kept (versions, ids). */
102
- export function tokenize(text: string): readonly string[] {
107
+ export function wordTokens(text: string): readonly string[] {
103
108
  return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
104
109
  }
105
110
 
106
- /** FNV-1a 32-bit. Chosen for stability across runtimes, not for cryptographic strength. */
107
- export function fnv1a(text: string): number {
108
- let hash = 0x811c9dc5;
109
- for (let i = 0; i < text.length; i += 1) {
110
- hash ^= text.charCodeAt(i);
111
- hash = Math.imul(hash, 0x01000193) >>> 0;
112
- }
113
- return hash;
111
+ /**
112
+ * A token's slot hash: 32 bits of core's `fingerprint`, never a local FNV-1a. FNV-1a/32 collides on
113
+ * ordinary words (`costarring` / `liquid`), and two colliding words embed IDENTICALLY here — cosine
114
+ * 1, so a semantic lookup answered one with the other's entry.
115
+ */
116
+ function hashOf(text: string): number {
117
+ return Number.parseInt(fingerprint(text).slice(0, 8), 16);
114
118
  }
115
119
 
116
- /** L2 normalise in place so cosine similarity reduces to a dot product. */
117
- export function normalize(vector: Float32Array): Float32Array {
120
+ /** L2 normalise in place, so every embedder hands a store unit vectors whatever its scale. */
121
+ export function normalizeVector(vector: Float32Array): Float32Array {
118
122
  let sum = 0;
119
123
  for (const value of vector) sum += value * value;
120
124
  if (sum === 0) return vector;
@@ -123,10 +127,25 @@ export function normalize(vector: Float32Array): Float32Array {
123
127
  return vector;
124
128
  }
125
129
 
126
- /** Dot product. Correct as cosine only for normalised vectors — which `normalize` ensures. */
130
+ /**
131
+ * Cosine similarity — pgvector's `1 - (a <=> b)`, computed the way pgvector computes it, so the dev
132
+ * store and production rank alike. A bare dot product was cosine only for unit vectors, and a
133
+ * store accepts whatever an app upserts. A zero-norm side is `0 / 0`: `NaN`, as pgvector answers,
134
+ * never a `0` that would rank a vector with no direction above every opposed one. Clamped to
135
+ * [-1, 1] as pgvector clamps, so float error never reports a similarity past identical.
136
+ */
127
137
  export function cosine(a: Float32Array, b: Float32Array): number {
128
- let sum = 0;
138
+ let dot = 0;
139
+ let normA = 0;
140
+ let normB = 0;
129
141
  const length = Math.min(a.length, b.length);
130
- for (let i = 0; i < length; i += 1) sum += (a[i] ?? 0) * (b[i] ?? 0);
131
- return sum;
142
+ for (let i = 0; i < length; i += 1) {
143
+ const x = a[i] ?? 0;
144
+ const y = b[i] ?? 0;
145
+ dot += x * y;
146
+ normA += x * x;
147
+ normB += y * y;
148
+ }
149
+ const similarity = dot / Math.sqrt(normA * normB);
150
+ return similarity > 1 ? 1 : similarity < -1 ? -1 : similarity;
132
151
  }
package/src/error-body.ts CHANGED
@@ -11,13 +11,20 @@ import { REDACTED } from '@ultimat3/core';
11
11
  /** Enough of an error body to name the field that was wrong, not enough to fill a log. */
12
12
  const DETAIL_LIMIT = 300;
13
13
 
14
+ /**
15
+ * How much of a failure body is READ. Larger than what is shown, because the message sits inside a
16
+ * JSON envelope that has to parse; bounded, because a provider streaming an endless 5xx body held
17
+ * the call and its memory for as long as it kept sending.
18
+ */
19
+ const READ_LIMIT = 4 * 1024;
20
+
14
21
  /**
15
22
  * The provider's own message, when it sent one — it names the offending field, we name the fix.
16
23
  * Falls back to the raw text because a proxy or a gateway timeout page is not JSON and is still
17
- * the best evidence there is.
24
+ * the best evidence there is. One reader for every transport, the embedder included.
18
25
  */
19
26
  export async function detailOf(response: Response): Promise<string> {
20
- const body = await response.text().catch(() => '');
27
+ const body = await boundedText(response.body);
21
28
  try {
22
29
  const parsed: unknown = JSON.parse(body);
23
30
  if (typeof parsed === 'object' && parsed !== null) {
@@ -28,11 +35,39 @@ export async function detailOf(response: Response): Promise<string> {
28
35
  }
29
36
  }
30
37
  } catch {
31
- // Not JSON — a proxy or a gateway timeout page. The raw text is still the best evidence.
38
+ // Not JSON — a proxy or a gateway timeout page, or a body cut at the read limit.
32
39
  }
33
40
  return body === '' ? response.statusText : body.slice(0, DETAIL_LIMIT);
34
41
  }
35
42
 
43
+ /** The start of a body, never more than `READ_LIMIT` bytes of it; the rest is cancelled. */
44
+ async function boundedText(body: ReadableStream<Uint8Array> | null): Promise<string> {
45
+ if (body === null) return '';
46
+ const reader = body.getReader();
47
+ const kept: Uint8Array[] = [];
48
+ let total = 0;
49
+ try {
50
+ while (total < READ_LIMIT) {
51
+ const { done, value } = await reader.read();
52
+ if (done) break;
53
+ kept.push(value);
54
+ total += value.byteLength;
55
+ }
56
+ } catch {
57
+ // A body that fails mid-read still has whatever arrived; the status is the error's substance.
58
+ } finally {
59
+ await reader.cancel().catch(() => undefined);
60
+ reader.releaseLock();
61
+ }
62
+ const bytes = new Uint8Array(total);
63
+ let offset = 0;
64
+ for (const chunk of kept) {
65
+ bytes.set(chunk, offset);
66
+ offset += chunk.byteLength;
67
+ }
68
+ return new TextDecoder().decode(bytes);
69
+ }
70
+
36
71
  /**
37
72
  * Every occurrence of the credential replaced with `[redacted]`. Cheap, and the one leak path:
38
73
  * a proxy that echoes the request headers into its own 400 body puts the key in an error, and an
package/src/errors.ts CHANGED
@@ -27,6 +27,9 @@ export const AI_ERROR_CODES = [
27
27
  'X_VECTOR_DIM_MISMATCH',
28
28
  'X_VECTOR_SCOPE_WIDENED',
29
29
  'X_AI_EMBEDDER_INVALID',
30
+ 'X_VECTOR_UNSCOPED',
31
+ 'X_AI_CONTENT_UNSUPPORTED',
32
+ 'X_AI_MODEL_UNRESOLVED',
30
33
  ] as const;
31
34
 
32
35
  export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
@@ -55,6 +58,9 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
55
58
  X_VECTOR_DIM_MISMATCH: 'embedding dimensions differ from the store',
56
59
  X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
57
60
  X_AI_EMBEDDER_INVALID: 'an Embedder returned fewer vectors than texts it was given',
61
+ X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
62
+ X_AI_CONTENT_UNSUPPORTED: 'an image or document block the role, model or wire format cannot take',
63
+ X_AI_MODEL_UNRESOLVED: 'a model call named no model',
58
64
  };
59
65
 
60
66
  // Titles must be registered for `format()` to render the contract's first line. Unconditional and
@@ -128,25 +134,27 @@ export class AiGatewayMissingError extends UltimateError {
128
134
  constructor(input: { prompt: string }) {
129
135
  super({
130
136
  code: 'X_AI_GATEWAY_MISSING',
131
- cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured`,
132
- fix: 'configureAi({ gateway: createGateway({ providers: [new AnthropicProvider()] }) }) at boot',
137
+ // The provider is the app's choice, so no vendor is the default (M12): the fix is the shape
138
+ // and the cause names what `provider` is — one of the peers, or the app's own.
139
+ cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured (provider: anthropicProvider({ models }) for the Anthropic Messages format, openAiProvider({ baseUrl, models }) for the OpenAI chat-completions format on any compatible server, or your own Provider)`,
140
+ fix: 'configureAi({ gateway: providerGateway({ providers: [provider] }) }) at boot',
133
141
  });
134
142
  }
135
143
  }
136
144
 
137
145
  /**
138
- * A model id nothing put in the catalogue. This is what replaced the closed `ModelId` union: the
139
- * union made a company's own model id inexpressible, so the only way past `tsc` was to claim a
140
- * Claude id — and then `costOf` priced an internal model at Anthropic list rates and the budget
141
- * ledger reserved against a number belonging to a model nobody ran. A wrong id is still refused;
142
- * it is refused HERE, at the first read of the spec, instead of by making a right one impossible.
146
+ * A model id the app never registered. The framework registers none, so every id a call names —
147
+ * any vendor's — is one the app's own `registerModel` wrote: priced, reserved and recorded by the
148
+ * app's numbers, never by a list this package shipped. Refused HERE, at the first read of the spec
149
+ * (`costOf`, the budget's estimate, the request builders), before anything is sent or charged.
143
150
  */
144
151
  export class AiModelUnknownError extends UltimateError {
145
152
  constructor(input: { model: string; registered: readonly string[] }) {
146
153
  super({
147
154
  code: 'X_AI_MODEL_UNKNOWN',
148
155
  cause:
149
- `model "${input.model}" has no registered spec, so nothing can price it ` +
156
+ `model "${input.model}" has no registered spec, so nothing can price or budget it — ` +
157
+ `the framework registers no model of its own ` +
150
158
  `(registered: ${input.registered.length > 0 ? input.registered.join(', ') : 'none'})`,
151
159
  // The `errors` gate blanks every interpolation, so the literal half alone has to name the
152
160
  // call. Which ids ARE registered is a fact of the failure, and cause is where facts live.
@@ -259,8 +267,8 @@ export class LlmRefusedError extends UltimateError {
259
267
  prompt: string;
260
268
  model: string;
261
269
  /**
262
- * A blessed model MORE capable than the one that refused, or `undefined` when the refusal
263
- * came from the most capable one this build knows. Retrying a refusal on a weaker model is
270
+ * A model the APP registered, in the same family, MORE capable than the one that refused —
271
+ * or `undefined` when the app registered none above it. Retrying a refusal on a weaker model is
264
272
  * the one retry that cannot help, so the fix line drops the suggestion rather than inventing
265
273
  * a downgrade.
266
274
  */
@@ -276,7 +284,7 @@ export class LlmRefusedError extends UltimateError {
276
284
  `${input.explanation === undefined ? '' : `: ${input.explanation}`}`,
277
285
  fix:
278
286
  input.alternative === undefined
279
- ? `edit the template in definePrompt('${input.prompt}') and bump its version — no blessed model is more capable than '${input.model}'`
287
+ ? `edit the template in definePrompt('${input.prompt}') and bump its version — no model your app registered in its family is more capable than '${input.model}'`
280
288
  : `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
281
289
  meta: { model: input.model, category: input.category },
282
290
  });
@@ -91,7 +91,11 @@ export function regressionsAgainst(input: {
91
91
  readonly tolerance: number;
92
92
  }): readonly Regression[] {
93
93
  const found: Regression[] = [];
94
- const fell = (was: number, now: number): boolean => now < was - input.tolerance;
94
+ // Both sides at the precision the baseline is WRITTEN at, and the drop rounded too: a raw 2/3
95
+ // against its own recorded 0.667 fell at `tolerance: 0`, and `0.9 - 0.05` is not `0.85` in
96
+ // binary — a drop of exactly the tolerance must read as within it.
97
+ const fell = (was: number, now: number): boolean =>
98
+ round(round(was) - round(now)) > input.tolerance;
95
99
  if (fell(input.baseline.score, input.score)) {
96
100
  found.push({ case: OVERALL, baseline: input.baseline.score, score: input.score });
97
101
  }