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/dist/validate.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
// Validators take *alleged* canonical values and return a ValidationResult.
|
|
2
|
+
// Errors are emitted in depth-first, left-to-right traversal order.
|
|
3
|
+
export function validate_luv_block(input, basePath = "/") {
|
|
4
|
+
const errors = [];
|
|
5
|
+
validateBlockInto(input, basePath, errors);
|
|
6
|
+
return errors.length === 0 ? { valid: true } : { valid: false, errors };
|
|
7
|
+
}
|
|
8
|
+
export function validate_luv_message(input, basePath = "/") {
|
|
9
|
+
const errors = [];
|
|
10
|
+
validateMessageInto(input, basePath, errors);
|
|
11
|
+
return errors.length === 0 ? { valid: true } : { valid: false, errors };
|
|
12
|
+
}
|
|
13
|
+
export function validate_luv_reply(input, basePath = "/") {
|
|
14
|
+
const errors = [];
|
|
15
|
+
validateReplyInto(input, basePath, errors);
|
|
16
|
+
return errors.length === 0 ? { valid: true } : { valid: false, errors };
|
|
17
|
+
}
|
|
18
|
+
export function validate_luv_stream_reply(input, basePath = "/") {
|
|
19
|
+
const errors = [];
|
|
20
|
+
validateStreamReplyInto(input, basePath, errors);
|
|
21
|
+
return errors.length === 0 ? { valid: true } : { valid: false, errors };
|
|
22
|
+
}
|
|
23
|
+
const SUPPORTED_SPEC_VERSION = "1.0";
|
|
24
|
+
export function validate_luv_conversation(input) {
|
|
25
|
+
const errors = [];
|
|
26
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
27
|
+
errors.push({
|
|
28
|
+
path: "/",
|
|
29
|
+
rule: "shape.conversation.is_object",
|
|
30
|
+
message: "Conversation must be a JSON object",
|
|
31
|
+
});
|
|
32
|
+
return { valid: false, errors };
|
|
33
|
+
}
|
|
34
|
+
const c = input;
|
|
35
|
+
// spec_version checks
|
|
36
|
+
if (typeof c.spec_version !== "string") {
|
|
37
|
+
errors.push({
|
|
38
|
+
path: "/spec_version",
|
|
39
|
+
rule: "shape.conversation.fields",
|
|
40
|
+
message: "Conversation.spec_version must be a string",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
else if (c.spec_version !== SUPPORTED_SPEC_VERSION) {
|
|
44
|
+
errors.push({
|
|
45
|
+
path: "/spec_version",
|
|
46
|
+
rule: "shape.conversation.spec_version",
|
|
47
|
+
message: `Unknown spec_version '${c.spec_version}'; this implementation supports '${SUPPORTED_SPEC_VERSION}'`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// nodes check
|
|
51
|
+
if (!Array.isArray(c.nodes)) {
|
|
52
|
+
errors.push({
|
|
53
|
+
path: "/nodes",
|
|
54
|
+
rule: "shape.conversation.fields",
|
|
55
|
+
message: "Conversation.nodes must be an array",
|
|
56
|
+
});
|
|
57
|
+
return { valid: false, errors };
|
|
58
|
+
}
|
|
59
|
+
const nodes = c.nodes;
|
|
60
|
+
// Single-pass walk of nodes. The id map is populated as we go, so
|
|
61
|
+
// any parent_id referencing a yet-unseen node fails either
|
|
62
|
+
// parent_reference (if the id never appears) or topological_order
|
|
63
|
+
// (if the id appears later).
|
|
64
|
+
const idIndex = new Map();
|
|
65
|
+
const rootIndices = [];
|
|
66
|
+
// Walk first to gather shape errors, ids, and root positions.
|
|
67
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
68
|
+
const node = nodes[i];
|
|
69
|
+
const nodePath = `/nodes/${i}`;
|
|
70
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
71
|
+
errors.push({
|
|
72
|
+
path: nodePath,
|
|
73
|
+
rule: "shape.node.fields",
|
|
74
|
+
message: "Node must be a JSON object",
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Shape: id
|
|
79
|
+
if (typeof node.id !== "string") {
|
|
80
|
+
errors.push({
|
|
81
|
+
path: `${nodePath}/id`,
|
|
82
|
+
rule: "shape.node.fields",
|
|
83
|
+
message: "Node.id must be a string",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// unique_ids
|
|
88
|
+
if (idIndex.has(node.id)) {
|
|
89
|
+
errors.push({
|
|
90
|
+
path: `${nodePath}/id`,
|
|
91
|
+
rule: "invariant.unique_ids",
|
|
92
|
+
message: `id '${node.id}' already appears at /nodes/${idIndex.get(node.id)}/id`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
idIndex.set(node.id, i);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Shape: parent_id
|
|
100
|
+
if (node.parent_id !== null && typeof node.parent_id !== "string") {
|
|
101
|
+
errors.push({
|
|
102
|
+
path: `${nodePath}/parent_id`,
|
|
103
|
+
rule: "shape.node.fields",
|
|
104
|
+
message: "Node.parent_id must be a string or null",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else if (node.parent_id === null) {
|
|
108
|
+
rootIndices.push(i);
|
|
109
|
+
}
|
|
110
|
+
// Shape + content: message
|
|
111
|
+
if (node.message === null ||
|
|
112
|
+
typeof node.message !== "object" ||
|
|
113
|
+
Array.isArray(node.message)) {
|
|
114
|
+
errors.push({
|
|
115
|
+
path: `${nodePath}/message`,
|
|
116
|
+
rule: "shape.node.fields",
|
|
117
|
+
message: "Node.message must be a JSON object",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
validateMessageInto(node.message, `${nodePath}/message`, errors);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Cross-element invariants (parent_reference, topological_order, single_root)
|
|
125
|
+
// emitted after the per-node pass, but inserted in path order via a sort below.
|
|
126
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
127
|
+
const node = nodes[i];
|
|
128
|
+
if (node === null || typeof node !== "object" || Array.isArray(node))
|
|
129
|
+
continue;
|
|
130
|
+
const pid = node.parent_id;
|
|
131
|
+
if (typeof pid !== "string")
|
|
132
|
+
continue;
|
|
133
|
+
const idx = idIndex.get(pid);
|
|
134
|
+
if (idx === undefined) {
|
|
135
|
+
errors.push({
|
|
136
|
+
path: `/nodes/${i}/parent_id`,
|
|
137
|
+
rule: "invariant.parent_reference",
|
|
138
|
+
message: `parent_id '${pid}' does not resolve to any node in the conversation`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
else if (idx >= i) {
|
|
142
|
+
errors.push({
|
|
143
|
+
path: `/nodes/${i}/parent_id`,
|
|
144
|
+
rule: "invariant.topological_order",
|
|
145
|
+
message: `parent appears at index ${idx}; must appear before the node at index ${i}`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (nodes.length > 0 && rootIndices.length === 0) {
|
|
150
|
+
errors.push({
|
|
151
|
+
path: "/nodes",
|
|
152
|
+
rule: "invariant.single_root",
|
|
153
|
+
message: "No root node found (no node has parent_id: null)",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else if (rootIndices.length > 1) {
|
|
157
|
+
for (let i = 1; i < rootIndices.length; i++) {
|
|
158
|
+
errors.push({
|
|
159
|
+
path: `/nodes/${rootIndices[i]}`,
|
|
160
|
+
rule: "invariant.single_root",
|
|
161
|
+
message: `Second root node; first root is at /nodes/${rootIndices[0]}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// tool_result_ancestry: for each tool_result block in the conversation,
|
|
166
|
+
// walk its node's parent chain looking for a matching tool_call.id.
|
|
167
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
168
|
+
const node = nodes[i];
|
|
169
|
+
if (node === null || typeof node !== "object" || Array.isArray(node))
|
|
170
|
+
continue;
|
|
171
|
+
const msg = node.message;
|
|
172
|
+
if (!msg || typeof msg !== "object")
|
|
173
|
+
continue;
|
|
174
|
+
const content = msg.content;
|
|
175
|
+
if (!Array.isArray(content))
|
|
176
|
+
continue;
|
|
177
|
+
for (let j = 0; j < content.length; j++) {
|
|
178
|
+
const b = content[j];
|
|
179
|
+
if (!b || typeof b !== "object" || b.kind !== "tool_result")
|
|
180
|
+
continue;
|
|
181
|
+
const callId = b.call_id;
|
|
182
|
+
if (typeof callId !== "string")
|
|
183
|
+
continue;
|
|
184
|
+
if (!ancestryHasToolCall(nodes, i, callId)) {
|
|
185
|
+
errors.push({
|
|
186
|
+
path: `/nodes/${i}/message/content/${j}/call_id`,
|
|
187
|
+
rule: "invariant.tool_result_ancestry",
|
|
188
|
+
message: `call_id '${callId}' is not present as a tool_call.id on any ancestor node`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Sort errors by JSON Pointer path to match the depth-first traversal
|
|
194
|
+
// order the spec requires for byte-equal ValidationResult comparison.
|
|
195
|
+
errors.sort(comparePaths);
|
|
196
|
+
return errors.length === 0 ? { valid: true } : { valid: false, errors };
|
|
197
|
+
}
|
|
198
|
+
function ancestryHasToolCall(nodes, startIndex, callId) {
|
|
199
|
+
// Walk up parent_id chain from nodes[startIndex] to root.
|
|
200
|
+
const idIndex = new Map();
|
|
201
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
202
|
+
const n = nodes[i];
|
|
203
|
+
if (n && typeof n === "object" && typeof n.id === "string") {
|
|
204
|
+
idIndex.set(n.id, i);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
let cursor = startIndex;
|
|
208
|
+
const visited = new Set();
|
|
209
|
+
while (cursor !== undefined) {
|
|
210
|
+
if (visited.has(cursor))
|
|
211
|
+
return false; // cycle safeguard
|
|
212
|
+
visited.add(cursor);
|
|
213
|
+
const n = nodes[cursor];
|
|
214
|
+
if (!n)
|
|
215
|
+
break;
|
|
216
|
+
const parent = n.parent_id;
|
|
217
|
+
cursor = typeof parent === "string" ? idIndex.get(parent) : undefined;
|
|
218
|
+
if (cursor === undefined)
|
|
219
|
+
break;
|
|
220
|
+
const parentNode = nodes[cursor];
|
|
221
|
+
if (!parentNode)
|
|
222
|
+
break;
|
|
223
|
+
const msg = parentNode.message;
|
|
224
|
+
if (!msg)
|
|
225
|
+
continue;
|
|
226
|
+
const content = msg.content;
|
|
227
|
+
if (!Array.isArray(content))
|
|
228
|
+
continue;
|
|
229
|
+
for (const b of content) {
|
|
230
|
+
const block = b;
|
|
231
|
+
if (block && block.kind === "tool_call" && block.id === callId) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
function validateMessageInto(input, basePath, errors) {
|
|
239
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
240
|
+
errors.push({
|
|
241
|
+
path: basePath,
|
|
242
|
+
rule: "shape.message.fields",
|
|
243
|
+
message: "Message must be a JSON object",
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const m = input;
|
|
248
|
+
if (m.role !== "system" && m.role !== "user" && m.role !== "assistant") {
|
|
249
|
+
errors.push({
|
|
250
|
+
path: `${basePath}/role`,
|
|
251
|
+
rule: "shape.role",
|
|
252
|
+
message: "Role must be 'system', 'user', or 'assistant'",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (!Array.isArray(m.content)) {
|
|
256
|
+
errors.push({
|
|
257
|
+
path: `${basePath}/content`,
|
|
258
|
+
rule: "shape.message.fields",
|
|
259
|
+
message: "Message.content must be an array",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
if (m.content.length === 0) {
|
|
264
|
+
errors.push({
|
|
265
|
+
path: `${basePath}/content`,
|
|
266
|
+
rule: "shape.message.content_nonempty",
|
|
267
|
+
message: "Message.content must contain at least one Block",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
for (let i = 0; i < m.content.length; i++) {
|
|
271
|
+
validateBlockInto(m.content[i], `${basePath}/content/${i}`, errors);
|
|
272
|
+
const block = m.content[i];
|
|
273
|
+
if (block && typeof block === "object") {
|
|
274
|
+
// Block role conventions.
|
|
275
|
+
if (block.kind === "tool_call" && m.role !== "assistant") {
|
|
276
|
+
errors.push({
|
|
277
|
+
path: `${basePath}/content/${i}`,
|
|
278
|
+
rule: "convention.tool_call_block_role",
|
|
279
|
+
message: "tool_call blocks may only appear in assistant messages",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (block.kind === "tool_result" && m.role !== "user") {
|
|
283
|
+
errors.push({
|
|
284
|
+
path: `${basePath}/content/${i}`,
|
|
285
|
+
rule: "convention.tool_result_block_role",
|
|
286
|
+
message: "tool_result blocks may only appear in user messages",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function validateBlockInto(input, basePath, errors) {
|
|
294
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
295
|
+
errors.push({
|
|
296
|
+
path: basePath,
|
|
297
|
+
rule: "shape.block.kind",
|
|
298
|
+
message: "Block must be a JSON object",
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const b = input;
|
|
303
|
+
if (b.kind === "text") {
|
|
304
|
+
if (typeof b.text !== "string") {
|
|
305
|
+
errors.push({
|
|
306
|
+
path: `${basePath}/text`,
|
|
307
|
+
rule: "shape.block.text",
|
|
308
|
+
message: "text block requires text: string",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else if (b.kind === "tool_call") {
|
|
313
|
+
if (typeof b.id !== "string") {
|
|
314
|
+
errors.push({
|
|
315
|
+
path: `${basePath}/id`,
|
|
316
|
+
rule: "shape.block.tool_call",
|
|
317
|
+
message: "tool_call requires id: string",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (typeof b.name !== "string") {
|
|
321
|
+
errors.push({
|
|
322
|
+
path: `${basePath}/name`,
|
|
323
|
+
rule: "shape.block.tool_call",
|
|
324
|
+
message: "tool_call requires name: string",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (typeof b.args !== "string") {
|
|
328
|
+
errors.push({
|
|
329
|
+
path: `${basePath}/args`,
|
|
330
|
+
rule: "shape.block.tool_call",
|
|
331
|
+
message: "tool_call requires args: string",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else if (b.kind === "tool_result") {
|
|
336
|
+
if (typeof b.call_id !== "string") {
|
|
337
|
+
errors.push({
|
|
338
|
+
path: `${basePath}/call_id`,
|
|
339
|
+
rule: "shape.block.tool_result",
|
|
340
|
+
message: "tool_result requires call_id: string",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (typeof b.text !== "string") {
|
|
344
|
+
errors.push({
|
|
345
|
+
path: `${basePath}/text`,
|
|
346
|
+
rule: "shape.block.tool_result",
|
|
347
|
+
message: "tool_result requires text: string",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
else if (b.kind === "error") {
|
|
352
|
+
if (b.category !== "auth" &&
|
|
353
|
+
b.category !== "rate_limit" &&
|
|
354
|
+
b.category !== "bad_request" &&
|
|
355
|
+
b.category !== "content_filter" &&
|
|
356
|
+
b.category !== "server_error" &&
|
|
357
|
+
b.category !== "network" &&
|
|
358
|
+
b.category !== "tool_execution" &&
|
|
359
|
+
b.category !== "local_validation" &&
|
|
360
|
+
b.category !== "unknown") {
|
|
361
|
+
errors.push({
|
|
362
|
+
path: `${basePath}/category`,
|
|
363
|
+
rule: "shape.block.error",
|
|
364
|
+
message: "error block requires a known ErrorCategory value",
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (typeof b.message !== "string") {
|
|
368
|
+
errors.push({
|
|
369
|
+
path: `${basePath}/message`,
|
|
370
|
+
rule: "shape.block.error",
|
|
371
|
+
message: "error block requires message: string",
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
if (typeof b.details !== "string") {
|
|
375
|
+
errors.push({
|
|
376
|
+
path: `${basePath}/details`,
|
|
377
|
+
rule: "shape.block.error",
|
|
378
|
+
message: "error block requires details: string",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
errors.push({
|
|
384
|
+
path: `${basePath}/kind`,
|
|
385
|
+
rule: "shape.block.kind",
|
|
386
|
+
message: "Block.kind must be 'text', 'tool_call', 'tool_result', or 'error'",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// (shape.reply.content_restriction now allows error blocks alongside
|
|
391
|
+
// text and tool_call; only tool_result blocks are rejected. See above.)
|
|
392
|
+
function validateReplyInto(input, basePath, errors) {
|
|
393
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
394
|
+
errors.push({
|
|
395
|
+
path: basePath,
|
|
396
|
+
rule: "shape.reply.fields",
|
|
397
|
+
message: "Reply must be a JSON object",
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const r = input;
|
|
402
|
+
if (r.message === null ||
|
|
403
|
+
typeof r.message !== "object" ||
|
|
404
|
+
Array.isArray(r.message)) {
|
|
405
|
+
errors.push({
|
|
406
|
+
path: `${basePath}/message`,
|
|
407
|
+
rule: "shape.reply.fields",
|
|
408
|
+
message: "Reply.message must be an object",
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
const mm = r.message;
|
|
413
|
+
if (mm.role !== "assistant") {
|
|
414
|
+
errors.push({
|
|
415
|
+
path: `${basePath}/message/role`,
|
|
416
|
+
rule: "shape.reply.assistant_role",
|
|
417
|
+
message: "Reply.message.role must be 'assistant'",
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
validateMessageInto(r.message, `${basePath}/message`, errors);
|
|
421
|
+
// content_restriction: only text, tool_call, or error (no tool_result)
|
|
422
|
+
if (Array.isArray(mm.content)) {
|
|
423
|
+
for (let i = 0; i < mm.content.length; i++) {
|
|
424
|
+
const block = mm.content[i];
|
|
425
|
+
if (block && block.kind === "tool_result") {
|
|
426
|
+
errors.push({
|
|
427
|
+
path: `${basePath}/message/content/${i}`,
|
|
428
|
+
rule: "shape.reply.content_restriction",
|
|
429
|
+
message: "Reply.message.content may not contain tool_result blocks",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (r.finish_reason !== "end_turn" &&
|
|
436
|
+
r.finish_reason !== "max_tokens" &&
|
|
437
|
+
r.finish_reason !== "content_filter" &&
|
|
438
|
+
r.finish_reason !== "error") {
|
|
439
|
+
errors.push({
|
|
440
|
+
path: `${basePath}/finish_reason`,
|
|
441
|
+
rule: "shape.finish_reason",
|
|
442
|
+
message: "FinishReason must be 'end_turn', 'max_tokens', 'content_filter', or 'error'",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
// shape.reply.usage: when present and non-null, usage is {provider, model,
|
|
446
|
+
// raw} with provider and model strings (raw is morphism-defined, unchecked).
|
|
447
|
+
if (r.usage !== null && r.usage !== undefined) {
|
|
448
|
+
if (typeof r.usage !== "object" || Array.isArray(r.usage)) {
|
|
449
|
+
errors.push({
|
|
450
|
+
path: `${basePath}/usage`,
|
|
451
|
+
rule: "shape.reply.usage",
|
|
452
|
+
message: "Reply.usage must be a Usage object or null",
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
const u = r.usage;
|
|
457
|
+
if (typeof u.provider !== "string" || typeof u.model !== "string") {
|
|
458
|
+
errors.push({
|
|
459
|
+
path: `${basePath}/usage`,
|
|
460
|
+
rule: "shape.reply.usage",
|
|
461
|
+
message: "Reply.usage requires string 'provider' and 'model'",
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function validateStreamReplyInto(input, basePath, errors) {
|
|
468
|
+
if (!Array.isArray(input)) {
|
|
469
|
+
errors.push({
|
|
470
|
+
path: basePath,
|
|
471
|
+
rule: "shape.stream_event.kind",
|
|
472
|
+
message: "Stream<Reply> must be a JSON array",
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// grammar walk
|
|
477
|
+
let starts = 0;
|
|
478
|
+
let ends = 0;
|
|
479
|
+
let blockOpen = null;
|
|
480
|
+
for (let i = 0; i < input.length; i++) {
|
|
481
|
+
const e = input[i];
|
|
482
|
+
if (!e || typeof e !== "object") {
|
|
483
|
+
errors.push({
|
|
484
|
+
path: `${basePath}/${i}`,
|
|
485
|
+
rule: "shape.stream_event.kind",
|
|
486
|
+
message: "Stream event must be an object",
|
|
487
|
+
});
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
switch (e.kind) {
|
|
491
|
+
case "message_start":
|
|
492
|
+
starts++;
|
|
493
|
+
if (i !== 0) {
|
|
494
|
+
errors.push({
|
|
495
|
+
path: `${basePath}/${i}`,
|
|
496
|
+
rule: "stream.message_start_unique",
|
|
497
|
+
message: "message_start must be the first event",
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
case "message_end":
|
|
502
|
+
ends++;
|
|
503
|
+
if (i !== input.length - 1) {
|
|
504
|
+
errors.push({
|
|
505
|
+
path: `${basePath}/${i}`,
|
|
506
|
+
rule: "stream.message_end_unique",
|
|
507
|
+
message: "message_end must be the last event",
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
break;
|
|
511
|
+
case "block_start": {
|
|
512
|
+
if (blockOpen !== null) {
|
|
513
|
+
errors.push({
|
|
514
|
+
path: `${basePath}/${i}`,
|
|
515
|
+
rule: "stream.block_balance",
|
|
516
|
+
message: "block_start while a block is already open",
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const b = e.block;
|
|
520
|
+
if (!b || typeof b !== "object") {
|
|
521
|
+
errors.push({
|
|
522
|
+
path: `${basePath}/${i}/block`,
|
|
523
|
+
rule: "shape.stream_event.variant_fields",
|
|
524
|
+
message: "block_start requires a block object",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
else if (b.kind === "text") {
|
|
528
|
+
blockOpen = "text";
|
|
529
|
+
}
|
|
530
|
+
else if (b.kind === "tool_call") {
|
|
531
|
+
blockOpen = "tool_call";
|
|
532
|
+
}
|
|
533
|
+
else if (b.kind === "tool_result") {
|
|
534
|
+
errors.push({
|
|
535
|
+
path: `${basePath}/${i}/block`,
|
|
536
|
+
rule: "stream.no_tool_result_blocks",
|
|
537
|
+
message: "tool_result blocks may not appear in Stream<Reply> (assistant-only)",
|
|
538
|
+
});
|
|
539
|
+
blockOpen = "text"; // tolerate for the rest of the walk
|
|
540
|
+
}
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
case "block_end":
|
|
544
|
+
if (blockOpen === null) {
|
|
545
|
+
errors.push({
|
|
546
|
+
path: `${basePath}/${i}`,
|
|
547
|
+
rule: "stream.block_balance",
|
|
548
|
+
message: "block_end with no block open",
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
blockOpen = null;
|
|
552
|
+
break;
|
|
553
|
+
case "text_delta":
|
|
554
|
+
if (blockOpen !== "text") {
|
|
555
|
+
errors.push({
|
|
556
|
+
path: `${basePath}/${i}`,
|
|
557
|
+
rule: "stream.text_delta_in_text_block",
|
|
558
|
+
message: "text_delta outside a text block",
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
case "args_delta":
|
|
563
|
+
if (blockOpen !== "tool_call") {
|
|
564
|
+
errors.push({
|
|
565
|
+
path: `${basePath}/${i}`,
|
|
566
|
+
rule: "stream.args_delta_in_tool_call_block",
|
|
567
|
+
message: "args_delta outside a tool_call block",
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
break;
|
|
571
|
+
default:
|
|
572
|
+
errors.push({
|
|
573
|
+
path: `${basePath}/${i}/kind`,
|
|
574
|
+
rule: "shape.stream_event.kind",
|
|
575
|
+
message: "Unknown StreamEvent kind",
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (starts !== 1) {
|
|
580
|
+
errors.push({
|
|
581
|
+
path: basePath,
|
|
582
|
+
rule: "stream.message_start_unique",
|
|
583
|
+
message: `expected exactly one message_start; got ${starts}`,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
if (ends !== 1) {
|
|
587
|
+
errors.push({
|
|
588
|
+
path: basePath,
|
|
589
|
+
rule: "stream.message_end_unique",
|
|
590
|
+
message: `expected exactly one message_end; got ${ends}`,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// Comparator for JSON Pointer paths in depth-first, left-to-right order.
|
|
595
|
+
// Splits on "/" and compares segment-by-segment; numeric segments compare
|
|
596
|
+
// numerically; string segments compare lexicographically.
|
|
597
|
+
function comparePaths(a, b) {
|
|
598
|
+
const as = a.path.split("/").slice(1);
|
|
599
|
+
const bs = b.path.split("/").slice(1);
|
|
600
|
+
const n = Math.min(as.length, bs.length);
|
|
601
|
+
for (let i = 0; i < n; i++) {
|
|
602
|
+
const c = compareSegment(as[i], bs[i]);
|
|
603
|
+
if (c !== 0)
|
|
604
|
+
return c;
|
|
605
|
+
}
|
|
606
|
+
return as.length - bs.length;
|
|
607
|
+
}
|
|
608
|
+
function compareSegment(a, b) {
|
|
609
|
+
const an = /^\d+$/.test(a) ? Number(a) : NaN;
|
|
610
|
+
const bn = /^\d+$/.test(b) ? Number(b) : NaN;
|
|
611
|
+
if (!Number.isNaN(an) && !Number.isNaN(bn))
|
|
612
|
+
return an - bn;
|
|
613
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
614
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "luv-ai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Canonical conversation type and provider morphisms for portable LLM apps. Forks, tool calls, streaming, and errors are first-class. Zero runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./openai": {
|
|
15
|
+
"types": "./dist/transport/openai_chat.d.ts",
|
|
16
|
+
"import": "./dist/transport/openai_chat.js",
|
|
17
|
+
"default": "./dist/transport/openai_chat.js"
|
|
18
|
+
},
|
|
19
|
+
"./anthropic": {
|
|
20
|
+
"types": "./dist/transport/anthropic_messages.d.ts",
|
|
21
|
+
"import": "./dist/transport/anthropic_messages.js",
|
|
22
|
+
"default": "./dist/transport/anthropic_messages.js"
|
|
23
|
+
},
|
|
24
|
+
"./morphisms/openai_chat": {
|
|
25
|
+
"types": "./dist/morphisms/openai_chat.d.ts",
|
|
26
|
+
"import": "./dist/morphisms/openai_chat.js",
|
|
27
|
+
"default": "./dist/morphisms/openai_chat.js"
|
|
28
|
+
},
|
|
29
|
+
"./morphisms/anthropic_messages": {
|
|
30
|
+
"types": "./dist/morphisms/anthropic_messages.d.ts",
|
|
31
|
+
"import": "./dist/morphisms/anthropic_messages.js",
|
|
32
|
+
"default": "./dist/morphisms/anthropic_messages.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
42
|
+
"test": "bun test",
|
|
43
|
+
"record": "bun run scripts/record.ts",
|
|
44
|
+
"verify": "bun run scripts/record.ts --verify",
|
|
45
|
+
"smoke": "bun run scripts/smoke.ts",
|
|
46
|
+
"prepublishOnly": "bun run build"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18",
|
|
50
|
+
"bun": ">=1.0.0"
|
|
51
|
+
},
|
|
52
|
+
"keywords": [
|
|
53
|
+
"llm",
|
|
54
|
+
"ai",
|
|
55
|
+
"openai",
|
|
56
|
+
"anthropic",
|
|
57
|
+
"claude",
|
|
58
|
+
"agents",
|
|
59
|
+
"agent",
|
|
60
|
+
"chat",
|
|
61
|
+
"streaming",
|
|
62
|
+
"tool-use",
|
|
63
|
+
"conversation",
|
|
64
|
+
"fork",
|
|
65
|
+
"canonical",
|
|
66
|
+
"spec"
|
|
67
|
+
],
|
|
68
|
+
"author": "Monarch Wadia",
|
|
69
|
+
"license": "MIT",
|
|
70
|
+
"repository": {
|
|
71
|
+
"type": "git",
|
|
72
|
+
"url": "git+https://github.com/monarchwadia/luv.git"
|
|
73
|
+
},
|
|
74
|
+
"bugs": {
|
|
75
|
+
"url": "https://github.com/monarchwadia/luv/issues"
|
|
76
|
+
},
|
|
77
|
+
"homepage": "https://github.com/monarchwadia/luv#readme",
|
|
78
|
+
"dependencies": {},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"typescript": "^5.7.0"
|
|
81
|
+
},
|
|
82
|
+
"sideEffects": false
|
|
83
|
+
}
|