Skip to content

Commit b717354

Browse files
committed
Release 0.5.0
1 parent 2e08424 commit b717354

12 files changed

Lines changed: 280 additions & 26 deletions

CHANGELOG.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,28 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
## [0.5.0] - 2026-06-07
10+
911
### Added
1012

1113
- Added OpenAI Responses API response normalization via
12-
`responseFromOpenAIResponses(...)` and `normalizeResponse(body, { from:
13-
'openai-responses' })`, mapping `output_text` to assistant content,
14-
`function_call` to Chat Completions-compatible `tool_calls`, and Responses
15-
usage/status fields to neutral `usage` and `finishReason` values.
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.
1631

1732
## [0.4.9] - 2026-06-04
1833

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ fromGemini(toGemini(messages)); // deep-equals the original `messages`
8888

8989
Arguments are parsed and re-serialized, ids are preserved (and regenerated
9090
deterministically when a Gemini payload omits them), and parallel tool results
91-
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.
9295

9396
## Conversion report
9497

@@ -102,7 +105,9 @@ toGemini(messages, {
102105
```
103106

104107
Warning codes: `generated-id`, `unmapped-tool-result`, `merged-role`,
105-
`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`.
106111

107112
## Reading responses
108113

@@ -175,7 +180,11 @@ dropped with an `unsupported-modality` warning. Documents convert across all thr
175180

176181
Version 0.x covers text, system prompts, tool calls/results, images, audio and
177182
documents, which is the core of every agent loop. Unsupported parts are reported
178-
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`.
179188

180189
## Roadmap
181190

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "llm-messages",
3-
"version": "0.4.9",
3+
"version": "0.5.0",
44
"description": "Convert chat conversations and responses between OpenAI, Anthropic and Gemini. Tool calls, images, audio, documents and roles handled. Zero dependencies.",
55
"keywords": [
66
"openai",

src/providers/anthropic.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,18 @@ export function toAnthropic(messages: OpenAIMessage[], options: ConvertOptions =
3737
let j = i;
3838
while (j < rest.length && rest[j].role === 'tool') {
3939
const tool = rest[j] as OpenAIToolMessage;
40-
blocks.push({ type: 'tool_result', tool_use_id: tool.tool_call_id, content: textOf(tool.content) });
40+
if (typeof tool.name === 'string') {
41+
reporter.warn(
42+
'dropped-metadata',
43+
`Tool message name '${tool.name}' has no Anthropic tool_result equivalent; dropped.`,
44+
);
45+
}
46+
blocks.push({
47+
type: 'tool_result',
48+
tool_use_id: tool.tool_call_id,
49+
content: textOf(tool.content),
50+
...(typeof tool.is_error === 'boolean' ? { is_error: tool.is_error } : {}),
51+
});
4152
j++;
4253
}
4354
out.push({ role: 'user', content: blocks });
@@ -46,6 +57,7 @@ export function toAnthropic(messages: OpenAIMessage[], options: ConvertOptions =
4657
}
4758

4859
if (message.role === 'user') {
60+
warnDroppedName('User', message.name, 'Anthropic', reporter);
4961
out.push({ role: 'user', content: userContent(message.content, reporter) });
5062
continue;
5163
}
@@ -83,6 +95,7 @@ function userContent(content: string | OpenAIContentPart[], reporter: Reporter):
8395
}
8496

8597
function assistantContent(message: OpenAIAssistantMessage, reporter: Reporter): string | AnthropicContentBlock[] {
98+
warnDroppedName('Assistant', message.name, 'Anthropic', reporter);
8699
const text = textOf(message.content ?? '');
87100
const toolCalls = message.tool_calls ?? [];
88101
if (toolCalls.length === 0) return text;
@@ -100,6 +113,11 @@ function assistantContent(message: OpenAIAssistantMessage, reporter: Reporter):
100113
return blocks;
101114
}
102115

116+
function warnDroppedName(role: string, name: string | undefined, provider: string, reporter: Reporter): void {
117+
if (typeof name !== 'string') return;
118+
reporter.warn('dropped-metadata', `${role} message name '${name}' has no ${provider} equivalent; dropped.`);
119+
}
120+
103121
/** Merges adjacent same-role messages by concatenating their content blocks. */
104122
function mergeConsecutive(messages: AnthropicMessage[], reporter: Reporter): AnthropicMessage[] {
105123
const result: AnthropicMessage[] = [];
@@ -144,18 +162,36 @@ export function fromAnthropic(conversation: AnthropicConversation, options: Conv
144162
const blocks = asBlocks(message.content);
145163

146164
if (message.role === 'user') {
147-
const toolResults = blocks.filter((b) => b.type === 'tool_result');
148-
const contentBlocks = blocks.filter((b) => b.type !== 'tool_result');
149-
for (const block of toolResults) {
165+
if (blocks.length === 0) {
166+
out.push({ role: 'user', content: '' });
167+
continue;
168+
}
169+
170+
let contentBlocks: AnthropicContentBlock[] = [];
171+
const flushContent = (): void => {
172+
if (contentBlocks.length > 0) {
173+
out.push({ role: 'user', content: userContentToOpenAI(contentBlocks, reporter) });
174+
contentBlocks = [];
175+
}
176+
};
177+
178+
for (const block of blocks) {
179+
if (block.type !== 'tool_result') {
180+
contentBlocks.push(block);
181+
continue;
182+
}
183+
184+
flushContent();
150185
out.push({
151186
role: 'tool',
152187
tool_call_id: String((block as { tool_use_id?: string }).tool_use_id ?? ''),
153188
content: textOf((block as { content?: unknown }).content),
189+
...(typeof (block as { is_error?: unknown }).is_error === 'boolean'
190+
? { is_error: (block as { is_error: boolean }).is_error }
191+
: {}),
154192
});
155193
}
156-
if (contentBlocks.length > 0) {
157-
out.push({ role: 'user', content: userContentToOpenAI(contentBlocks, reporter) });
158-
}
194+
flushContent();
159195
continue;
160196
}
161197

src/providers/gemini.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,20 @@ export function toGemini(messages: OpenAIMessage[], options: ConvertOptions = {}
4848
let j = i;
4949
while (j < rest.length && rest[j].role === 'tool') {
5050
const tool = rest[j] as OpenAIToolMessage;
51-
const name = idToName.get(tool.tool_call_id);
51+
const matchingName = idToName.get(tool.tool_call_id);
52+
if (typeof tool.is_error === 'boolean') {
53+
reporter.warn(
54+
'dropped-metadata',
55+
`Tool message is_error=${tool.is_error} has no Gemini functionResponse equivalent; dropped.`,
56+
);
57+
}
58+
if (typeof tool.name === 'string' && matchingName && tool.name !== matchingName) {
59+
reporter.warn(
60+
'dropped-metadata',
61+
`Tool message name '${tool.name}' differs from matching tool call '${matchingName}'; used the tool-call function name for Gemini.`,
62+
);
63+
}
64+
const name = matchingName ?? tool.name;
5265
if (!name) {
5366
reporter.warn(
5467
'unmapped-tool-result',
@@ -70,10 +83,12 @@ export function toGemini(messages: OpenAIMessage[], options: ConvertOptions = {}
7083
}
7184

7285
if (message.role === 'user') {
86+
warnDroppedName('User', message.name, 'Gemini', reporter);
7387
contents.push({ role: 'user', parts: userParts(message.content, reporter) });
7488
continue;
7589
}
7690

91+
warnDroppedName('Assistant', message.name, 'Gemini', reporter);
7792
contents.push({ role: 'model', parts: assistantParts(message, reporter) });
7893
}
7994

@@ -123,6 +138,11 @@ function assistantParts(message: OpenAIAssistantMessage, reporter: Reporter): Ge
123138
return parts.length > 0 ? parts : [{ text: '' }];
124139
}
125140

141+
function warnDroppedName(role: string, name: string | undefined, provider: string, reporter: Reporter): void {
142+
if (typeof name !== 'string') return;
143+
reporter.warn('dropped-metadata', `${role} message name '${name}' has no ${provider} equivalent; dropped.`);
144+
}
145+
126146
/** Merges adjacent same-role contents by concatenating their `parts` arrays. */
127147
function mergeConsecutive(contents: GeminiContent[], reporter: Reporter): GeminiContent[] {
128148
const result: GeminiContent[] = [];
@@ -196,8 +216,13 @@ export function fromGemini(conversation: GeminiConversation, options: ConvertOpt
196216
for (const part of parts) {
197217
if (isRecord(part) && isRecord(part.functionResponse)) {
198218
const fr = part.functionResponse as { id?: string; name: string; response?: Record<string, unknown> };
199-
const id = resolveResponseId(fr, pending, reporter, generateId);
200-
out.push({ role: 'tool', tool_call_id: id, content: unwrapResponse(fr.response ?? {}) });
219+
const { id, matched } = resolveResponseId(fr, pending, reporter, generateId);
220+
out.push({
221+
role: 'tool',
222+
tool_call_id: id,
223+
content: unwrapResponse(fr.response ?? {}),
224+
...(matched ? {} : { name: fr.name }),
225+
});
201226
continue;
202227
}
203228
const image = imageFromGemini(part);
@@ -224,7 +249,7 @@ export function fromGemini(conversation: GeminiConversation, options: ConvertOpt
224249
out.push({ role: 'user', content: contentParts });
225250
} else {
226251
const text = textOf(contentParts);
227-
if (text) out.push({ role: 'user', content: text });
252+
out.push({ role: 'user', content: text });
228253
}
229254
}
230255
}
@@ -237,22 +262,22 @@ function resolveResponseId(
237262
pending: { id: string; name: string }[],
238263
reporter: Reporter,
239264
generateId: (name: string) => string,
240-
): string {
265+
): { id: string; matched: boolean } {
241266
if (response.id) {
242267
const index = pending.findIndex((p) => p.id === response.id);
243268
if (index >= 0) pending.splice(index, 1);
244-
return response.id;
269+
return { id: response.id, matched: index >= 0 };
245270
}
246271
const index = pending.findIndex((p) => p.name === response.name);
247272
if (index >= 0) {
248273
const { id } = pending[index];
249274
pending.splice(index, 1);
250-
return id;
275+
return { id, matched: true };
251276
}
252277
const id = generateId(response.name);
253278
reporter.warn(
254279
'unmapped-tool-result',
255280
`Gemini functionResponse for '${response.name}' had no matching call; generated '${id}'.`,
256281
);
257-
return id;
282+
return { id, matched: false };
258283
}

src/providers/openai.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export function splitSystem(
3131

3232
for (const message of messages) {
3333
if (isSystem(message)) {
34+
if (typeof message.name === 'string') {
35+
reporter.warn(
36+
'dropped-metadata',
37+
`${message.role} message name '${message.name}' has no top-level system prompt equivalent; dropped.`,
38+
);
39+
}
3440
if (started) {
3541
reporter.warn(
3642
'system-midstream',

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type WarningCode =
1717
| 'unmapped-tool-result'
1818
| 'merged-role'
1919
| 'dropped-content'
20+
| 'dropped-metadata'
2021
| 'invalid-json-arguments'
2122
| 'system-midstream'
2223
| 'gemini-url-image'
@@ -104,7 +105,11 @@ export interface OpenAIAssistantMessage {
104105
export interface OpenAIToolMessage {
105106
role: 'tool';
106107
tool_call_id: string;
108+
/** Optional provider metadata used to preserve Gemini functionResponse names. */
109+
name?: string;
107110
content: string | OpenAITextPart[];
111+
/** Optional provider metadata used to preserve Anthropic tool_result errors. */
112+
is_error?: boolean;
108113
}
109114

110115
export type OpenAIMessage = OpenAISystemMessage | OpenAIUserMessage | OpenAIAssistantMessage | OpenAIToolMessage;

test/anthropic.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,41 @@ describe('toAnthropic', () => {
4242
expect((userTurn.content as AnthropicContentBlock[]).map((b) => b.type)).toEqual(['tool_result', 'tool_result']);
4343
});
4444

45+
it('preserves tool_result error flags from canonical tool messages', () => {
46+
const { messages } = toAnthropic([
47+
{ role: 'tool', tool_call_id: 't1', content: 'failed', is_error: true },
48+
{ role: 'tool', tool_call_id: 't2', content: 'ok', is_error: false },
49+
]);
50+
const content = messages[0].content as AnthropicContentBlock[];
51+
expect(content).toEqual([
52+
{ type: 'tool_result', tool_use_id: 't1', content: 'failed', is_error: true },
53+
{ type: 'tool_result', tool_use_id: 't2', content: 'ok', is_error: false },
54+
]);
55+
});
56+
57+
it('reports OpenAI message metadata that Anthropic cannot represent', () => {
58+
const warnings: Warning[] = [];
59+
toAnthropic(
60+
[
61+
{ role: 'system', name: 'policy', content: 'Be concise.' },
62+
{ role: 'user', name: 'customer', content: 'Hi' },
63+
{ role: 'assistant', name: 'planner', content: null },
64+
{ role: 'tool', tool_call_id: 'orphan', name: 'lookup_order', content: 'done' },
65+
],
66+
{ onWarning: (w) => warnings.push(w) },
67+
);
68+
69+
const messages = warnings.filter((w) => w.code === 'dropped-metadata').map((w) => w.message);
70+
expect(messages).toEqual(
71+
expect.arrayContaining([
72+
expect.stringContaining("system message name 'policy'"),
73+
expect.stringContaining("User message name 'customer'"),
74+
expect.stringContaining("Assistant message name 'planner'"),
75+
expect.stringContaining("Tool message name 'lookup_order'"),
76+
]),
77+
);
78+
});
79+
4580
it('reports invalid tool-call arguments instead of throwing', () => {
4681
const warnings: Warning[] = [];
4782
toAnthropic(
@@ -73,4 +108,41 @@ describe('fromAnthropic', () => {
73108
});
74109
expect(out).toEqual([{ role: 'tool', tool_call_id: 't1', content: 'result' }]);
75110
});
111+
112+
it('preserves mixed user text, tool_result errors and block order', () => {
113+
const out = fromAnthropic({
114+
messages: [
115+
{
116+
role: 'user',
117+
content: [
118+
{ type: 'text', text: 'before' },
119+
{ type: 'tool_result', tool_use_id: 't1', content: 'failed', is_error: true },
120+
{ type: 'text', text: 'after' },
121+
],
122+
},
123+
],
124+
});
125+
expect(out).toEqual([
126+
{ role: 'user', content: 'before' },
127+
{ role: 'tool', tool_call_id: 't1', content: 'failed', is_error: true },
128+
{ role: 'user', content: 'after' },
129+
]);
130+
});
131+
132+
it('round trips mixed text and tool_result blocks back to one Anthropic user turn', () => {
133+
const conversation = {
134+
messages: [
135+
{
136+
role: 'user' as const,
137+
content: [
138+
{ type: 'text' as const, text: 'before' },
139+
{ type: 'tool_result' as const, tool_use_id: 't1', content: 'failed', is_error: true },
140+
{ type: 'text' as const, text: 'after' },
141+
],
142+
},
143+
],
144+
};
145+
146+
expect(toAnthropic(fromAnthropic(conversation)).messages).toEqual(conversation.messages);
147+
});
76148
});

0 commit comments

Comments
 (0)