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.
@@ -0,0 +1,373 @@
1
+ import { LuvError } from "../types.js";
2
+ import { stringify } from "../encode.js";
3
+ import { luv_conversation_to_openai_request, openai_response_to_luv_reply, openai_stream_to_luv_stream, openaiUsageEnvelope, } from "../morphisms/openai_chat.js";
4
+ const DEFAULT_BASE_URL = "https://api.openai.com/v1";
5
+ const DEFAULT_ON_ERROR = {
6
+ auth: "throw",
7
+ rate_limit: "throw",
8
+ bad_request: "throw",
9
+ content_filter: "as_block",
10
+ server_error: "throw",
11
+ network: "throw",
12
+ tool_execution: "throw",
13
+ local_validation: "throw",
14
+ unknown: "throw",
15
+ };
16
+ function policyFor(config, category) {
17
+ return config.on_error?.[category] ?? DEFAULT_ON_ERROR[category];
18
+ }
19
+ // ---------- Status -> ErrorCategory ----------
20
+ function mapStatusToCategory(status) {
21
+ if (status === 401 || status === 403)
22
+ return "auth";
23
+ if (status === 408 || status === 504)
24
+ return "network";
25
+ if (status === 429)
26
+ return "rate_limit";
27
+ if (status === 0)
28
+ return "network";
29
+ if (status >= 500 && status < 600)
30
+ return "server_error";
31
+ if (status >= 400 && status < 500)
32
+ return "bad_request";
33
+ return "unknown";
34
+ }
35
+ function shortMessageFor(status, category) {
36
+ return `HTTP ${status}: ${category}`;
37
+ }
38
+ // ---------- Arrow: luv_send_to_openai_http_request ----------
39
+ export function luv_send_to_openai_http_request(conv, opts, config) {
40
+ const baseUrl = config.base_url ?? DEFAULT_BASE_URL;
41
+ const url = `${baseUrl}/chat/completions`;
42
+ // Build headers in canonical order: required first (authorization,
43
+ // content-type), then optional alphabetically.
44
+ const headers = {};
45
+ headers["authorization"] = `Bearer ${config.api_key}`;
46
+ headers["content-type"] = "application/json";
47
+ const optionals = [];
48
+ if (config.organization) {
49
+ optionals.push(["openai-organization", config.organization]);
50
+ }
51
+ if (config.project) {
52
+ optionals.push(["openai-project", config.project]);
53
+ }
54
+ optionals.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
55
+ for (const [k, v] of optionals)
56
+ headers[k] = v;
57
+ const body = stringify(luv_conversation_to_openai_request(conv, opts));
58
+ return { method: "POST", url, headers, body };
59
+ }
60
+ // ---------- Arrow: openai_http_response_to_luv_reply ----------
61
+ export function openai_http_response_to_luv_reply(response) {
62
+ if (response.status >= 200 && response.status < 300) {
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(response.body);
66
+ }
67
+ catch {
68
+ return makeErrorReply("unknown", `HTTP ${response.status}: failed to parse response body as JSON`, { status: response.status, body: response.body });
69
+ }
70
+ return openai_response_to_luv_reply(parsed);
71
+ }
72
+ const category = mapStatusToCategory(response.status);
73
+ return makeErrorReply(category, shortMessageFor(response.status, category), { status: response.status, body: response.body });
74
+ }
75
+ function makeErrorReply(category, message, detailsObj) {
76
+ return {
77
+ message: {
78
+ role: "assistant",
79
+ content: [
80
+ {
81
+ kind: "error",
82
+ category,
83
+ message,
84
+ details: stringify(detailsObj),
85
+ },
86
+ ],
87
+ },
88
+ finish_reason: "error",
89
+ usage: null,
90
+ };
91
+ }
92
+ // ---------- Arrow: openai_http_stream_to_luv_stream ----------
93
+ export function openai_http_stream_to_luv_stream(response) {
94
+ if (response.status < 200 || response.status >= 300) {
95
+ const category = mapStatusToCategory(response.status);
96
+ return [
97
+ { kind: "message_start" },
98
+ {
99
+ kind: "block_start",
100
+ block: {
101
+ kind: "error",
102
+ category,
103
+ message: shortMessageFor(response.status, category),
104
+ details: stringify({ status: response.status, body: response.body }),
105
+ },
106
+ },
107
+ { kind: "block_end" },
108
+ { kind: "message_end", finish_reason: "error", usage: null },
109
+ ];
110
+ }
111
+ const chunks = parseSSE(response.body);
112
+ return openai_stream_to_luv_stream(chunks);
113
+ }
114
+ function parseSSE(body) {
115
+ const chunks = [];
116
+ const events = body.split("\n\n");
117
+ for (const event of events) {
118
+ for (const line of event.split("\n")) {
119
+ if (!line.startsWith("data: "))
120
+ continue;
121
+ const payload = line.slice(6);
122
+ if (payload === "[DONE]")
123
+ return chunks;
124
+ try {
125
+ chunks.push(JSON.parse(payload));
126
+ }
127
+ catch {
128
+ // Skip unparseable data lines silently. A more aggressive
129
+ // implementation might surface a parse-error event here.
130
+ }
131
+ }
132
+ }
133
+ return chunks;
134
+ }
135
+ export function openaiClient(config) {
136
+ return {
137
+ async send(conv, opts) {
138
+ const req = luv_send_to_openai_http_request(conv, opts, config);
139
+ const fetchRes = await fetch(req.url, {
140
+ method: req.method,
141
+ headers: req.headers,
142
+ body: req.body,
143
+ });
144
+ const httpRes = {
145
+ status: fetchRes.status,
146
+ headers: headersToRecord(fetchRes.headers),
147
+ body: await fetchRes.text(),
148
+ };
149
+ const reply = openai_http_response_to_luv_reply(httpRes);
150
+ applyErrorPolicyOrThrow(reply, config);
151
+ return reply;
152
+ },
153
+ async *stream(conv, opts) {
154
+ const req = luv_send_to_openai_http_request(conv, { ...opts, stream: true }, config);
155
+ const fetchRes = await fetch(req.url, {
156
+ method: req.method,
157
+ headers: req.headers,
158
+ body: req.body,
159
+ });
160
+ if (fetchRes.status < 200 || fetchRes.status >= 300) {
161
+ const httpRes = {
162
+ status: fetchRes.status,
163
+ headers: headersToRecord(fetchRes.headers),
164
+ body: await fetchRes.text(),
165
+ };
166
+ const events = openai_http_stream_to_luv_stream(httpRes);
167
+ const errBlock = findErrorBlock(events);
168
+ if (errBlock && policyFor(config, errBlock.category) === "throw") {
169
+ throw new LuvError({
170
+ category: errBlock.category,
171
+ message: errBlock.message,
172
+ details: errBlock.details,
173
+ });
174
+ }
175
+ for (const e of events)
176
+ yield e;
177
+ return;
178
+ }
179
+ // 2xx — stream chunks as they arrive.
180
+ yield* streamSSE(fetchRes);
181
+ },
182
+ };
183
+ }
184
+ function headersToRecord(h) {
185
+ const out = {};
186
+ h.forEach((v, k) => {
187
+ out[k.toLowerCase()] = v;
188
+ });
189
+ return out;
190
+ }
191
+ function findErrorBlock(events) {
192
+ for (const e of events) {
193
+ if (e.kind === "block_start" && e.block.kind === "error") {
194
+ return {
195
+ category: e.block.category,
196
+ message: e.block.message,
197
+ details: e.block.details,
198
+ };
199
+ }
200
+ }
201
+ return null;
202
+ }
203
+ function applyErrorPolicyOrThrow(reply, config) {
204
+ for (const block of reply.message.content) {
205
+ if (block.kind === "error") {
206
+ const policy = policyFor(config, block.category);
207
+ if (policy === "throw") {
208
+ throw new LuvError({
209
+ category: block.category,
210
+ message: block.message,
211
+ details: block.details,
212
+ });
213
+ }
214
+ }
215
+ }
216
+ }
217
+ // Real-time SSE streaming from a fetch Response. Decodes bytes as
218
+ // they arrive, scans for complete event blocks (separated by "\n\n"),
219
+ // parses each data: line, and yields luv events incrementally.
220
+ async function* streamSSE(res) {
221
+ if (!res.body)
222
+ return;
223
+ const reader = res.body.getReader();
224
+ const decoder = new TextDecoder("utf-8");
225
+ let buffer = "";
226
+ // Stateful chunk-to-luv processor mirrors the morphism, but operates
227
+ // chunk-at-a-time so we can yield events as bytes arrive.
228
+ const state = createStreamState();
229
+ let doneSignal = false;
230
+ while (!doneSignal) {
231
+ const { value, done } = await reader.read();
232
+ if (done)
233
+ break;
234
+ buffer += decoder.decode(value, { stream: true });
235
+ let sep;
236
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
237
+ const event = buffer.slice(0, sep);
238
+ buffer = buffer.slice(sep + 2);
239
+ for (const line of event.split("\n")) {
240
+ if (!line.startsWith("data: "))
241
+ continue;
242
+ const payload = line.slice(6);
243
+ if (payload === "[DONE]") {
244
+ doneSignal = true;
245
+ break;
246
+ }
247
+ let chunk;
248
+ try {
249
+ chunk = JSON.parse(payload);
250
+ }
251
+ catch {
252
+ continue;
253
+ }
254
+ for (const ev of processChunk(state, chunk))
255
+ yield ev;
256
+ }
257
+ if (doneSignal)
258
+ break;
259
+ }
260
+ }
261
+ // Emit the deferred message_end (with usage if a trailing usage chunk
262
+ // arrived before [DONE]).
263
+ for (const ev of finishStream(state))
264
+ yield ev;
265
+ }
266
+ function createStreamState() {
267
+ return {
268
+ blockOpen: null,
269
+ messageStartEmitted: false,
270
+ model: null,
271
+ usage: null,
272
+ finishReason: null,
273
+ };
274
+ }
275
+ function processChunk(state, chunk) {
276
+ const out = [];
277
+ const c = chunk;
278
+ if (typeof c.model === "string")
279
+ state.model = c.model;
280
+ // Trailing usage chunk (stream_options.include_usage): empty choices + usage.
281
+ if (c.usage !== undefined && c.usage !== null) {
282
+ state.usage = openaiUsageEnvelope(c.model ?? state.model, c.usage);
283
+ }
284
+ const choice = c.choices[0];
285
+ if (choice === undefined)
286
+ return out;
287
+ const delta = choice.delta;
288
+ const finishReason = choice.finish_reason;
289
+ if (delta.role === "assistant" && !state.messageStartEmitted) {
290
+ out.push({ kind: "message_start" });
291
+ state.messageStartEmitted = true;
292
+ }
293
+ if (Array.isArray(delta.tool_calls)) {
294
+ for (const tcDelta of delta.tool_calls) {
295
+ if (tcDelta.id !== undefined) {
296
+ if (state.blockOpen === "text") {
297
+ out.push({ kind: "block_end" });
298
+ state.blockOpen = null;
299
+ }
300
+ out.push({
301
+ kind: "block_start",
302
+ block: {
303
+ kind: "tool_call",
304
+ id: tcDelta.id,
305
+ name: tcDelta.function?.name ?? "",
306
+ args: "",
307
+ },
308
+ });
309
+ state.blockOpen = "tool_call";
310
+ const initialArgs = tcDelta.function?.arguments;
311
+ if (typeof initialArgs === "string" && initialArgs.length > 0) {
312
+ out.push({ kind: "args_delta", args: initialArgs });
313
+ }
314
+ }
315
+ else if (tcDelta.function?.arguments !== undefined &&
316
+ tcDelta.function.arguments !== "") {
317
+ out.push({ kind: "args_delta", args: tcDelta.function.arguments });
318
+ }
319
+ }
320
+ }
321
+ if (typeof delta.content === "string" && delta.content.length > 0) {
322
+ if (state.blockOpen !== "text") {
323
+ if (state.blockOpen === "tool_call") {
324
+ out.push({ kind: "block_end" });
325
+ }
326
+ out.push({ kind: "block_start", block: { kind: "text", text: "" } });
327
+ state.blockOpen = "text";
328
+ }
329
+ out.push({ kind: "text_delta", text: delta.content });
330
+ }
331
+ if (finishReason !== null && finishReason !== undefined) {
332
+ if (state.blockOpen !== null) {
333
+ out.push({ kind: "block_end" });
334
+ state.blockOpen = null;
335
+ }
336
+ // Defer message_end to finishStream: OpenAI sends usage in a trailing
337
+ // chunk AFTER this finish chunk, so message_end is emitted at stream end.
338
+ state.finishReason = finishReason;
339
+ }
340
+ return out;
341
+ }
342
+ // Emit the deferred message_end once the SSE stream ends, carrying usage if a
343
+ // trailing usage chunk was seen.
344
+ function finishStream(state) {
345
+ const out = [];
346
+ if (!state.messageStartEmitted)
347
+ return out;
348
+ if (state.blockOpen !== null) {
349
+ out.push({ kind: "block_end" });
350
+ state.blockOpen = null;
351
+ }
352
+ out.push({
353
+ kind: "message_end",
354
+ finish_reason: state.finishReason !== null ? mapFinishReason(state.finishReason) : "end_turn",
355
+ usage: state.usage,
356
+ });
357
+ return out;
358
+ }
359
+ function mapFinishReason(r) {
360
+ switch (r) {
361
+ case "stop":
362
+ return "end_turn";
363
+ case "length":
364
+ return "max_tokens";
365
+ case "content_filter":
366
+ return "content_filter";
367
+ case "tool_calls":
368
+ case "function_call":
369
+ return "end_turn";
370
+ default:
371
+ return "end_turn";
372
+ }
373
+ }
@@ -0,0 +1,101 @@
1
+ export type Role = "system" | "user" | "assistant";
2
+ export type ErrorCategory = "auth" | "rate_limit" | "bad_request" | "content_filter" | "server_error" | "network" | "tool_execution" | "local_validation" | "unknown";
3
+ export declare const ERROR_CATEGORIES: readonly ErrorCategory[];
4
+ export type Block = {
5
+ kind: "text";
6
+ text: string;
7
+ } | {
8
+ kind: "tool_call";
9
+ id: string;
10
+ name: string;
11
+ args: string;
12
+ } | {
13
+ kind: "tool_result";
14
+ call_id: string;
15
+ text: string;
16
+ } | {
17
+ kind: "error";
18
+ category: ErrorCategory;
19
+ message: string;
20
+ details: string;
21
+ };
22
+ export interface Message {
23
+ role: Role;
24
+ content: Block[];
25
+ }
26
+ export interface Node {
27
+ id: string;
28
+ parent_id: string | null;
29
+ message: Message;
30
+ }
31
+ export interface Conversation {
32
+ spec_version: string;
33
+ nodes: Node[];
34
+ }
35
+ export declare const LUV_SPEC_VERSION = "1.0";
36
+ export type FinishReason = "end_turn" | "max_tokens" | "content_filter" | "error";
37
+ /**
38
+ * Single throwable class paired with the canonical error Block shape.
39
+ * Switching from "throw" to "as_block" is conversion between this
40
+ * exception form and a Block with kind: "error".
41
+ */
42
+ export declare class LuvError extends Error {
43
+ readonly data: {
44
+ category: ErrorCategory;
45
+ message: string;
46
+ details: string;
47
+ };
48
+ constructor(data: {
49
+ category: ErrorCategory;
50
+ message: string;
51
+ details: string;
52
+ });
53
+ }
54
+ /**
55
+ * Provider-tagged usage envelope. Token accounting is NOT normalized across
56
+ * providers (token counts are not commensurable and billing differs); instead
57
+ * the provider's own usage object is preserved verbatim in `raw`, tagged with
58
+ * the morphism `provider` id and the `model` that produced the reply. The
59
+ * shape of `raw` is defined by each morphism spec and is opaque to the
60
+ * universal core.
61
+ */
62
+ export interface Usage {
63
+ provider: string;
64
+ model: string;
65
+ raw: unknown;
66
+ }
67
+ export interface Reply {
68
+ message: Message;
69
+ finish_reason: FinishReason;
70
+ usage: Usage | null;
71
+ }
72
+ export type StreamEventReply = {
73
+ kind: "message_start";
74
+ } | {
75
+ kind: "block_start";
76
+ block: Block;
77
+ } | {
78
+ kind: "text_delta";
79
+ text: string;
80
+ } | {
81
+ kind: "args_delta";
82
+ args: string;
83
+ } | {
84
+ kind: "block_end";
85
+ } | {
86
+ kind: "message_end";
87
+ finish_reason: FinishReason;
88
+ usage: Usage | null;
89
+ };
90
+ export type StreamReply = StreamEventReply[];
91
+ export interface ValidationError {
92
+ path: string;
93
+ rule: string;
94
+ message: string;
95
+ }
96
+ export type ValidationResult = {
97
+ valid: true;
98
+ } | {
99
+ valid: false;
100
+ errors: ValidationError[];
101
+ };
package/dist/types.js ADDED
@@ -0,0 +1,25 @@
1
+ export const ERROR_CATEGORIES = [
2
+ "auth",
3
+ "rate_limit",
4
+ "bad_request",
5
+ "content_filter",
6
+ "server_error",
7
+ "network",
8
+ "tool_execution",
9
+ "local_validation",
10
+ "unknown",
11
+ ];
12
+ export const LUV_SPEC_VERSION = "1.0";
13
+ /**
14
+ * Single throwable class paired with the canonical error Block shape.
15
+ * Switching from "throw" to "as_block" is conversion between this
16
+ * exception form and a Block with kind: "error".
17
+ */
18
+ export class LuvError extends Error {
19
+ data;
20
+ constructor(data) {
21
+ super(data.message);
22
+ this.name = "LuvError";
23
+ this.data = data;
24
+ }
25
+ }
@@ -0,0 +1,6 @@
1
+ import type { ValidationResult } from "./types.js";
2
+ export declare function validate_luv_block(input: unknown, basePath?: string): ValidationResult;
3
+ export declare function validate_luv_message(input: unknown, basePath?: string): ValidationResult;
4
+ export declare function validate_luv_reply(input: unknown, basePath?: string): ValidationResult;
5
+ export declare function validate_luv_stream_reply(input: unknown, basePath?: string): ValidationResult;
6
+ export declare function validate_luv_conversation(input: unknown): ValidationResult;