{"id":50578307,"url":"https://github.com/slegarraga/llm-messages","last_synced_at":"2026-06-05T00:01:33.751Z","repository":{"id":361910817,"uuid":"1256279465","full_name":"slegarraga/llm-messages","owner":"slegarraga","description":"Convert chat conversations between OpenAI, Anthropic and Gemini message formats. Tool calls, system prompts and roles handled. Zero dependencies.","archived":false,"fork":false,"pushed_at":"2026-06-01T19:36:27.000Z","size":71,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-01T20:27:04.476Z","etag":null,"topics":["ai-agents","anthropic","claude","function-calling","gemini","llm","messages","openai","provider-portability","tool-use"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/slegarraga.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-01T16:18:03.000Z","updated_at":"2026-06-01T19:36:30.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/slegarraga/llm-messages","commit_stats":null,"previous_names":["slegarraga/llm-messages"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/slegarraga/llm-messages","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/slegarraga%2Fllm-messages","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/slegarraga%2Fllm-messages/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/slegarraga%2Fllm-messages/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/slegarraga%2Fllm-messages/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/slegarraga","download_url":"https://codeload.github.com/slegarraga/llm-messages/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/slegarraga%2Fllm-messages/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33923398,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-04T02:00:06.755Z","response_time":64,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai-agents","anthropic","claude","function-calling","gemini","llm","messages","openai","provider-portability","tool-use"],"created_at":"2026-06-05T00:01:33.116Z","updated_at":"2026-06-05T00:01:33.741Z","avatar_url":"https://github.com/slegarraga.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# llm-messages\n\n[![npm version](https://img.shields.io/npm/v/llm-messages.svg)](https://www.npmjs.com/package/llm-messages)\n[![npm downloads](https://img.shields.io/npm/dm/llm-messages.svg)](https://www.npmjs.com/package/llm-messages)\n[![CI](https://github.com/slegarraga/llm-messages/actions/workflows/ci.yml/badge.svg)](https://github.com/slegarraga/llm-messages/actions/workflows/ci.yml)\n[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/slegarraga/llm-messages/badge)](https://scorecard.dev/viewer/?uri=github.com/slegarraga/llm-messages)\n[![license](https://img.shields.io/npm/l/llm-messages.svg)](./LICENSE)\n[![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](./package.json)\n\nConvert chat conversations between **OpenAI**, **Anthropic** and **Gemini** message formats. Tool calls, system prompts and roles handled correctly. Zero dependencies.\n\nSwitching 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:\n\n- The **system prompt** is a message in OpenAI, a top-level `system` field in Anthropic, and `systemInstruction` in Gemini.\n- The assistant role is `assistant` in OpenAI and Anthropic but `model` in Gemini.\n- Tool-call arguments are a **JSON string** in OpenAI but a **parsed object** in Anthropic and Gemini.\n- 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.\n- Gemini matches tool calls to results **by function name**, while OpenAI and Anthropic use ids.\n- Anthropic and Gemini reject consecutive same-role turns; OpenAI does not.\n\n`llm-messages` handles all of it. Write the conversation once, send it to any provider.\n\n## Install\n\n```sh\nnpm install llm-messages\n```\n\nRequires Node 18+. Ships ESM and CommonJS with full TypeScript types.\n\n## Quick start\n\n```ts\nimport { toAnthropic, toGemini } from 'llm-messages';\n\n// A normal OpenAI Chat Completions conversation\nconst messages = [\n  { role: 'system', content: 'You are a weather assistant.' },\n  { role: 'user', content: \"What's the weather in Paris?\" },\n];\n\nconst anthropic = toAnthropic(messages);\n// -\u003e { system: 'You are a weather assistant.', messages: [{ role: 'user', content: \"What's the weather in Paris?\" }] }\n\nconst gemini = toGemini(messages);\n// -\u003e { systemInstruction: { parts: [{ text: 'You are a weather assistant.' }] },\n//      contents: [{ role: 'user', parts: [{ text: \"What's the weather in Paris?\" }] }] }\n```\n\n## The canonical hub\n\nOpenAI Chat Completions is the canonical format. Every conversion routes through\nit, so you get a function for each direction:\n\n```ts\nimport { toAnthropic, fromAnthropic, toGemini, fromGemini, convert } from 'llm-messages';\n\ntoAnthropic(openaiMessages); // OpenAI  -\u003e Anthropic\nfromAnthropic(anthropicBody); // Anthropic -\u003e OpenAI\ntoGemini(openaiMessages); // OpenAI  -\u003e Gemini\nfromGemini(geminiBody); // Gemini  -\u003e OpenAI\n\n// Or convert between any two providers in one call:\nconvert(anthropicBody, { from: 'anthropic', to: 'gemini' });\n```\n\n`convert` is fully typed: the input and output shapes are inferred from the\n`from` and `to` providers.\n\n## Tool calls round trip losslessly\n\nThe hard part is tool use, and it survives a full round trip unchanged:\n\n```ts\nconst messages = [\n  {\n    role: 'assistant',\n    content: null,\n    tool_calls: [\n      { id: 'call_abc', type: 'function', function: { name: 'get_weather', arguments: '{\"location\":\"Paris\"}' } },\n    ],\n  },\n  { role: 'tool', tool_call_id: 'call_abc', content: '15C partly cloudy' },\n];\n\nfromGemini(toGemini(messages)); // deep-equals the original `messages`\n```\n\nArguments are parsed and re-serialized, ids are preserved (and regenerated\ndeterministically when a Gemini payload omits them), and parallel tool results\nare grouped into the single user turn each provider expects.\n\n## Conversion report\n\nConversions never throw on malformed input. Instead they make a deterministic\nchoice and optionally report it, so you can surface or log what happened:\n\n```ts\ntoGemini(messages, {\n  onWarning: (w) =\u003e console.warn(`[${w.code}] ${w.message}`),\n});\n```\n\nWarning codes: `generated-id`, `unmapped-tool-result`, `merged-role`,\n`dropped-content`, `invalid-json-arguments`, `system-midstream`.\n\n## Reading responses\n\nThe same idea applies to the read side. Normalize a provider's response body into\na canonical OpenAI assistant message, plus a neutral finish reason and token usage:\n\n```ts\nimport { responseFromAnthropic, normalizeResponse } from 'llm-messages';\n\nconst { message, finishReason, usage } = responseFromAnthropic(anthropicResponseBody);\n// message     -\u003e { role: 'assistant', content, tool_calls? }  (tool input re-serialized to a JSON string)\n// finishReason -\u003e 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'unknown'\n// usage       -\u003e { inputTokens, outputTokens }\n\n// Or dispatch by provider:\nnormalizeResponse(geminiResponseBody, { from: 'gemini' });\n```\n\n`finishReason` is normalized to `tool_calls` whenever the model called a tool, even\nfor Gemini (which reports `STOP`). Gemini tool calls without an id get a\ndeterministic one.\n\n## Format cheatsheet\n\n|                  | OpenAI                   | Anthropic                        | Gemini                          |\n| ---------------- | ------------------------ | -------------------------------- | ------------------------------- |\n| System prompt    | `role: \"system\"` message | top-level `system`               | `systemInstruction`             |\n| Assistant role   | `assistant`              | `assistant`                      | `model`                         |\n| Tool call        | `tool_calls[].function`  | `tool_use` block                 | `functionCall` part             |\n| Call arguments   | JSON string              | object (`input`)                 | object (`args`)                 |\n| Tool result      | `role: \"tool\"` message   | `tool_result` block in user turn | `functionResponse` part in user |\n| Match key        | `tool_call_id`           | `tool_use_id`                    | function `name` (id optional)   |\n| Role alternation | not required             | strict                           | strict                          |\n\n## Images, audio and documents\n\nImage parts convert across all three providers:\n\n```ts\nconst messages = [\n  {\n    role: 'user',\n    content: [\n      { type: 'text', text: 'What is in this image?' },\n      { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo...' } },\n    ],\n  },\n];\n\ntoAnthropic(messages); // -\u003e { type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } }\ntoGemini(messages); //    -\u003e { inlineData: { mimeType: 'image/png', data: '...' } }\n```\n\nBase64 data URLs round trip losslessly. A remote `https` URL maps to an Anthropic\n`url` source; for Gemini it is emitted as `fileData.fileUri` with a\n`gemini-url-image` warning, since Gemini may require the Files API for non-Google\nURIs.\n\n**Audio** (`input_audio`) and **documents** (`file`, e.g. PDF) convert too. Audio\nmoves between OpenAI and Gemini; Anthropic has no audio input, so an audio part is\ndropped with an `unsupported-modality` warning. Documents convert across all three\n(OpenAI `file`, Anthropic `document`, Gemini `inlineData`).\n\n## Scope\n\nVersion 0.x covers text, system prompts, tool calls/results, images, audio and\ndocuments, which is the core of every agent loop. Unsupported parts are reported\nvia `dropped-content` rather than failing.\n\n## Roadmap\n\nSee [ROADMAP.md](./ROADMAP.md) for current maintenance priorities, including\nOpenAI Responses API coverage, live conformance fixtures and tool-call edge\ncases. The [conformance fixtures plan](./docs/conformance-fixtures.md) describes\nhow API credits should be used to refresh deterministic public fixtures without\nputting secrets in CI.\n\nFor teams evaluating the package, the\n[adoption guide](./docs/adoption-guide.md) covers the OpenAI-compatible boundary,\nlocal validation and production checks.\n\nSecurity posture is tracked in [docs/security-posture.md](./docs/security-posture.md),\nincluding CodeQL, OpenSSF Scorecard, Dependabot and branch rules.\n\n## Provider portability suite\n\n`llm-messages` is the conversation boundary in a small provider-portability\nsuite for OpenAI-compatible agent infrastructure:\n\n- [`tool-schema`](https://github.com/slegarraga/tool-schema) converts one JSON\n  Schema into provider-specific tool/function schemas.\n- [`llm-sse`](https://github.com/slegarraga/llm-sse) parses streaming provider\n  responses into unified events.\n- [`llm-errors`](https://github.com/slegarraga/llm-errors) normalizes provider\n  errors, retry hints and fallback decisions.\n- [`json-from-llm`](https://github.com/slegarraga/json-from-llm) extracts JSON\n  before it enters a tool or message pipeline.\n- [`llm-portability-demo`](https://github.com/slegarraga/llm-portability-demo)\n  shows the whole flow offline, with no API key required.\n\nRead the\n[provider portability map](https://github.com/slegarraga/llm-portability-demo/blob/main/docs/provider-portability.md)\nfor the package roles, OpenAI-compatible hub shape and demo flow.\n\n## License\n\nMIT (c) Sebastian Legarraga. See [LICENSE](./LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fslegarraga%2Fllm-messages","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fslegarraga%2Fllm-messages","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fslegarraga%2Fllm-messages/lists"}