@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.
- package/CLAUDE.md +96 -32
- package/README.md +249 -75
- package/package.json +12 -11
- package/src/agent-facts.ts +9 -2
- package/src/agent-transcript.ts +1 -1
- package/src/agent.ts +43 -25
- package/src/budget.ts +178 -51
- package/src/content-blocks.ts +227 -0
- package/src/content-errors.ts +67 -0
- package/src/echo-provider.ts +102 -0
- package/src/embeddings.ts +40 -21
- package/src/error-body.ts +38 -3
- package/src/errors.ts +19 -11
- package/src/eval-baseline.ts +5 -1
- package/src/eval-errors.ts +3 -1
- package/src/evals.ts +1 -1
- package/src/failure-disclosure.ts +90 -0
- package/src/gateway.ts +73 -37
- package/src/hive-pool.ts +24 -16
- package/src/hive-result.ts +4 -1
- package/src/hive.ts +15 -5
- package/src/index.ts +66 -25
- package/src/llm-cache.ts +4 -3
- package/src/llm.ts +27 -65
- package/src/model-resolve.ts +79 -0
- package/src/models.ts +125 -111
- package/src/openai-body.ts +13 -2
- package/src/openai-messages.ts +86 -15
- package/src/openai-provider.ts +28 -31
- package/src/openai-wire.ts +11 -3
- package/src/pg-vector-sql.ts +11 -7
- package/src/pg-vector.ts +21 -10
- package/src/prompt-fence.ts +128 -0
- package/src/prompt.ts +43 -13
- package/src/provider.ts +82 -117
- package/src/rag.ts +17 -9
- package/src/remote-embedder.ts +15 -41
- package/src/respond.ts +86 -0
- package/src/runtime.ts +12 -14
- package/src/scorers.ts +14 -3
- package/src/tool-idempotency.ts +79 -0
- package/src/tools.ts +52 -39
- package/src/vector-scope.ts +46 -4
- package/src/vector.ts +117 -41
- package/src/wire.ts +7 -2
- package/src/fix-line.evals.ts +0 -35
- package/src/fix-line.ts +0 -27
- 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
|
|
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
|
|
91
|
-
const slot =
|
|
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 =
|
|
99
|
+
const sign = hashOf(`${token}#sign`) % 2 === 0 ? 1 : -1;
|
|
95
100
|
vector[slot] = (vector[slot] ?? 0) + sign;
|
|
96
101
|
}
|
|
97
|
-
return
|
|
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
|
|
107
|
+
export function wordTokens(text: string): readonly string[] {
|
|
103
108
|
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
117
|
-
export function
|
|
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
|
-
/**
|
|
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
|
|
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)
|
|
131
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
263
|
-
*
|
|
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
|
|
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
|
});
|
package/src/eval-baseline.ts
CHANGED
|
@@ -91,7 +91,11 @@ export function regressionsAgainst(input: {
|
|
|
91
91
|
readonly tolerance: number;
|
|
92
92
|
}): readonly Regression[] {
|
|
93
93
|
const found: Regression[] = [];
|
|
94
|
-
|
|
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
|
}
|