Skip to content

Chat Format Converter — JavaScript 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 JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * chat-format-converter — JavaScript polyglot showcase port.
 *
 * 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.
 *
 * This is the JavaScript sibling of src/lib/chatFormatConverter.ts (the
 * canonical TypeScript that powers the live tool). Standard library only.
 * Display source — part of CosmoDev's polyglot tool pages.
 */

/** @typedef {'openai'|'anthropic'|'gemini'|'markdown'} ChatFormat */
/** @typedef {'system'|'user'|'assistant'|'tool'} Role */

/** One assistant-emitted tool call, provider-agnostic. `args` is a JSON string.
 *  @typedef {{id: string, name: string, args: string}} ToolCall */

/** The shared internal shape every format parses into and serializes from.
 *  @typedef {{role: Role, content: string, name?: string, toolCalls?: ToolCall[], toolCallId?: string}} InternalMessage */

export const CHAT_FORMATS = ['openai', 'anthropic', 'gemini', 'markdown'];

function parseJson(text, label) {
  try {
    return JSON.parse(text);
  } catch (e) {
    throw new Error(`${label}: invalid JSON — ${e.message}`);
  }
}

/** Parse a JSON-string argument back to a value, `{}` when it is not a JSON object. */
function parseArgsOrEmpty(args) {
  try {
    const v = JSON.parse(args);
    return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : {};
  } catch {
    return {};
  }
}

function asRecord(v) {
  return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null;
}

/* ---------------------------------- parse --------------------------------- */

/** OpenAI messages[] (bare array, or wrapped in `{"messages": [...]}`). */
function parseOpenAI(text, warnings) {
  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 = [];
  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 = { 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, where, warnings) {
  if (typeof content === 'string') return content;
  if (content === undefined || content === null) return '';
  if (Array.isArray(content)) {
    const texts = [];
    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) {
  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, warnings) {
  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 = [];
  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 = { role: 'assistant', content: '' };
      const texts = [];
      const calls = [];
      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 = [];
      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, where) {
  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', text: b.text };
    if (b !== null && (b.type === 'tool_use' || b.type === 'tool_result')) {
      return { type: b.type, raw: b };
    }
    return { type: 'other', raw: b ?? {} };
  });
}

/** Anthropic string-or-block[] content → plain text. */
function blocksToText(content, where, warnings) {
  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.text)
    .join('\n');
}

/** Gemini contents[] (+ optional systemInstruction). */
function parseGemini(text, warnings) {
  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 = [];
  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 = [];
      const calls = [];
      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 = { role: 'assistant', content: texts.join('\n') };
      if (calls.length > 0) msg.toolCalls = calls;
      out.push(msg);
      return;
    }

    const texts = [];
    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, where, warnings) {
  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. */
const MARKDOWN_HEADER = /^\*\*(system|user|assistant|model|tool)\*\*:\s*(.*)$/;

function parseMarkdown(text, _warnings) {
  const out = [];
  let current = null;
  for (const line of text.split('\n')) {
    const match = MARKDOWN_HEADER.exec(line);
    if (match !== null) {
      if (current !== null) out.push(finishMessage(current));
      current = { role: match[1] === 'model' ? 'assistant' : match[1], 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) {
  // 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) {
  const arr = messages.map((m) => {
    if (m.role === 'tool') {
      const o = { 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 = {
        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 = { 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, warnings) {
  const system = messages.filter((m) => m.role === 'system').map((m) => m.content);
  const out = [];
  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 = [];
        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 = 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, warnings) {
  const system = messages.filter((m) => m.role === 'system').map((m) => m.content);
  const contents = [];
  const push = (role, part) => {
    const last = contents[contents.length - 1];
    if (last !== undefined && last.role === role) {
      last.parts.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 = 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, warnings) {
  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 = {
  openai: parseOpenAI,
  anthropic: parseAnthropic,
  gemini: parseGemini,
  markdown: parseMarkdown,
};

const SERIALIZERS = {
  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, from, to) {
  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 = [];
  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 →