Skip to content

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

// chat-format-converter — C# 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
//
// Language: C# (12, standard library only — System.Text.Json)
// Source:   CosmoDev polyglot showcase port of the Chat Format Converter tool,
//           ported from src/lib/chatFormatConverter.ts (the canonical
//           TypeScript that powers the live tool).
// License:  display source — part of CosmoDev's polyglot tool pages.
//
// 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.
// System.Text.Json enumerates and writes object properties in document order,
// so key order survives a round-trip exactly like the TypeScript original.
// Parse failures throw ArgumentException.

using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace CosmoDev.ChatFormatConverter;

public static class ChatFormats
{
    public static readonly string[] All = ["openai", "anthropic", "gemini", "markdown"];
}

/// One assistant-emitted tool call, provider-agnostic. <c>Args</c> is a JSON string.
public sealed record ToolCall(string Id = "", string Name = "", string Args = "");

/// The shared internal shape every format parses into and serializes from.
public sealed record InternalMessage(
    string Role,            // "system" | "user" | "assistant" | "tool"
    string Content = "",    // "" when the message only carries tool calls/results
    string? Name = null,
    IReadOnlyList<ToolCall>? ToolCalls = null,
    string? ToolCallId = null);

/// The conversion output plus human-readable notes about unmapped fields.
public sealed record ConversionResult(string Output, IReadOnlyList<string> Warnings);

public static partial class ChatFormatConverter
{
    /* -------------------------------- helpers -------------------------------- */

    private static JsonDocument ParseTranscriptJson(string text, string label)
    {
        try
        {
            return JsonDocument.Parse(text);
        }
        catch (JsonException e)
        {
            throw new ArgumentException($"{label}: invalid JSON — {e.Message}");
        }
    }

    private static string Str(JsonElement el, string key) =>
        el.ValueKind == JsonValueKind.Object
        && el.TryGetProperty(key, out var v)
        && v.ValueKind == JsonValueKind.String ? v.GetString()! : null
        ?? "";

    /// Like <see cref="Str"/> but keeps the string/missing distinction: a JSON
    /// string value (even "") is present, anything else is null.
    private static string? StrOrNull(JsonElement el, string key) =>
        el.ValueKind == JsonValueKind.Object
        && el.TryGetProperty(key, out var v)
        && v.ValueKind == JsonValueKind.String ? v.GetString()! : null;

    private static bool Has(JsonElement el, string key) =>
        el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out _);

    private static JsonElement? Get(JsonElement el, string key) =>
        el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var v) ? v : null;

    /// The TS <c>?? {}</c> capture: null/missing becomes {}, any other value
    /// passes through and stringifies as-is.
    private static string ArgsOf(JsonElement? v)
    {
        if (v is null || v.Value.ValueKind == JsonValueKind.Null) return "{}";
        using var ms = new MemoryStream();
        using (var w = new Utf8JsonWriter(ms)) v.Value.WriteTo(w);
        return Encoding.UTF8.GetString(ms.ToArray());
    }

    /// Parse a JSON-string argument back to an element, an empty object when
    /// it is not a JSON object (mirrors parseArgsOrEmpty).
    private static JsonElement ParseArgsOrEmpty(string args)
    {
        try
        {
            var doc = JsonDocument.Parse(args);
            if (doc.RootElement.ValueKind == JsonValueKind.Object) return doc.RootElement;
            doc.Dispose();
        }
        catch (JsonException) { }
        return JsonDocument.Parse("{}").RootElement;
    }

    private static string BlockTypeName(JsonElement raw) =>
        raw.ValueKind == JsonValueKind.Object
        && raw.TryGetProperty("type", out var t)
        && t.ValueKind == JsonValueKind.String ? t.GetString()! : "content";

    private static string Compact(JsonElement el)
    {
        using var ms = new MemoryStream();
        using (var w = new Utf8JsonWriter(ms)) el.WriteTo(w);
        return Encoding.UTF8.GetString(ms.ToArray());
    }

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

    /// OpenAI messages[] (bare array, or wrapped in {"messages": [...]}).
    public static List<InternalMessage> ParseOpenAi(string text, List<string> warnings)
    {
        using var doc = ParseTranscriptJson(text, "OpenAI transcript");
        var root = doc.RootElement;
        JsonElement items = root.ValueKind == JsonValueKind.Array ? root
            : Get(root, "messages") is { ValueKind: JsonValueKind.Array } messages ? messages
            : throw new ArgumentException(
                """OpenAI transcript must be a messages[] array or an object with a "messages" array""");
        var @out = new List<InternalMessage>();
        var i = 0;
        foreach (var entry in items.EnumerateArray())
        {
            if (entry.ValueKind != JsonValueKind.Object)
                throw new ArgumentException($"messages[{i}] is not an object");
            var where = $"messages[{i}]";
            var role = Str(entry, "role");
            switch (role)
            {
                case "system" or "developer":
                    @out.Add(new("system", ContentText(Get(entry, "content"), where, warnings)));
                    break;
                case "user" or "assistant" or "tool":
                    var msg = new InternalMessage(role, ContentText(Get(entry, "content"), where, warnings))
                    {
                        Name = StrOrNull(entry, "name"),
                        ToolCallId = StrOrNull(entry, "tool_call_id"),
                        ToolCalls = ParseOpenAiToolCalls(Get(entry, "tool_calls")),
                    };
                    @out.Add(msg);
                    break;
                default:
                    throw new ArgumentException(
                        $"messages[{i}] has unsupported role: {(Has(entry, "role") ? Compact(entry.GetProperty("role")) : "null")}");
            }
            i++;
        }
        return @out;
    }

    /// OpenAI content → plain text (string, or text parts joined; other parts flagged).
    private static string ContentText(JsonElement? content, string where, List<string> warnings)
    {
        switch (content)
        {
            case null or { ValueKind: JsonValueKind.Null }:
                return "";
            case { ValueKind: JsonValueKind.String } s:
                return s.GetString()!;
            case { ValueKind: JsonValueKind.Array } parts:
            {
                var texts = new List<string>();
                var dropped = 0;
                foreach (var part in parts.EnumerateArray())
                {
                    var isText = Str(part, "type") == "text"
                        && Get(part, "text") is { ValueKind: JsonValueKind.String };
                    if (isText) texts.Add(part.GetProperty("text").GetString()!);
                    else dropped++;
                }
                if (dropped > 0)
                    warnings.Add($"{where}: dropped {dropped} non-text content part(s)");
                return string.Concat(texts);
            }
            default:
                throw new ArgumentException($"{where}: content must be a string, an array of parts, or null");
        }
    }

    private static IReadOnlyList<ToolCall>? ParseOpenAiToolCalls(JsonElement? raw)
    {
        if (raw is null or { ValueKind: JsonValueKind.Null }) return null;
        if (raw.Value.ValueKind != JsonValueKind.Array)
            throw new ArgumentException("tool_calls must be an array");
        var calls = new List<ToolCall>();
        foreach (var entry in raw.Value.EnumerateArray())
        {
            var fn = Get(entry, "function");
            calls.Add(new ToolCall(
                Id: Str(entry, "id"),
                Name: fn is { } f ? Str(f, "name") : "",
                Args: fn is { } f2 ? Str(f2, "arguments") : ""));
        }
        return calls;
    }

    /// One normalized Anthropic content block.
    private enum BlockKind { Text, ToolUse, ToolResult, Other }

    private static List<(BlockKind Kind, string Text, JsonElement Raw)> ContentBlocks(
        JsonElement? content, string where)
    {
        if (content is { ValueKind: JsonValueKind.String } s)
            return [(BlockKind.Text, s.GetString()!, default)];
        if (content is not { ValueKind: JsonValueKind.Array } blocks)
            throw new ArgumentException($"{where}: content must be a string or an array of blocks");
        var @out = new List<(BlockKind, string, JsonElement)>();
        foreach (var block in blocks.EnumerateArray())
        {
            var isText = Str(block, "type") == "text"
                && Get(block, "text") is { ValueKind: JsonValueKind.String };
            var kind = Str(block, "type");
            if (isText) @out.Add((BlockKind.Text, block.GetProperty("text").GetString()!, default));
            else if (kind == "tool_use") @out.Add((BlockKind.ToolUse, "", block));
            else if (kind == "tool_result") @out.Add((BlockKind.ToolResult, "", block));
            else @out.Add((BlockKind.Other, "", block));
        }
        return @out;
    }

    /// Anthropic string-or-block[] content → plain text.
    private static string BlocksToText(JsonElement? content, string where, List<string> warnings)
    {
        if (content is null or { ValueKind: JsonValueKind.Null }) return "";
        if (content is { ValueKind: JsonValueKind.String } s) return s.GetString()!;
        var texts = new List<string>();
        foreach (var (kind, text, raw) in ContentBlocks(content, where))
        {
            switch (kind)
            {
                case BlockKind.Text:
                    texts.Add(text);
                    break;
                case BlockKind.Other:
                    warnings.Add($"{where}: dropped unsupported {BlockTypeName(raw)} block");
                    break;
                default:
                    warnings.Add($"{where}: dropped {kind.ToString().ToLowerInvariant()} block from text-only content");
                    break;
            }
        }
        return string.Join("\n", texts);
    }

    private static ToolCall ToolCallFromBlock(JsonElement raw) => new(
        Id: Str(raw, "id"),
        Name: Str(raw, "name"),
        Args: ArgsOf(Get(raw, "input")));

    /// Anthropic messages[] plus the top-level system field.
    public static List<InternalMessage> ParseAnthropic(string text, List<string> warnings)
    {
        using var doc = ParseTranscriptJson(text, "Anthropic transcript");
        var root = doc.RootElement;
        if (root.ValueKind != JsonValueKind.Object
            || Get(root, "messages") is not { ValueKind: JsonValueKind.Array })
            throw new ArgumentException("""Anthropic transcript must be an object with a "messages" array""");
        var @out = new List<InternalMessage>();
        if (Has(root, "system"))
            @out.Add(new("system", BlocksToText(Get(root, "system"), "system", warnings)));

        var i = 0;
        foreach (var entry in root.GetProperty("messages").EnumerateArray())
        {
            if (entry.ValueKind != JsonValueKind.Object)
                throw new ArgumentException($"messages[{i}] is not an object");
            var where = $"messages[{i}]";
            switch (Str(entry, "role"))
            {
                case "assistant":
                {
                    var texts = new List<string>();
                    var calls = new List<ToolCall>();
                    foreach (var (kind, t, raw) in ContentBlocks(Get(entry, "content"), where))
                    {
                        switch (kind)
                        {
                            case BlockKind.Text: texts.Add(t); break;
                            case BlockKind.ToolUse: calls.Add(ToolCallFromBlock(raw)); break;
                            case BlockKind.Other:
                                warnings.Add($"{where}: dropped unsupported {BlockTypeName(raw)} block");
                                break;
                        }
                    }
                    var msg = new InternalMessage("assistant", string.Join("\n", texts));
                    if (calls.Count > 0) msg = msg with { ToolCalls = calls };
                    @out.Add(msg);
                    break;
                }
                case "user":
                {
                    var texts = new List<string>();
                    void Flush()
                    {
                        if (texts.Count > 0)
                        {
                            @out.Add(new("user", string.Join("\n", texts)));
                            texts.Clear();
                        }
                    }
                    foreach (var (kind, t, raw) in ContentBlocks(Get(entry, "content"), where))
                    {
                        switch (kind)
                        {
                            case BlockKind.Text:
                                texts.Add(t);
                                break;
                            case BlockKind.ToolUse:
                                warnings.Add($"{where}: tool_use block inside a user message moved to an assistant tool call");
                                Flush();
                                @out.Add(new("assistant", "", ToolCalls: [ToolCallFromBlock(raw)]));
                                break;
                            case BlockKind.ToolResult:
                                Flush();
                                @out.Add(new("tool",
                                    BlocksToText(Get(raw, "content"), where, warnings),
                                    ToolCallId: Str(raw, "tool_use_id")));
                                break;
                            default:
                                warnings.Add($"{where}: dropped unsupported {BlockTypeName(raw)} block");
                                break;
                        }
                    }
                    Flush();
                    break;
                }
                default:
                    throw new ArgumentException(
                        $"{where} has unsupported role: {(Has(entry, "role") ? Compact(entry.GetProperty("role")) : "null")}");
            }
            i++;
        }
        return @out;
    }

    /// Gemini systemInstruction (string or {parts}) → plain text.
    private static string GeminiText(JsonElement v, string where, List<string> warnings)
    {
        if (v.ValueKind == JsonValueKind.String) return v.GetString()!;
        if (v.ValueKind == JsonValueKind.Object)
        {
            if (Get(v, "text") is { ValueKind: JsonValueKind.String } t) return t.GetString()!;
            if (Get(v, "parts") is { ValueKind: JsonValueKind.Array } parts)
                return string.Join("\n", parts.EnumerateArray().Select(p => Str(p, "text")));
        }
        warnings.Add($"{where}: unsupported systemInstruction shape, treated as empty");
        return "";
    }

    /// Gemini contents[] plus the optional systemInstruction.
    public static List<InternalMessage> ParseGemini(string text, List<string> warnings)
    {
        using var doc = ParseTranscriptJson(text, "Gemini transcript");
        var root = doc.RootElement;
        if (root.ValueKind != JsonValueKind.Object
            || Get(root, "contents") is not { ValueKind: JsonValueKind.Array })
            throw new ArgumentException("""Gemini transcript must be an object with a "contents" array""");
        var @out = new List<InternalMessage>();
        if (Has(root, "systemInstruction"))
            @out.Add(new("system", GeminiText(root.GetProperty("systemInstruction"), "systemInstruction", warnings)));

        var i = 0;
        foreach (var entry in root.GetProperty("contents").EnumerateArray())
        {
            if (entry.ValueKind != JsonValueKind.Object)
                throw new ArgumentException($"contents[{i}] is not an object");
            var where = $"contents[{i}]";
            var role = Str(entry, "role");
            if (role is not ("user" or "model"))
                throw new ArgumentException(
                    $"{where} has unsupported role: {(Has(entry, "role") ? Compact(entry.GetProperty("role")) : "null")} (Gemini uses \"user\" or \"model\")");
            var parts = Get(entry, "parts");
            if (parts is not { ValueKind: JsonValueKind.Array })
                throw new ArgumentException($"{where}: parts must be an array");

            if (role == "model")
            {
                // A model turn keeps its text and function calls in ONE message,
                // mirroring an OpenAI assistant message with tool_calls.
                var texts = new List<string>();
                var calls = new List<ToolCall>();
                foreach (var part in parts.Value.EnumerateArray())
                {
                    if (Get(part, "text") is { ValueKind: JsonValueKind.String } t)
                    {
                        texts.Add(t.GetString()!);
                        continue;
                    }
                    if (Get(part, "functionCall") is { ValueKind: JsonValueKind.Object } fc)
                    {
                        calls.Add(new ToolCall(Name: Str(fc, "name"), Args: ArgsOf(Get(fc, "args"))));
                        continue;
                    }
                    warnings.Add($"{where}: dropped unsupported part (inlineData or similar)");
                }
                var msg = new InternalMessage("assistant", string.Join("\n", texts));
                if (calls.Count > 0) msg = msg with { ToolCalls = calls };
                @out.Add(msg);
                i++;
                continue;
            }

            var userTexts = new List<string>();
            void FlushText()
            {
                if (userTexts.Count > 0)
                {
                    @out.Add(new("user", string.Join("\n", userTexts)));
                    userTexts.Clear();
                }
            }
            foreach (var part in parts.Value.EnumerateArray())
            {
                if (Get(part, "text") is { ValueKind: JsonValueKind.String } t)
                {
                    userTexts.Add(t.GetString()!);
                    continue;
                }
                if (Get(part, "functionCall") is { ValueKind: JsonValueKind.Object } fc)
                {
                    FlushText();
                    @out.Add(new("assistant", "",
                        ToolCalls: [new ToolCall(Name: Str(fc, "name"), Args: ArgsOf(Get(fc, "args")))]));
                    continue;
                }
                if (Get(part, "functionResponse") is { ValueKind: JsonValueKind.Object } fr)
                {
                    FlushText();
                    var name = Str(fr, "name");
                    @out.Add(new("tool", ArgsOf(Get(fr, "response")), name, null, name));
                    continue;
                }
                warnings.Add($"{where}: dropped unsupported part (inlineData or similar)");
            }
            FlushText();
            i++;
        }
        return @out;
    }

    [GeneratedRegex(@"^\*\*(system|user|assistant|model|tool)\*\*:\s*(.*)$")]
    private static partial Regex MarkdownHeaderRegex();

    private static InternalMessage FinishMessage(string role, List<string> lines)
    {
        // Trim leading/trailing blank continuation lines but keep inner blank lines.
        while (lines.Count > 1 && lines[0].Trim().Length == 0) lines.RemoveAt(0);
        while (lines.Count > 1 && lines[^1].Trim().Length == 0) lines.RemoveAt(lines.Count - 1);
        return new(role, string.Join("\n", lines));
    }

    /// Markdown transcript: `**role**: text` header lines with continuation
    /// lines belonging to the same message. `model` maps to assistant.
    public static List<InternalMessage> ParseMarkdown(string text, List<string> _)
    {
        var @out = new List<InternalMessage>();
        (string Role, List<string> Lines)? current = null;
        foreach (var line in text.Split('\n'))
        {
            var match = MarkdownHeaderRegex().Match(line);
            if (match.Success)
            {
                if (current is { } c) @out.Add(FinishMessage(c.Role, c.Lines));
                current = (match.Groups[1].Value == "model" ? "assistant" : match.Groups[1].Value,
                    [match.Groups[2].Value]);
                continue;
            }
            if (current is { } cur) cur.Lines.Add(line);
            else if (line.Trim().Length != 0)
                throw new ArgumentException("Markdown transcript must start with a `**role**:` header line");
        }
        if (current is { } last) @out.Add(FinishMessage(last.Role, last.Lines));
        return @out;
    }

    /* -------------------------------- serialize ------------------------------ */

    private static string WritePretty(Action<Utf8JsonWriter> write)
    {
        using var ms = new MemoryStream();
        using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true })) write(w);
        return Encoding.UTF8.GetString(ms.ToArray()) + "\n";
    }

    public static string SerializeOpenAi(List<InternalMessage> messages, List<string> _)
    {
        return WritePretty(w =>
        {
            w.WriteStartObject();
            w.WritePropertyName("messages");
            w.WriteStartArray();
            foreach (var m in messages)
            {
                w.WriteStartObject();
                if (m.Role == "tool")
                {
                    w.WriteString("role", "tool");
                    w.WriteString("content", m.Content);
                    w.WriteString("tool_call_id", m.ToolCallId ?? "");
                    if (m.Name is { } name) w.WriteString("name", name);
                }
                else if (m.Role == "assistant" && m.ToolCalls is { })
                {
                    w.WriteString("role", "assistant");
                    if (m.Content == "") w.WriteNull("content");
                    else w.WriteString("content", m.Content);
                    w.WritePropertyName("tool_calls");
                    w.WriteStartArray();
                    foreach (var tc in m.ToolCalls)
                    {
                        w.WriteStartObject();
                        w.WriteString("id", tc.Id);
                        w.WriteString("type", "function");
                        w.WritePropertyName("function");
                        w.WriteStartObject();
                        w.WriteString("name", tc.Name);
                        w.WriteString("arguments", tc.Args);
                        w.WriteEndObject();
                        w.WriteEndObject();
                    }
                    w.WriteEndArray();
                    if (m.Name is { } name) w.WriteString("name", name);
                }
                else
                {
                    w.WriteString("role", m.Role);
                    w.WriteString("content", m.Content);
                    if (m.Name is { } name) w.WriteString("name", name);
                }
                w.WriteEndObject();
            }
            w.WriteEndArray();
            w.WriteEndObject();
        });
    }

    public static string SerializeAnthropic(List<InternalMessage> messages, List<string> warnings)
    {
        var system = messages.Where(m => m.Role == "system").Select(m => m.Content).ToList();
        var wroteMessages = false;
        var output = WritePretty(w =>
        {
            w.WriteStartObject();
            var i = 0;
            foreach (var m in messages)
            {
                var index = i++;
                if (m.Role == "system") continue;
                if (m.Name is not null && m.Role != "tool")
                    warnings.Add($"message {index}: \"name\" has no Anthropic equivalent and was dropped");
                if (!wroteMessages)
                {
                    w.WritePropertyName("messages");
                    w.WriteStartArray();
                    wroteMessages = true;
                }
                w.WriteStartObject();
                switch (m.Role)
                {
                    case "user":
                        w.WriteString("role", "user");
                        w.WriteString("content", m.Content);
                        break;
                    case "assistant" when m.ToolCalls is { } calls:
                        w.WriteString("role", "assistant");
                        w.WritePropertyName("content");
                        w.WriteStartArray();
                        if (m.Content != "")
                        {
                            w.WriteStartObject();
                            w.WriteString("type", "text");
                            w.WriteString("text", m.Content);
                            w.WriteEndObject();
                        }
                        foreach (var tc in calls)
                        {
                            w.WriteStartObject();
                            w.WriteString("type", "tool_use");
                            w.WriteString("id", tc.Id);
                            w.WriteString("name", tc.Name);
                            w.WritePropertyName("input");
                            ParseArgsOrEmpty(tc.Args).WriteTo(w);
                            w.WriteEndObject();
                        }
                        w.WriteEndArray();
                        break;
                    case "assistant":
                        w.WriteString("role", "assistant");
                        w.WriteString("content", m.Content);
                        break;
                    default:
                        w.WriteString("role", "user");
                        w.WritePropertyName("content");
                        w.WriteStartArray();
                        w.WriteStartObject();
                        w.WriteString("type", "tool_result");
                        w.WriteString("tool_use_id", m.ToolCallId ?? "");
                        w.WriteString("content", m.Content);
                        w.WriteEndObject();
                        w.WriteEndArray();
                        break;
                }
                w.WriteEndObject();
            }
            if (wroteMessages) w.WriteEndArray();
            if (system.Count > 0) w.WriteString("system", string.Join("\n\n", system));
            w.WriteEndObject();
        });
        return output;
    }

    public static string SerializeGemini(List<InternalMessage> messages, List<string> warnings)
    {
        var system = messages.Where(m => m.Role == "system").Select(m => m.Content).ToList();
        // (role, parts) turns; consecutive same-role parts merge into one turn.
        var turns = new List<(string Role, List<Action<Utf8JsonWriter>> Parts)>();
        void Push(string role, Action<Utf8JsonWriter> part)
        {
            if (turns.Count > 0 && turns[^1].Role == role) turns[^1].Parts.Add(part);
            else turns.Add((role, [part]));
        }
        var i = 0;
        foreach (var m in messages)
        {
            var index = i++;
            if (m.Role == "system") continue;
            if (m.Name is not null && m.Role != "tool")
                warnings.Add($"message {index}: \"name\" has no Gemini equivalent and was dropped");
            switch (m.Role)
            {
                case "user":
                    Push("user", w => w.WriteString("text", m.Content));
                    break;
                case "assistant":
                    if (m.Content != "") Push("model", w => w.WriteString("text", m.Content));
                    foreach (var tc in m.ToolCalls ?? [])
                        Push("model", w =>
                        {
                            w.WritePropertyName("functionCall");
                            w.WriteStartObject();
                            w.WriteString("name", tc.Name);
                            w.WritePropertyName("args");
                            ParseArgsOrEmpty(tc.Args).WriteTo(w);
                            w.WriteEndObject();
                        });
                    break;
                default:
                    var name = m.Name ?? m.ToolCallId ?? "";
                    Push("user", w =>
                    {
                        w.WritePropertyName("functionResponse");
                        w.WriteStartObject();
                        w.WriteString("name", name);
                        w.WritePropertyName("response");
                        ParseArgsOrEmpty(m.Content).WriteTo(w);
                        w.WriteEndObject();
                    });
                    break;
            }
        }
        return WritePretty(w =>
        {
            w.WriteStartObject();
            if (turns.Count > 0)
            {
                w.WritePropertyName("contents");
                w.WriteStartArray();
                foreach (var (role, parts) in turns)
                {
                    w.WriteStartObject();
                    w.WriteString("role", role);
                    w.WritePropertyName("parts");
                    w.WriteStartArray();
                    foreach (var part in parts)
                    {
                        w.WriteStartObject();
                        part(w);
                        w.WriteEndObject();
                    }
                    w.WriteEndArray();
                    w.WriteEndObject();
                }
                w.WriteEndArray();
            }
            if (system.Count > 0)
            {
                w.WritePropertyName("systemInstruction");
                w.WriteStartObject();
                w.WritePropertyName("parts");
                w.WriteStartArray();
                w.WriteStartObject();
                w.WriteString("text", string.Join("\n\n", system));
                w.WriteEndObject();
                w.WriteEndArray();
                w.WriteEndObject();
            }
            w.WriteEndObject();
        });
    }

    public static string SerializeMarkdown(List<InternalMessage> messages, List<string> warnings)
    {
        for (var i = 0; i < messages.Count; i++)
        {
            var m = messages[i];
            if (m.ToolCalls is { Count: > 0 })
                warnings.Add($"message {i}: tool calls are not representable in Markdown and were dropped");
            if (m.Role == "tool" && m.ToolCallId is not null)
                warnings.Add($"message {i}: tool result id is not representable in Markdown and was dropped");
        }
        return string.Join("\n\n", messages.Select(m => $"**{m.Role}**: {m.Content}")) + "\n";
    }

    private static readonly Dictionary<string, Func<string, List<string>, List<InternalMessage>>> Parsers = new()
    {
        ["openai"] = ParseOpenAi,
        ["anthropic"] = ParseAnthropic,
        ["gemini"] = ParseGemini,
        ["markdown"] = ParseMarkdown,
    };

    private static readonly Dictionary<string, Func<List<InternalMessage>, List<string>, string>> Serializers = new()
    {
        ["openai"] = SerializeOpenAi,
        ["anthropic"] = SerializeAnthropic,
        ["gemini"] = SerializeGemini,
        ["markdown"] = SerializeMarkdown,
    };

    /// Convert a transcript between chat formats through the shared internal shape.
    ///
    /// Throws ArgumentException when the transcript is empty, the source format
    /// fails to parse, or from/to is not a known format.
    public static ConversionResult Convert(string transcript, string from, string to)
    {
        if (string.IsNullOrWhiteSpace(transcript))
            throw new ArgumentException("Transcript is empty — paste a transcript first");
        if (!Parsers.TryGetValue(from, out var parser))
            throw new ArgumentException($"Unknown source format: {from}");
        if (!Serializers.TryGetValue(to, out var serializer))
            throw new ArgumentException($"Unknown target format: {to}");
        var warnings = new List<string>();
        var messages = parser(transcript, warnings);
        return new ConversionResult(serializer(messages, warnings), 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 →