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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Monarch Wadia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # luv — TypeScript reference implementation
2
+
3
+ Hydration of the luv spec (`spec/SPEC.md`) in TypeScript. Runs in Bun
4
+ during development; the published package is a plain ESM library that
5
+ works in browsers, Node (≥18), Bun, and Deno. Zero runtime dependencies.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npm install luv
11
+ # or
12
+ bun add luv
13
+ # or
14
+ pnpm add luv
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```ts
20
+ import { openaiClient, anthropicClient } from "luv";
21
+
22
+ const openai = openaiClient({ api_key: process.env.OPENAI_API_KEY! });
23
+ const anthropic = anthropicClient({ api_key: process.env.ANTHROPIC_API_KEY! });
24
+
25
+ const conv = {
26
+ spec_version: "1.0",
27
+ nodes: [
28
+ {
29
+ id: "n1",
30
+ parent_id: null,
31
+ message: {
32
+ role: "user",
33
+ content: [{ kind: "text", text: "Hello!" }],
34
+ },
35
+ },
36
+ ],
37
+ };
38
+
39
+ // Same conversation, either provider, identical Reply shape.
40
+ const r1 = await openai.send(conv, { model: "gpt-4o-mini" });
41
+ const r2 = await anthropic.send(conv, {
42
+ model: "claude-haiku-4-5",
43
+ max_tokens: 1024,
44
+ });
45
+ ```
46
+
47
+ Streaming:
48
+
49
+ ```ts
50
+ for await (const event of client.stream(conv, { model: "gpt-4o-mini" })) {
51
+ if (event.kind === "text_delta") process.stdout.write(event.text);
52
+ }
53
+ ```
54
+
55
+ Errors:
56
+
57
+ ```ts
58
+ import { LuvError } from "luv";
59
+
60
+ try {
61
+ await client.send(conv, { model: "gpt-4o-mini" });
62
+ } catch (e) {
63
+ if (e instanceof LuvError) {
64
+ console.log(e.data.category); // "auth" | "rate_limit" | ...
65
+ console.log(e.data.message);
66
+ console.log(e.data.details); // canonical JSON string
67
+ }
68
+ }
69
+ ```
70
+
71
+ Configure per-error policy:
72
+
73
+ ```ts
74
+ const client = openaiClient({
75
+ api_key,
76
+ on_error: {
77
+ rate_limit: "as_block", // surface as data instead of throwing
78
+ content_filter: "as_block",
79
+ },
80
+ });
81
+ ```
82
+
83
+ ## Layout
84
+
85
+ ```
86
+ impl/typescript/
87
+ package.json
88
+ tsconfig.json
89
+ src/
90
+ index.ts — public exports
91
+ types.ts — canonical types + LuvError + ErrorCategory
92
+ encode.ts — canonical JSON encoders + stringify
93
+ stream.ts — consume_luv_stream_reply, produce_luv_stream_reply
94
+ validate.ts — five validators
95
+ morphisms/
96
+ openai_chat.ts — three morphism arrows
97
+ transport/
98
+ openai_chat.ts — three transport arrows + openaiClient
99
+ test/
100
+ bench.test.ts — walks spec/{cases,morphisms/*/cases}, byte-compares
101
+ scripts/
102
+ record.ts — refresh recorded fixtures against live API
103
+ smoke.ts — end-to-end live API smoke test
104
+ ```
105
+
106
+ ## Scripts
107
+
108
+ | Command | What it does |
109
+ |---|---|
110
+ | `bun test` | Run the bench against on-disk fixtures (no network). |
111
+ | `bun run build` | Compile `src/` to `dist/` with type declarations (uses `tsc`). |
112
+ | `bun run verify` | Verify request-shape cases (luv→provider) against the live API; no file writes. |
113
+ | `bun run record` | Refresh recorded fixtures (input.json + regenerated expected.json) by hitting the live API. Reviewable via `git diff`. |
114
+ | `bun run smoke` | Live end-to-end smoke test of `client.send` + `client.stream`. |
115
+
116
+ All scripts that hit the live API expect `OPENAI_API_KEY` and/or
117
+ `ANTHROPIC_API_KEY` in either the environment or `<repo-root>/.env`.
118
+ Providers without a configured key are skipped.
119
+
120
+ ## Universal use
121
+
122
+ The `src/` code uses only standard JavaScript and Web APIs (`fetch`,
123
+ `ReadableStream`, `TextDecoder`). It can be imported directly in a
124
+ browser, in Node, in Bun, or in any modern JS runtime. The bench runner
125
+ (`test/`) is Bun-specific because it walks the filesystem; everything
126
+ under `src/` is universal.
127
+
128
+ ## Arrows registered with the bench
129
+
130
+ Universal (spec-level) arrows — `spec/cases/`:
131
+ - `consume_luv_stream_reply`
132
+ - `produce_luv_stream_reply`
133
+ - `validate_luv_conversation`
134
+
135
+ OpenAI morphism arrows — `spec/morphisms/openai_chat/cases/`:
136
+ - `luv_conversation_to_openai_request`
137
+ - `openai_response_to_luv_reply`
138
+ - `openai_stream_to_luv_stream`
139
+
140
+ OpenAI transport arrows — `spec/morphisms/openai_chat/cases/`:
141
+ - `luv_send_to_openai_http_request`
142
+ - `openai_http_response_to_luv_reply`
143
+ - `openai_http_stream_to_luv_stream`
144
+
145
+ Anthropic morphism arrows — `spec/morphisms/anthropic_messages/cases/`:
146
+ - `luv_conversation_to_anthropic_request`
147
+ - `anthropic_response_to_luv_reply`
148
+ - `anthropic_stream_to_luv_stream`
149
+
150
+ Anthropic transport arrows — `spec/morphisms/anthropic_messages/cases/`:
151
+ - `luv_send_to_anthropic_http_request`
152
+ - `anthropic_http_response_to_luv_reply`
153
+ - `anthropic_http_stream_to_luv_stream`
154
+
155
+ Also exported but not (yet) exercised by bench cases:
156
+ `validate_luv_message`, `validate_luv_block`, `validate_luv_reply`,
157
+ `validate_luv_stream_reply`.
158
+
159
+ ## OpenAI-compatible providers
160
+
161
+ `openaiClient` works with any provider that mirrors OpenAI's Chat
162
+ Completions wire format. Pass a `base_url`:
163
+
164
+ ```ts
165
+ const togetherClient = openaiClient({
166
+ api_key: process.env.TOGETHER_API_KEY!,
167
+ base_url: "https://api.together.xyz/v1",
168
+ });
169
+ ```
170
+
171
+ See `spec/morphisms/openai_chat/transport.md` for the full list of
172
+ known-compatible providers.
173
+
174
+ ## Design notes
175
+
176
+ - **Canonical JSON.** Encoders construct plain objects with property
177
+ insertion in canonical key order; `JSON.stringify` preserves that
178
+ order in ES2015+. `stringify()` walks the value tree to reject lone
179
+ surrogates before serializing (Section 3 rule 3).
180
+ - **Validators.** Single-pass walk, stable sort by JSON Pointer path at
181
+ the end. Path format matches the spec exactly (`/nodes/<i>/...`).
182
+ - **Streaming.** `openaiClient.stream()` returns `AsyncIterable<StreamEventReply>` —
183
+ the natural shape for TS (`for await`). Internally it reads the
184
+ Response body via `ReadableStream` and emits luv events as bytes
185
+ arrive; no buffering of the full response.
186
+ - **Recording.** `bun run record` refreshes `input.json` from the live
187
+ API and regenerates `expected.json` from the current arrow. Diffs
188
+ surface in `git diff` for human review before commit. Standard
189
+ snapshot-test workflow.
190
+ - **Zero runtime dependencies.** All shipped code is hand-written. The
191
+ transport layer uses `fetch`, `ReadableStream`, and `TextDecoder` —
192
+ all Web Standard APIs available in every modern runtime. The only
193
+ dev dependency is `typescript` (for type-declaration emission during
194
+ publish; see `DECISIONS.md`).
195
+ - **Bun for development.** `bun test`, `bun build`, `bun:test`, and
196
+ hand-rolled scripts. No bundlers, linters, or other tooling.
@@ -0,0 +1,12 @@
1
+ import type { Block, Message, Node, Conversation, Reply, Usage, StreamEventReply, StreamReply, ValidationError, ValidationResult } from "./types.js";
2
+ export declare function encodeBlock(b: Block): unknown;
3
+ export declare function encodeMessage(m: Message): unknown;
4
+ export declare function encodeNode(n: Node): unknown;
5
+ export declare function encodeConversation(c: Conversation): unknown;
6
+ export declare function encodeUsage(u: Usage | null): unknown;
7
+ export declare function encodeReply(r: Reply): unknown;
8
+ export declare function encodeStreamEventReply(e: StreamEventReply): unknown;
9
+ export declare function encodeStreamReply(s: StreamReply): unknown;
10
+ export declare function encodeValidationError(e: ValidationError): unknown;
11
+ export declare function encodeValidationResult(v: ValidationResult): unknown;
12
+ export declare function stringify(v: unknown): string;
package/dist/encode.js ADDED
@@ -0,0 +1,115 @@
1
+ // Each encoder produces a plain JS object whose property insertion order
2
+ // matches the canonical key order for its type (Section 3 rule 1).
3
+ // JSON.stringify preserves insertion order in ES2015+, so stringifying
4
+ // the result yields canonical bytes.
5
+ export function encodeBlock(b) {
6
+ switch (b.kind) {
7
+ case "text":
8
+ return { kind: b.kind, text: b.text };
9
+ case "tool_call":
10
+ return { kind: b.kind, id: b.id, name: b.name, args: b.args };
11
+ case "tool_result":
12
+ return { kind: b.kind, call_id: b.call_id, text: b.text };
13
+ case "error":
14
+ return {
15
+ kind: b.kind,
16
+ category: b.category,
17
+ message: b.message,
18
+ details: b.details,
19
+ };
20
+ }
21
+ }
22
+ export function encodeMessage(m) {
23
+ return { role: m.role, content: m.content.map(encodeBlock) };
24
+ }
25
+ export function encodeNode(n) {
26
+ return { id: n.id, parent_id: n.parent_id, message: encodeMessage(n.message) };
27
+ }
28
+ export function encodeConversation(c) {
29
+ return {
30
+ spec_version: c.spec_version,
31
+ nodes: c.nodes.map(encodeNode),
32
+ };
33
+ }
34
+ export function encodeUsage(u) {
35
+ if (u === null)
36
+ return null;
37
+ // Envelope key order: provider, model, raw. `raw` is morphism-defined and
38
+ // already in canonical key order by construction; passed through unchanged.
39
+ return { provider: u.provider, model: u.model, raw: u.raw };
40
+ }
41
+ export function encodeReply(r) {
42
+ return {
43
+ message: encodeMessage(r.message),
44
+ finish_reason: r.finish_reason,
45
+ usage: encodeUsage(r.usage),
46
+ };
47
+ }
48
+ export function encodeStreamEventReply(e) {
49
+ switch (e.kind) {
50
+ case "message_start":
51
+ return { kind: e.kind };
52
+ case "block_start":
53
+ return { kind: e.kind, block: encodeBlock(e.block) };
54
+ case "text_delta":
55
+ return { kind: e.kind, text: e.text };
56
+ case "args_delta":
57
+ return { kind: e.kind, args: e.args };
58
+ case "block_end":
59
+ return { kind: e.kind };
60
+ case "message_end":
61
+ return {
62
+ kind: e.kind,
63
+ finish_reason: e.finish_reason,
64
+ usage: encodeUsage(e.usage),
65
+ };
66
+ }
67
+ }
68
+ export function encodeStreamReply(s) {
69
+ return s.map(encodeStreamEventReply);
70
+ }
71
+ export function encodeValidationError(e) {
72
+ return { path: e.path, rule: e.rule, message: e.message };
73
+ }
74
+ export function encodeValidationResult(v) {
75
+ if (v.valid)
76
+ return { valid: true };
77
+ return { valid: false, errors: v.errors.map(encodeValidationError) };
78
+ }
79
+ // Final canonical serialization. JSON.stringify with no replacer/spacer
80
+ // produces no insignificant whitespace and preserves the property
81
+ // insertion order set up by the encoders above.
82
+ export function stringify(v) {
83
+ // Reject lone surrogates anywhere in the value tree (Section 3 rule 3).
84
+ checkStrings(v);
85
+ return JSON.stringify(v);
86
+ }
87
+ function checkStrings(v) {
88
+ if (typeof v === "string") {
89
+ for (let i = 0; i < v.length; i++) {
90
+ const code = v.charCodeAt(i);
91
+ if (code >= 0xd800 && code <= 0xdbff) {
92
+ if (i + 1 >= v.length) {
93
+ throw new Error(`Lone high surrogate at position ${i}`);
94
+ }
95
+ const next = v.charCodeAt(i + 1);
96
+ if (next < 0xdc00 || next > 0xdfff) {
97
+ throw new Error(`Lone high surrogate at position ${i}`);
98
+ }
99
+ i++;
100
+ }
101
+ else if (code >= 0xdc00 && code <= 0xdfff) {
102
+ throw new Error(`Lone low surrogate at position ${i}`);
103
+ }
104
+ }
105
+ }
106
+ else if (Array.isArray(v)) {
107
+ for (const item of v)
108
+ checkStrings(item);
109
+ }
110
+ else if (v !== null && typeof v === "object") {
111
+ for (const k of Object.keys(v)) {
112
+ checkStrings(v[k]);
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,7 @@
1
+ export type { Role, Block, ErrorCategory, Message, Node, Conversation, FinishReason, Reply, Usage, StreamEventReply, StreamReply, ValidationError, ValidationResult, } from "./types.js";
2
+ export { LUV_SPEC_VERSION, ERROR_CATEGORIES, LuvError } from "./types.js";
3
+ export { encodeBlock, encodeMessage, encodeNode, encodeConversation, encodeReply, encodeUsage, encodeStreamEventReply, encodeStreamReply, encodeValidationError, encodeValidationResult, stringify, } from "./encode.js";
4
+ export { consume_luv_stream_reply, produce_luv_stream_reply, } from "./stream.js";
5
+ export { validate_luv_conversation, validate_luv_message, validate_luv_block, validate_luv_reply, validate_luv_stream_reply, } from "./validate.js";
6
+ export { luv_send_to_openai_http_request, openai_http_response_to_luv_reply, openai_http_stream_to_luv_stream, openaiClient, type HTTPRequest, type HTTPResponse, type OpenAIClient, type OpenAIClientConfig, type ErrorPolicy, type ErrorPolicyMap, } from "./transport/openai_chat.js";
7
+ export { luv_send_to_anthropic_http_request, anthropic_http_response_to_luv_reply, anthropic_http_stream_to_luv_stream, anthropicClient, type AnthropicClient, type AnthropicClientConfig, } from "./transport/anthropic_messages.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { LUV_SPEC_VERSION, ERROR_CATEGORIES, LuvError } from "./types.js";
2
+ export { encodeBlock, encodeMessage, encodeNode, encodeConversation, encodeReply, encodeUsage, encodeStreamEventReply, encodeStreamReply, encodeValidationError, encodeValidationResult, stringify, } from "./encode.js";
3
+ export { consume_luv_stream_reply, produce_luv_stream_reply, } from "./stream.js";
4
+ export { validate_luv_conversation, validate_luv_message, validate_luv_block, validate_luv_reply, validate_luv_stream_reply, } from "./validate.js";
5
+ export { luv_send_to_openai_http_request, openai_http_response_to_luv_reply, openai_http_stream_to_luv_stream, openaiClient, } from "./transport/openai_chat.js";
6
+ export { luv_send_to_anthropic_http_request, anthropic_http_response_to_luv_reply, anthropic_http_stream_to_luv_stream, anthropicClient, } from "./transport/anthropic_messages.js";
@@ -0,0 +1,14 @@
1
+ import type { Conversation, Reply, Usage, StreamReply } from "../types.js";
2
+ export interface AnthropicRequestOptions {
3
+ model: string;
4
+ max_tokens: number;
5
+ stream?: boolean;
6
+ tools?: unknown[];
7
+ tool_choice?: unknown;
8
+ temperature?: number;
9
+ stop_sequences?: string[];
10
+ }
11
+ export declare function luv_conversation_to_anthropic_request(conv: Conversation, opts: AnthropicRequestOptions): unknown;
12
+ export declare function anthropicUsageEnvelope(model: unknown, usage: unknown): Usage | null;
13
+ export declare function anthropic_response_to_luv_reply(resp: unknown): Reply;
14
+ export declare function anthropic_stream_to_luv_stream(events: unknown[]): StreamReply;
@@ -0,0 +1,238 @@
1
+ // luv_conversation_to_anthropic_request
2
+ // Builds an Anthropic Messages request body from a luv Conversation
3
+ // (walked linearly in array order) plus per-call options.
4
+ export function luv_conversation_to_anthropic_request(conv, opts) {
5
+ const systemTexts = [];
6
+ // First pass: per-node emission into a flat messages list.
7
+ const initial = [];
8
+ for (const node of conv.nodes) {
9
+ const m = node.message;
10
+ if (m.role === "system") {
11
+ const txt = m.content
12
+ .filter((b) => b.kind === "text")
13
+ .map((b) => b.text)
14
+ .join("");
15
+ systemTexts.push(txt);
16
+ continue;
17
+ }
18
+ if (m.role !== "user" && m.role !== "assistant")
19
+ continue;
20
+ const allText = m.content.every((b) => b.kind === "text");
21
+ let content;
22
+ if (allText) {
23
+ content = m.content
24
+ .map((b) => b.text)
25
+ .join("");
26
+ }
27
+ else {
28
+ const arr = [];
29
+ for (const b of m.content) {
30
+ const cb = blockToAnthropic(b);
31
+ if (cb !== null)
32
+ arr.push(cb);
33
+ }
34
+ // If every block dropped (e.g., only error blocks), Anthropic
35
+ // rejects content: []. Fall back to empty string.
36
+ content = arr.length > 0 ? arr : "";
37
+ }
38
+ initial.push({ role: m.role, content });
39
+ }
40
+ // Second pass: merge consecutive same-role messages.
41
+ const merged = [];
42
+ for (const msg of initial) {
43
+ const prev = merged[merged.length - 1];
44
+ if (!prev || prev.role !== msg.role) {
45
+ merged.push({ role: msg.role, content: msg.content });
46
+ continue;
47
+ }
48
+ if (typeof prev.content === "string" && typeof msg.content === "string") {
49
+ prev.content = prev.content + msg.content;
50
+ }
51
+ else {
52
+ const prevArr = typeof prev.content === "string"
53
+ ? prev.content.length > 0
54
+ ? [{ type: "text", text: prev.content }]
55
+ : []
56
+ : prev.content;
57
+ const newArr = typeof msg.content === "string"
58
+ ? msg.content.length > 0
59
+ ? [{ type: "text", text: msg.content }]
60
+ : []
61
+ : msg.content;
62
+ prev.content = [...prevArr, ...newArr];
63
+ }
64
+ }
65
+ // Canonical key order: model, max_tokens, messages, [system], [stream],
66
+ // [tools], [tool_choice], [temperature], [stop_sequences].
67
+ const req = {
68
+ model: opts.model,
69
+ max_tokens: opts.max_tokens,
70
+ messages: merged,
71
+ };
72
+ if (systemTexts.length > 0)
73
+ req.system = systemTexts.join("\n\n");
74
+ if (opts.stream !== undefined)
75
+ req.stream = opts.stream;
76
+ if (opts.tools !== undefined)
77
+ req.tools = opts.tools;
78
+ if (opts.tool_choice !== undefined)
79
+ req.tool_choice = opts.tool_choice;
80
+ if (opts.temperature !== undefined)
81
+ req.temperature = opts.temperature;
82
+ if (opts.stop_sequences !== undefined)
83
+ req.stop_sequences = opts.stop_sequences;
84
+ return req;
85
+ }
86
+ function blockToAnthropic(b) {
87
+ if (b.kind === "text") {
88
+ return { type: "text", text: b.text };
89
+ }
90
+ if (b.kind === "tool_call") {
91
+ let input = {};
92
+ try {
93
+ input = JSON.parse(b.args);
94
+ }
95
+ catch {
96
+ // Malformed args: pass empty object. Documented in homomorphism_exceptions.
97
+ }
98
+ return { type: "tool_use", id: b.id, name: b.name, input };
99
+ }
100
+ if (b.kind === "tool_result") {
101
+ return { type: "tool_result", tool_use_id: b.call_id, content: b.text };
102
+ }
103
+ // error blocks not representable; drop (documented in homomorphism_exceptions).
104
+ return null;
105
+ }
106
+ // Build the luv usage envelope from an Anthropic usage object + model.
107
+ // Token counts are preserved faithfully (not normalized); see SPEC §2.5.
108
+ export function anthropicUsageEnvelope(model, usage) {
109
+ if (usage === null || typeof usage !== "object")
110
+ return null;
111
+ // Pass the provider's usage object through verbatim — every field, in the
112
+ // provider's key order. Nothing is dropped or normalized (SPEC §2.5). For
113
+ // streams this is the merged message_start + message_delta usage. `raw` is
114
+ // opaque to the core.
115
+ return {
116
+ provider: "anthropic_messages",
117
+ model: typeof model === "string" ? model : "",
118
+ raw: usage,
119
+ };
120
+ }
121
+ // anthropic_response_to_luv_reply
122
+ export function anthropic_response_to_luv_reply(resp) {
123
+ const r = resp;
124
+ const blocks = [];
125
+ for (const cb of r.content) {
126
+ if (cb.type === "text") {
127
+ blocks.push({ kind: "text", text: cb.text });
128
+ }
129
+ else if (cb.type === "tool_use") {
130
+ blocks.push({
131
+ kind: "tool_call",
132
+ id: cb.id,
133
+ name: cb.name,
134
+ args: JSON.stringify(cb.input),
135
+ });
136
+ }
137
+ }
138
+ return {
139
+ message: { role: "assistant", content: blocks },
140
+ finish_reason: mapStopReason(r.stop_reason),
141
+ usage: anthropicUsageEnvelope(r.model, r.usage),
142
+ };
143
+ }
144
+ function mapStopReason(r) {
145
+ switch (r) {
146
+ case "end_turn":
147
+ return "end_turn";
148
+ case "max_tokens":
149
+ return "max_tokens";
150
+ case "stop_sequence":
151
+ return "end_turn";
152
+ case "tool_use":
153
+ return "end_turn";
154
+ default:
155
+ return "end_turn";
156
+ }
157
+ }
158
+ // anthropic_stream_to_luv_stream
159
+ export function anthropic_stream_to_luv_stream(events) {
160
+ const out = [];
161
+ let storedStopReason = null;
162
+ let model = null;
163
+ let usageObj = null;
164
+ for (const evt of events) {
165
+ const e = evt;
166
+ switch (e.type) {
167
+ case "message_start":
168
+ out.push({ kind: "message_start" });
169
+ if (e.message) {
170
+ if (typeof e.message.model === "string")
171
+ model = e.message.model;
172
+ if (e.message.usage)
173
+ usageObj = { ...e.message.usage };
174
+ }
175
+ break;
176
+ case "content_block_start": {
177
+ const cb = e.content_block;
178
+ if (!cb)
179
+ break;
180
+ if (cb.type === "text") {
181
+ out.push({
182
+ kind: "block_start",
183
+ block: { kind: "text", text: "" },
184
+ });
185
+ }
186
+ else if (cb.type === "tool_use") {
187
+ out.push({
188
+ kind: "block_start",
189
+ block: {
190
+ kind: "tool_call",
191
+ id: cb.id ?? "",
192
+ name: cb.name ?? "",
193
+ args: "",
194
+ },
195
+ });
196
+ }
197
+ break;
198
+ }
199
+ case "content_block_delta": {
200
+ const d = e.delta;
201
+ if (!d)
202
+ break;
203
+ if (d.type === "text_delta" && typeof d.text === "string") {
204
+ out.push({ kind: "text_delta", text: d.text });
205
+ }
206
+ else if (d.type === "input_json_delta" &&
207
+ typeof d.partial_json === "string") {
208
+ out.push({ kind: "args_delta", args: d.partial_json });
209
+ }
210
+ break;
211
+ }
212
+ case "content_block_stop":
213
+ out.push({ kind: "block_end" });
214
+ break;
215
+ case "message_delta":
216
+ if (e.delta && typeof e.delta.stop_reason === "string") {
217
+ storedStopReason = e.delta.stop_reason;
218
+ }
219
+ // Anthropic reports final output_tokens (and running fields) here;
220
+ // merge over the message_start usage.
221
+ if (e.usage) {
222
+ usageObj = { ...(usageObj ?? {}), ...e.usage };
223
+ }
224
+ break;
225
+ case "message_stop":
226
+ out.push({
227
+ kind: "message_end",
228
+ finish_reason: mapStopReason(storedStopReason),
229
+ usage: anthropicUsageEnvelope(model, usageObj),
230
+ });
231
+ break;
232
+ case "ping":
233
+ default:
234
+ break;
235
+ }
236
+ }
237
+ return out;
238
+ }
@@ -0,0 +1,10 @@
1
+ import type { Conversation, Reply, Usage, StreamReply } from "../types.js";
2
+ export interface OpenAIRequestOptions {
3
+ model: string;
4
+ stream?: boolean;
5
+ tools?: unknown[];
6
+ }
7
+ export declare function luv_conversation_to_openai_request(conv: Conversation, opts: OpenAIRequestOptions): unknown;
8
+ export declare function openaiUsageEnvelope(model: unknown, usage: unknown): Usage | null;
9
+ export declare function openai_response_to_luv_reply(resp: unknown): Reply;
10
+ export declare function openai_stream_to_luv_stream(chunks: unknown[]): StreamReply;