Chat Format Converter — TypeScript source
Convert chat transcripts between OpenAI messages[], Anthropic system+messages, Gemini contents[], and plain Markdown. Roles map faithfully, tool calls are preserved where possible, and anything unmappable is flagged — never dropped silently. Runs entirely in your browser.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
/**
* Chat transcript format converter — pure logic, no React/DOM.
*
* Converts one chat transcript between provider shapes through a shared
* internal message list:
*
* - openai — `{"messages": [{"role": "system|user|assistant|tool", ...}]}`
* - anthropic — `{"system": "...", "messages": [{"role": "user|assistant", ...}]}`
* - gemini — `{"contents": [{"role": "user|model", "parts": [...]}]}`
* - markdown — a plain `**role**: text` transcript
*
* Roles map faithfully (Gemini has no assistant — it is `model`; Anthropic
* system lives top-level). Fields a target format cannot represent
* (e.g. tool calls in Markdown) are flagged as warnings, never dropped
* silently.
*/
export type ChatFormat = 'openai' | 'anthropic' | 'gemini' | 'markdown';
export const CHAT_FORMATS: ChatFormat[] = ['openai', 'anthropic', 'gemini', 'markdown'];
/** One assistant-emitted tool call, provider-agnostic. `args` is a JSON string. */
export interface ToolCall {
id: string;
name: string;
args: string;
}
/** The shared internal shape every format parses into and serializes from. */
export interface InternalMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
/** Message text ('' when the message only carries tool calls/results). */
content: string;
/** Tool / function name (OpenAI `name`, Anthropic tool_use, Gemini functionResponse). */
name?: string;
/** Assistant-emitted tool calls. */
toolCalls?: ToolCall[];
/** Tool-result correlation id (OpenAI tool_call_id, Anthropic tool_use_id, Gemini name). */
toolCallId?: string;
}
export interface ConversionResult {
output: string;
/** Human-readable notes about fields that could not be mapped. */
warnings: string[];
}
type Parser = (text: string, warnings: string[]) => InternalMessage[];
type Serializer = (messages: InternalMessage[], warnings: string[]) => string;
function parseJson(text: string, label: string): unknown {
try {
return JSON.parse(text);
} catch (e) {
throw new Error(`${label}: invalid JSON — ${(e as Error).message}`);
}
}
/** Parse a JSON-string argument back to a value, `{}` when it is not valid JSON. */
function parseArgsOrEmpty(args: string): Record<string, unknown> {
try {
const v = JSON.parse(args);
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
} catch {
return {};
}
}
function asRecord(v: unknown): Record<string, unknown> | null {
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
/* ---------------------------------- parse --------------------------------- */
/** OpenAI messages[] (bare array, or wrapped in `{"messages": [...]}`). */
function parseOpenAI(text: string, warnings: string[]): InternalMessage[] {
const parsed = parseJson(text, 'OpenAI transcript');
const root = asRecord(parsed);
const raw = root !== null && Array.isArray(root.messages) ? root.messages : parsed;
if (!Array.isArray(raw)) {
throw new Error('OpenAI transcript must be a messages[] array or an object with a "messages" array');
}
const out: InternalMessage[] = [];
raw.forEach((entry, i) => {
const m = asRecord(entry);
if (m === null) throw new Error(`messages[${i}] is not an object`);
const role = m.role;
if (role === 'system' || role === 'developer') {
out.push({ role: 'system', content: contentText(m.content, `messages[${i}]`, warnings) });
return;
}
if (role === 'user' || role === 'assistant' || role === 'tool') {
const msg: InternalMessage = { role, content: contentText(m.content, `messages[${i}]`, warnings) };
if (typeof m.name === 'string') msg.name = m.name;
if (typeof m.tool_call_id === 'string') msg.toolCallId = m.tool_call_id;
const calls = parseOpenAIToolCalls(m.tool_calls);
if (calls !== null) msg.toolCalls = calls;
out.push(msg);
return;
}
throw new Error(`messages[${i}] has unsupported role: ${JSON.stringify(role)}`);
});
return out;
}
/** OpenAI content → plain text (string, or text parts joined; other parts flagged). */
function contentText(content: unknown, where: string, warnings: string[]): string {
if (typeof content === 'string') return content;
if (content === undefined || content === null) return '';
if (Array.isArray(content)) {
const texts: string[] = [];
let dropped = 0;
for (const part of content) {
const p = asRecord(part);
if (p !== null && p.type === 'text' && typeof p.text === 'string') texts.push(p.text);
else dropped += 1;
}
if (dropped > 0) warnings.push(`${where}: dropped ${dropped} non-text content part(s)`);
return texts.join('');
}
throw new Error(`${where}: content must be a string, an array of parts, or null`);
}
function parseOpenAIToolCalls(raw: unknown): ToolCall[] | null {
if (raw === undefined || raw === null) return null;
if (!Array.isArray(raw)) throw new Error('tool_calls must be an array');
return raw.map((entry) => {
const tc = asRecord(entry);
const fn = tc !== null ? asRecord(tc.function) : null;
return {
id: tc !== null && typeof tc.id === 'string' ? tc.id : '',
name: fn !== null && typeof fn.name === 'string' ? fn.name : '',
args: fn !== null && typeof fn.arguments === 'string' ? fn.arguments : '',
};
});
}
/** Anthropic messages[] (+ top-level `system`). */
function parseAnthropic(text: string, warnings: string[]): InternalMessage[] {
const root = asRecord(parseJson(text, 'Anthropic transcript'));
if (root === null || !Array.isArray(root.messages)) {
throw new Error('Anthropic transcript must be an object with a "messages" array');
}
const out: InternalMessage[] = [];
if (root.system !== undefined) out.push({ role: 'system', content: blocksToText(root.system, 'system', warnings) });
root.messages.forEach((entry, i) => {
const m = asRecord(entry);
if (m === null) throw new Error(`messages[${i}] is not an object`);
const where = `messages[${i}]`;
if (m.role === 'assistant') {
const msg: InternalMessage = { role: 'assistant', content: '' };
const texts: string[] = [];
const calls: ToolCall[] = [];
for (const block of contentBlocks(m.content, where)) {
if (block.type === 'text') texts.push(block.text);
else if (block.type === 'tool_use') {
calls.push({
id: typeof block.raw.id === 'string' ? block.raw.id : '',
name: typeof block.raw.name === 'string' ? block.raw.name : '',
args: JSON.stringify(block.raw.input ?? {}),
});
} else if (block.type === 'other') {
warnings.push(`${where}: dropped unsupported ${String(block.raw.type ?? 'content')} block`);
}
}
msg.content = texts.join('\n');
if (calls.length > 0) msg.toolCalls = calls;
out.push(msg);
return;
}
if (m.role === 'user') {
const texts: string[] = [];
const flush = () => {
if (texts.length > 0) {
out.push({ role: 'user', content: texts.join('\n') });
texts.length = 0;
}
};
for (const block of contentBlocks(m.content, where)) {
if (block.type === 'text') texts.push(block.text);
else if (block.type === 'tool_use') {
warnings.push(`${where}: tool_use block inside a user message moved to an assistant tool call`);
flush();
out.push({
role: 'assistant',
content: '',
toolCalls: [{
id: typeof block.raw.id === 'string' ? block.raw.id : '',
name: typeof block.raw.name === 'string' ? block.raw.name : '',
args: JSON.stringify(block.raw.input ?? {}),
}],
});
} else if (block.type === 'tool_result') {
flush();
out.push({
role: 'tool',
toolCallId: typeof block.raw.tool_use_id === 'string' ? block.raw.tool_use_id : '',
content: blocksToText(block.raw.content ?? '', where, warnings),
});
} else {
warnings.push(`${where}: dropped unsupported ${String(block.raw.type ?? 'content')} block`);
}
}
flush();
return;
}
throw new Error(`${where} has unsupported role: ${JSON.stringify(m.role)}`);
});
return out;
}
/** Normalize Anthropic content (string | block[]) into typed blocks. */
function contentBlocks(content: unknown, where: string): Array<{ type: 'text'; text: string } | { type: 'tool_use' | 'tool_result' | 'other'; raw: Record<string, unknown> }> {
if (typeof content === 'string') return [{ type: 'text', text: content }];
if (!Array.isArray(content)) throw new Error(`${where}: content must be a string or an array of blocks`);
return content.map((block) => {
const b = asRecord(block);
if (b !== null && b.type === 'text' && typeof b.text === 'string') return { type: 'text' as const, text: b.text };
if (b !== null && (b.type === 'tool_use' || b.type === 'tool_result')) {
return { type: b.type as 'tool_use' | 'tool_result', raw: b };
}
return { type: 'other' as const, raw: b ?? {} };
});
}
/** Anthropic string-or-block[] content → plain text. */
function blocksToText(content: unknown, where: string, warnings: string[]): string {
if (typeof content === 'string') return content;
return contentBlocks(content, where)
.filter((b) => {
if (b.type === 'text') return true;
if (b.type === 'other') warnings.push(`${where}: dropped unsupported ${String(b.raw.type ?? 'content')} block`);
else warnings.push(`${where}: dropped ${b.type} block from text-only content`);
return false;
})
.map((b) => (b as { type: 'text'; text: string }).text)
.join('\n');
}
/** Gemini contents[] (+ optional systemInstruction). */
function parseGemini(text: string, warnings: string[]): InternalMessage[] {
const root = asRecord(parseJson(text, 'Gemini transcript'));
if (root === null || !Array.isArray(root.contents)) {
throw new Error('Gemini transcript must be an object with a "contents" array');
}
const out: InternalMessage[] = [];
if (root.systemInstruction !== undefined) {
out.push({ role: 'system', content: geminiText(root.systemInstruction, 'systemInstruction', warnings) });
}
root.contents.forEach((entry, i) => {
const m = asRecord(entry);
if (m === null) throw new Error(`contents[${i}] is not an object`);
const where = `contents[${i}]`;
if (m.role !== 'user' && m.role !== 'model') {
throw new Error(`${where} has unsupported role: ${JSON.stringify(m.role)} (Gemini uses "user" or "model")`);
}
const role = m.role === 'model' ? 'assistant' : 'user';
const parts = m.parts;
if (!Array.isArray(parts)) throw new Error(`${where}: parts must be an array`);
if (role === 'assistant') {
// A model turn keeps its text and function calls in ONE message,
// mirroring an OpenAI assistant message with tool_calls.
const texts: string[] = [];
const calls: ToolCall[] = [];
for (const part of parts) {
const p = asRecord(part);
if (p !== null && typeof p.text === 'string') {
texts.push(p.text);
continue;
}
const fc = asRecord(p?.functionCall);
if (fc !== null) {
calls.push({
id: '',
name: typeof fc.name === 'string' ? fc.name : '',
args: JSON.stringify(fc.args ?? {}),
});
continue;
}
warnings.push(`${where}: dropped unsupported part (inlineData or similar)`);
}
const msg: InternalMessage = { role: 'assistant', content: texts.join('\n') };
if (calls.length > 0) msg.toolCalls = calls;
out.push(msg);
return;
}
const texts: string[] = [];
const flushText = () => {
if (texts.length > 0) {
out.push({ role: 'user', content: texts.join('\n') });
texts.length = 0;
}
};
for (const part of parts) {
const p = asRecord(part);
if (p !== null && typeof p.text === 'string') {
texts.push(p.text);
continue;
}
const fc = asRecord(p?.functionCall);
if (fc !== null) {
flushText();
out.push({
role: 'assistant',
content: '',
toolCalls: [{
id: '',
name: typeof fc.name === 'string' ? fc.name : '',
args: JSON.stringify(fc.args ?? {}),
}],
});
continue;
}
const fr = asRecord(p?.functionResponse);
if (fr !== null) {
flushText();
out.push({
role: 'tool',
name: typeof fr.name === 'string' ? fr.name : '',
toolCallId: typeof fr.name === 'string' ? fr.name : '',
content: JSON.stringify(fr.response ?? {}),
});
continue;
}
warnings.push(`${where}: dropped unsupported part (inlineData or similar)`);
}
flushText();
});
return out;
}
/** Gemini systemInstruction (string or {parts}) → plain text. */
function geminiText(v: unknown, where: string, warnings: string[]): string {
if (typeof v === 'string') return v;
const rec = asRecord(v);
if (rec !== null) {
if (typeof rec.text === 'string') return rec.text;
if (Array.isArray(rec.parts)) {
return rec.parts
.map((part) => {
const p = asRecord(part);
return p !== null && typeof p.text === 'string' ? p.text : '';
})
.join('\n');
}
}
warnings.push(`${where}: unsupported systemInstruction shape, treated as empty`);
return '';
}
/**
* Markdown transcript: `**role**: text` header lines with continuation lines
* belonging to the same message. `model` maps to assistant.
*/
function parseMarkdown(text: string, _warnings: string[]): InternalMessage[] {
const out: InternalMessage[] = [];
let current: { role: InternalMessage['role']; lines: string[] } | null = null;
const header = /^\*\*(system|user|assistant|model|tool)\*\*:\s*(.*)$/;
for (const line of text.split('\n')) {
const match = header.exec(line);
if (match !== null) {
if (current !== null) out.push(finishMessage(current));
current = { role: match[1] === 'model' ? 'assistant' : (match[1] as InternalMessage['role']), lines: [match[2]] };
continue;
}
if (current !== null) current.lines.push(line);
else if (line.trim() !== '') throw new Error('Markdown transcript must start with a `**role**:` header line');
}
if (current !== null) out.push(finishMessage(current));
return out;
}
function finishMessage(current: { role: InternalMessage['role']; lines: string[] }): InternalMessage {
// Trim leading/trailing blank continuation lines but keep inner blank lines.
while (current.lines.length > 1 && current.lines[0].trim() === '') current.lines.shift();
while (current.lines.length > 1 && current.lines[current.lines.length - 1].trim() === '') current.lines.pop();
return { role: current.role, content: current.lines.join('\n') };
}
/* -------------------------------- serialize ------------------------------- */
function serializeOpenAI(messages: InternalMessage[]): string {
const arr = messages.map((m) => {
if (m.role === 'tool') {
const o: Record<string, unknown> = { role: 'tool', content: m.content, tool_call_id: m.toolCallId ?? '' };
if (m.name !== undefined) o.name = m.name;
return o;
}
if (m.role === 'assistant' && m.toolCalls !== undefined) {
const o: Record<string, unknown> = {
role: 'assistant',
content: m.content === '' ? null : m.content,
tool_calls: m.toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.args },
})),
};
if (m.name !== undefined) o.name = m.name;
return o;
}
const o: Record<string, unknown> = { role: m.role, content: m.content };
if (m.name !== undefined) o.name = m.name;
return o;
});
return `${JSON.stringify({ messages: arr }, null, 2)}\n`;
}
function serializeAnthropic(messages: InternalMessage[], warnings: string[]): string {
const system = messages.filter((m) => m.role === 'system').map((m) => m.content);
const out: Array<Record<string, unknown>> = [];
messages.forEach((m, i) => {
if (m.role === 'system') return;
if (m.name !== undefined && m.role !== 'tool') {
warnings.push(`message ${i}: "name" has no Anthropic equivalent and was dropped`);
}
if (m.role === 'user') {
out.push({ role: 'user', content: m.content });
} else if (m.role === 'assistant') {
if (m.toolCalls !== undefined) {
const blocks: Array<Record<string, unknown>> = [];
if (m.content !== '') blocks.push({ type: 'text', text: m.content });
for (const tc of m.toolCalls) {
blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: parseArgsOrEmpty(tc.args) });
}
out.push({ role: 'assistant', content: blocks });
} else {
out.push({ role: 'assistant', content: m.content });
}
} else {
out.push({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: m.toolCallId ?? '', content: m.content }],
});
}
});
const root: Record<string, unknown> = out.length > 0 ? { messages: out } : {};
if (system.length > 0) root.system = system.join('\n\n');
return `${JSON.stringify(root, null, 2)}\n`;
}
function serializeGemini(messages: InternalMessage[], warnings: string[]): string {
const system = messages.filter((m) => m.role === 'system').map((m) => m.content);
const contents: Array<Record<string, unknown>> = [];
const push = (role: 'user' | 'model', part: Record<string, unknown>) => {
const last = contents[contents.length - 1];
if (last !== undefined && last.role === role) {
(last.parts as Array<Record<string, unknown>>).push(part);
} else {
contents.push({ role, parts: [part] });
}
};
messages.forEach((m, i) => {
if (m.role === 'system') return;
if (m.name !== undefined && m.role !== 'tool') {
warnings.push(`message ${i}: "name" has no Gemini equivalent and was dropped`);
}
if (m.role === 'user') {
push('user', { text: m.content });
} else if (m.role === 'assistant') {
if (m.content !== '') push('model', { text: m.content });
for (const tc of m.toolCalls ?? []) {
push('model', { functionCall: { name: tc.name, args: parseArgsOrEmpty(tc.args) } });
}
} else {
push('user', {
functionResponse: { name: m.name ?? m.toolCallId ?? '', response: parseArgsOrEmpty(m.content) },
});
}
});
const root: Record<string, unknown> = contents.length > 0 ? { contents } : {};
if (system.length > 0) root.systemInstruction = { parts: [{ text: system.join('\n\n') }] };
return `${JSON.stringify(root, null, 2)}\n`;
}
function serializeMarkdown(messages: InternalMessage[], warnings: string[]): string {
messages.forEach((m, i) => {
if (m.toolCalls !== undefined && m.toolCalls.length > 0) {
warnings.push(`message ${i}: tool calls are not representable in Markdown and were dropped`);
}
if (m.role === 'tool' && m.toolCallId !== undefined) {
warnings.push(`message ${i}: tool result id is not representable in Markdown and was dropped`);
}
});
return `${messages.map((m) => `**${m.role}**: ${m.content}`).join('\n\n')}\n`;
}
const PARSERS: Record<ChatFormat, Parser> = {
openai: parseOpenAI,
anthropic: parseAnthropic,
gemini: parseGemini,
markdown: parseMarkdown,
};
const SERIALIZERS: Record<ChatFormat, Serializer> = {
openai: serializeOpenAI,
anthropic: serializeAnthropic,
gemini: serializeGemini,
markdown: serializeMarkdown,
};
/**
* Convert a transcript between chat formats through the shared internal shape.
*
* @throws Error when the transcript is empty, the source format fails to
* parse, or `from`/`to` is not a known format.
*/
export function convert(transcript: string, from: ChatFormat, to: ChatFormat): ConversionResult {
if (typeof transcript !== 'string' || transcript.trim() === '') {
throw new Error('Transcript is empty — paste a transcript first');
}
const parser = PARSERS[from];
if (parser === undefined) throw new Error(`Unknown source format: ${String(from)}`);
const serializer = SERIALIZERS[to];
if (serializer === undefined) throw new Error(`Unknown target format: ${String(to)}`);
const warnings: string[] = [];
const messages = parser(transcript, warnings);
const output = serializer(messages, warnings);
return { output, warnings };
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →