llm-messages 0.4.9 → 0.5.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/CHANGELOG.md +25 -0
- package/README.md +24 -6
- package/ROADMAP.md +3 -2
- package/dist/index.cjs +121 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +120 -15
- package/dist/index.js.map +1 -1
- package/docs/security-posture.md +19 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
|
|
5
5
|
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.5.0] - 2026-06-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added OpenAI Responses API response normalization via
|
|
14
|
+
`responseFromOpenAIResponses(...)` and explicit `normalizeResponse(...)`
|
|
15
|
+
routing, mapping `output_text` to assistant content, `function_call` to Chat
|
|
16
|
+
Completions-compatible `tool_calls`, and Responses usage/status fields to
|
|
17
|
+
neutral `usage` and `finishReason` values.
|
|
18
|
+
- Preserved Anthropic `tool_result.is_error` as optional canonical tool-message
|
|
19
|
+
metadata across Anthropic round trips.
|
|
20
|
+
- Preserved standalone Gemini `functionResponse.name` as optional canonical
|
|
21
|
+
tool-message metadata so orphaned tool results can convert back to Gemini
|
|
22
|
+
without using the result id as the function name.
|
|
23
|
+
- Added `dropped-metadata` warnings for OpenAI message names and provider-only
|
|
24
|
+
tool result metadata that the selected target provider cannot represent.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Kept empty user turns intact when round-tripping through Anthropic or Gemini.
|
|
29
|
+
- Preserved mixed Anthropic user text and `tool_result` block order when
|
|
30
|
+
converting into canonical OpenAI-compatible messages and back.
|
|
31
|
+
|
|
7
32
|
## [0.4.9] - 2026-06-04
|
|
8
33
|
|
|
9
34
|
### Added
|
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/llm-messages)
|
|
4
4
|
[](https://www.npmjs.com/package/llm-messages)
|
|
5
5
|
[](https://github.com/slegarraga/llm-messages/actions/workflows/ci.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/slegarraga/llm-messages)
|
|
6
7
|
[](./LICENSE)
|
|
7
8
|
[](./package.json)
|
|
8
9
|
|
|
@@ -87,7 +88,10 @@ fromGemini(toGemini(messages)); // deep-equals the original `messages`
|
|
|
87
88
|
|
|
88
89
|
Arguments are parsed and re-serialized, ids are preserved (and regenerated
|
|
89
90
|
deterministically when a Gemini payload omits them), and parallel tool results
|
|
90
|
-
are grouped into the single user turn each provider expects.
|
|
91
|
+
are grouped into the single user turn each provider expects. Anthropic
|
|
92
|
+
`tool_result.is_error` is preserved as optional canonical tool-message metadata;
|
|
93
|
+
standalone Gemini `functionResponse.name` is also preserved so orphaned tool
|
|
94
|
+
results can be sent back to Gemini without renaming the function to the id.
|
|
91
95
|
|
|
92
96
|
## Conversion report
|
|
93
97
|
|
|
@@ -101,7 +105,9 @@ toGemini(messages, {
|
|
|
101
105
|
```
|
|
102
106
|
|
|
103
107
|
Warning codes: `generated-id`, `unmapped-tool-result`, `merged-role`,
|
|
104
|
-
`dropped-content`, `invalid-json-arguments`,
|
|
108
|
+
`dropped-content`, `dropped-metadata`, `invalid-json-arguments`,
|
|
109
|
+
`system-midstream`, `gemini-url-image`, `gemini-url-media`,
|
|
110
|
+
`unsupported-modality`.
|
|
105
111
|
|
|
106
112
|
## Reading responses
|
|
107
113
|
|
|
@@ -109,20 +115,25 @@ The same idea applies to the read side. Normalize a provider's response body int
|
|
|
109
115
|
a canonical OpenAI assistant message, plus a neutral finish reason and token usage:
|
|
110
116
|
|
|
111
117
|
```ts
|
|
112
|
-
import { responseFromAnthropic, normalizeResponse } from 'llm-messages';
|
|
118
|
+
import { responseFromAnthropic, responseFromOpenAIResponses, normalizeResponse } from 'llm-messages';
|
|
113
119
|
|
|
114
120
|
const { message, finishReason, usage } = responseFromAnthropic(anthropicResponseBody);
|
|
115
121
|
// message -> { role: 'assistant', content, tool_calls? } (tool input re-serialized to a JSON string)
|
|
116
122
|
// finishReason -> 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'unknown'
|
|
117
123
|
// usage -> { inputTokens, outputTokens }
|
|
118
124
|
|
|
125
|
+
const responses = responseFromOpenAIResponses(openaiResponsesBody);
|
|
126
|
+
// OpenAI Responses API `output_text` items become assistant `content`.
|
|
127
|
+
// `function_call` items become Chat Completions-compatible `tool_calls`.
|
|
128
|
+
|
|
119
129
|
// Or dispatch by provider:
|
|
120
130
|
normalizeResponse(geminiResponseBody, { from: 'gemini' });
|
|
131
|
+
normalizeResponse(openaiResponsesBody, { from: 'openai-responses' });
|
|
121
132
|
```
|
|
122
133
|
|
|
123
134
|
`finishReason` is normalized to `tool_calls` whenever the model called a tool, even
|
|
124
|
-
for Gemini (which reports `STOP`)
|
|
125
|
-
deterministic one.
|
|
135
|
+
for Gemini (which reports `STOP`) and Responses API bodies with `function_call`
|
|
136
|
+
items. Gemini tool calls without an id get a deterministic one.
|
|
126
137
|
|
|
127
138
|
## Format cheatsheet
|
|
128
139
|
|
|
@@ -169,7 +180,11 @@ dropped with an `unsupported-modality` warning. Documents convert across all thr
|
|
|
169
180
|
|
|
170
181
|
Version 0.x covers text, system prompts, tool calls/results, images, audio and
|
|
171
182
|
documents, which is the core of every agent loop. Unsupported parts are reported
|
|
172
|
-
via `dropped-content` rather than failing.
|
|
183
|
+
via `dropped-content` rather than failing. Provider-only fields are preserved
|
|
184
|
+
only when the canonical OpenAI-compatible shape has an explicit optional
|
|
185
|
+
metadata field for them, such as Anthropic `tool_result.is_error` and standalone
|
|
186
|
+
Gemini `functionResponse.name`. When that metadata has no target-provider
|
|
187
|
+
equivalent, conversion continues and reports `dropped-metadata`.
|
|
173
188
|
|
|
174
189
|
## Roadmap
|
|
175
190
|
|
|
@@ -183,6 +198,9 @@ For teams evaluating the package, the
|
|
|
183
198
|
[adoption guide](./docs/adoption-guide.md) covers the OpenAI-compatible boundary,
|
|
184
199
|
local validation and production checks.
|
|
185
200
|
|
|
201
|
+
Security posture is tracked in [docs/security-posture.md](./docs/security-posture.md),
|
|
202
|
+
including CodeQL, OpenSSF Scorecard, Dependabot and branch rules.
|
|
203
|
+
|
|
186
204
|
## Provider portability suite
|
|
187
205
|
|
|
188
206
|
`llm-messages` is the conversation boundary in a small provider-portability
|
package/ROADMAP.md
CHANGED
|
@@ -9,8 +9,9 @@ fallback behavior matter.
|
|
|
9
9
|
|
|
10
10
|
1. **OpenAI Responses API coverage**
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
Initial response normalization now maps Responses API `output_text` and
|
|
13
|
+
`function_call` items back into the current OpenAI Chat Completions-compatible
|
|
14
|
+
hub shape. Next: expand multimodal and streaming conformance fixtures.
|
|
14
15
|
|
|
15
16
|
Public issue: https://github.com/slegarraga/llm-messages/issues/6
|
|
16
17
|
|
package/dist/index.cjs
CHANGED
|
@@ -28,6 +28,7 @@ __export(index_exports, {
|
|
|
28
28
|
responseFromAnthropic: () => responseFromAnthropic,
|
|
29
29
|
responseFromGemini: () => responseFromGemini,
|
|
30
30
|
responseFromOpenAI: () => responseFromOpenAI,
|
|
31
|
+
responseFromOpenAIResponses: () => responseFromOpenAIResponses,
|
|
31
32
|
toAnthropic: () => toAnthropic,
|
|
32
33
|
toDataUrl: () => toDataUrl,
|
|
33
34
|
toGemini: () => toGemini
|
|
@@ -280,6 +281,12 @@ function splitSystem(messages, reporter) {
|
|
|
280
281
|
let started = false;
|
|
281
282
|
for (const message of messages) {
|
|
282
283
|
if (isSystem(message)) {
|
|
284
|
+
if (typeof message.name === "string") {
|
|
285
|
+
reporter.warn(
|
|
286
|
+
"dropped-metadata",
|
|
287
|
+
`${message.role} message name '${message.name}' has no top-level system prompt equivalent; dropped.`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
283
290
|
if (started) {
|
|
284
291
|
reporter.warn(
|
|
285
292
|
"system-midstream",
|
|
@@ -308,7 +315,18 @@ function toAnthropic(messages, options = {}) {
|
|
|
308
315
|
let j = i;
|
|
309
316
|
while (j < rest.length && rest[j].role === "tool") {
|
|
310
317
|
const tool = rest[j];
|
|
311
|
-
|
|
318
|
+
if (typeof tool.name === "string") {
|
|
319
|
+
reporter.warn(
|
|
320
|
+
"dropped-metadata",
|
|
321
|
+
`Tool message name '${tool.name}' has no Anthropic tool_result equivalent; dropped.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
blocks.push({
|
|
325
|
+
type: "tool_result",
|
|
326
|
+
tool_use_id: tool.tool_call_id,
|
|
327
|
+
content: textOf(tool.content),
|
|
328
|
+
...typeof tool.is_error === "boolean" ? { is_error: tool.is_error } : {}
|
|
329
|
+
});
|
|
312
330
|
j++;
|
|
313
331
|
}
|
|
314
332
|
out.push({ role: "user", content: blocks });
|
|
@@ -316,6 +334,7 @@ function toAnthropic(messages, options = {}) {
|
|
|
316
334
|
continue;
|
|
317
335
|
}
|
|
318
336
|
if (message.role === "user") {
|
|
337
|
+
warnDroppedName("User", message.name, "Anthropic", reporter);
|
|
319
338
|
out.push({ role: "user", content: userContent(message.content, reporter) });
|
|
320
339
|
continue;
|
|
321
340
|
}
|
|
@@ -349,6 +368,7 @@ function userContent(content, reporter) {
|
|
|
349
368
|
return blocks;
|
|
350
369
|
}
|
|
351
370
|
function assistantContent(message, reporter) {
|
|
371
|
+
warnDroppedName("Assistant", message.name, "Anthropic", reporter);
|
|
352
372
|
const text = textOf(message.content ?? "");
|
|
353
373
|
const toolCalls = message.tool_calls ?? [];
|
|
354
374
|
if (toolCalls.length === 0) return text;
|
|
@@ -364,6 +384,10 @@ function assistantContent(message, reporter) {
|
|
|
364
384
|
}
|
|
365
385
|
return blocks;
|
|
366
386
|
}
|
|
387
|
+
function warnDroppedName(role, name, provider, reporter) {
|
|
388
|
+
if (typeof name !== "string") return;
|
|
389
|
+
reporter.warn("dropped-metadata", `${role} message name '${name}' has no ${provider} equivalent; dropped.`);
|
|
390
|
+
}
|
|
367
391
|
function mergeConsecutive(messages, reporter) {
|
|
368
392
|
const result = [];
|
|
369
393
|
for (const message of messages) {
|
|
@@ -393,18 +417,31 @@ function fromAnthropic(conversation, options = {}) {
|
|
|
393
417
|
for (const message of conversation.messages) {
|
|
394
418
|
const blocks = asBlocks(message.content);
|
|
395
419
|
if (message.role === "user") {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
420
|
+
if (blocks.length === 0) {
|
|
421
|
+
out.push({ role: "user", content: "" });
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
let contentBlocks = [];
|
|
425
|
+
const flushContent = () => {
|
|
426
|
+
if (contentBlocks.length > 0) {
|
|
427
|
+
out.push({ role: "user", content: userContentToOpenAI(contentBlocks, reporter) });
|
|
428
|
+
contentBlocks = [];
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
for (const block of blocks) {
|
|
432
|
+
if (block.type !== "tool_result") {
|
|
433
|
+
contentBlocks.push(block);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
flushContent();
|
|
399
437
|
out.push({
|
|
400
438
|
role: "tool",
|
|
401
439
|
tool_call_id: String(block.tool_use_id ?? ""),
|
|
402
|
-
content: textOf(block.content)
|
|
440
|
+
content: textOf(block.content),
|
|
441
|
+
...typeof block.is_error === "boolean" ? { is_error: block.is_error } : {}
|
|
403
442
|
});
|
|
404
443
|
}
|
|
405
|
-
|
|
406
|
-
out.push({ role: "user", content: userContentToOpenAI(contentBlocks, reporter) });
|
|
407
|
-
}
|
|
444
|
+
flushContent();
|
|
408
445
|
continue;
|
|
409
446
|
}
|
|
410
447
|
const text = textOf(blocks.filter((b) => b.type === "text"));
|
|
@@ -462,7 +499,20 @@ function toGemini(messages, options = {}) {
|
|
|
462
499
|
let j = i;
|
|
463
500
|
while (j < rest.length && rest[j].role === "tool") {
|
|
464
501
|
const tool = rest[j];
|
|
465
|
-
const
|
|
502
|
+
const matchingName = idToName.get(tool.tool_call_id);
|
|
503
|
+
if (typeof tool.is_error === "boolean") {
|
|
504
|
+
reporter.warn(
|
|
505
|
+
"dropped-metadata",
|
|
506
|
+
`Tool message is_error=${tool.is_error} has no Gemini functionResponse equivalent; dropped.`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (typeof tool.name === "string" && matchingName && tool.name !== matchingName) {
|
|
510
|
+
reporter.warn(
|
|
511
|
+
"dropped-metadata",
|
|
512
|
+
`Tool message name '${tool.name}' differs from matching tool call '${matchingName}'; used the tool-call function name for Gemini.`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
const name = matchingName ?? tool.name;
|
|
466
516
|
if (!name) {
|
|
467
517
|
reporter.warn(
|
|
468
518
|
"unmapped-tool-result",
|
|
@@ -483,9 +533,11 @@ function toGemini(messages, options = {}) {
|
|
|
483
533
|
continue;
|
|
484
534
|
}
|
|
485
535
|
if (message.role === "user") {
|
|
536
|
+
warnDroppedName2("User", message.name, "Gemini", reporter);
|
|
486
537
|
contents.push({ role: "user", parts: userParts(message.content, reporter) });
|
|
487
538
|
continue;
|
|
488
539
|
}
|
|
540
|
+
warnDroppedName2("Assistant", message.name, "Gemini", reporter);
|
|
489
541
|
contents.push({ role: "model", parts: assistantParts(message, reporter) });
|
|
490
542
|
}
|
|
491
543
|
const merged = mergeConsecutive2(contents, reporter);
|
|
@@ -529,6 +581,10 @@ function assistantParts(message, reporter) {
|
|
|
529
581
|
}
|
|
530
582
|
return parts.length > 0 ? parts : [{ text: "" }];
|
|
531
583
|
}
|
|
584
|
+
function warnDroppedName2(role, name, provider, reporter) {
|
|
585
|
+
if (typeof name !== "string") return;
|
|
586
|
+
reporter.warn("dropped-metadata", `${role} message name '${name}' has no ${provider} equivalent; dropped.`);
|
|
587
|
+
}
|
|
532
588
|
function mergeConsecutive2(contents, reporter) {
|
|
533
589
|
const result = [];
|
|
534
590
|
for (const content of contents) {
|
|
@@ -583,8 +639,13 @@ function fromGemini(conversation, options = {}) {
|
|
|
583
639
|
for (const part of parts) {
|
|
584
640
|
if (isRecord(part) && isRecord(part.functionResponse)) {
|
|
585
641
|
const fr = part.functionResponse;
|
|
586
|
-
const id = resolveResponseId(fr, pending, reporter, generateId);
|
|
587
|
-
out.push({
|
|
642
|
+
const { id, matched } = resolveResponseId(fr, pending, reporter, generateId);
|
|
643
|
+
out.push({
|
|
644
|
+
role: "tool",
|
|
645
|
+
tool_call_id: id,
|
|
646
|
+
content: unwrapResponse(fr.response ?? {}),
|
|
647
|
+
...matched ? {} : { name: fr.name }
|
|
648
|
+
});
|
|
588
649
|
continue;
|
|
589
650
|
}
|
|
590
651
|
const image = imageFromGemini(part);
|
|
@@ -611,7 +672,7 @@ function fromGemini(conversation, options = {}) {
|
|
|
611
672
|
out.push({ role: "user", content: contentParts });
|
|
612
673
|
} else {
|
|
613
674
|
const text = textOf(contentParts);
|
|
614
|
-
|
|
675
|
+
out.push({ role: "user", content: text });
|
|
615
676
|
}
|
|
616
677
|
}
|
|
617
678
|
}
|
|
@@ -621,20 +682,20 @@ function resolveResponseId(response, pending, reporter, generateId) {
|
|
|
621
682
|
if (response.id) {
|
|
622
683
|
const index2 = pending.findIndex((p) => p.id === response.id);
|
|
623
684
|
if (index2 >= 0) pending.splice(index2, 1);
|
|
624
|
-
return response.id;
|
|
685
|
+
return { id: response.id, matched: index2 >= 0 };
|
|
625
686
|
}
|
|
626
687
|
const index = pending.findIndex((p) => p.name === response.name);
|
|
627
688
|
if (index >= 0) {
|
|
628
689
|
const { id: id2 } = pending[index];
|
|
629
690
|
pending.splice(index, 1);
|
|
630
|
-
return id2;
|
|
691
|
+
return { id: id2, matched: true };
|
|
631
692
|
}
|
|
632
693
|
const id = generateId(response.name);
|
|
633
694
|
reporter.warn(
|
|
634
695
|
"unmapped-tool-result",
|
|
635
696
|
`Gemini functionResponse for '${response.name}' had no matching call; generated '${id}'.`
|
|
636
697
|
);
|
|
637
|
-
return id;
|
|
698
|
+
return { id, matched: false };
|
|
638
699
|
}
|
|
639
700
|
|
|
640
701
|
// src/convert.ts
|
|
@@ -697,6 +758,48 @@ function responseFromOpenAI(body) {
|
|
|
697
758
|
usage: { inputTokens: num(usage.prompt_tokens), outputTokens: num(usage.completion_tokens) }
|
|
698
759
|
};
|
|
699
760
|
}
|
|
761
|
+
var OPENAI_RESPONSES_INCOMPLETE = {
|
|
762
|
+
max_output_tokens: "length",
|
|
763
|
+
content_filter: "content_filter"
|
|
764
|
+
};
|
|
765
|
+
function responseApiFinishReason(root) {
|
|
766
|
+
if (root.status === "completed") return "stop";
|
|
767
|
+
if (root.status !== "incomplete") return "unknown";
|
|
768
|
+
const details = isRecord(root.incomplete_details) ? root.incomplete_details : {};
|
|
769
|
+
return OPENAI_RESPONSES_INCOMPLETE[String(details.reason)] ?? "unknown";
|
|
770
|
+
}
|
|
771
|
+
function responseFromOpenAIResponses(body) {
|
|
772
|
+
const root = isRecord(body) ? body : {};
|
|
773
|
+
const output = Array.isArray(root.output) ? root.output : [];
|
|
774
|
+
const textPieces = [];
|
|
775
|
+
const toolCalls = [];
|
|
776
|
+
let counter = 0;
|
|
777
|
+
for (const item of output) {
|
|
778
|
+
if (!isRecord(item)) continue;
|
|
779
|
+
if (item.type === "message") {
|
|
780
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
781
|
+
for (const part of content) {
|
|
782
|
+
if (!isRecord(part)) continue;
|
|
783
|
+
if (typeof part.text === "string" && (part.type === "output_text" || part.type === "text")) {
|
|
784
|
+
textPieces.push(part.text);
|
|
785
|
+
} else if (part.type === "refusal" && typeof part.refusal === "string") {
|
|
786
|
+
textPieces.push(part.refusal);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
} else if (item.type === "function_call" && typeof item.name === "string") {
|
|
790
|
+
const name = item.name;
|
|
791
|
+
const id = typeof item.call_id === "string" ? item.call_id : typeof item.id === "string" ? item.id : `call_${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_${counter++}`;
|
|
792
|
+
const args = typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {});
|
|
793
|
+
toolCalls.push({ id, type: "function", function: { name, arguments: args } });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const usage = isRecord(root.usage) ? root.usage : {};
|
|
797
|
+
return {
|
|
798
|
+
message: buildMessage(textPieces.join(""), toolCalls),
|
|
799
|
+
finishReason: finalReason(responseApiFinishReason(root), toolCalls),
|
|
800
|
+
usage: { inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens) }
|
|
801
|
+
};
|
|
802
|
+
}
|
|
700
803
|
var ANTHROPIC_FINISH = {
|
|
701
804
|
end_turn: "stop",
|
|
702
805
|
stop_sequence: "stop",
|
|
@@ -771,6 +874,8 @@ function normalizeResponse(body, route, options = {}) {
|
|
|
771
874
|
switch (route.from) {
|
|
772
875
|
case "openai":
|
|
773
876
|
return responseFromOpenAI(body);
|
|
877
|
+
case "openai-responses":
|
|
878
|
+
return responseFromOpenAIResponses(body);
|
|
774
879
|
case "anthropic":
|
|
775
880
|
return responseFromAnthropic(body);
|
|
776
881
|
case "gemini":
|
|
@@ -789,6 +894,7 @@ function normalizeResponse(body, route, options = {}) {
|
|
|
789
894
|
responseFromAnthropic,
|
|
790
895
|
responseFromGemini,
|
|
791
896
|
responseFromOpenAI,
|
|
897
|
+
responseFromOpenAIResponses,
|
|
792
898
|
toAnthropic,
|
|
793
899
|
toDataUrl,
|
|
794
900
|
toGemini
|