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,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
+ }
@@ -0,0 +1,3 @@
1
+ import type { Reply, StreamReply } from "./types.js";
2
+ export declare function consume_luv_stream_reply(stream: StreamReply): Reply;
3
+ export declare function produce_luv_stream_reply(reply: Reply): StreamReply;
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;