Skip to content

slegarraga/llm-messages

llm-messages

npm version npm downloads CI OpenSSF Scorecard license zero dependencies

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 system field in Anthropic, and systemInstruction in Gemini.
  • The assistant role is assistant in OpenAI and Anthropic but model in 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, a tool_result block inside a user turn in Anthropic, and a functionResponse part 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-messages

Requires 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-schema converts one JSON Schema into provider-specific tool/function schemas.
  • llm-sse parses streaming provider responses into unified events.
  • llm-errors normalizes provider errors, retry hints and fallback decisions.
  • json-from-llm extracts JSON before it enters a tool or message pipeline.
  • llm-portability-demo shows 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.

Packages

 
 
 

Contributors