llm-messages 0.4.8 → 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 CHANGED
@@ -4,6 +4,39 @@ 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
+
32
+ ## [0.4.9] - 2026-06-04
33
+
34
+ ### Added
35
+
36
+ - Added a public adoption guide for teams evaluating OpenAI-compatible provider
37
+ fallback, local validation, and production conversion checks.
38
+ - Included the examples directory in the published npm package.
39
+
7
40
  ## [0.4.8] - 2026-06-04
8
41
 
9
42
  ### Changed
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/llm-messages.svg)](https://www.npmjs.com/package/llm-messages)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/llm-messages.svg)](https://www.npmjs.com/package/llm-messages)
5
5
  [![CI](https://github.com/slegarraga/llm-messages/actions/workflows/ci.yml/badge.svg)](https://github.com/slegarraga/llm-messages/actions/workflows/ci.yml)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/slegarraga/llm-messages/badge)](https://scorecard.dev/viewer/?uri=github.com/slegarraga/llm-messages)
6
7
  [![license](https://img.shields.io/npm/l/llm-messages.svg)](./LICENSE)
7
8
  [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](./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`, `system-midstream`.
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`). Gemini tool calls without an id get a
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
 
@@ -179,6 +194,13 @@ cases. The [conformance fixtures plan](./docs/conformance-fixtures.md) describes
179
194
  how API credits should be used to refresh deterministic public fixtures without
180
195
  putting secrets in CI.
181
196
 
197
+ For teams evaluating the package, the
198
+ [adoption guide](./docs/adoption-guide.md) covers the OpenAI-compatible boundary,
199
+ local validation and production checks.
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
+
182
204
  ## Provider portability suite
183
205
 
184
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
- Track and test how Responses API text, multimodal and tool-call payloads map
13
- to the current OpenAI Chat Completions-compatible hub shape.
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
- blocks.push({ type: "tool_result", tool_use_id: tool.tool_call_id, content: textOf(tool.content) });
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
- const toolResults = blocks.filter((b) => b.type === "tool_result");
397
- const contentBlocks = blocks.filter((b) => b.type !== "tool_result");
398
- for (const block of toolResults) {
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
- if (contentBlocks.length > 0) {
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 name = idToName.get(tool.tool_call_id);
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({ role: "tool", tool_call_id: id, content: unwrapResponse(fr.response ?? {}) });
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
- if (text) out.push({ role: "user", content: text });
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