luv-ai 0.1.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 +196 -0
- package/dist/encode.d.ts +12 -0
- package/dist/encode.js +115 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/morphisms/anthropic_messages.d.ts +14 -0
- package/dist/morphisms/anthropic_messages.js +238 -0
- package/dist/morphisms/openai_chat.d.ts +10 -0
- package/dist/morphisms/openai_chat.js +247 -0
- package/dist/stream.d.ts +3 -0
- package/dist/stream.js +100 -0
- package/dist/transport/anthropic_messages.d.ts +31 -0
- package/dist/transport/anthropic_messages.js +343 -0
- package/dist/transport/openai_chat.d.ts +31 -0
- package/dist/transport/openai_chat.js +373 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +25 -0
- package/dist/validate.d.ts +6 -0
- package/dist/validate.js +614 -0
- package/package.json +83 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { LuvError } from "../types.js";
|
|
2
|
+
import { stringify } from "../encode.js";
|
|
3
|
+
import { luv_conversation_to_anthropic_request, anthropic_response_to_luv_reply, anthropic_stream_to_luv_stream, anthropicUsageEnvelope, } from "../morphisms/anthropic_messages.js";
|
|
4
|
+
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
|
|
5
|
+
const DEFAULT_VERSION = "2023-06-01";
|
|
6
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
7
|
+
const DEFAULT_ON_ERROR = {
|
|
8
|
+
auth: "throw",
|
|
9
|
+
rate_limit: "throw",
|
|
10
|
+
bad_request: "throw",
|
|
11
|
+
content_filter: "as_block",
|
|
12
|
+
server_error: "throw",
|
|
13
|
+
network: "throw",
|
|
14
|
+
tool_execution: "throw",
|
|
15
|
+
local_validation: "throw",
|
|
16
|
+
unknown: "throw",
|
|
17
|
+
};
|
|
18
|
+
function policyFor(config, category) {
|
|
19
|
+
return config.on_error?.[category] ?? DEFAULT_ON_ERROR[category];
|
|
20
|
+
}
|
|
21
|
+
function mapStatusToCategory(status) {
|
|
22
|
+
if (status === 401 || status === 403)
|
|
23
|
+
return "auth";
|
|
24
|
+
if (status === 408 || status === 504)
|
|
25
|
+
return "network";
|
|
26
|
+
if (status === 429)
|
|
27
|
+
return "rate_limit";
|
|
28
|
+
if (status === 0)
|
|
29
|
+
return "network";
|
|
30
|
+
if (status >= 500 && status < 600)
|
|
31
|
+
return "server_error";
|
|
32
|
+
if (status >= 400 && status < 500)
|
|
33
|
+
return "bad_request";
|
|
34
|
+
return "unknown";
|
|
35
|
+
}
|
|
36
|
+
function shortMessageFor(status, category) {
|
|
37
|
+
return `HTTP ${status}: ${category}`;
|
|
38
|
+
}
|
|
39
|
+
// Arrow: luv_send_to_anthropic_http_request
|
|
40
|
+
export function luv_send_to_anthropic_http_request(conv, opts, config) {
|
|
41
|
+
const baseUrl = config.base_url ?? DEFAULT_BASE_URL;
|
|
42
|
+
const version = config.anthropic_version ?? DEFAULT_VERSION;
|
|
43
|
+
const url = `${baseUrl}/messages`;
|
|
44
|
+
// Canonical header order: anthropic-version, content-type, x-api-key.
|
|
45
|
+
const headers = {};
|
|
46
|
+
headers["anthropic-version"] = version;
|
|
47
|
+
headers["content-type"] = "application/json";
|
|
48
|
+
headers["x-api-key"] = config.api_key;
|
|
49
|
+
const body = stringify(luv_conversation_to_anthropic_request(conv, opts));
|
|
50
|
+
return { method: "POST", url, headers, body };
|
|
51
|
+
}
|
|
52
|
+
// Arrow: anthropic_http_response_to_luv_reply
|
|
53
|
+
export function anthropic_http_response_to_luv_reply(response) {
|
|
54
|
+
if (response.status >= 200 && response.status < 300) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(response.body);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return makeErrorReply("unknown", `HTTP ${response.status}: failed to parse response body as JSON`, { status: response.status, body: response.body });
|
|
61
|
+
}
|
|
62
|
+
return anthropic_response_to_luv_reply(parsed);
|
|
63
|
+
}
|
|
64
|
+
const category = mapStatusToCategory(response.status);
|
|
65
|
+
return makeErrorReply(category, shortMessageFor(response.status, category), {
|
|
66
|
+
status: response.status,
|
|
67
|
+
body: response.body,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function makeErrorReply(category, message, detailsObj) {
|
|
71
|
+
return {
|
|
72
|
+
message: {
|
|
73
|
+
role: "assistant",
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
kind: "error",
|
|
77
|
+
category,
|
|
78
|
+
message,
|
|
79
|
+
details: stringify(detailsObj),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
finish_reason: "error",
|
|
84
|
+
usage: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Arrow: anthropic_http_stream_to_luv_stream
|
|
88
|
+
export function anthropic_http_stream_to_luv_stream(response) {
|
|
89
|
+
if (response.status < 200 || response.status >= 300) {
|
|
90
|
+
const category = mapStatusToCategory(response.status);
|
|
91
|
+
return [
|
|
92
|
+
{ kind: "message_start" },
|
|
93
|
+
{
|
|
94
|
+
kind: "block_start",
|
|
95
|
+
block: {
|
|
96
|
+
kind: "error",
|
|
97
|
+
category,
|
|
98
|
+
message: shortMessageFor(response.status, category),
|
|
99
|
+
details: stringify({ status: response.status, body: response.body }),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{ kind: "block_end" },
|
|
103
|
+
{ kind: "message_end", finish_reason: "error", usage: null },
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
const events = parseSSE(response.body);
|
|
107
|
+
return anthropic_stream_to_luv_stream(events);
|
|
108
|
+
}
|
|
109
|
+
function parseSSE(body) {
|
|
110
|
+
// Anthropic SSE: blocks separated by \n\n; each block has an "event:"
|
|
111
|
+
// line (ignored) and a "data: <json>" line. No [DONE] terminator;
|
|
112
|
+
// stream ends with message_stop event.
|
|
113
|
+
const events = [];
|
|
114
|
+
for (const block of body.split("\n\n")) {
|
|
115
|
+
for (const line of block.split("\n")) {
|
|
116
|
+
if (!line.startsWith("data: "))
|
|
117
|
+
continue;
|
|
118
|
+
const payload = line.slice(6);
|
|
119
|
+
try {
|
|
120
|
+
events.push(JSON.parse(payload));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Skip unparseable.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return events;
|
|
128
|
+
}
|
|
129
|
+
export function anthropicClient(config) {
|
|
130
|
+
return {
|
|
131
|
+
async send(conv, opts) {
|
|
132
|
+
const effectiveOpts = {
|
|
133
|
+
...opts,
|
|
134
|
+
max_tokens: opts.max_tokens ?? config.default_max_tokens ?? DEFAULT_MAX_TOKENS,
|
|
135
|
+
};
|
|
136
|
+
const req = luv_send_to_anthropic_http_request(conv, effectiveOpts, config);
|
|
137
|
+
const fetchRes = await fetch(req.url, {
|
|
138
|
+
method: req.method,
|
|
139
|
+
headers: req.headers,
|
|
140
|
+
body: req.body,
|
|
141
|
+
});
|
|
142
|
+
const httpRes = {
|
|
143
|
+
status: fetchRes.status,
|
|
144
|
+
headers: headersToRecord(fetchRes.headers),
|
|
145
|
+
body: await fetchRes.text(),
|
|
146
|
+
};
|
|
147
|
+
const reply = anthropic_http_response_to_luv_reply(httpRes);
|
|
148
|
+
applyErrorPolicyOrThrow(reply, config);
|
|
149
|
+
return reply;
|
|
150
|
+
},
|
|
151
|
+
async *stream(conv, opts) {
|
|
152
|
+
const effectiveOpts = {
|
|
153
|
+
...opts,
|
|
154
|
+
max_tokens: opts.max_tokens ?? config.default_max_tokens ?? DEFAULT_MAX_TOKENS,
|
|
155
|
+
stream: true,
|
|
156
|
+
};
|
|
157
|
+
const req = luv_send_to_anthropic_http_request(conv, effectiveOpts, config);
|
|
158
|
+
const fetchRes = await fetch(req.url, {
|
|
159
|
+
method: req.method,
|
|
160
|
+
headers: req.headers,
|
|
161
|
+
body: req.body,
|
|
162
|
+
});
|
|
163
|
+
if (fetchRes.status < 200 || fetchRes.status >= 300) {
|
|
164
|
+
const httpRes = {
|
|
165
|
+
status: fetchRes.status,
|
|
166
|
+
headers: headersToRecord(fetchRes.headers),
|
|
167
|
+
body: await fetchRes.text(),
|
|
168
|
+
};
|
|
169
|
+
const events = anthropic_http_stream_to_luv_stream(httpRes);
|
|
170
|
+
const errBlock = findErrorBlock(events);
|
|
171
|
+
if (errBlock && policyFor(config, errBlock.category) === "throw") {
|
|
172
|
+
throw new LuvError({
|
|
173
|
+
category: errBlock.category,
|
|
174
|
+
message: errBlock.message,
|
|
175
|
+
details: errBlock.details,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
for (const e of events)
|
|
179
|
+
yield e;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
yield* streamSSE(fetchRes);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function headersToRecord(h) {
|
|
187
|
+
const out = {};
|
|
188
|
+
h.forEach((v, k) => {
|
|
189
|
+
out[k.toLowerCase()] = v;
|
|
190
|
+
});
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
function findErrorBlock(events) {
|
|
194
|
+
for (const e of events) {
|
|
195
|
+
if (e.kind === "block_start" && e.block.kind === "error") {
|
|
196
|
+
return {
|
|
197
|
+
category: e.block.category,
|
|
198
|
+
message: e.block.message,
|
|
199
|
+
details: e.block.details,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
function applyErrorPolicyOrThrow(reply, config) {
|
|
206
|
+
for (const block of reply.message.content) {
|
|
207
|
+
if (block.kind === "error") {
|
|
208
|
+
const policy = policyFor(config, block.category);
|
|
209
|
+
if (policy === "throw") {
|
|
210
|
+
throw new LuvError({
|
|
211
|
+
category: block.category,
|
|
212
|
+
message: block.message,
|
|
213
|
+
details: block.details,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Real-time SSE stream of Anthropic typed events; maps to luv events
|
|
220
|
+
// chunk-by-chunk via a stateful processor that mirrors the morphism.
|
|
221
|
+
async function* streamSSE(res) {
|
|
222
|
+
if (!res.body)
|
|
223
|
+
return;
|
|
224
|
+
const reader = res.body.getReader();
|
|
225
|
+
const decoder = new TextDecoder("utf-8");
|
|
226
|
+
let buffer = "";
|
|
227
|
+
const state = createStreamState();
|
|
228
|
+
while (true) {
|
|
229
|
+
const { value, done } = await reader.read();
|
|
230
|
+
if (done)
|
|
231
|
+
break;
|
|
232
|
+
buffer += decoder.decode(value, { stream: true });
|
|
233
|
+
let sep;
|
|
234
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
235
|
+
const block = buffer.slice(0, sep);
|
|
236
|
+
buffer = buffer.slice(sep + 2);
|
|
237
|
+
for (const line of block.split("\n")) {
|
|
238
|
+
if (!line.startsWith("data: "))
|
|
239
|
+
continue;
|
|
240
|
+
const payload = line.slice(6);
|
|
241
|
+
let evt;
|
|
242
|
+
try {
|
|
243
|
+
evt = JSON.parse(payload);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
for (const ev of processEvent(state, evt))
|
|
249
|
+
yield ev;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function createStreamState() {
|
|
255
|
+
return { storedStopReason: null, model: null, usageObj: null };
|
|
256
|
+
}
|
|
257
|
+
function processEvent(state, evt) {
|
|
258
|
+
const out = [];
|
|
259
|
+
const e = evt;
|
|
260
|
+
switch (e.type) {
|
|
261
|
+
case "message_start":
|
|
262
|
+
out.push({ kind: "message_start" });
|
|
263
|
+
if (e.message) {
|
|
264
|
+
if (typeof e.message.model === "string")
|
|
265
|
+
state.model = e.message.model;
|
|
266
|
+
if (e.message.usage)
|
|
267
|
+
state.usageObj = { ...e.message.usage };
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
case "content_block_start": {
|
|
271
|
+
const cb = e.content_block;
|
|
272
|
+
if (!cb)
|
|
273
|
+
break;
|
|
274
|
+
if (cb.type === "text") {
|
|
275
|
+
out.push({
|
|
276
|
+
kind: "block_start",
|
|
277
|
+
block: { kind: "text", text: "" },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
else if (cb.type === "tool_use") {
|
|
281
|
+
out.push({
|
|
282
|
+
kind: "block_start",
|
|
283
|
+
block: {
|
|
284
|
+
kind: "tool_call",
|
|
285
|
+
id: cb.id ?? "",
|
|
286
|
+
name: cb.name ?? "",
|
|
287
|
+
args: "",
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case "content_block_delta": {
|
|
294
|
+
const d = e.delta;
|
|
295
|
+
if (!d)
|
|
296
|
+
break;
|
|
297
|
+
if (d.type === "text_delta" && typeof d.text === "string") {
|
|
298
|
+
out.push({ kind: "text_delta", text: d.text });
|
|
299
|
+
}
|
|
300
|
+
else if (d.type === "input_json_delta" &&
|
|
301
|
+
typeof d.partial_json === "string") {
|
|
302
|
+
out.push({ kind: "args_delta", args: d.partial_json });
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
case "content_block_stop":
|
|
307
|
+
out.push({ kind: "block_end" });
|
|
308
|
+
break;
|
|
309
|
+
case "message_delta":
|
|
310
|
+
if (e.delta && typeof e.delta.stop_reason === "string") {
|
|
311
|
+
state.storedStopReason = e.delta.stop_reason;
|
|
312
|
+
}
|
|
313
|
+
if (e.usage) {
|
|
314
|
+
state.usageObj = { ...(state.usageObj ?? {}), ...e.usage };
|
|
315
|
+
}
|
|
316
|
+
break;
|
|
317
|
+
case "message_stop":
|
|
318
|
+
out.push({
|
|
319
|
+
kind: "message_end",
|
|
320
|
+
finish_reason: mapStopReason(state.storedStopReason),
|
|
321
|
+
usage: anthropicUsageEnvelope(state.model, state.usageObj),
|
|
322
|
+
});
|
|
323
|
+
break;
|
|
324
|
+
default:
|
|
325
|
+
// ping and unknown events: skip
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
function mapStopReason(r) {
|
|
331
|
+
switch (r) {
|
|
332
|
+
case "end_turn":
|
|
333
|
+
return "end_turn";
|
|
334
|
+
case "max_tokens":
|
|
335
|
+
return "max_tokens";
|
|
336
|
+
case "stop_sequence":
|
|
337
|
+
return "end_turn";
|
|
338
|
+
case "tool_use":
|
|
339
|
+
return "end_turn";
|
|
340
|
+
default:
|
|
341
|
+
return "end_turn";
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Conversation, ErrorCategory, Reply, StreamEventReply, StreamReply } from "../types.js";
|
|
2
|
+
import { type OpenAIRequestOptions } from "../morphisms/openai_chat.js";
|
|
3
|
+
export interface HTTPRequest {
|
|
4
|
+
method: string;
|
|
5
|
+
url: string;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
body: string;
|
|
8
|
+
}
|
|
9
|
+
export interface HTTPResponse {
|
|
10
|
+
status: number;
|
|
11
|
+
headers: Record<string, string>;
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
export type ErrorPolicy = "throw" | "as_block";
|
|
15
|
+
export type ErrorPolicyMap = Partial<Record<ErrorCategory, ErrorPolicy>>;
|
|
16
|
+
export interface OpenAIClientConfig {
|
|
17
|
+
api_key: string;
|
|
18
|
+
base_url?: string;
|
|
19
|
+
organization?: string;
|
|
20
|
+
project?: string;
|
|
21
|
+
timeout_ms?: number;
|
|
22
|
+
on_error?: ErrorPolicyMap;
|
|
23
|
+
}
|
|
24
|
+
export declare function luv_send_to_openai_http_request(conv: Conversation, opts: OpenAIRequestOptions, config: OpenAIClientConfig): HTTPRequest;
|
|
25
|
+
export declare function openai_http_response_to_luv_reply(response: HTTPResponse): Reply;
|
|
26
|
+
export declare function openai_http_stream_to_luv_stream(response: HTTPResponse): StreamReply;
|
|
27
|
+
export interface OpenAIClient {
|
|
28
|
+
send(conv: Conversation, opts: OpenAIRequestOptions): Promise<Reply>;
|
|
29
|
+
stream(conv: Conversation, opts: OpenAIRequestOptions): AsyncIterable<StreamEventReply>;
|
|
30
|
+
}
|
|
31
|
+
export declare function openaiClient(config: OpenAIClientConfig): OpenAIClient;
|