llm-messages
v0.5.2
Published
Convert chat conversations and responses between OpenAI, Anthropic and Gemini. Tool calls, images, audio, documents and roles handled. Zero dependencies.
Maintainers
Readme
llm-messages
Convert chat conversations between OpenAI, Anthropic and Gemini message formats, and normalize provider responses into the same OpenAI-compatible assistant shape. Tool calls, system prompts, roles and response metadata handled correctly. Zero dependencies.
Switching an agent from one provider to another (or running fallback across providers) means rewriting the whole conversation, and the differences are subtle enough to break at runtime:
- The system prompt is a message in OpenAI, a top-level
systemfield in Anthropic, andsystemInstructionin Gemini. - The assistant role is
assistantin OpenAI and Anthropic butmodelin Gemini. - Tool-call arguments are a JSON string in OpenAI but a parsed object in Anthropic and Gemini.
- Tool results are a standalone
role: "tool"message in OpenAI, atool_resultblock inside a user turn in Anthropic, and afunctionResponsepart in Gemini. - Gemini can match tool calls to results by id when present or by function name when ids are omitted, while OpenAI and Anthropic require ids.
- Anthropic and Gemini reject consecutive same-role turns; OpenAI does not.
llm-messages handles all of it. Write the conversation once, send it to any provider.
Install
npm install llm-messagesRequires Node 18+. Ships ESM and CommonJS with full TypeScript types.
CommonJS consumers can import the same package root:
const { toAnthropic, toGemini } = require('llm-messages');Quick start
import { toAnthropic, toGemini, type OpenAIMessage } from 'llm-messages';
// A normal OpenAI Chat Completions conversation
const messages: OpenAIMessage[] = [
{ role: 'system', content: 'You are a weather assistant.' },
{ role: 'user', content: "What's the weather in Paris?" },
];
const anthropic = toAnthropic(messages);
// -> { system: 'You are a weather assistant.', messages: [{ role: 'user', content: "What's the weather in Paris?" }] }
const gemini = toGemini(messages);
// -> { systemInstruction: { parts: [{ text: 'You are a weather assistant.' }] },
// contents: [{ role: 'user', parts: [{ text: "What's the weather in Paris?" }] }] }API
The canonical hub
OpenAI Chat Completions is the canonical format. Every conversion routes through it, so you get a function for each direction:
import { toAnthropic, fromAnthropic, toGemini, fromGemini, convert } from 'llm-messages';
toAnthropic(openaiMessages); // OpenAI -> Anthropic
fromAnthropic(anthropicBody); // Anthropic -> OpenAI
toGemini(openaiMessages); // OpenAI -> Gemini
fromGemini(geminiBody); // Gemini -> OpenAI
// Or convert between any two providers in one call:
convert(anthropicBody, { from: 'anthropic', to: 'gemini' });convert is fully typed: the input and output shapes are inferred from the
from and to providers.
Tool calls round trip losslessly
The hard part is tool use, and it survives a full round trip unchanged:
import { fromGemini, toGemini, type OpenAIMessage } from 'llm-messages';
const messages: OpenAIMessage[] = [
{
role: 'assistant',
content: null,
tool_calls: [
{ id: 'call_abc', type: 'function', function: { name: 'get_weather', arguments: '{"location":"Paris"}' } },
],
},
{ role: 'tool', tool_call_id: 'call_abc', content: '15C partly cloudy' },
];
fromGemini(toGemini(messages)); // deep-equals the original `messages`Arguments are parsed and re-serialized, ids are preserved (and regenerated
deterministically when a Gemini payload does not provide a non-empty string id), and
parallel tool results are grouped into the single user turn each provider expects. Anthropic
tool_result.is_error is preserved as optional canonical tool-message metadata;
standalone Gemini functionResponse.name is also preserved so orphaned tool
results can be sent back to Gemini without renaming the function to the id. When
Anthropic includes tool_result.tool_use_id or Gemini includes
functionResponse.id, it is matched before provider-specific fallback behavior.
Conversion report
When typed provider payloads contain malformed tool-call or media fields, conversions make a deterministic choice and optionally report it, so you can surface or log what happened:
toGemini(messages, {
onWarning: (w) => console.warn(`[${w.code}] ${w.message}`),
});Warning codes: generated-id, unmapped-tool-result, merged-role,
dropped-content, dropped-metadata, invalid-json-arguments,
system-midstream, gemini-url-image, gemini-url-media,
unsupported-modality.
Consumers that validate fixture metadata or warning filters can import the same
stable list from the package root as warningCodes.
Reading responses
The same idea applies to the read side. Normalize a provider's response body into a canonical OpenAI assistant message, plus a neutral finish reason and token usage:
import {
responseFromAnthropic,
responseFromGemini,
responseFromOpenAI,
responseFromOpenAIResponses,
normalizeResponse,
} from 'llm-messages';
const { message, finishReason, usage } = responseFromAnthropic(anthropicResponseBody);
// message -> { role: 'assistant', content, tool_calls? } (tool input re-serialized to a JSON string)
// finishReason -> 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'unknown'
// usage -> { inputTokens, outputTokens }
const responses = responseFromOpenAIResponses(openaiResponsesBody);
// OpenAI Responses API `output_text` items become assistant `content`.
// `function_call` items become Chat Completions-compatible `tool_calls`.
const chat = responseFromOpenAI(openaiChatBody);
// Chat Completions `choices[0].message.tool_calls` stay Chat Completions-compatible.
const gemini = responseFromGemini(geminiResponseBody);
// Gemini `functionCall` parts become assistant `tool_calls`.
// Or dispatch by provider:
normalizeResponse(geminiResponseBody, { from: 'gemini' });
normalizeResponse(openaiResponsesBody, { from: 'openai-responses' });finishReason is normalized to tool_calls whenever the model called a tool, even
for Gemini (which reports STOP) and Responses API bodies with function_call
items. OpenAI Chat Completions, OpenAI Responses, Anthropic and Gemini tool
calls without a non-empty string id get a deterministic one.
Format cheatsheet
| | OpenAI | Anthropic | Gemini |
| ---------------- | ------------------------ | -------------------------------- | ------------------------------- |
| System prompt | role: "system" message | top-level system | systemInstruction |
| Assistant role | assistant | assistant | model |
| Tool call | tool_calls[].function | tool_use block | functionCall part |
| Call arguments | JSON string | object (input) | object (args) |
| Tool result | role: "tool" message | tool_result block in user turn | functionResponse part in user |
| Match key | tool_call_id | tool_use_id | id when present, else name |
| Role alternation | not required | strict | strict |
Images, audio and documents
Image parts convert across all three providers:
import { toAnthropic, toGemini, type OpenAIMessage } from 'llm-messages';
const messages: OpenAIMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'What is in this image?' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo...' } },
],
},
];
toAnthropic(messages).messages[0]?.content;
// -> [{ type: 'text', ... }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } }]
toGemini(messages).contents[0]?.parts;
// -> [{ text: 'What is in this image?' }, { inlineData: { mimeType: 'image/png', data: '...' } }]Base64 data URLs round trip losslessly. A remote https URL maps to an Anthropic
url source; for Gemini it is emitted as fileData.fileUri with a
gemini-url-image warning, since Gemini may require the Files API for non-Google
URIs.
If you need to handle image payloads directly, parseDataUrl and toDataUrl
are exported for the same base64 data URL shape used by the converters.
Audio (input_audio) and documents (file, e.g. PDF) convert too. Audio
moves between OpenAI and Gemini; Anthropic has no audio input, so an audio part is
dropped with an unsupported-modality warning. Base64 document payloads convert
across all three providers (OpenAI file, Anthropic document, Gemini
inlineData). OpenAI file_id document references map to Anthropic file
sources; Gemini has no equivalent and drops them with unsupported-modality.
Scope
Version 0.x covers text, system prompts, tool calls/results, images, audio and
documents, which is the core of every agent loop. Unsupported or lossy parts are
reported through stable warning codes such as dropped-content,
unsupported-modality or provider-specific media warnings rather than failing.
Provider-only fields are preserved only when the canonical OpenAI-compatible
shape has an explicit optional metadata field for them, such as Anthropic
tool_result.is_error and standalone Gemini functionResponse.name. When that
metadata has no target-provider equivalent, conversion continues and reports
dropped-metadata.
Roadmap
See ROADMAP.md for current maintenance priorities, including OpenAI Responses API coverage, offline conformance fixtures and tool-call edge cases. The conformance fixtures guide describes how API credits should be used to refresh deterministic public fixtures without putting secrets in CI.
For teams evaluating the package, the adoption guide covers the OpenAI-compatible boundary, local validation and production checks.
Security posture is tracked in docs/security-posture.md, including CodeQL, OpenSSF Scorecard, Dependabot and branch rules.
Provider portability suite
llm-messages is the conversation boundary in a small provider-portability
suite for OpenAI-compatible agent infrastructure:
tool-schemaconverts one JSON Schema into provider-specific tool/function schemas.llm-sseparses streaming provider responses into unified events.llm-errorsnormalizes provider errors, retry hints and fallback decisions.json-from-llmextracts JSON before it enters a tool or message pipeline.llm-portability-demoshows the whole flow offline, with no API key required.
Read the provider portability map for the package roles, OpenAI-compatible hub shape and demo flow.
License
MIT (c) Sebastian Legarraga. See LICENSE.
