@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/wire.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Single responsibility: reading what the Anthropic Messages API sends back — the `usage` and
|
|
2
|
+
// `stop_reason` shapes both transports share, and the assembler that turns one SSE stream into
|
|
3
|
+
// `StreamChunk`s.
|
|
4
|
+
//
|
|
5
|
+
// Split from provider.ts so the request half (what we send) and the response half (what we
|
|
6
|
+
// read) stay separately readable, and so the assembler can be driven frame by frame in a test
|
|
7
|
+
// with no socket, which is the only way to cover a stream that arrives out of order or stops
|
|
8
|
+
// half way. Imports from provider.ts are types only — the dependency runs one way.
|
|
9
|
+
|
|
10
|
+
import { AiTransportError } from './errors';
|
|
11
|
+
import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
|
|
12
|
+
import type { SseFrame } from './sse';
|
|
13
|
+
import type { LlmToolCall } from './tools';
|
|
14
|
+
|
|
15
|
+
export const ZERO_USAGE: TokenUsage = {
|
|
16
|
+
inputTokens: 0,
|
|
17
|
+
outputTokens: 0,
|
|
18
|
+
cacheReadTokens: 0,
|
|
19
|
+
cacheWriteTokens: 0,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const USAGE_FIELDS: Readonly<Record<keyof TokenUsage, string>> = {
|
|
23
|
+
inputTokens: 'input_tokens',
|
|
24
|
+
outputTokens: 'output_tokens',
|
|
25
|
+
cacheReadTokens: 'cache_read_input_tokens',
|
|
26
|
+
cacheWriteTokens: 'cache_creation_input_tokens',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Every counter, zero filled — what a completed non-streaming response reports. */
|
|
30
|
+
export function parseUsage(raw: unknown): TokenUsage {
|
|
31
|
+
return { ...ZERO_USAGE, ...parsePartialUsage(raw) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Only the counters the payload actually carries. A stream reports usage twice — an opening
|
|
36
|
+
* `message_start` and a closing `message_delta` — and zero filling the second would erase the
|
|
37
|
+
* cache counters the first one carried, silently under-reporting spend.
|
|
38
|
+
*/
|
|
39
|
+
export function parsePartialUsage(raw: unknown): Partial<TokenUsage> {
|
|
40
|
+
const record = asRecord(raw);
|
|
41
|
+
if (record === undefined) return {};
|
|
42
|
+
const usage: Partial<Record<keyof TokenUsage, number>> = {};
|
|
43
|
+
for (const [field, wire] of Object.entries(USAGE_FIELDS) as [keyof TokenUsage, string][]) {
|
|
44
|
+
const value = record[wire];
|
|
45
|
+
if (typeof value === 'number') usage[field] = value;
|
|
46
|
+
}
|
|
47
|
+
return usage;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const STOP_REASONS: readonly StopReason[] = [
|
|
51
|
+
'end_turn',
|
|
52
|
+
'max_tokens',
|
|
53
|
+
'stop_sequence',
|
|
54
|
+
'tool_use',
|
|
55
|
+
'pause_turn',
|
|
56
|
+
'refusal',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export function parseStopReason(raw: unknown): StopReason {
|
|
60
|
+
return STOP_REASONS.find((reason) => reason === raw) ?? 'end_turn';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The refusal detail, when the payload carries one. Populated ONLY on a refusal — every other
|
|
65
|
+
* stop reason leaves it null — so a caller reading it unguarded reads null on the happy path.
|
|
66
|
+
* `category` is passed through as written rather than matched against a union: it is an open
|
|
67
|
+
* set, and a new category is information, not a parse failure.
|
|
68
|
+
*/
|
|
69
|
+
export function parseStopDetails(raw: unknown): StopDetails | undefined {
|
|
70
|
+
const record = asRecord(raw);
|
|
71
|
+
if (record === undefined || record['type'] !== 'refusal') return undefined;
|
|
72
|
+
return {
|
|
73
|
+
type: 'refusal',
|
|
74
|
+
category: typeof record['category'] === 'string' ? record['category'] : undefined,
|
|
75
|
+
explanation: typeof record['explanation'] === 'string' ? record['explanation'] : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* In-band `error` events carry a type, not a status. Mapping them back to one keeps a single
|
|
81
|
+
* retry rule in the gateway: an overloaded provider is retryable whether it says so with a
|
|
82
|
+
* 529 on the handshake or with an `overloaded_error` frame ten tokens in.
|
|
83
|
+
*/
|
|
84
|
+
const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
85
|
+
invalid_request_error: 400,
|
|
86
|
+
authentication_error: 401,
|
|
87
|
+
permission_error: 403,
|
|
88
|
+
not_found_error: 404,
|
|
89
|
+
request_too_large: 413,
|
|
90
|
+
rate_limit_error: 429,
|
|
91
|
+
api_error: 500,
|
|
92
|
+
timeout_error: 504,
|
|
93
|
+
overloaded_error: 529,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
interface PendingTool {
|
|
97
|
+
readonly id: string;
|
|
98
|
+
readonly name: string;
|
|
99
|
+
json: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What the stream has accumulated so far. `cost` is applied by the provider that owns prices. */
|
|
103
|
+
export interface StreamState {
|
|
104
|
+
readonly text: string;
|
|
105
|
+
readonly toolCalls: readonly LlmToolCall[];
|
|
106
|
+
readonly stopReason: StopReason;
|
|
107
|
+
readonly stopDetails: StopDetails | undefined;
|
|
108
|
+
readonly usage: TokenUsage;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One streamed message, assembled frame by frame. Text and thinking are yielded as they land;
|
|
113
|
+
* a tool call is not — its arguments arrive as `input_json_delta` fragments that are only
|
|
114
|
+
* parseable once the block closes, so the call is emitted whole or not at all.
|
|
115
|
+
*/
|
|
116
|
+
export class MessageStream {
|
|
117
|
+
private text = '';
|
|
118
|
+
private readonly toolCalls: LlmToolCall[] = [];
|
|
119
|
+
private stopReason: StopReason = 'end_turn';
|
|
120
|
+
private stopDetails: StopDetails | undefined;
|
|
121
|
+
private usage: TokenUsage = ZERO_USAGE;
|
|
122
|
+
private readonly pending = new Map<number, PendingTool>();
|
|
123
|
+
private stopped = false;
|
|
124
|
+
|
|
125
|
+
/** Chunks this frame produced, in order. Unknown events yield nothing — the API adds them. */
|
|
126
|
+
push(frame: SseFrame): readonly StreamChunk[] {
|
|
127
|
+
const payload = this.payloadOf(frame);
|
|
128
|
+
const type = typeof payload['type'] === 'string' ? payload['type'] : frame.event;
|
|
129
|
+
switch (type) {
|
|
130
|
+
case 'message_start':
|
|
131
|
+
return this.onMessageStart(payload);
|
|
132
|
+
case 'content_block_start':
|
|
133
|
+
return this.onBlockStart(payload);
|
|
134
|
+
case 'content_block_delta':
|
|
135
|
+
return this.onBlockDelta(payload);
|
|
136
|
+
case 'content_block_stop':
|
|
137
|
+
return this.onBlockStop(payload);
|
|
138
|
+
case 'message_delta':
|
|
139
|
+
return this.onMessageDelta(payload);
|
|
140
|
+
case 'message_stop':
|
|
141
|
+
this.stopped = true;
|
|
142
|
+
return [];
|
|
143
|
+
case 'error':
|
|
144
|
+
return this.onError(payload);
|
|
145
|
+
default:
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True once `message_stop` arrived. False means the connection died mid-answer. */
|
|
151
|
+
isComplete(): boolean {
|
|
152
|
+
return this.stopped;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
state(): StreamState {
|
|
156
|
+
return {
|
|
157
|
+
text: this.text,
|
|
158
|
+
toolCalls: [...this.toolCalls],
|
|
159
|
+
stopReason: this.stopReason,
|
|
160
|
+
stopDetails: this.stopDetails,
|
|
161
|
+
usage: this.usage,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private payloadOf(frame: SseFrame): Record<string, unknown> {
|
|
166
|
+
try {
|
|
167
|
+
const parsed: unknown = JSON.parse(frame.data);
|
|
168
|
+
const record = asRecord(parsed);
|
|
169
|
+
if (record === undefined) throw new SyntaxError('frame data is not an object');
|
|
170
|
+
return record;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new AiTransportError({
|
|
173
|
+
provider: 'anthropic',
|
|
174
|
+
detail: `unreadable "${frame.event}" frame: ${
|
|
175
|
+
error instanceof Error ? error.message : String(error)
|
|
176
|
+
}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private onMessageStart(payload: Record<string, unknown>): readonly StreamChunk[] {
|
|
182
|
+
const message = asRecord(payload['message']);
|
|
183
|
+
this.usage = { ...this.usage, ...parsePartialUsage(message?.['usage']) };
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private onBlockStart(payload: Record<string, unknown>): readonly StreamChunk[] {
|
|
188
|
+
const block = asRecord(payload['content_block']);
|
|
189
|
+
const index = asIndex(payload['index']);
|
|
190
|
+
if (block === undefined || index === undefined) return [];
|
|
191
|
+
if (block['type'] !== 'tool_use') return [];
|
|
192
|
+
const id = typeof block['id'] === 'string' ? block['id'] : '';
|
|
193
|
+
const name = typeof block['name'] === 'string' ? block['name'] : '';
|
|
194
|
+
this.pending.set(index, { id, name, json: '' });
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private onBlockDelta(payload: Record<string, unknown>): readonly StreamChunk[] {
|
|
199
|
+
const delta = asRecord(payload['delta']);
|
|
200
|
+
if (delta === undefined) return [];
|
|
201
|
+
const text = delta['text'];
|
|
202
|
+
if (delta['type'] === 'text_delta' && typeof text === 'string') {
|
|
203
|
+
this.text += text;
|
|
204
|
+
return [{ type: 'text', text }];
|
|
205
|
+
}
|
|
206
|
+
const thinking = delta['thinking'];
|
|
207
|
+
if (delta['type'] === 'thinking_delta' && typeof thinking === 'string') {
|
|
208
|
+
// Deliberately NOT appended to `text`: thinking is not the answer, and a caller that
|
|
209
|
+
// concatenated every chunk would otherwise ship the reasoning to the user.
|
|
210
|
+
return [{ type: 'thinking', text: thinking }];
|
|
211
|
+
}
|
|
212
|
+
const partial = delta['partial_json'];
|
|
213
|
+
const index = asIndex(payload['index']);
|
|
214
|
+
if (
|
|
215
|
+
delta['type'] === 'input_json_delta' &&
|
|
216
|
+
typeof partial === 'string' &&
|
|
217
|
+
index !== undefined
|
|
218
|
+
) {
|
|
219
|
+
const tool = this.pending.get(index);
|
|
220
|
+
if (tool !== undefined) tool.json += partial;
|
|
221
|
+
}
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private onBlockStop(payload: Record<string, unknown>): readonly StreamChunk[] {
|
|
226
|
+
const index = asIndex(payload['index']);
|
|
227
|
+
if (index === undefined) return [];
|
|
228
|
+
const tool = this.pending.get(index);
|
|
229
|
+
if (tool === undefined) return [];
|
|
230
|
+
this.pending.delete(index);
|
|
231
|
+
const call: LlmToolCall = { id: tool.id, name: tool.name, input: this.inputOf(tool) };
|
|
232
|
+
this.toolCalls.push(call);
|
|
233
|
+
return [{ type: 'tool-call', call }];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A tool with no arguments streams no `input_json_delta` at all, which is `{}`, not a fault. */
|
|
237
|
+
private inputOf(tool: PendingTool): Record<string, unknown> {
|
|
238
|
+
if (tool.json === '') return {};
|
|
239
|
+
try {
|
|
240
|
+
const parsed: unknown = JSON.parse(tool.json);
|
|
241
|
+
return asRecord(parsed) ?? {};
|
|
242
|
+
} catch (error) {
|
|
243
|
+
throw new AiTransportError({
|
|
244
|
+
provider: 'anthropic',
|
|
245
|
+
detail: `tool "${tool.name}" streamed arguments that are not JSON: ${
|
|
246
|
+
error instanceof Error ? error.message : String(error)
|
|
247
|
+
}`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private onMessageDelta(payload: Record<string, unknown>): readonly StreamChunk[] {
|
|
253
|
+
const delta = asRecord(payload['delta']);
|
|
254
|
+
if (delta !== undefined && delta['stop_reason'] !== null) {
|
|
255
|
+
this.stopReason = parseStopReason(delta['stop_reason']);
|
|
256
|
+
// A refusal mid-stream keeps whatever was already streamed, so the reason alone reads as
|
|
257
|
+
// a complete answer that simply stopped. The detail is what says it is not one.
|
|
258
|
+
this.stopDetails = parseStopDetails(delta['stop_details']);
|
|
259
|
+
}
|
|
260
|
+
this.usage = { ...this.usage, ...parsePartialUsage(payload['usage']) };
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private onError(payload: Record<string, unknown>): never {
|
|
265
|
+
const error = asRecord(payload['error']);
|
|
266
|
+
const type = typeof error?.['type'] === 'string' ? error['type'] : 'api_error';
|
|
267
|
+
const message = typeof error?.['message'] === 'string' ? error['message'] : type;
|
|
268
|
+
throw new AiTransportError({
|
|
269
|
+
provider: 'anthropic',
|
|
270
|
+
status: ERROR_STATUS[type],
|
|
271
|
+
detail: message,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
277
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
278
|
+
return value as Record<string, unknown>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function asIndex(value: unknown): number | undefined {
|
|
282
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
283
|
+
}
|