@ultimat3/ai 1.2.0 → 3.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 +521 -0
- package/README.md +367 -6
- package/package.json +11 -9
- package/src/agent-facts.ts +70 -0
- package/src/agent-job.ts +97 -0
- package/src/agent-transcript.ts +94 -0
- package/src/agent.ts +396 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +144 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +57 -19
- package/src/hive-errors.ts +30 -0
- package/src/hive-pool.ts +90 -0
- package/src/hive-result.ts +96 -0
- package/src/hive.ts +177 -0
- package/src/index.ts +55 -9
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +274 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +112 -38
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +107 -11
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
package/src/provider.ts
CHANGED
|
@@ -4,16 +4,63 @@
|
|
|
4
4
|
// catalogue and the per-model rules, ./wire owns the response half.
|
|
5
5
|
|
|
6
6
|
import type { Money } from '@ultimat3/money';
|
|
7
|
+
import { detailOf, withoutKey } from './error-body';
|
|
7
8
|
import { AiKeyMissingError, AiTransportError } from './errors';
|
|
8
9
|
import type { Effort, ModelId, ThinkingMode } from './models';
|
|
9
|
-
import { DEFAULT_MODEL,
|
|
10
|
+
import { ANTHROPIC_MODEL_IDS, DEFAULT_MODEL, modelIds, modelSpec, reasoningBody } from './models';
|
|
10
11
|
import { readSse } from './sse';
|
|
11
12
|
import type { LlmTool, LlmToolCall } from './tools';
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
asToolInput,
|
|
15
|
+
MessageStream,
|
|
16
|
+
parseStopDetails,
|
|
17
|
+
parseStopReason,
|
|
18
|
+
parseUsage,
|
|
19
|
+
throwInBandError,
|
|
20
|
+
} from './wire';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One block of a structured message. A plain string message is still the common case and still
|
|
24
|
+
* legal; blocks exist because a tool loop cannot be expressed without them — a `tool_result` has
|
|
25
|
+
* to name the `tool_use` it answers, and a string has nowhere to put the id.
|
|
26
|
+
*
|
|
27
|
+
* The field names are the Messages API's, not a translated set, so `body()` passes a block
|
|
28
|
+
* through untouched. A second vocabulary here would be a mapping table to keep in step with a
|
|
29
|
+
* wire format we do not own.
|
|
30
|
+
*/
|
|
31
|
+
export type AiContentBlock =
|
|
32
|
+
| { readonly type: 'text'; readonly text: string }
|
|
33
|
+
| {
|
|
34
|
+
readonly type: 'tool_use';
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly input: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
readonly type: 'tool_result';
|
|
41
|
+
readonly tool_use_id: string;
|
|
42
|
+
readonly content: string;
|
|
43
|
+
readonly is_error?: boolean;
|
|
44
|
+
};
|
|
13
45
|
|
|
14
46
|
export interface AiMessage {
|
|
15
47
|
readonly role: 'user' | 'assistant';
|
|
16
|
-
readonly content: string;
|
|
48
|
+
readonly content: string | readonly AiContentBlock[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The readable text of a message, blocks flattened. For ESTIMATING and for the echo provider —
|
|
53
|
+
* never for building a request, which sends `content` as it stands.
|
|
54
|
+
*/
|
|
55
|
+
export function messageText(message: AiMessage): string {
|
|
56
|
+
if (typeof message.content === 'string') return message.content;
|
|
57
|
+
return message.content
|
|
58
|
+
.map((block) => {
|
|
59
|
+
if (block.type === 'text') return block.text;
|
|
60
|
+
if (block.type === 'tool_result') return block.content;
|
|
61
|
+
return JSON.stringify(block.input);
|
|
62
|
+
})
|
|
63
|
+
.join(' ');
|
|
17
64
|
}
|
|
18
65
|
|
|
19
66
|
export interface GenerateRequest {
|
|
@@ -26,6 +73,15 @@ export interface GenerateRequest {
|
|
|
26
73
|
readonly thinking?: ThinkingMode;
|
|
27
74
|
readonly tools?: readonly LlmTool[];
|
|
28
75
|
readonly stopSequences?: readonly string[];
|
|
76
|
+
/**
|
|
77
|
+
* The caller's abort signal, forwarded to the socket by every provider in this package.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately absent from `cacheKeyFor` and from every estimate: it says whether a request was
|
|
80
|
+
* ABANDONED, never what it asked for, so two calls that differ only in it are the same call and
|
|
81
|
+
* must share a cache entry. Omitted means the call runs to completion — there is no ambient
|
|
82
|
+
* default, because a timeout the caller did not ask for is a truncated answer nothing reports.
|
|
83
|
+
*/
|
|
84
|
+
readonly signal?: AbortSignal;
|
|
29
85
|
}
|
|
30
86
|
|
|
31
87
|
export interface TokenUsage {
|
|
@@ -61,6 +117,14 @@ export interface StopDetails {
|
|
|
61
117
|
|
|
62
118
|
export interface GenerateResult {
|
|
63
119
|
readonly model: ModelId;
|
|
120
|
+
/**
|
|
121
|
+
* Which provider actually answered. Stamped by the GATEWAY, not by the provider: falling back
|
|
122
|
+
* from one provider to the next is a gateway concept, and a `Provider` implementation an app
|
|
123
|
+
* wrote cannot be asked to report on a decision it did not make. Optional for exactly that
|
|
124
|
+
* reason — a provider's own result has not been through the router yet. `llm()` puts it on the
|
|
125
|
+
* span as `llm.provider`, which is what makes a fallback visible rather than silent.
|
|
126
|
+
*/
|
|
127
|
+
readonly provider?: string;
|
|
64
128
|
readonly text: string;
|
|
65
129
|
readonly toolCalls: readonly LlmToolCall[];
|
|
66
130
|
readonly stopReason: StopReason;
|
|
@@ -91,7 +155,7 @@ export interface Provider {
|
|
|
91
155
|
* away is money the framework silently absorbs, and under-reporting spend defeats a budget.
|
|
92
156
|
*/
|
|
93
157
|
export function costOf(model: ModelId, usage: TokenUsage): Money {
|
|
94
|
-
const spec =
|
|
158
|
+
const spec = modelSpec(model);
|
|
95
159
|
// Cache reads are ~0.1x input; cache writes ~1.25x. Scaled by 10 to stay integral.
|
|
96
160
|
const inputUnits =
|
|
97
161
|
usage.inputTokens * 10 + usage.cacheReadTokens * 1 + Math.ceil(usage.cacheWriteTokens * 12.5);
|
|
@@ -121,8 +185,6 @@ export interface AnthropicProviderInput {
|
|
|
121
185
|
|
|
122
186
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
123
187
|
const API_KEY_ENV = 'ANTHROPIC_API_KEY';
|
|
124
|
-
/** Enough of an error body to name the field that was wrong, not enough to fill a log. */
|
|
125
|
-
const DETAIL_LIMIT = 300;
|
|
126
188
|
|
|
127
189
|
/**
|
|
128
190
|
* Above this ceiling a non-streaming request sits on an open socket past the HTTP timeout and
|
|
@@ -134,7 +196,7 @@ export const STREAM_ONLY_MAX_TOKENS = 16_000;
|
|
|
134
196
|
/** Whether this request has to go over the streaming transport to arrive at all. */
|
|
135
197
|
export function requiresStreaming(request: GenerateRequest): boolean {
|
|
136
198
|
const model = request.model ?? DEFAULT_MODEL;
|
|
137
|
-
return Math.min(request.maxTokens,
|
|
199
|
+
return Math.min(request.maxTokens, modelSpec(model).maxOutput) > STREAM_ONLY_MAX_TOKENS;
|
|
138
200
|
}
|
|
139
201
|
|
|
140
202
|
/**
|
|
@@ -146,7 +208,8 @@ export function requiresStreaming(request: GenerateRequest): boolean {
|
|
|
146
208
|
*/
|
|
147
209
|
export class AnthropicProvider implements Provider {
|
|
148
210
|
readonly name = 'anthropic';
|
|
149
|
-
|
|
211
|
+
/** Its own list, never the registry's: an app's internal model must not be routed here. */
|
|
212
|
+
readonly models: readonly ModelId[] = ANTHROPIC_MODEL_IDS;
|
|
150
213
|
private readonly config: AnthropicProviderInput;
|
|
151
214
|
|
|
152
215
|
constructor(config: AnthropicProviderInput = {}) {
|
|
@@ -162,7 +225,7 @@ export class AnthropicProvider implements Provider {
|
|
|
162
225
|
*/
|
|
163
226
|
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
164
227
|
if (requiresStreaming(request)) return this.assemble(request);
|
|
165
|
-
const response = await this.send({ ...this.body(request), stream: false });
|
|
228
|
+
const response = await this.send({ ...this.body(request), stream: false }, request.signal);
|
|
166
229
|
const raw = (await response.json()) as Record<string, unknown>;
|
|
167
230
|
return parseMessage(request.model ?? DEFAULT_MODEL, raw);
|
|
168
231
|
}
|
|
@@ -185,7 +248,7 @@ export class AnthropicProvider implements Provider {
|
|
|
185
248
|
*/
|
|
186
249
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
187
250
|
const model = request.model ?? DEFAULT_MODEL;
|
|
188
|
-
const response = await this.send({ ...this.body(request), stream: true });
|
|
251
|
+
const response = await this.send({ ...this.body(request), stream: true }, request.signal);
|
|
189
252
|
if (response.body === null) {
|
|
190
253
|
throw new AiTransportError({
|
|
191
254
|
provider: this.name,
|
|
@@ -217,7 +280,7 @@ export class AnthropicProvider implements Provider {
|
|
|
217
280
|
const model = request.model ?? DEFAULT_MODEL;
|
|
218
281
|
const body: Record<string, unknown> = {
|
|
219
282
|
model,
|
|
220
|
-
max_tokens: Math.min(request.maxTokens,
|
|
283
|
+
max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
|
|
221
284
|
messages: request.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
222
285
|
...reasoningBody(model, request.effort, request.thinking),
|
|
223
286
|
};
|
|
@@ -232,7 +295,10 @@ export class AnthropicProvider implements Provider {
|
|
|
232
295
|
* carrying its status, because the gateway decides whether to retry from that status and a
|
|
233
296
|
* body parsed as if it were a message would read as an empty, successful answer.
|
|
234
297
|
*/
|
|
235
|
-
private async send(
|
|
298
|
+
private async send(
|
|
299
|
+
body: Record<string, unknown>,
|
|
300
|
+
signal: AbortSignal | undefined,
|
|
301
|
+
): Promise<Response> {
|
|
236
302
|
const apiKey = this.config.apiKey ?? Bun.env[API_KEY_ENV];
|
|
237
303
|
if (apiKey === undefined || apiKey === '') {
|
|
238
304
|
throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
|
|
@@ -248,38 +314,32 @@ export class AnthropicProvider implements Provider {
|
|
|
248
314
|
accept: body['stream'] === true ? 'text/event-stream' : 'application/json',
|
|
249
315
|
},
|
|
250
316
|
body: JSON.stringify(body),
|
|
317
|
+
// Attached only when the caller has one: `exactOptionalPropertyTypes`, and an explicit
|
|
318
|
+
// `signal: undefined` is a different value to some fetch implementations.
|
|
319
|
+
...(signal === undefined ? {} : { signal }),
|
|
251
320
|
});
|
|
252
321
|
if (!response.ok) {
|
|
253
322
|
throw new AiTransportError({
|
|
254
323
|
provider: this.name,
|
|
255
324
|
status: response.status,
|
|
256
|
-
|
|
325
|
+
// The endpoint's own message, with the credential scrubbed out of it — the same rule the
|
|
326
|
+
// OpenAI-format provider follows, and for the same reason: a proxy echoing the request
|
|
327
|
+
// headers into its 4xx body is the one path by which `x-api-key` reaches an error.
|
|
328
|
+
detail: withoutKey(await detailOf(response), apiKey),
|
|
329
|
+
envVar: API_KEY_ENV,
|
|
257
330
|
});
|
|
258
331
|
}
|
|
259
332
|
return response;
|
|
260
333
|
}
|
|
261
334
|
}
|
|
262
335
|
|
|
263
|
-
/** The provider's own message, when it sent one — it names the offending field, we name the fix. */
|
|
264
|
-
async function detailOf(response: Response): Promise<string> {
|
|
265
|
-
const body = await response.text().catch(() => '');
|
|
266
|
-
try {
|
|
267
|
-
const parsed: unknown = JSON.parse(body);
|
|
268
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
269
|
-
const error = (parsed as Record<string, unknown>)['error'];
|
|
270
|
-
if (typeof error === 'object' && error !== null) {
|
|
271
|
-
const message = (error as Record<string, unknown>)['message'];
|
|
272
|
-
if (typeof message === 'string') return message.slice(0, DETAIL_LIMIT);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
} catch {
|
|
276
|
-
// Not JSON — a proxy or a gateway timeout page. The raw text is still the best evidence.
|
|
277
|
-
}
|
|
278
|
-
return body === '' ? response.statusText : body.slice(0, DETAIL_LIMIT);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
336
|
/** Map a Messages API response onto `GenerateResult`. Exported so tests can drive it. */
|
|
282
337
|
export function parseMessage(model: ModelId, raw: Record<string, unknown>): GenerateResult {
|
|
338
|
+
// A 200 carrying an `error` object instead of an answer — how a gateway in front of a model
|
|
339
|
+
// reports a fault it noticed after the headers were sent. Read as a message it is an empty,
|
|
340
|
+
// successful answer, which is the one outcome nothing downstream can detect; the STREAMED half
|
|
341
|
+
// of this same provider has always refused it.
|
|
342
|
+
throwInBandError(raw);
|
|
283
343
|
const content = Array.isArray(raw['content']) ? raw['content'] : [];
|
|
284
344
|
let text = '';
|
|
285
345
|
const toolCalls: LlmToolCall[] = [];
|
|
@@ -291,17 +351,24 @@ export function parseMessage(model: ModelId, raw: Record<string, unknown>): Gene
|
|
|
291
351
|
toolCalls.push({
|
|
292
352
|
id: b['id'],
|
|
293
353
|
name: b['name'],
|
|
294
|
-
|
|
354
|
+
// Parsed, never cast: `input` is untrusted, and a string under `Record<string, unknown>`
|
|
355
|
+
// is a lie every later reader indexes into. The streamed half already parses it.
|
|
356
|
+
input: asToolInput(b['input']),
|
|
295
357
|
});
|
|
296
358
|
}
|
|
297
359
|
}
|
|
298
360
|
const usage = parseUsage(raw['usage']);
|
|
361
|
+
const stopDetails = parseStopDetails(raw['stop_details']);
|
|
299
362
|
return {
|
|
300
363
|
model,
|
|
301
364
|
text,
|
|
302
365
|
toolCalls,
|
|
303
|
-
|
|
304
|
-
|
|
366
|
+
// A refusal detail is a refusal whatever the stop field says: `parseStopReason` answers
|
|
367
|
+
// `end_turn` for a spelling this build has never seen, and `llm()` branches on the REASON —
|
|
368
|
+
// `stopDetails` has no reader that can refuse — so the pair would arrive as a complete answer
|
|
369
|
+
// that happens to be empty. The OpenAI-format read has always forced it.
|
|
370
|
+
stopReason: stopDetails === undefined ? parseStopReason(raw['stop_reason']) : 'refusal',
|
|
371
|
+
stopDetails,
|
|
305
372
|
usage,
|
|
306
373
|
cost: costOf(model, usage),
|
|
307
374
|
};
|
|
@@ -324,7 +391,14 @@ export interface EchoProviderInput {
|
|
|
324
391
|
*/
|
|
325
392
|
export class EchoProvider implements Provider {
|
|
326
393
|
readonly name = 'echo';
|
|
327
|
-
|
|
394
|
+
/**
|
|
395
|
+
* A getter over the whole registry, not a snapshot: the test double has to serve whatever the
|
|
396
|
+
* test registered, and a field read at construction time would miss a model registered after.
|
|
397
|
+
*/
|
|
398
|
+
get models(): readonly ModelId[] {
|
|
399
|
+
return modelIds();
|
|
400
|
+
}
|
|
401
|
+
|
|
328
402
|
private readonly config: EchoProviderInput;
|
|
329
403
|
|
|
330
404
|
constructor(config: EchoProviderInput = {}) {
|
|
@@ -365,7 +439,7 @@ export class EchoProvider implements Provider {
|
|
|
365
439
|
function lastUserMessage(messages: readonly AiMessage[]): string {
|
|
366
440
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
367
441
|
const message = messages[i];
|
|
368
|
-
if (message !== undefined && message.role === 'user') return message
|
|
442
|
+
if (message !== undefined && message.role === 'user') return messageText(message);
|
|
369
443
|
}
|
|
370
444
|
return '';
|
|
371
445
|
}
|
|
@@ -381,7 +455,7 @@ export function estimateTokens(request: GenerateRequest): number {
|
|
|
381
455
|
|
|
382
456
|
/** The prompt half alone — what the provider bills at the input rate. */
|
|
383
457
|
export function estimateInputTokens(request: GenerateRequest): number {
|
|
384
|
-
const body = request.messages.map(
|
|
458
|
+
const body = request.messages.map(messageText).join(' ');
|
|
385
459
|
return estimateTextTokens(body) + estimateTextTokens(request.system ?? '');
|
|
386
460
|
}
|
|
387
461
|
|
|
@@ -394,7 +468,7 @@ export function estimateCost(request: GenerateRequest): Money {
|
|
|
394
468
|
const model = request.model ?? DEFAULT_MODEL;
|
|
395
469
|
return costOf(model, {
|
|
396
470
|
inputTokens: estimateInputTokens(request),
|
|
397
|
-
outputTokens: Math.min(request.maxTokens,
|
|
471
|
+
outputTokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
|
|
398
472
|
cacheReadTokens: 0,
|
|
399
473
|
cacheWriteTokens: 0,
|
|
400
474
|
});
|
package/src/rag.ts
CHANGED
|
@@ -156,6 +156,28 @@ export interface AssembledContext {
|
|
|
156
156
|
readonly dropped: readonly string[];
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
const BLOCK_OPEN = '<document id=';
|
|
160
|
+
const BLOCK_CLOSE = '</document>';
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* One retrieved document, fenced and labelled with the id it came from. A bare separator was not
|
|
164
|
+
* a boundary: it is a string a document can simply contain, and the assembled text lands in the
|
|
165
|
+
* `user` message indistinguishable from the author's own instructions — while a tool RESULT
|
|
166
|
+
* carries provenance and this carried none. So the fence is neutralised inside the payload, the
|
|
167
|
+
* shape `cdata()` uses in `@ultimat3/seo`'s `xml.ts`: the marker is broken, never deleted, so the
|
|
168
|
+
* model still reads every word the document actually said.
|
|
169
|
+
*
|
|
170
|
+
* Influence only, and deliberately not sold as more: the actor is `ctx.actor` and tool dispatch is
|
|
171
|
+
* matched against `def.tools`, so retrieved text can persuade a model and can never authorise it.
|
|
172
|
+
*/
|
|
173
|
+
function documentBlock(id: string, text: string): string {
|
|
174
|
+
const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
|
|
175
|
+
const body = text
|
|
176
|
+
.replaceAll(BLOCK_CLOSE, '<\\/document>')
|
|
177
|
+
.replaceAll(BLOCK_OPEN, '<\\document id=');
|
|
178
|
+
return `${BLOCK_OPEN}"${label}">\n${body}\n${BLOCK_CLOSE}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
159
181
|
/**
|
|
160
182
|
* Fill a context window in rank order until the budget is reached. Skips oversized hits
|
|
161
183
|
* rather than stopping, so one long chunk does not starve every shorter one behind it.
|
|
@@ -163,9 +185,10 @@ export interface AssembledContext {
|
|
|
163
185
|
export function assembleContext(input: {
|
|
164
186
|
readonly hits: readonly SearchHit[];
|
|
165
187
|
readonly maxTokens: number;
|
|
188
|
+
/** Between BLOCKS, never inside one — the block fence is what separates documents. */
|
|
166
189
|
readonly separator?: string;
|
|
167
190
|
}): AssembledContext {
|
|
168
|
-
const separator = input.separator ?? '\n\n
|
|
191
|
+
const separator = input.separator ?? '\n\n';
|
|
169
192
|
const separatorTokens = estimateChunkTokens(separator);
|
|
170
193
|
const parts: string[] = [];
|
|
171
194
|
const used: string[] = [];
|
|
@@ -173,12 +196,13 @@ export function assembleContext(input: {
|
|
|
173
196
|
let tokens = 0;
|
|
174
197
|
|
|
175
198
|
for (const hit of input.hits) {
|
|
176
|
-
const
|
|
199
|
+
const block = documentBlock(hit.id, hit.text);
|
|
200
|
+
const cost = estimateChunkTokens(block) + (parts.length === 0 ? 0 : separatorTokens);
|
|
177
201
|
if (tokens + cost > input.maxTokens) {
|
|
178
202
|
dropped.push(hit.id);
|
|
179
203
|
continue;
|
|
180
204
|
}
|
|
181
|
-
parts.push(
|
|
205
|
+
parts.push(block);
|
|
182
206
|
used.push(hit.id);
|
|
183
207
|
tokens += cost;
|
|
184
208
|
}
|
package/src/redaction.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The one gate between `vars()` and the provider: a `Secret` never reaches a prompt.
|
|
2
|
+
//
|
|
3
|
+
// Not a leak check — `Secret` redacts by value, so the string would have arrived as `[redacted]`.
|
|
4
|
+
// It is a CORRECTNESS check, and the same one `render()` already makes for an unfilled `{{slot}}`:
|
|
5
|
+
// a prompt that reads fine and means something else is the failure nobody sees, and here it is
|
|
6
|
+
// also a token bill for an answer about a placeholder.
|
|
7
|
+
|
|
8
|
+
import { isSecret } from '@ultimat3/core';
|
|
9
|
+
import { AiPromptSecretError } from './errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Refuse a `Secret` among a prompt's variables, naming every key that carries one. Runs whether
|
|
13
|
+
* or not an app installed a redactor: the redactor is the app's policy, this is the framework's
|
|
14
|
+
* invariant, and an invariant that only holds when something optional is configured is not one.
|
|
15
|
+
*
|
|
16
|
+
* Structural (`isSecret` reads the shared brand), so two copies of `@ultimat3/core` in one tree
|
|
17
|
+
* still recognise each other's secrets.
|
|
18
|
+
*/
|
|
19
|
+
export function assertNoSecrets(ref: string, vars: Readonly<Record<string, unknown>>): void {
|
|
20
|
+
const keys = Object.keys(vars).filter((key) => isSecret(vars[key]));
|
|
21
|
+
if (keys.length > 0) throw new AiPromptSecretError({ ref, keys: keys.sort() });
|
|
22
|
+
}
|
package/src/remote-embedder.ts
CHANGED
|
@@ -6,6 +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
10
|
import type { Embedder } from './embeddings';
|
|
10
11
|
import { normalize } from './embeddings';
|
|
11
12
|
import { AiKeyMissingError, AiTransportError, EmbedderDimMismatchError } from './errors';
|
|
@@ -15,6 +16,14 @@ const DEFAULT_BASE_URL = 'https://api.voyageai.com/v1';
|
|
|
15
16
|
/** Providers cap a batch around 128 inputs; 96 leaves headroom for long texts. */
|
|
16
17
|
const DEFAULT_BATCH_SIZE = 96;
|
|
17
18
|
const DETAIL_LIMIT = 300;
|
|
19
|
+
/**
|
|
20
|
+
* A hosted endpoint is a third party and `baseUrl` is app config, so neither its latency nor its
|
|
21
|
+
* response size is ours to assume. 30s is longer than any healthy embedding batch and shorter than
|
|
22
|
+
* a job lease; 32 MiB is an order of magnitude past the worst legitimate batch (96 inputs x 3072
|
|
23
|
+
* dimensions of JSON floats is under 4 MiB), so nothing real hits it and nothing unreal is held.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
26
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
18
27
|
|
|
19
28
|
export interface RemoteEmbedderInput {
|
|
20
29
|
/** The provider's model id. Doubles as the embedder name, so a store records what wrote it. */
|
|
@@ -30,6 +39,10 @@ export interface RemoteEmbedderInput {
|
|
|
30
39
|
readonly baseUrl?: string;
|
|
31
40
|
/** Inputs per request. Larger calls are split; the provider's own cap is not the caller's. */
|
|
32
41
|
readonly batchSize?: number;
|
|
42
|
+
/** Deadline for ONE batch request. Defaults to 30s; `AbortSignal.timeout` enforces it. */
|
|
43
|
+
readonly timeoutMs?: number;
|
|
44
|
+
/** Bytes this process will hold of one response. Defaults to 32 MiB. */
|
|
45
|
+
readonly maxResponseBytes?: number;
|
|
33
46
|
/** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
|
|
34
47
|
readonly fetch?: typeof fetch;
|
|
35
48
|
}
|
|
@@ -65,19 +78,53 @@ export class RemoteEmbedder implements Embedder {
|
|
|
65
78
|
throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
|
|
66
79
|
}
|
|
67
80
|
const doFetch = this.config.fetch ?? fetch;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
82
|
+
const url = `${this.config.baseUrl ?? DEFAULT_BASE_URL}/embeddings`;
|
|
83
|
+
let response: Response;
|
|
84
|
+
try {
|
|
85
|
+
response = await doFetch(url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ model: this.name, input: texts }),
|
|
89
|
+
// Without this the call has no deadline at all: the per-request budget produces a
|
|
90
|
+
// `ctx.signal` that never reaches here, so a provider that accepts the connection and
|
|
91
|
+
// never answers holds a worker for as long as the socket stays open.
|
|
92
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new AiTransportError({
|
|
96
|
+
provider: this.name,
|
|
97
|
+
detail: `${error instanceof Error ? error.message : 'the request failed before a response'} — no answer within ${timeoutMs}ms (deadline, egress, DNS or TLS)`,
|
|
98
|
+
envVar: API_KEY_ENV,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
73
101
|
if (!response.ok) {
|
|
74
102
|
throw new AiTransportError({
|
|
75
103
|
provider: this.name,
|
|
76
104
|
status: response.status,
|
|
77
105
|
detail: (await response.text().catch(() => '')).slice(0, DETAIL_LIMIT),
|
|
106
|
+
envVar: API_KEY_ENV,
|
|
78
107
|
});
|
|
79
108
|
}
|
|
80
|
-
|
|
109
|
+
// Read through core's counting reader rather than `response.json()`: a body is buffered whole
|
|
110
|
+
// before anything measures it otherwise, and a `content-length` a remote wrote is not a bound.
|
|
111
|
+
const maxBytes = this.config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
112
|
+
const read = await readWithinLimit(response.body, maxBytes);
|
|
113
|
+
if ('over' in read) {
|
|
114
|
+
throw new AiTransportError({
|
|
115
|
+
provider: this.name,
|
|
116
|
+
status: response.status,
|
|
117
|
+
detail: `response body is at least ${read.over} bytes, limit is ${maxBytes}`,
|
|
118
|
+
envVar: API_KEY_ENV,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
let payload: unknown;
|
|
122
|
+
try {
|
|
123
|
+
payload = JSON.parse(new TextDecoder().decode(read.bytes));
|
|
124
|
+
} catch {
|
|
125
|
+
throw this.malformed('the response body is not valid JSON');
|
|
126
|
+
}
|
|
127
|
+
return this.decode(payload, texts.length);
|
|
81
128
|
}
|
|
82
129
|
|
|
83
130
|
private decode(payload: unknown, expected: number): readonly Float32Array[] {
|
package/src/runtime.ts
CHANGED
|
@@ -24,12 +24,35 @@ export interface AiRuntimeInput {
|
|
|
24
24
|
readonly embedder?: Embedder;
|
|
25
25
|
/** One cache per scope key. Defaults to the in-memory driver; pgvector in production. */
|
|
26
26
|
readonly semanticCache?: (scope: string) => SemanticCache;
|
|
27
|
+
/**
|
|
28
|
+
* The last thing that runs over a prompt before it leaves the process. Declared here, in the
|
|
29
|
+
* one place the framework already owns, because `vars()` is the one declared place a model call
|
|
30
|
+
* loads data — the framework knows exactly where the row enters the prompt, and until now
|
|
31
|
+
* nothing sat between that and a third-party endpoint.
|
|
32
|
+
*
|
|
33
|
+
* WHAT to remove is the app's decision, not the framework's: a PII classifier is a model choice
|
|
34
|
+
* and ships in no framework (axiom 8). Ultimate ships the seam, the span attribute
|
|
35
|
+
* (`llm.redacted`) and the one rule it can enforce structurally — a `Secret` reaching `vars()`
|
|
36
|
+
* is `X_AI_PROMPT_SECRET`, whether or not a redactor is installed.
|
|
37
|
+
*/
|
|
38
|
+
readonly redact?: Redactor;
|
|
27
39
|
}
|
|
28
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Rewrites a prompt on its way to the provider. Sees the whole RENDERED text, system prompt
|
|
43
|
+
* included — template as well as values, because a redactor shown only the values cannot tell
|
|
44
|
+
* a name in a data slot from the same name in an instruction.
|
|
45
|
+
*/
|
|
46
|
+
export type Redactor = (text: string) => string;
|
|
47
|
+
|
|
48
|
+
/** No app redactor installed. Named rather than inline so `llm.redacted` has one thing to mean. */
|
|
49
|
+
const noRedaction: Redactor = (text) => text;
|
|
50
|
+
|
|
29
51
|
interface AiRuntime {
|
|
30
52
|
readonly gateway: Gateway;
|
|
31
53
|
readonly embedder: Embedder;
|
|
32
54
|
readonly semanticCache: (scope: string) => SemanticCache;
|
|
55
|
+
readonly redact: Redactor;
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
let runtime: AiRuntime | undefined;
|
|
@@ -40,6 +63,7 @@ export function configureAi(input: AiRuntimeInput): void {
|
|
|
40
63
|
gateway: input.gateway,
|
|
41
64
|
embedder: input.embedder ?? new HashEmbedder(),
|
|
42
65
|
semanticCache: input.semanticCache ?? (() => createMemorySemanticCache()),
|
|
66
|
+
redact: input.redact ?? noRedaction,
|
|
43
67
|
};
|
|
44
68
|
// A new runtime means a new embedder and a new gateway; vectors from the old one are not
|
|
45
69
|
// comparable to vectors from the new one, and a stale hit would answer the wrong question.
|
|
@@ -56,6 +80,11 @@ export function aiEmbedder(): Embedder {
|
|
|
56
80
|
return runtime?.embedder ?? new HashEmbedder();
|
|
57
81
|
}
|
|
58
82
|
|
|
83
|
+
/** The installed redactor, or the identity. Never absent, so the call site has no branch. */
|
|
84
|
+
export function aiRedactor(): Redactor {
|
|
85
|
+
return runtime?.redact ?? noRedaction;
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
/**
|
|
60
89
|
* The cache for one scope. Scopes are separate CACHE INSTANCES, never a filter over a shared
|
|
61
90
|
* one: cosine similarity has no notion of a tenant, so two tenants asking near-identical
|