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,247 @@
|
|
|
1
|
+
// luv_conversation_to_openai_request
|
|
2
|
+
// Maps a luv Conversation (walked linearly in array order) plus per-call
|
|
3
|
+
// options into an OpenAI Chat Completions request body.
|
|
4
|
+
export function luv_conversation_to_openai_request(conv, opts) {
|
|
5
|
+
const messages = [];
|
|
6
|
+
for (const node of conv.nodes) {
|
|
7
|
+
const m = node.message;
|
|
8
|
+
if (m.role === "system") {
|
|
9
|
+
const text = concatTextBlocks(m.content);
|
|
10
|
+
messages.push({ role: "system", content: text });
|
|
11
|
+
}
|
|
12
|
+
else if (m.role === "user") {
|
|
13
|
+
const onlyToolResults = m.content.every((b) => b.kind === "tool_result");
|
|
14
|
+
const onlyText = m.content.every((b) => b.kind === "text");
|
|
15
|
+
if (onlyText) {
|
|
16
|
+
messages.push({ role: "user", content: concatTextBlocks(m.content) });
|
|
17
|
+
}
|
|
18
|
+
else if (onlyToolResults) {
|
|
19
|
+
for (const b of m.content) {
|
|
20
|
+
if (b.kind === "tool_result") {
|
|
21
|
+
messages.push({
|
|
22
|
+
role: "tool",
|
|
23
|
+
tool_call_id: b.call_id,
|
|
24
|
+
content: b.text,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
// Mixed: emit in block order, one OpenAI message per block.
|
|
31
|
+
for (const b of m.content) {
|
|
32
|
+
if (b.kind === "text") {
|
|
33
|
+
messages.push({ role: "user", content: b.text });
|
|
34
|
+
}
|
|
35
|
+
else if (b.kind === "tool_result") {
|
|
36
|
+
messages.push({
|
|
37
|
+
role: "tool",
|
|
38
|
+
tool_call_id: b.call_id,
|
|
39
|
+
content: b.text,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (m.role === "assistant") {
|
|
46
|
+
const textPieces = [];
|
|
47
|
+
const toolCalls = [];
|
|
48
|
+
for (const b of m.content) {
|
|
49
|
+
if (b.kind === "text")
|
|
50
|
+
textPieces.push(b.text);
|
|
51
|
+
else if (b.kind === "tool_call") {
|
|
52
|
+
toolCalls.push({
|
|
53
|
+
id: b.id,
|
|
54
|
+
type: "function",
|
|
55
|
+
function: { name: b.name, arguments: b.args },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Canonical key order: role, content, [tool_calls].
|
|
60
|
+
// content is null only when tool_calls are present (OpenAI's
|
|
61
|
+
// convention). If both text and tool_calls are empty (e.g., the
|
|
62
|
+
// assistant message contained only error blocks that the morphism
|
|
63
|
+
// dropped), emit content: "" rather than null to satisfy OpenAI's
|
|
64
|
+
// request validation.
|
|
65
|
+
const out = {
|
|
66
|
+
role: "assistant",
|
|
67
|
+
content: textPieces.length > 0
|
|
68
|
+
? textPieces.join("")
|
|
69
|
+
: toolCalls.length > 0
|
|
70
|
+
? null
|
|
71
|
+
: "",
|
|
72
|
+
};
|
|
73
|
+
if (toolCalls.length > 0)
|
|
74
|
+
out.tool_calls = toolCalls;
|
|
75
|
+
messages.push(out);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Canonical key order for Request: model, messages, [stream], [tools].
|
|
79
|
+
const req = {
|
|
80
|
+
model: opts.model,
|
|
81
|
+
messages,
|
|
82
|
+
};
|
|
83
|
+
if (opts.stream !== undefined)
|
|
84
|
+
req.stream = opts.stream;
|
|
85
|
+
if (opts.stream === true)
|
|
86
|
+
req.stream_options = { include_usage: true };
|
|
87
|
+
if (opts.tools !== undefined)
|
|
88
|
+
req.tools = opts.tools;
|
|
89
|
+
return req;
|
|
90
|
+
}
|
|
91
|
+
function concatTextBlocks(content) {
|
|
92
|
+
return content
|
|
93
|
+
.filter((b) => b.kind === "text")
|
|
94
|
+
.map((b) => b.text)
|
|
95
|
+
.join("");
|
|
96
|
+
}
|
|
97
|
+
// Build the luv usage envelope from an OpenAI usage object + model.
|
|
98
|
+
// Token counts are preserved faithfully (not normalized); see SPEC §2.5.
|
|
99
|
+
export function openaiUsageEnvelope(model, usage) {
|
|
100
|
+
if (usage === null || typeof usage !== "object")
|
|
101
|
+
return null;
|
|
102
|
+
// Pass the provider's usage object through verbatim — every field, in the
|
|
103
|
+
// provider's key order. Nothing is dropped or normalized (SPEC §2.5);
|
|
104
|
+
// `raw` is opaque to the core.
|
|
105
|
+
return {
|
|
106
|
+
provider: "openai_chat",
|
|
107
|
+
model: typeof model === "string" ? model : "",
|
|
108
|
+
raw: usage,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// openai_response_to_luv_reply
|
|
112
|
+
export function openai_response_to_luv_reply(resp) {
|
|
113
|
+
const r = resp;
|
|
114
|
+
const choice = r.choices[0];
|
|
115
|
+
const msg = choice.message;
|
|
116
|
+
const blocks = [];
|
|
117
|
+
if (typeof msg.content === "string") {
|
|
118
|
+
blocks.push({ kind: "text", text: msg.content });
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(msg.tool_calls)) {
|
|
121
|
+
for (const tc of msg.tool_calls) {
|
|
122
|
+
blocks.push({
|
|
123
|
+
kind: "tool_call",
|
|
124
|
+
id: tc.id,
|
|
125
|
+
name: tc.function.name,
|
|
126
|
+
args: tc.function.arguments,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
message: { role: "assistant", content: blocks },
|
|
132
|
+
finish_reason: mapFinishReason(choice.finish_reason),
|
|
133
|
+
usage: openaiUsageEnvelope(r.model, r.usage),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function mapFinishReason(r) {
|
|
137
|
+
switch (r) {
|
|
138
|
+
case "stop":
|
|
139
|
+
return "end_turn";
|
|
140
|
+
case "length":
|
|
141
|
+
return "max_tokens";
|
|
142
|
+
case "content_filter":
|
|
143
|
+
return "content_filter";
|
|
144
|
+
case "tool_calls":
|
|
145
|
+
case "function_call":
|
|
146
|
+
return "end_turn";
|
|
147
|
+
default:
|
|
148
|
+
return "end_turn";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// openai_stream_to_luv_stream
|
|
152
|
+
// Consumes a sequence of OpenAI streaming chunks and emits luv stream
|
|
153
|
+
// events. Stateful: tracks which kind of block (if any) is currently
|
|
154
|
+
// open so deltas are tagged correctly and block boundaries fire.
|
|
155
|
+
export function openai_stream_to_luv_stream(chunks) {
|
|
156
|
+
const events = [];
|
|
157
|
+
let blockOpen = null;
|
|
158
|
+
let messageStartEmitted = false;
|
|
159
|
+
let model = null;
|
|
160
|
+
let usageEnvelope = null;
|
|
161
|
+
for (const chunk of chunks) {
|
|
162
|
+
const c = chunk;
|
|
163
|
+
if (typeof c.model === "string")
|
|
164
|
+
model = c.model;
|
|
165
|
+
// Trailing usage chunk (stream_options.include_usage) carries usage and
|
|
166
|
+
// an empty choices array.
|
|
167
|
+
if (c.usage !== undefined && c.usage !== null) {
|
|
168
|
+
usageEnvelope = openaiUsageEnvelope(c.model ?? model, c.usage);
|
|
169
|
+
}
|
|
170
|
+
const choice = c.choices[0];
|
|
171
|
+
if (choice === undefined)
|
|
172
|
+
continue;
|
|
173
|
+
const delta = choice.delta;
|
|
174
|
+
const finishReason = choice.finish_reason;
|
|
175
|
+
if (delta.role === "assistant" && !messageStartEmitted) {
|
|
176
|
+
events.push({ kind: "message_start" });
|
|
177
|
+
messageStartEmitted = true;
|
|
178
|
+
}
|
|
179
|
+
// Tool call openings/continuations.
|
|
180
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
181
|
+
for (const tcDelta of delta.tool_calls) {
|
|
182
|
+
if (tcDelta.id !== undefined) {
|
|
183
|
+
// First chunk for this tool_call slot: close any text block
|
|
184
|
+
// and open a new tool_call block.
|
|
185
|
+
if (blockOpen === "text") {
|
|
186
|
+
events.push({ kind: "block_end" });
|
|
187
|
+
blockOpen = null;
|
|
188
|
+
}
|
|
189
|
+
events.push({
|
|
190
|
+
kind: "block_start",
|
|
191
|
+
block: {
|
|
192
|
+
kind: "tool_call",
|
|
193
|
+
id: tcDelta.id,
|
|
194
|
+
name: tcDelta.function?.name ?? "",
|
|
195
|
+
args: "",
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
blockOpen = "tool_call";
|
|
199
|
+
// Emit initial args_delta only if the first chunk carried any.
|
|
200
|
+
const initialArgs = tcDelta.function?.arguments;
|
|
201
|
+
if (typeof initialArgs === "string" && initialArgs.length > 0) {
|
|
202
|
+
events.push({ kind: "args_delta", args: initialArgs });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else if (tcDelta.function?.arguments !== undefined &&
|
|
206
|
+
tcDelta.function.arguments !== "") {
|
|
207
|
+
events.push({ kind: "args_delta", args: tcDelta.function.arguments });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Text content deltas.
|
|
212
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
213
|
+
if (blockOpen !== "text") {
|
|
214
|
+
if (blockOpen === "tool_call") {
|
|
215
|
+
events.push({ kind: "block_end" });
|
|
216
|
+
}
|
|
217
|
+
events.push({
|
|
218
|
+
kind: "block_start",
|
|
219
|
+
block: { kind: "text", text: "" },
|
|
220
|
+
});
|
|
221
|
+
blockOpen = "text";
|
|
222
|
+
}
|
|
223
|
+
events.push({ kind: "text_delta", text: delta.content });
|
|
224
|
+
}
|
|
225
|
+
// Finish marker chunk.
|
|
226
|
+
if (finishReason !== null && finishReason !== undefined) {
|
|
227
|
+
if (blockOpen !== null) {
|
|
228
|
+
events.push({ kind: "block_end" });
|
|
229
|
+
blockOpen = null;
|
|
230
|
+
}
|
|
231
|
+
events.push({
|
|
232
|
+
kind: "message_end",
|
|
233
|
+
finish_reason: mapFinishReason(finishReason),
|
|
234
|
+
usage: null,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// OpenAI sends usage in a trailing chunk (after the finish chunk) when
|
|
239
|
+
// stream_options.include_usage is set; attach it to message_end.
|
|
240
|
+
if (usageEnvelope !== null) {
|
|
241
|
+
const last = events[events.length - 1];
|
|
242
|
+
if (last && last.kind === "message_end") {
|
|
243
|
+
last.usage = usageEnvelope;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return events;
|
|
247
|
+
}
|
package/dist/stream.d.ts
ADDED
package/dist/stream.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// consume_luv_stream_reply : Stream<Reply> -> Reply
|
|
2
|
+
// Collapses a well-formed Stream<Reply> into the Reply it represents.
|
|
3
|
+
export function consume_luv_stream_reply(stream) {
|
|
4
|
+
let finishReason = "end_turn";
|
|
5
|
+
let usage = null;
|
|
6
|
+
const blocks = [];
|
|
7
|
+
let current = null;
|
|
8
|
+
for (const evt of stream) {
|
|
9
|
+
switch (evt.kind) {
|
|
10
|
+
case "message_start":
|
|
11
|
+
break;
|
|
12
|
+
case "block_start": {
|
|
13
|
+
// Append a fresh copy of the initial block to content.
|
|
14
|
+
const b = evt.block;
|
|
15
|
+
if (b.kind === "text") {
|
|
16
|
+
current = { kind: "text", text: b.text };
|
|
17
|
+
}
|
|
18
|
+
else if (b.kind === "tool_call") {
|
|
19
|
+
current = { kind: "tool_call", id: b.id, name: b.name, args: b.args };
|
|
20
|
+
}
|
|
21
|
+
else if (b.kind === "tool_result") {
|
|
22
|
+
current = { kind: "tool_result", call_id: b.call_id, text: b.text };
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
current = {
|
|
26
|
+
kind: "error",
|
|
27
|
+
category: b.category,
|
|
28
|
+
message: b.message,
|
|
29
|
+
details: b.details,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
blocks.push(current);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
case "text_delta":
|
|
36
|
+
if (current && current.kind === "text") {
|
|
37
|
+
current.text += evt.text;
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
case "args_delta":
|
|
41
|
+
if (current && current.kind === "tool_call") {
|
|
42
|
+
current.args += evt.args;
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
case "block_end":
|
|
46
|
+
current = null;
|
|
47
|
+
break;
|
|
48
|
+
case "message_end":
|
|
49
|
+
finishReason = evt.finish_reason;
|
|
50
|
+
usage = evt.usage ?? null;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
message: { role: "assistant", content: blocks },
|
|
56
|
+
finish_reason: finishReason,
|
|
57
|
+
usage,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// produce_luv_stream_reply : Reply -> Stream<Reply>
|
|
61
|
+
// Lifts a Reply into the canonical singleton stream that consumes back
|
|
62
|
+
// to it. Always emits exactly one delta per block (even if empty).
|
|
63
|
+
export function produce_luv_stream_reply(reply) {
|
|
64
|
+
const events = [];
|
|
65
|
+
events.push({ kind: "message_start" });
|
|
66
|
+
for (const block of reply.message.content) {
|
|
67
|
+
if (block.kind === "text") {
|
|
68
|
+
events.push({
|
|
69
|
+
kind: "block_start",
|
|
70
|
+
block: { kind: "text", text: "" },
|
|
71
|
+
});
|
|
72
|
+
events.push({ kind: "text_delta", text: block.text });
|
|
73
|
+
events.push({ kind: "block_end" });
|
|
74
|
+
}
|
|
75
|
+
else if (block.kind === "tool_call") {
|
|
76
|
+
events.push({
|
|
77
|
+
kind: "block_start",
|
|
78
|
+
block: {
|
|
79
|
+
kind: "tool_call",
|
|
80
|
+
id: block.id,
|
|
81
|
+
name: block.name,
|
|
82
|
+
args: "",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
events.push({ kind: "args_delta", args: block.args });
|
|
86
|
+
events.push({ kind: "block_end" });
|
|
87
|
+
}
|
|
88
|
+
else if (block.kind === "error") {
|
|
89
|
+
events.push({ kind: "block_start", block });
|
|
90
|
+
events.push({ kind: "block_end" });
|
|
91
|
+
}
|
|
92
|
+
// tool_result blocks don't appear in Stream<Reply> (assistant-only).
|
|
93
|
+
}
|
|
94
|
+
events.push({
|
|
95
|
+
kind: "message_end",
|
|
96
|
+
finish_reason: reply.finish_reason,
|
|
97
|
+
usage: reply.usage ?? null,
|
|
98
|
+
});
|
|
99
|
+
return events;
|
|
100
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Conversation, ErrorCategory, Reply, StreamEventReply, StreamReply } from "../types.js";
|
|
2
|
+
import { type AnthropicRequestOptions } from "../morphisms/anthropic_messages.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 AnthropicClientConfig {
|
|
17
|
+
api_key: string;
|
|
18
|
+
base_url?: string;
|
|
19
|
+
anthropic_version?: string;
|
|
20
|
+
default_max_tokens?: number;
|
|
21
|
+
timeout_ms?: number;
|
|
22
|
+
on_error?: ErrorPolicyMap;
|
|
23
|
+
}
|
|
24
|
+
export declare function luv_send_to_anthropic_http_request(conv: Conversation, opts: AnthropicRequestOptions, config: AnthropicClientConfig): HTTPRequest;
|
|
25
|
+
export declare function anthropic_http_response_to_luv_reply(response: HTTPResponse): Reply;
|
|
26
|
+
export declare function anthropic_http_stream_to_luv_stream(response: HTTPResponse): StreamReply;
|
|
27
|
+
export interface AnthropicClient {
|
|
28
|
+
send(conv: Conversation, opts: AnthropicRequestOptions): Promise<Reply>;
|
|
29
|
+
stream(conv: Conversation, opts: AnthropicRequestOptions): AsyncIterable<StreamEventReply>;
|
|
30
|
+
}
|
|
31
|
+
export declare function anthropicClient(config: AnthropicClientConfig): AnthropicClient;
|