@ultimat3/ai 1.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/LICENSE +21 -0
- package/README.md +273 -0
- package/package.json +42 -0
- package/src/budget.ts +233 -0
- package/src/embeddings.ts +107 -0
- package/src/errors.ts +422 -0
- package/src/eval-baseline.ts +140 -0
- package/src/evals.ts +242 -0
- package/src/gateway.ts +203 -0
- package/src/index.ts +187 -0
- package/src/llm.ts +313 -0
- package/src/models.ts +163 -0
- package/src/pg-vector-sql.ts +198 -0
- package/src/pg-vector.ts +179 -0
- package/src/prompt.ts +169 -0
- package/src/provider.ts +405 -0
- package/src/rag.ts +186 -0
- package/src/remote-embedder.ts +143 -0
- package/src/runtime.ts +77 -0
- package/src/scorers.ts +107 -0
- package/src/sse.ts +81 -0
- package/src/tools.ts +116 -0
- package/src/vector-scope.ts +76 -0
- package/src/vector.ts +0 -0
- package/src/wire.ts +283 -0
package/src/provider.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// The `Provider` interface, its two implementations — `AnthropicProvider` (the real Messages API,
|
|
2
|
+
// streaming and not) and `EchoProvider` (deterministic, for tests and `x dev` without a key) —
|
|
3
|
+
// and the money arithmetic over a call's reported usage. The REQUEST half only: ./models owns the
|
|
4
|
+
// catalogue and the per-model rules, ./wire owns the response half.
|
|
5
|
+
|
|
6
|
+
import type { Money } from '@ultimat3/money';
|
|
7
|
+
import { AiKeyMissingError, AiTransportError } from './errors';
|
|
8
|
+
import type { Effort, ModelId, ThinkingMode } from './models';
|
|
9
|
+
import { DEFAULT_MODEL, MODEL_IDS, MODELS, reasoningBody } from './models';
|
|
10
|
+
import { readSse } from './sse';
|
|
11
|
+
import type { LlmTool, LlmToolCall } from './tools';
|
|
12
|
+
import { MessageStream, parseStopDetails, parseStopReason, parseUsage } from './wire';
|
|
13
|
+
|
|
14
|
+
export interface AiMessage {
|
|
15
|
+
readonly role: 'user' | 'assistant';
|
|
16
|
+
readonly content: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GenerateRequest {
|
|
20
|
+
readonly model?: ModelId;
|
|
21
|
+
readonly system?: string;
|
|
22
|
+
readonly messages: readonly AiMessage[];
|
|
23
|
+
/** Enforced output ceiling. The model never sees it, so it can be cut off mid-answer. */
|
|
24
|
+
readonly maxTokens: number;
|
|
25
|
+
readonly effort?: Effort;
|
|
26
|
+
readonly thinking?: ThinkingMode;
|
|
27
|
+
readonly tools?: readonly LlmTool[];
|
|
28
|
+
readonly stopSequences?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TokenUsage {
|
|
32
|
+
readonly inputTokens: number;
|
|
33
|
+
readonly outputTokens: number;
|
|
34
|
+
readonly cacheReadTokens: number;
|
|
35
|
+
readonly cacheWriteTokens: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Why generation stopped. `refusal` is a successful HTTP response whose content may be
|
|
40
|
+
* empty or partial — callers must branch on this BEFORE reading `text`.
|
|
41
|
+
*/
|
|
42
|
+
export type StopReason =
|
|
43
|
+
| 'end_turn'
|
|
44
|
+
| 'max_tokens'
|
|
45
|
+
| 'stop_sequence'
|
|
46
|
+
| 'tool_use'
|
|
47
|
+
| 'pause_turn'
|
|
48
|
+
| 'refusal';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Why a refusal happened. Only ever present when `stopReason` is `refusal`, and `category` is
|
|
52
|
+
* an open set — a closed union here would turn the provider adding a category into a parse
|
|
53
|
+
* failure. Carried rather than dropped because the category is the one thing that says whether
|
|
54
|
+
* a different model would answer, which is the only decision a caller can act on.
|
|
55
|
+
*/
|
|
56
|
+
export interface StopDetails {
|
|
57
|
+
readonly type: 'refusal';
|
|
58
|
+
readonly category: string | undefined;
|
|
59
|
+
readonly explanation: string | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GenerateResult {
|
|
63
|
+
readonly model: ModelId;
|
|
64
|
+
readonly text: string;
|
|
65
|
+
readonly toolCalls: readonly LlmToolCall[];
|
|
66
|
+
readonly stopReason: StopReason;
|
|
67
|
+
/** Required, not optional: a provider that forgets it turns a refusal into a silent empty answer. */
|
|
68
|
+
readonly stopDetails: StopDetails | undefined;
|
|
69
|
+
readonly usage: TokenUsage;
|
|
70
|
+
/** Cost of this call, computed from `usage` and the model's prices. */
|
|
71
|
+
readonly cost: Money;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One streamed increment. `done` carries the assembled result. */
|
|
75
|
+
export type StreamChunk =
|
|
76
|
+
| { readonly type: 'text'; readonly text: string }
|
|
77
|
+
| { readonly type: 'thinking'; readonly text: string }
|
|
78
|
+
| { readonly type: 'tool-call'; readonly call: LlmToolCall }
|
|
79
|
+
| { readonly type: 'done'; readonly result: GenerateResult };
|
|
80
|
+
|
|
81
|
+
export interface Provider {
|
|
82
|
+
readonly name: string;
|
|
83
|
+
/** Models this provider can serve. The gateway routes by membership. */
|
|
84
|
+
readonly models: readonly ModelId[];
|
|
85
|
+
generate(request: GenerateRequest): Promise<GenerateResult>;
|
|
86
|
+
stream(request: GenerateRequest): AsyncIterable<StreamChunk>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Cost of one call, in integer minor units. Rounded UP: a fraction of a cent that we round
|
|
91
|
+
* away is money the framework silently absorbs, and under-reporting spend defeats a budget.
|
|
92
|
+
*/
|
|
93
|
+
export function costOf(model: ModelId, usage: TokenUsage): Money {
|
|
94
|
+
const spec = MODELS[model];
|
|
95
|
+
// Cache reads are ~0.1x input; cache writes ~1.25x. Scaled by 10 to stay integral.
|
|
96
|
+
const inputUnits =
|
|
97
|
+
usage.inputTokens * 10 + usage.cacheReadTokens * 1 + Math.ceil(usage.cacheWriteTokens * 12.5);
|
|
98
|
+
const inputMinor = divideCeil(inputUnits * spec.inputPerMillion.minor, 10_000_000);
|
|
99
|
+
const outputMinor = divideCeil(usage.outputTokens * spec.outputPerMillion.minor, 1_000_000);
|
|
100
|
+
return { minor: inputMinor + outputMinor, currency: spec.inputPerMillion.currency };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function divideCeil(numerator: number, denominator: number): number {
|
|
104
|
+
return Math.ceil(numerator / denominator);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Total tokens a usage record accounts for — what a budget is debited by. */
|
|
108
|
+
export function totalTokens(usage: TokenUsage): number {
|
|
109
|
+
return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Anthropic ────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export interface AnthropicProviderInput {
|
|
115
|
+
/** Reads `ANTHROPIC_API_KEY` when omitted. Absent at call time is a labelled throw. */
|
|
116
|
+
readonly apiKey?: string;
|
|
117
|
+
readonly baseUrl?: string;
|
|
118
|
+
/** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
|
|
119
|
+
readonly fetch?: typeof fetch;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
123
|
+
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
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Above this ceiling a non-streaming request sits on an open socket past the HTTP timeout and
|
|
129
|
+
* fails AFTER the completion was generated and billed. Every current model can be asked for
|
|
130
|
+
* eight times this, so the limit is the transport's, not the model's.
|
|
131
|
+
*/
|
|
132
|
+
export const STREAM_ONLY_MAX_TOKENS = 16_000;
|
|
133
|
+
|
|
134
|
+
/** Whether this request has to go over the streaming transport to arrive at all. */
|
|
135
|
+
export function requiresStreaming(request: GenerateRequest): boolean {
|
|
136
|
+
const model = request.model ?? DEFAULT_MODEL;
|
|
137
|
+
return Math.min(request.maxTokens, MODELS[model].maxOutput) > STREAM_ONLY_MAX_TOKENS;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The real Messages API shape. The request-surface rules are encoded rather than documented,
|
|
142
|
+
* because getting them wrong is a 400:
|
|
143
|
+
* - `temperature`/`top_p`/`top_k` are REJECTED on every current model. Steer with the prompt.
|
|
144
|
+
* - `thinking.budget_tokens` is REJECTED. Use `output_config.effort`.
|
|
145
|
+
* - `effort` and adaptive thinking are per-model, and ./models owns which model takes which.
|
|
146
|
+
*/
|
|
147
|
+
export class AnthropicProvider implements Provider {
|
|
148
|
+
readonly name = 'anthropic';
|
|
149
|
+
readonly models = MODEL_IDS;
|
|
150
|
+
private readonly config: AnthropicProviderInput;
|
|
151
|
+
|
|
152
|
+
constructor(config: AnthropicProviderInput = {}) {
|
|
153
|
+
this.config = config;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Above `STREAM_ONLY_MAX_TOKENS` this runs the STREAMING transport and returns the assembled
|
|
158
|
+
* result, rather than refusing. Refusing would leak a transport limit into the API: `llm()`
|
|
159
|
+
* has no streaming path, so a declaration asking for a legal 64k completion would become
|
|
160
|
+
* undeclarable instead of merely awkward — and the caller wanting the whole answer at once is
|
|
161
|
+
* the same caller either way.
|
|
162
|
+
*/
|
|
163
|
+
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
164
|
+
if (requiresStreaming(request)) return this.assemble(request);
|
|
165
|
+
const response = await this.send({ ...this.body(request), stream: false });
|
|
166
|
+
const raw = (await response.json()) as Record<string, unknown>;
|
|
167
|
+
return parseMessage(request.model ?? DEFAULT_MODEL, raw);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Drive `stream()` to its `done` chunk. It throws on a cut stream, so a partial never lands. */
|
|
171
|
+
private async assemble(request: GenerateRequest): Promise<GenerateResult> {
|
|
172
|
+
for await (const chunk of this.stream(request)) {
|
|
173
|
+
if (chunk.type === 'done') return chunk.result;
|
|
174
|
+
}
|
|
175
|
+
throw new AiTransportError({
|
|
176
|
+
provider: this.name,
|
|
177
|
+
detail: 'the stream completed without a result',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Streaming is mandatory above ~16k `maxTokens`: a non-streaming request that large hits the
|
|
183
|
+
* HTTP timeout before the model finishes. The final `done` chunk carries the assembled
|
|
184
|
+
* result, so a consumer that only wants the answer can ignore every chunk before it.
|
|
185
|
+
*/
|
|
186
|
+
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
187
|
+
const model = request.model ?? DEFAULT_MODEL;
|
|
188
|
+
const response = await this.send({ ...this.body(request), stream: true });
|
|
189
|
+
if (response.body === null) {
|
|
190
|
+
throw new AiTransportError({
|
|
191
|
+
provider: this.name,
|
|
192
|
+
status: response.status,
|
|
193
|
+
detail: 'a streaming response arrived with no body',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const message = new MessageStream();
|
|
197
|
+
for await (const frame of readSse(response.body)) {
|
|
198
|
+
for (const chunk of message.push(frame)) yield chunk;
|
|
199
|
+
}
|
|
200
|
+
// A connection cut mid-answer must fail, not resolve: the partial text reads as a complete
|
|
201
|
+
// answer, and `end_turn` would be a lie the caller has no way to detect.
|
|
202
|
+
if (!message.isComplete()) {
|
|
203
|
+
throw new AiTransportError({
|
|
204
|
+
provider: this.name,
|
|
205
|
+
detail: 'the stream ended before message_stop — the answer is truncated',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const state = message.state();
|
|
209
|
+
yield {
|
|
210
|
+
type: 'done',
|
|
211
|
+
result: { model, ...state, cost: costOf(model, state.usage) },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The request body. Pure and side-effect free so a test can assert it directly. */
|
|
216
|
+
body(request: GenerateRequest): Record<string, unknown> {
|
|
217
|
+
const model = request.model ?? DEFAULT_MODEL;
|
|
218
|
+
const body: Record<string, unknown> = {
|
|
219
|
+
model,
|
|
220
|
+
max_tokens: Math.min(request.maxTokens, MODELS[model].maxOutput),
|
|
221
|
+
messages: request.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
222
|
+
...reasoningBody(model, request.effort, request.thinking),
|
|
223
|
+
};
|
|
224
|
+
if (request.system !== undefined) body['system'] = request.system;
|
|
225
|
+
if (request.tools !== undefined && request.tools.length > 0) body['tools'] = request.tools;
|
|
226
|
+
if (request.stopSequences !== undefined) body['stop_sequences'] = request.stopSequences;
|
|
227
|
+
return body;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The one place a request leaves the process. A non-2xx becomes an `AiTransportError`
|
|
232
|
+
* carrying its status, because the gateway decides whether to retry from that status and a
|
|
233
|
+
* body parsed as if it were a message would read as an empty, successful answer.
|
|
234
|
+
*/
|
|
235
|
+
private async send(body: Record<string, unknown>): Promise<Response> {
|
|
236
|
+
const apiKey = this.config.apiKey ?? Bun.env[API_KEY_ENV];
|
|
237
|
+
if (apiKey === undefined || apiKey === '') {
|
|
238
|
+
throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
|
|
239
|
+
}
|
|
240
|
+
const doFetch = this.config.fetch ?? fetch;
|
|
241
|
+
const url = `${this.config.baseUrl ?? 'https://api.anthropic.com'}/v1/messages`;
|
|
242
|
+
const response = await doFetch(url, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: {
|
|
245
|
+
'x-api-key': apiKey,
|
|
246
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
247
|
+
'content-type': 'application/json',
|
|
248
|
+
accept: body['stream'] === true ? 'text/event-stream' : 'application/json',
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify(body),
|
|
251
|
+
});
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
throw new AiTransportError({
|
|
254
|
+
provider: this.name,
|
|
255
|
+
status: response.status,
|
|
256
|
+
detail: await detailOf(response),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return response;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
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
|
+
/** Map a Messages API response onto `GenerateResult`. Exported so tests can drive it. */
|
|
282
|
+
export function parseMessage(model: ModelId, raw: Record<string, unknown>): GenerateResult {
|
|
283
|
+
const content = Array.isArray(raw['content']) ? raw['content'] : [];
|
|
284
|
+
let text = '';
|
|
285
|
+
const toolCalls: LlmToolCall[] = [];
|
|
286
|
+
for (const block of content) {
|
|
287
|
+
if (typeof block !== 'object' || block === null) continue;
|
|
288
|
+
const b = block as Record<string, unknown>;
|
|
289
|
+
if (b['type'] === 'text' && typeof b['text'] === 'string') text += b['text'];
|
|
290
|
+
if (b['type'] === 'tool_use' && typeof b['id'] === 'string' && typeof b['name'] === 'string') {
|
|
291
|
+
toolCalls.push({
|
|
292
|
+
id: b['id'],
|
|
293
|
+
name: b['name'],
|
|
294
|
+
input: (b['input'] ?? {}) as Record<string, unknown>,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const usage = parseUsage(raw['usage']);
|
|
299
|
+
return {
|
|
300
|
+
model,
|
|
301
|
+
text,
|
|
302
|
+
toolCalls,
|
|
303
|
+
stopReason: parseStopReason(raw['stop_reason']),
|
|
304
|
+
stopDetails: parseStopDetails(raw['stop_details']),
|
|
305
|
+
usage,
|
|
306
|
+
cost: costOf(model, usage),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Echo ─────────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
export interface EchoProviderInput {
|
|
313
|
+
/** Fixed replies keyed by the last user message, for eval fixtures. */
|
|
314
|
+
readonly replies?: Readonly<Record<string, string>>;
|
|
315
|
+
/** Fallback when no key matches. Defaults to echoing the last user message. */
|
|
316
|
+
readonly fallback?: (prompt: string) => string;
|
|
317
|
+
readonly tokensPerCall?: number;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Deterministic provider. Same input, same output, same usage — which is what makes an eval
|
|
322
|
+
* suite a test rather than a sample. Token counts are derived from length, so a budget test
|
|
323
|
+
* can assert a refusal without a network.
|
|
324
|
+
*/
|
|
325
|
+
export class EchoProvider implements Provider {
|
|
326
|
+
readonly name = 'echo';
|
|
327
|
+
readonly models = MODEL_IDS;
|
|
328
|
+
private readonly config: EchoProviderInput;
|
|
329
|
+
|
|
330
|
+
constructor(config: EchoProviderInput = {}) {
|
|
331
|
+
this.config = config;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
335
|
+
const model = request.model ?? DEFAULT_MODEL;
|
|
336
|
+
const prompt = lastUserMessage(request.messages);
|
|
337
|
+
const text = this.config.replies?.[prompt] ?? this.config.fallback?.(prompt) ?? prompt;
|
|
338
|
+
const usage: TokenUsage = {
|
|
339
|
+
inputTokens: this.config.tokensPerCall ?? estimateTokens(request),
|
|
340
|
+
outputTokens: estimateTextTokens(text),
|
|
341
|
+
cacheReadTokens: 0,
|
|
342
|
+
cacheWriteTokens: 0,
|
|
343
|
+
};
|
|
344
|
+
return {
|
|
345
|
+
model,
|
|
346
|
+
text,
|
|
347
|
+
toolCalls: [],
|
|
348
|
+
stopReason: 'end_turn',
|
|
349
|
+
stopDetails: undefined,
|
|
350
|
+
usage,
|
|
351
|
+
cost: costOf(model, usage),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
356
|
+
const result = await this.generate(request);
|
|
357
|
+
// One word per chunk: enough to exercise a consumer's assembly logic.
|
|
358
|
+
for (const word of result.text.split(' ')) {
|
|
359
|
+
if (word !== '') yield { type: 'text', text: `${word} ` };
|
|
360
|
+
}
|
|
361
|
+
yield { type: 'done', result };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function lastUserMessage(messages: readonly AiMessage[]): string {
|
|
366
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
367
|
+
const message = messages[i];
|
|
368
|
+
if (message !== undefined && message.role === 'user') return message.content;
|
|
369
|
+
}
|
|
370
|
+
return '';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* ~4 characters per token. Deliberately an ESTIMATE and never used for billing — the
|
|
375
|
+
* gateway's pre-flight budget check needs a number before the call exists, and the real
|
|
376
|
+
* count from `usage` replaces it afterwards.
|
|
377
|
+
*/
|
|
378
|
+
export function estimateTokens(request: GenerateRequest): number {
|
|
379
|
+
return estimateInputTokens(request) + request.maxTokens;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The prompt half alone — what the provider bills at the input rate. */
|
|
383
|
+
export function estimateInputTokens(request: GenerateRequest): number {
|
|
384
|
+
const body = request.messages.map((m) => m.content).join(' ');
|
|
385
|
+
return estimateTextTokens(body) + estimateTextTokens(request.system ?? '');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Worst-case price of a request before it exists: the prompt at the input rate, the FULL
|
|
390
|
+
* `maxTokens` at the output rate. Deliberately pessimistic — a ceiling checked against an
|
|
391
|
+
* optimistic estimate is a ceiling one long completion walks through.
|
|
392
|
+
*/
|
|
393
|
+
export function estimateCost(request: GenerateRequest): Money {
|
|
394
|
+
const model = request.model ?? DEFAULT_MODEL;
|
|
395
|
+
return costOf(model, {
|
|
396
|
+
inputTokens: estimateInputTokens(request),
|
|
397
|
+
outputTokens: Math.min(request.maxTokens, MODELS[model].maxOutput),
|
|
398
|
+
cacheReadTokens: 0,
|
|
399
|
+
cacheWriteTokens: 0,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function estimateTextTokens(text: string): number {
|
|
404
|
+
return Math.ceil(text.length / 4);
|
|
405
|
+
}
|
package/src/rag.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// The retrieval pipeline: chunk → embed → retrieve → rerank → assemble under a token budget.
|
|
2
|
+
//
|
|
3
|
+
// The budget is the point. Retrieval that returns "the top 20 chunks" and lets the caller
|
|
4
|
+
// discover at request time that they don't fit is a truncation bug waiting to happen; the
|
|
5
|
+
// assembler fills to a declared ceiling and reports what it dropped.
|
|
6
|
+
|
|
7
|
+
import type { Embedder } from './embeddings';
|
|
8
|
+
import { embedBatched, embedOne } from './embeddings';
|
|
9
|
+
import { estimateTextTokens as estimateChunkTokens } from './provider';
|
|
10
|
+
import type { SearchHit, VectorStore } from './vector';
|
|
11
|
+
|
|
12
|
+
export interface Chunk {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly text: string;
|
|
15
|
+
readonly tokens: number;
|
|
16
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ChunkInput {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly text: string;
|
|
22
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
23
|
+
/** Target chunk size in tokens. */
|
|
24
|
+
readonly size?: number;
|
|
25
|
+
/** Overlap in tokens. Without it a fact split across a boundary is retrievable by neither. */
|
|
26
|
+
readonly overlap?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Token-aware chunker. Splits on paragraph, then sentence, then hard-wraps — so a chunk
|
|
31
|
+
* boundary lands at a meaning boundary whenever one is available within the budget.
|
|
32
|
+
*/
|
|
33
|
+
export function chunk(input: ChunkInput): readonly Chunk[] {
|
|
34
|
+
const size = input.size ?? 512;
|
|
35
|
+
const overlap = Math.min(input.overlap ?? 64, size - 1);
|
|
36
|
+
const units = splitUnits(input.text);
|
|
37
|
+
const chunks: Chunk[] = [];
|
|
38
|
+
let buffer: string[] = [];
|
|
39
|
+
let tokens = 0;
|
|
40
|
+
|
|
41
|
+
const flush = (): void => {
|
|
42
|
+
if (buffer.length === 0) return;
|
|
43
|
+
const text = buffer.join(' ').trim();
|
|
44
|
+
if (text !== '') {
|
|
45
|
+
chunks.push({
|
|
46
|
+
id: `${input.id}#${chunks.length}`,
|
|
47
|
+
text,
|
|
48
|
+
tokens: estimateChunkTokens(text),
|
|
49
|
+
metadata: { source: input.id, ...(input.metadata ?? {}) },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// Carry the tail forward as the overlap for the next chunk.
|
|
53
|
+
const carried: string[] = [];
|
|
54
|
+
let carriedTokens = 0;
|
|
55
|
+
for (let i = buffer.length - 1; i >= 0 && carriedTokens < overlap; i -= 1) {
|
|
56
|
+
const unit = buffer[i] ?? '';
|
|
57
|
+
carried.unshift(unit);
|
|
58
|
+
carriedTokens += estimateChunkTokens(unit);
|
|
59
|
+
}
|
|
60
|
+
buffer = carried;
|
|
61
|
+
tokens = carriedTokens;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (const unit of units) {
|
|
65
|
+
const unitTokens = estimateChunkTokens(unit);
|
|
66
|
+
if (tokens + unitTokens > size && buffer.length > 0) flush();
|
|
67
|
+
buffer.push(unit);
|
|
68
|
+
tokens += unitTokens;
|
|
69
|
+
}
|
|
70
|
+
flush();
|
|
71
|
+
// The final flush leaves the overlap tail in the buffer; it is already covered.
|
|
72
|
+
return chunks;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function splitUnits(text: string): readonly string[] {
|
|
76
|
+
const units: string[] = [];
|
|
77
|
+
for (const paragraph of text.split(/\n{2,}/)) {
|
|
78
|
+
const trimmed = paragraph.trim();
|
|
79
|
+
if (trimmed === '') continue;
|
|
80
|
+
// Sentence split that keeps the terminator attached.
|
|
81
|
+
const sentences = trimmed.match(/[^.!?]+[.!?]*\s*/g) ?? [trimmed];
|
|
82
|
+
for (const sentence of sentences) {
|
|
83
|
+
const s = sentence.trim();
|
|
84
|
+
if (s !== '') units.push(s);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return units;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Index a document: chunk, embed, upsert. One call so no step is skipped by accident. */
|
|
91
|
+
export async function indexDocument(input: {
|
|
92
|
+
readonly store: VectorStore;
|
|
93
|
+
readonly embedder: Embedder;
|
|
94
|
+
readonly document: ChunkInput;
|
|
95
|
+
}): Promise<readonly Chunk[]> {
|
|
96
|
+
const chunks = chunk(input.document);
|
|
97
|
+
const vectors = await embedBatched(
|
|
98
|
+
input.embedder,
|
|
99
|
+
chunks.map((c) => c.text),
|
|
100
|
+
);
|
|
101
|
+
await input.store.upsert(
|
|
102
|
+
chunks.map((c, index) => ({
|
|
103
|
+
id: c.id,
|
|
104
|
+
vector: vectors[index] as Float32Array,
|
|
105
|
+
text: c.text,
|
|
106
|
+
metadata: c.metadata,
|
|
107
|
+
})),
|
|
108
|
+
);
|
|
109
|
+
return chunks;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A reranker reorders candidates with a stronger (and slower) signal than retrieval. */
|
|
113
|
+
export interface Reranker {
|
|
114
|
+
readonly name: string;
|
|
115
|
+
rerank(input: {
|
|
116
|
+
readonly query: string;
|
|
117
|
+
readonly hits: readonly SearchHit[];
|
|
118
|
+
readonly k: number;
|
|
119
|
+
}): Promise<readonly SearchHit[]>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Identity reranker — the honest default when no cross-encoder is configured. */
|
|
123
|
+
export const passthroughReranker: Reranker = {
|
|
124
|
+
name: 'passthrough',
|
|
125
|
+
rerank: async ({ hits, k }) => hits.slice(0, k),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export interface RetrieveInput {
|
|
129
|
+
readonly store: VectorStore;
|
|
130
|
+
readonly embedder: Embedder;
|
|
131
|
+
readonly query: string;
|
|
132
|
+
readonly k?: number;
|
|
133
|
+
readonly filter?: Readonly<Record<string, string>>;
|
|
134
|
+
readonly reranker?: Reranker;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Hybrid retrieval then rerank. Hybrid by default because pure vector loses on exact terms. */
|
|
138
|
+
export async function retrieve(input: RetrieveInput): Promise<readonly SearchHit[]> {
|
|
139
|
+
const k = input.k ?? 8;
|
|
140
|
+
const vector = await embedOne(input.embedder, input.query);
|
|
141
|
+
const hits = await input.store.hybrid({
|
|
142
|
+
query: input.query,
|
|
143
|
+
vector,
|
|
144
|
+
k: k * 3,
|
|
145
|
+
...(input.filter !== undefined ? { filter: input.filter } : {}),
|
|
146
|
+
});
|
|
147
|
+
const reranker = input.reranker ?? passthroughReranker;
|
|
148
|
+
return reranker.rerank({ query: input.query, hits, k });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface AssembledContext {
|
|
152
|
+
readonly text: string;
|
|
153
|
+
readonly tokens: number;
|
|
154
|
+
readonly used: readonly string[];
|
|
155
|
+
/** Ids that did not fit. Reported, never silently dropped. */
|
|
156
|
+
readonly dropped: readonly string[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Fill a context window in rank order until the budget is reached. Skips oversized hits
|
|
161
|
+
* rather than stopping, so one long chunk does not starve every shorter one behind it.
|
|
162
|
+
*/
|
|
163
|
+
export function assembleContext(input: {
|
|
164
|
+
readonly hits: readonly SearchHit[];
|
|
165
|
+
readonly maxTokens: number;
|
|
166
|
+
readonly separator?: string;
|
|
167
|
+
}): AssembledContext {
|
|
168
|
+
const separator = input.separator ?? '\n\n---\n\n';
|
|
169
|
+
const separatorTokens = estimateChunkTokens(separator);
|
|
170
|
+
const parts: string[] = [];
|
|
171
|
+
const used: string[] = [];
|
|
172
|
+
const dropped: string[] = [];
|
|
173
|
+
let tokens = 0;
|
|
174
|
+
|
|
175
|
+
for (const hit of input.hits) {
|
|
176
|
+
const cost = estimateChunkTokens(hit.text) + (parts.length === 0 ? 0 : separatorTokens);
|
|
177
|
+
if (tokens + cost > input.maxTokens) {
|
|
178
|
+
dropped.push(hit.id);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
parts.push(hit.text);
|
|
182
|
+
used.push(hit.id);
|
|
183
|
+
tokens += cost;
|
|
184
|
+
}
|
|
185
|
+
return { text: parts.join(separator), tokens, used, dropped };
|
|
186
|
+
}
|