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
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.
|
package/dist/encode.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|