@ultimat3/ai 1.2.0 → 2.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 +363 -0
- package/README.md +229 -6
- package/package.json +10 -9
- package/src/agent.ts +287 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +142 -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 +40 -10
- package/src/index.ts +45 -8
- 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 +260 -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 +94 -35
- 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 +13 -4
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Single responsibility: reading what an OpenAI-format endpoint sends back — one chat completion,
|
|
2
|
+
// and the SSE stream of the same answer arriving in pieces.
|
|
3
|
+
//
|
|
4
|
+
// Split from the provider for the reason wire.ts is: the assembler can then be driven frame by
|
|
5
|
+
// frame with no socket, which is the only way to cover a stream that arrives fragmented, out of
|
|
6
|
+
// order, or stops half way. An LLM response is untrusted input, so every field is parsed and
|
|
7
|
+
// nothing is cast.
|
|
8
|
+
|
|
9
|
+
import { AiTransportError } from './errors';
|
|
10
|
+
import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
|
|
11
|
+
import type { SseFrame } from './sse';
|
|
12
|
+
import type { LlmToolCall } from './tools';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What one answer amounts to. `usage` is optional and that is the format's fault, not a laxity:
|
|
16
|
+
* a streamed answer reports usage only in a final chunk, and only when the request asked for it.
|
|
17
|
+
* The provider decides what to do with an absent one — never this file, which reports what arrived.
|
|
18
|
+
*/
|
|
19
|
+
export interface ChatAnswer {
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly toolCalls: readonly LlmToolCall[];
|
|
22
|
+
readonly stopReason: StopReason;
|
|
23
|
+
readonly stopDetails: StopDetails | undefined;
|
|
24
|
+
readonly usage: TokenUsage | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FINISH_REASONS: Readonly<Record<string, StopReason>> = {
|
|
28
|
+
stop: 'end_turn',
|
|
29
|
+
length: 'max_tokens',
|
|
30
|
+
tool_calls: 'tool_use',
|
|
31
|
+
// The legacy name for the same event; LiteLLM and older self-hosted servers still send it.
|
|
32
|
+
function_call: 'tool_use',
|
|
33
|
+
content_filter: 'refusal',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* In-band error frames carry a type, not a status, and the gateway's retry rule reads a status.
|
|
38
|
+
* Same mapping job as wire.ts's, over this format's own vocabulary.
|
|
39
|
+
*/
|
|
40
|
+
const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
41
|
+
invalid_request_error: 400,
|
|
42
|
+
authentication_error: 401,
|
|
43
|
+
permission_error: 403,
|
|
44
|
+
not_found_error: 404,
|
|
45
|
+
rate_limit_exceeded: 429,
|
|
46
|
+
insufficient_quota: 429,
|
|
47
|
+
server_error: 500,
|
|
48
|
+
api_error: 500,
|
|
49
|
+
overloaded_error: 503,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A finish reason this format knows, or `undefined` for `null` — which means "still going". */
|
|
53
|
+
export function parseFinishReason(raw: unknown): StopReason | undefined {
|
|
54
|
+
return typeof raw === 'string' ? FINISH_REASONS[raw] : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `usage`, or `undefined` when the payload carried none.
|
|
59
|
+
*
|
|
60
|
+
* `prompt_tokens` INCLUDES the cached prefix on this wire, where Anthropic's `input_tokens`
|
|
61
|
+
* excludes it — so the cached half is subtracted out before it is reported as `cacheReadTokens`.
|
|
62
|
+
* Left in place it would be billed twice: once at the full input rate and again at the cache rate.
|
|
63
|
+
*/
|
|
64
|
+
export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
|
|
65
|
+
const record = asRecord(raw);
|
|
66
|
+
if (record === undefined) return undefined;
|
|
67
|
+
const prompt = numberOf(record['prompt_tokens']);
|
|
68
|
+
const completion = numberOf(record['completion_tokens']);
|
|
69
|
+
if (prompt === undefined && completion === undefined) return undefined;
|
|
70
|
+
const cached = numberOf(asRecord(record['prompt_tokens_details'])?.['cached_tokens']) ?? 0;
|
|
71
|
+
return {
|
|
72
|
+
inputTokens: Math.max((prompt ?? 0) - cached, 0),
|
|
73
|
+
// `completion_tokens` already contains `reasoning_tokens`; adding them is a double count.
|
|
74
|
+
outputTokens: completion ?? 0,
|
|
75
|
+
cacheReadTokens: cached,
|
|
76
|
+
// Caching is automatic here and carries no write surcharge, so there is nothing to report.
|
|
77
|
+
cacheWriteTokens: 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One non-streamed chat completion. Refusal is read BEFORE anything else trusts the content. */
|
|
82
|
+
export function parseChatCompletion(raw: unknown, provider: string): ChatAnswer {
|
|
83
|
+
const record = asRecord(raw);
|
|
84
|
+
if (record === undefined) throw malformed(provider, 'the response body is not a JSON object');
|
|
85
|
+
throwInBandError(record, provider);
|
|
86
|
+
const choice = asRecord(Array.isArray(record['choices']) ? record['choices'][0] : undefined);
|
|
87
|
+
if (choice === undefined) throw malformed(provider, 'the response carried no choices');
|
|
88
|
+
const message = asRecord(choice['message']) ?? {};
|
|
89
|
+
const refusal = typeof message['refusal'] === 'string' ? message['refusal'] : undefined;
|
|
90
|
+
const finish = parseFinishReason(choice['finish_reason']);
|
|
91
|
+
return {
|
|
92
|
+
text: typeof message['content'] === 'string' ? message['content'] : '',
|
|
93
|
+
toolCalls: parseToolCalls(message['tool_calls'], provider),
|
|
94
|
+
// A refusal string is a refusal whatever the finish reason says: the field only ever appears
|
|
95
|
+
// when the model declined, and `stop` beside it would read downstream as an empty answer.
|
|
96
|
+
stopReason: refusal === undefined ? (finish ?? 'end_turn') : 'refusal',
|
|
97
|
+
stopDetails: refusalDetails(finish, refusal),
|
|
98
|
+
usage: parseOpenAiUsage(record['usage']),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function refusalDetails(
|
|
103
|
+
finish: StopReason | undefined,
|
|
104
|
+
refusal: string | undefined,
|
|
105
|
+
): StopDetails | undefined {
|
|
106
|
+
if (refusal !== undefined) return { type: 'refusal', category: 'refusal', explanation: refusal };
|
|
107
|
+
if (finish !== 'refusal') return undefined;
|
|
108
|
+
// `content_filter` is all the endpoint says. Carried as the category rather than dropped: it is
|
|
109
|
+
// the only thing that distinguishes a policy stop from a model that chose not to answer.
|
|
110
|
+
return { type: 'refusal', category: 'content_filter', explanation: undefined };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseToolCalls(raw: unknown, provider: string): readonly LlmToolCall[] {
|
|
114
|
+
if (!Array.isArray(raw)) return [];
|
|
115
|
+
const calls: LlmToolCall[] = [];
|
|
116
|
+
for (const entry of raw) {
|
|
117
|
+
const record = asRecord(entry);
|
|
118
|
+
const fn = asRecord(record?.['function']);
|
|
119
|
+
if (record === undefined || fn === undefined) continue;
|
|
120
|
+
const name = typeof fn['name'] === 'string' ? fn['name'] : '';
|
|
121
|
+
if (name === '') continue;
|
|
122
|
+
calls.push({
|
|
123
|
+
id: typeof record['id'] === 'string' ? record['id'] : '',
|
|
124
|
+
name,
|
|
125
|
+
input: parseArguments(fn['arguments'], name, provider),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return calls;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Arguments are a JSON string. A tool that takes none sends `''` or `'{}'`; neither is a fault. */
|
|
132
|
+
function parseArguments(raw: unknown, name: string, provider: string): Record<string, unknown> {
|
|
133
|
+
if (typeof raw !== 'string' || raw.trim() === '') return {};
|
|
134
|
+
try {
|
|
135
|
+
return asRecord(JSON.parse(raw)) ?? {};
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw malformed(
|
|
138
|
+
provider,
|
|
139
|
+
`tool "${name}" returned arguments that are not JSON: ${
|
|
140
|
+
error instanceof Error ? error.message : 'unreadable'
|
|
141
|
+
}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface PendingCall {
|
|
147
|
+
id: string;
|
|
148
|
+
name: string;
|
|
149
|
+
args: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One streamed chat completion, assembled frame by frame.
|
|
154
|
+
*
|
|
155
|
+
* Two things this format does that the Anthropic one does not, and both are easy to get subtly
|
|
156
|
+
* wrong: a tool call arrives FRAGMENTED and INDEXED — its id, its name and successive slices of its
|
|
157
|
+
* arguments spread across chunks, keyed only by `tool_calls[].index` — and `usage` arrives once, in
|
|
158
|
+
* a trailing chunk whose `choices` array is empty, long after the finish reason.
|
|
159
|
+
*/
|
|
160
|
+
export class ChatCompletionStream {
|
|
161
|
+
private text = '';
|
|
162
|
+
private refusal = '';
|
|
163
|
+
private stopReason: StopReason = 'end_turn';
|
|
164
|
+
private finished = false;
|
|
165
|
+
private done = false;
|
|
166
|
+
private usage: TokenUsage | undefined;
|
|
167
|
+
private readonly pending = new Map<number, PendingCall>();
|
|
168
|
+
private readonly toolCalls: LlmToolCall[] = [];
|
|
169
|
+
private readonly provider: string;
|
|
170
|
+
|
|
171
|
+
constructor(provider: string) {
|
|
172
|
+
this.provider = provider;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Chunks this frame produced, in order. An unknown field yields nothing — the format grows. */
|
|
176
|
+
push(frame: SseFrame): readonly StreamChunk[] {
|
|
177
|
+
// The sentinel is not JSON, and parsing it is how a stream reader ends in a syntax error.
|
|
178
|
+
if (frame.data.trim() === '[DONE]') {
|
|
179
|
+
this.done = true;
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
const payload = this.payloadOf(frame);
|
|
183
|
+
throwInBandError(payload, this.provider);
|
|
184
|
+
const usage = parseOpenAiUsage(payload['usage']);
|
|
185
|
+
if (usage !== undefined) this.usage = usage;
|
|
186
|
+
const choice = asRecord(Array.isArray(payload['choices']) ? payload['choices'][0] : undefined);
|
|
187
|
+
if (choice === undefined) return [];
|
|
188
|
+
const chunks = this.onDelta(asRecord(choice['delta']) ?? {});
|
|
189
|
+
return [...chunks, ...this.onFinish(choice['finish_reason'])];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** True once the answer is accounted for. False means the connection died mid-answer. */
|
|
193
|
+
isComplete(): boolean {
|
|
194
|
+
// Either sentinel counts. `[DONE]` is the format's own end marker, but plenty of servers in
|
|
195
|
+
// the family close the socket straight after the finish-reason chunk — and a finish reason IS
|
|
196
|
+
// the model saying why it stopped, which is the fact a truncated stream cannot produce.
|
|
197
|
+
return this.done || this.finished;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What the stream accumulated. `cost` is applied by the provider, which owns prices. */
|
|
201
|
+
state(): ChatAnswer {
|
|
202
|
+
const details = this.refusalDetails();
|
|
203
|
+
return {
|
|
204
|
+
text: this.text,
|
|
205
|
+
toolCalls: [...this.toolCalls],
|
|
206
|
+
// A refusal that arrived as a `refusal` delta finishes with `stop`, so the reason alone would
|
|
207
|
+
// read as a complete answer that happens to be empty.
|
|
208
|
+
stopReason: details === undefined ? this.stopReason : 'refusal',
|
|
209
|
+
stopDetails: details,
|
|
210
|
+
usage: this.usage,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private refusalDetails(): StopDetails | undefined {
|
|
215
|
+
if (this.refusal !== '') {
|
|
216
|
+
return { type: 'refusal', category: 'refusal', explanation: this.refusal };
|
|
217
|
+
}
|
|
218
|
+
return this.stopReason === 'refusal'
|
|
219
|
+
? { type: 'refusal', category: 'content_filter', explanation: undefined }
|
|
220
|
+
: undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private onDelta(delta: Record<string, unknown>): readonly StreamChunk[] {
|
|
224
|
+
const chunks: StreamChunk[] = [];
|
|
225
|
+
const content = delta['content'];
|
|
226
|
+
if (typeof content === 'string' && content !== '') {
|
|
227
|
+
this.text += content;
|
|
228
|
+
chunks.push({ type: 'text', text: content });
|
|
229
|
+
}
|
|
230
|
+
// `reasoning_content` is vLLM's and DeepSeek's; `reasoning` is the OpenRouter spelling. Neither
|
|
231
|
+
// is ever appended to `text`, for the reason thinking deltas are not: a consumer concatenating
|
|
232
|
+
// every chunk must not end up shipping the reasoning to the user.
|
|
233
|
+
const thinking = delta['reasoning_content'] ?? delta['reasoning'];
|
|
234
|
+
if (typeof thinking === 'string' && thinking !== '') {
|
|
235
|
+
chunks.push({ type: 'thinking', text: thinking });
|
|
236
|
+
}
|
|
237
|
+
const refusal = delta['refusal'];
|
|
238
|
+
if (typeof refusal === 'string') this.refusal += refusal;
|
|
239
|
+
this.accumulate(delta['tool_calls']);
|
|
240
|
+
return chunks;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Merge one chunk's tool-call fragments into the calls they belong to. `index` is the only key —
|
|
245
|
+
* `id` and `name` arrive on the first fragment and are absent from every later one, so appending
|
|
246
|
+
* by array position instead would build one call per chunk and lose every argument but the last.
|
|
247
|
+
*/
|
|
248
|
+
private accumulate(raw: unknown): void {
|
|
249
|
+
if (!Array.isArray(raw)) return;
|
|
250
|
+
for (const entry of raw) {
|
|
251
|
+
const record = asRecord(entry);
|
|
252
|
+
if (record === undefined) continue;
|
|
253
|
+
const index = numberOf(record['index']) ?? 0;
|
|
254
|
+
const call = this.pending.get(index) ?? { id: '', name: '', args: '' };
|
|
255
|
+
const id = record['id'];
|
|
256
|
+
if (typeof id === 'string' && id !== '') call.id = id;
|
|
257
|
+
const fn = asRecord(record['function']);
|
|
258
|
+
const name = fn?.['name'];
|
|
259
|
+
// Concatenated, not assigned: a server that splits the name across two frames is rare and
|
|
260
|
+
// legal, and assigning would keep only the tail.
|
|
261
|
+
if (typeof name === 'string') call.name += name;
|
|
262
|
+
const args = fn?.['arguments'];
|
|
263
|
+
if (typeof args === 'string') call.args += args;
|
|
264
|
+
this.pending.set(index, call);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The finish reason closes the turn, and it is the only close this format has: there is no
|
|
270
|
+
* per-block stop event, so pending tool calls are emitted here — whole, in index order, exactly
|
|
271
|
+
* as the Anthropic path emits them at `content_block_stop`. A fragment is never a call.
|
|
272
|
+
*/
|
|
273
|
+
private onFinish(raw: unknown): readonly StreamChunk[] {
|
|
274
|
+
const reason = parseFinishReason(raw);
|
|
275
|
+
if (reason === undefined) return [];
|
|
276
|
+
this.stopReason = reason;
|
|
277
|
+
this.finished = true;
|
|
278
|
+
const chunks: StreamChunk[] = [];
|
|
279
|
+
for (const index of [...this.pending.keys()].sort((a, b) => a - b)) {
|
|
280
|
+
const pending = this.pending.get(index);
|
|
281
|
+
if (pending === undefined || pending.name === '') continue;
|
|
282
|
+
const call: LlmToolCall = {
|
|
283
|
+
id: pending.id,
|
|
284
|
+
name: pending.name,
|
|
285
|
+
input: parseArguments(pending.args, pending.name, this.provider),
|
|
286
|
+
};
|
|
287
|
+
this.toolCalls.push(call);
|
|
288
|
+
chunks.push({ type: 'tool-call', call });
|
|
289
|
+
}
|
|
290
|
+
this.pending.clear();
|
|
291
|
+
return chunks;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private payloadOf(frame: SseFrame): Record<string, unknown> {
|
|
295
|
+
try {
|
|
296
|
+
const record = asRecord(JSON.parse(frame.data));
|
|
297
|
+
if (record === undefined) throw new SyntaxError('frame data is not an object');
|
|
298
|
+
return record;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
throw malformed(
|
|
301
|
+
this.provider,
|
|
302
|
+
`unreadable "${frame.event}" frame: ${
|
|
303
|
+
error instanceof Error ? error.message : 'unreadable'
|
|
304
|
+
}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A 200 that carries an `error` object instead of an answer — how a gateway in front of a model
|
|
312
|
+
* reports a fault it noticed after the headers were sent. Parsed as a message it would read as an
|
|
313
|
+
* empty, successful answer, which is the one outcome nothing downstream can detect.
|
|
314
|
+
*/
|
|
315
|
+
function throwInBandError(payload: Record<string, unknown>, provider: string): void {
|
|
316
|
+
const error = asRecord(payload['error']);
|
|
317
|
+
if (error === undefined) return;
|
|
318
|
+
const type = typeof error['type'] === 'string' ? error['type'] : 'api_error';
|
|
319
|
+
const code = typeof error['code'] === 'string' ? error['code'] : undefined;
|
|
320
|
+
const message = typeof error['message'] === 'string' ? error['message'] : type;
|
|
321
|
+
throw new AiTransportError({
|
|
322
|
+
provider,
|
|
323
|
+
status: ERROR_STATUS[type] ?? (code === undefined ? undefined : ERROR_STATUS[code]) ?? 500,
|
|
324
|
+
detail: message,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function malformed(provider: string, detail: string): AiTransportError {
|
|
329
|
+
return new AiTransportError({ provider, detail });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
333
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
334
|
+
return value as Record<string, unknown>;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function numberOf(value: unknown): number | undefined {
|
|
338
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
339
|
+
}
|
package/src/pg-vector-sql.ts
CHANGED
|
@@ -45,7 +45,11 @@ export function conditionsSql(scope: VectorScope, filter?: MetadataFilter): SqlF
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
48
|
+
* The store's whole schema as one string, for an app to split across migration files. **No
|
|
49
|
+
* command emits it** — `x db gen` diffs `describeEntities()` and a vector store is not an
|
|
50
|
+
* `entity()`, so no CLI file references this at all.
|
|
51
|
+
*
|
|
52
|
+
* The primary key is `(tenant, id)`, not `id`: it makes a cross-tenant
|
|
49
53
|
* overwrite impossible at the storage layer instead of relying on every upsert remembering to
|
|
50
54
|
* check. An unscoped store writes the empty tenant, which is a tenant like any other.
|
|
51
55
|
*/
|
package/src/pg-vector.ts
CHANGED
|
@@ -63,7 +63,8 @@ export class PgVectorStore implements VectorStore {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/**
|
|
66
|
+
/** An app pastes this into migrations — no command emits it. Beside the queries so the index
|
|
67
|
+
* choice is reviewable against the reads that depend on it. */
|
|
67
68
|
ddl(): string {
|
|
68
69
|
return ddlSql(this.target);
|
|
69
70
|
}
|
package/src/prompt.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface DefinePromptInput<V extends PromptVars> {
|
|
|
24
24
|
readonly template: string;
|
|
25
25
|
/** Optional system prompt. Part of the hash — it changes behaviour. */
|
|
26
26
|
readonly system?: string;
|
|
27
|
-
/** Schema of the variables, for the manifest
|
|
27
|
+
/** Schema of the variables, for the manifest. */
|
|
28
28
|
readonly input?: JsonSchema;
|
|
29
29
|
/** Expected output shape, fed to `output_config.format` when the caller opts in. */
|
|
30
30
|
readonly output?: JsonSchema;
|
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 {
|
|
@@ -61,6 +108,14 @@ export interface StopDetails {
|
|
|
61
108
|
|
|
62
109
|
export interface GenerateResult {
|
|
63
110
|
readonly model: ModelId;
|
|
111
|
+
/**
|
|
112
|
+
* Which provider actually answered. Stamped by the GATEWAY, not by the provider: falling back
|
|
113
|
+
* from one provider to the next is a gateway concept, and a `Provider` implementation an app
|
|
114
|
+
* wrote cannot be asked to report on a decision it did not make. Optional for exactly that
|
|
115
|
+
* reason — a provider's own result has not been through the router yet. `llm()` puts it on the
|
|
116
|
+
* span as `llm.provider`, which is what makes a fallback visible rather than silent.
|
|
117
|
+
*/
|
|
118
|
+
readonly provider?: string;
|
|
64
119
|
readonly text: string;
|
|
65
120
|
readonly toolCalls: readonly LlmToolCall[];
|
|
66
121
|
readonly stopReason: StopReason;
|
|
@@ -91,7 +146,7 @@ export interface Provider {
|
|
|
91
146
|
* away is money the framework silently absorbs, and under-reporting spend defeats a budget.
|
|
92
147
|
*/
|
|
93
148
|
export function costOf(model: ModelId, usage: TokenUsage): Money {
|
|
94
|
-
const spec =
|
|
149
|
+
const spec = modelSpec(model);
|
|
95
150
|
// Cache reads are ~0.1x input; cache writes ~1.25x. Scaled by 10 to stay integral.
|
|
96
151
|
const inputUnits =
|
|
97
152
|
usage.inputTokens * 10 + usage.cacheReadTokens * 1 + Math.ceil(usage.cacheWriteTokens * 12.5);
|
|
@@ -121,8 +176,6 @@ export interface AnthropicProviderInput {
|
|
|
121
176
|
|
|
122
177
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
123
178
|
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
179
|
|
|
127
180
|
/**
|
|
128
181
|
* Above this ceiling a non-streaming request sits on an open socket past the HTTP timeout and
|
|
@@ -134,7 +187,7 @@ export const STREAM_ONLY_MAX_TOKENS = 16_000;
|
|
|
134
187
|
/** Whether this request has to go over the streaming transport to arrive at all. */
|
|
135
188
|
export function requiresStreaming(request: GenerateRequest): boolean {
|
|
136
189
|
const model = request.model ?? DEFAULT_MODEL;
|
|
137
|
-
return Math.min(request.maxTokens,
|
|
190
|
+
return Math.min(request.maxTokens, modelSpec(model).maxOutput) > STREAM_ONLY_MAX_TOKENS;
|
|
138
191
|
}
|
|
139
192
|
|
|
140
193
|
/**
|
|
@@ -146,7 +199,8 @@ export function requiresStreaming(request: GenerateRequest): boolean {
|
|
|
146
199
|
*/
|
|
147
200
|
export class AnthropicProvider implements Provider {
|
|
148
201
|
readonly name = 'anthropic';
|
|
149
|
-
|
|
202
|
+
/** Its own list, never the registry's: an app's internal model must not be routed here. */
|
|
203
|
+
readonly models: readonly ModelId[] = ANTHROPIC_MODEL_IDS;
|
|
150
204
|
private readonly config: AnthropicProviderInput;
|
|
151
205
|
|
|
152
206
|
constructor(config: AnthropicProviderInput = {}) {
|
|
@@ -217,7 +271,7 @@ export class AnthropicProvider implements Provider {
|
|
|
217
271
|
const model = request.model ?? DEFAULT_MODEL;
|
|
218
272
|
const body: Record<string, unknown> = {
|
|
219
273
|
model,
|
|
220
|
-
max_tokens: Math.min(request.maxTokens,
|
|
274
|
+
max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
|
|
221
275
|
messages: request.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
222
276
|
...reasoningBody(model, request.effort, request.thinking),
|
|
223
277
|
};
|
|
@@ -253,33 +307,24 @@ export class AnthropicProvider implements Provider {
|
|
|
253
307
|
throw new AiTransportError({
|
|
254
308
|
provider: this.name,
|
|
255
309
|
status: response.status,
|
|
256
|
-
|
|
310
|
+
// The endpoint's own message, with the credential scrubbed out of it — the same rule the
|
|
311
|
+
// OpenAI-format provider follows, and for the same reason: a proxy echoing the request
|
|
312
|
+
// headers into its 4xx body is the one path by which `x-api-key` reaches an error.
|
|
313
|
+
detail: withoutKey(await detailOf(response), apiKey),
|
|
314
|
+
envVar: API_KEY_ENV,
|
|
257
315
|
});
|
|
258
316
|
}
|
|
259
317
|
return response;
|
|
260
318
|
}
|
|
261
319
|
}
|
|
262
320
|
|
|
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
321
|
/** Map a Messages API response onto `GenerateResult`. Exported so tests can drive it. */
|
|
282
322
|
export function parseMessage(model: ModelId, raw: Record<string, unknown>): GenerateResult {
|
|
323
|
+
// A 200 carrying an `error` object instead of an answer — how a gateway in front of a model
|
|
324
|
+
// reports a fault it noticed after the headers were sent. Read as a message it is an empty,
|
|
325
|
+
// successful answer, which is the one outcome nothing downstream can detect; the STREAMED half
|
|
326
|
+
// of this same provider has always refused it.
|
|
327
|
+
throwInBandError(raw);
|
|
283
328
|
const content = Array.isArray(raw['content']) ? raw['content'] : [];
|
|
284
329
|
let text = '';
|
|
285
330
|
const toolCalls: LlmToolCall[] = [];
|
|
@@ -291,17 +336,24 @@ export function parseMessage(model: ModelId, raw: Record<string, unknown>): Gene
|
|
|
291
336
|
toolCalls.push({
|
|
292
337
|
id: b['id'],
|
|
293
338
|
name: b['name'],
|
|
294
|
-
|
|
339
|
+
// Parsed, never cast: `input` is untrusted, and a string under `Record<string, unknown>`
|
|
340
|
+
// is a lie every later reader indexes into. The streamed half already parses it.
|
|
341
|
+
input: asToolInput(b['input']),
|
|
295
342
|
});
|
|
296
343
|
}
|
|
297
344
|
}
|
|
298
345
|
const usage = parseUsage(raw['usage']);
|
|
346
|
+
const stopDetails = parseStopDetails(raw['stop_details']);
|
|
299
347
|
return {
|
|
300
348
|
model,
|
|
301
349
|
text,
|
|
302
350
|
toolCalls,
|
|
303
|
-
|
|
304
|
-
|
|
351
|
+
// A refusal detail is a refusal whatever the stop field says: `parseStopReason` answers
|
|
352
|
+
// `end_turn` for a spelling this build has never seen, and `llm()` branches on the REASON —
|
|
353
|
+
// `stopDetails` has no reader that can refuse — so the pair would arrive as a complete answer
|
|
354
|
+
// that happens to be empty. The OpenAI-format read has always forced it.
|
|
355
|
+
stopReason: stopDetails === undefined ? parseStopReason(raw['stop_reason']) : 'refusal',
|
|
356
|
+
stopDetails,
|
|
305
357
|
usage,
|
|
306
358
|
cost: costOf(model, usage),
|
|
307
359
|
};
|
|
@@ -324,7 +376,14 @@ export interface EchoProviderInput {
|
|
|
324
376
|
*/
|
|
325
377
|
export class EchoProvider implements Provider {
|
|
326
378
|
readonly name = 'echo';
|
|
327
|
-
|
|
379
|
+
/**
|
|
380
|
+
* A getter over the whole registry, not a snapshot: the test double has to serve whatever the
|
|
381
|
+
* test registered, and a field read at construction time would miss a model registered after.
|
|
382
|
+
*/
|
|
383
|
+
get models(): readonly ModelId[] {
|
|
384
|
+
return modelIds();
|
|
385
|
+
}
|
|
386
|
+
|
|
328
387
|
private readonly config: EchoProviderInput;
|
|
329
388
|
|
|
330
389
|
constructor(config: EchoProviderInput = {}) {
|
|
@@ -365,7 +424,7 @@ export class EchoProvider implements Provider {
|
|
|
365
424
|
function lastUserMessage(messages: readonly AiMessage[]): string {
|
|
366
425
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
367
426
|
const message = messages[i];
|
|
368
|
-
if (message !== undefined && message.role === 'user') return message
|
|
427
|
+
if (message !== undefined && message.role === 'user') return messageText(message);
|
|
369
428
|
}
|
|
370
429
|
return '';
|
|
371
430
|
}
|
|
@@ -381,7 +440,7 @@ export function estimateTokens(request: GenerateRequest): number {
|
|
|
381
440
|
|
|
382
441
|
/** The prompt half alone — what the provider bills at the input rate. */
|
|
383
442
|
export function estimateInputTokens(request: GenerateRequest): number {
|
|
384
|
-
const body = request.messages.map(
|
|
443
|
+
const body = request.messages.map(messageText).join(' ');
|
|
385
444
|
return estimateTextTokens(body) + estimateTextTokens(request.system ?? '');
|
|
386
445
|
}
|
|
387
446
|
|
|
@@ -394,7 +453,7 @@ export function estimateCost(request: GenerateRequest): Money {
|
|
|
394
453
|
const model = request.model ?? DEFAULT_MODEL;
|
|
395
454
|
return costOf(model, {
|
|
396
455
|
inputTokens: estimateInputTokens(request),
|
|
397
|
-
outputTokens: Math.min(request.maxTokens,
|
|
456
|
+
outputTokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
|
|
398
457
|
cacheReadTokens: 0,
|
|
399
458
|
cacheWriteTokens: 0,
|
|
400
459
|
});
|
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
|
}
|