Skip to content

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

//! chat-format-converter — Zig 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: Zig (0.13, standard library only)
//! 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.
//! std.json.Value's ObjectMap is an array hash map, so key insertion order
//! survives a round-trip exactly like the TypeScript original. Everything is
//! allocated from one caller-provided arena; parse failures return
//! error.Invalid with the human-readable message stored in `diag`.

const std = @import("std");

const Value = std.json.Value;

pub const Error = error{ Invalid, OutOfMemory };

pub const chat_formats = [_][]const u8{ "openai", "anthropic", "gemini", "markdown" };

/// One assistant-emitted tool call, provider-agnostic. `args` is a JSON string.
pub const ToolCall = struct {
    id: []const u8 = "",
    name: []const u8 = "",
    args: []const u8 = "",
};

/// The shared internal shape every format parses into and serializes from.
pub const Message = struct {
    role: []const u8, // "system" | "user" | "assistant" | "tool"
    content: []const u8 = "", // "" when the message only carries tool calls/results
    name: ?[]const u8 = null,
    tool_calls: ?[]const ToolCall = null,
    tool_call_id: ?[]const u8 = null,
};

/// The conversion output plus human-readable notes about unmapped fields.
pub const ConversionResult = struct {
    output: []const u8,
    warnings: []const []const u8,
};

const Warnings = std.ArrayList([]const u8);
const Messages = std.ArrayList(Message);

/// Record the human-readable failure message and return error.Invalid.
fn fail(diag: *?[]const u8, arena: std.mem.Allocator, comptime fmt: []const u8, args: anytype) Error {
    diag.* = std.fmt.allocPrint(arena, fmt, args) catch null;
    return error.Invalid;
}

fn join(arena: std.mem.Allocator, parts: []const []const u8, sep: []const u8) Error![]const u8 {
    return std.mem.join(arena, sep, parts) catch return error.OutOfMemory;
}

// ------------------------------ JSON helpers --------------------------------

fn parseJson(arena: std.mem.Allocator, text: []const u8, label: []const u8, diag: *?[]const u8) Error!Value {
    return std.json.parseFromSliceLeaky(Value, arena, text, .{}) catch |e| switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        else => fail(diag, arena, "{s}: invalid JSON — {s}", .{ label, @errorName(e) }),
    }
}

fn jget(v: Value, key: []const u8) ?Value {
    switch (v) {
        .object => |map| return map.get(key),
        else => return null,
    }
}

fn jstr(v: ?Value) ?[]const u8 {
    const val = v orelse return null;
    switch (val) {
        .string => |s| return s,
        else => return null,
    }
}

/// str-or-null semantics: a JSON string value (even "") is present as ?[]const u8.
fn jstrOrNull(v: ?Value) ?[]const u8 {
    return jstr(v);
}

fn jarr(v: ?Value) ?[]Value {
    const val = v orelse return null;
    switch (val) {
        .array => |items| return items.items,
        else => return null,
    }
}

fn isObj(v: Value) bool {
    return v == .object;
}

fn has(v: Value, key: []const u8) bool {
    return jget(v, key) != null;
}

/// Compact JSON encoding of a value, the moral equivalent of TS `JSON.stringify`.
fn compact(arena: std.mem.Allocator, v: Value) Error![]const u8 {
    return std.json.stringifyAlloc(arena, v, .{}) catch return error.OutOfMemory;
}

/// Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`.
fn pretty(arena: std.mem.Allocator, v: Value) Error![]const u8 {
    return std.json.stringifyAlloc(arena, v, .{ .whitespace = .indent_2 }) catch return error.OutOfMemory;
}

/// The TS `?? {}` capture: null/missing becomes {}, any other value passes
/// through and stringifies as-is.
fn argsOf(arena: std.mem.Allocator, v: ?Value) Error![]const u8 {
    const val = v orelse return "{}";
    switch (val) {
        .null => return "{}",
        else => return compact(arena, val),
    }
}

/// Parse a JSON-string argument back to a value, an empty object when it is
/// not a JSON object (mirrors parseArgsOrEmpty).
fn parseArgsOrEmpty(arena: std.mem.Allocator, args: []const u8) Error!Value {
    const parsed = std.json.parseFromSliceLeaky(Value, arena, args, .{}) catch {
        return emptyObj(arena);
    };
    if (parsed == .object) return parsed;
    return emptyObj(arena);
}

fn emptyObj(arena: std.mem.Allocator) Error!Value {
    const map = std.json.ObjectMap.init(arena);
    return .{ .object = map };
}

fn newObj(arena: std.mem.Allocator) Error!std.json.ObjectMap {
    return std.json.ObjectMap.init(arena);
}

fn put(map: *std.json.ObjectMap, key: []const u8, v: Value) Error!void {
    map.put(key, v) catch return error.OutOfMemory;
}

fn strv(s: []const u8) Value {
    return .{ .string = s };
}

fn arrv(arena: std.mem.Allocator, items: []const Value) Error!Value {
    var list = std.json.Array.init(arena);
    list.appendSlice(items) catch return error.OutOfMemory;
    return .{ .array = list };
}

// ------------------------------ domain helpers ------------------------------

fn blockTypeName(raw: Value) []const u8 {
    return jstr(jget(raw, "type")) orelse "content";
}

/// One normalized Anthropic content block.
const Block = union(enum) {
    text: []const u8,
    tool_use: Value,
    tool_result: Value,
    other: Value,
};

const Blocks = std.ArrayList(Block);

fn contentBlocks(arena: std.mem.Allocator, content: Value, where: []const u8, diag: *?[]const u8) Error!Blocks {
    var blocks = Blocks.init(arena);
    switch (content) {
        .string => |s| {
            blocks.append(.{ .text = s }) catch return error.OutOfMemory;
            return blocks;
        },
        .array => |items| {
            for (items.items) |block| {
                const is_text = blk: {
                    const t = jstr(jget(block, "type")) orelse break :blk false;
                    break :blk std.mem.eql(u8, t, "text") and jstr(jget(block, "text")) != null;
                };
                const kind = jstr(jget(block, "type"));
                if (is_text) {
                    blocks.append(.{ .text = jstr(jget(block, "text")).? }) catch return error.OutOfMemory;
                } else if (kind != null and std.mem.eql(u8, kind.?, "tool_use")) {
                    blocks.append(.{ .tool_use = block }) catch return error.OutOfMemory;
                } else if (kind != null and std.mem.eql(u8, kind.?, "tool_result")) {
                    blocks.append(.{ .tool_result = block }) catch return error.OutOfMemory;
                } else {
                    blocks.append(.{ .other = block }) catch return error.OutOfMemory;
                }
            }
            return blocks;
        },
        else => return fail(diag, arena, "{s}: content must be a string or an array of blocks", .{where}),
    }
}

/// Anthropic string-or-block[] content → plain text.
fn blocksToText(arena: std.mem.Allocator, content: Value, where: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]const u8 {
    switch (content) {
        .string => |s| return s,
        .null => return "",
        else => {},
    }
    var texts = std.ArrayList([]const u8).init(arena);
    var blocks = try contentBlocks(arena, content, where, diag);
    for (blocks.items) |block| {
        switch (block) {
            .text => |t| texts.append(t) catch return error.OutOfMemory,
            .other => |raw| warnings.append(
                std.fmt.allocPrint(arena, "{s}: dropped unsupported {s} block", .{ where, blockTypeName(raw) }) catch return error.OutOfMemory,
            ) catch return error.OutOfMemory,
            .tool_use => warnings.append(
                std.fmt.allocPrint(arena, "{s}: dropped tool_use block from text-only content", .{where}) catch return error.OutOfMemory,
            ) catch return error.OutOfMemory,
            .tool_result => warnings.append(
                std.fmt.allocPrint(arena, "{s}: dropped tool_result block from text-only content", .{where}) catch return error.OutOfMemory,
            ) catch return error.OutOfMemory,
        }
    }
    return join(arena, texts.items, "\n");
}

fn toolCallFromBlock(arena: std.mem.Allocator, raw: Value) Error!ToolCall {
    return .{
        .id = jstr(jget(raw, "id")) orelse "",
        .name = jstr(jget(raw, "name")) orelse "",
        .args = try argsOf(arena, jget(raw, "input")),
    };
}

// ---------------------------------- parse -----------------------------------

/// OpenAI messages[] (bare array, or wrapped in {"messages": [...]}).
pub fn parseOpenAi(arena: std.mem.Allocator, text: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]Message {
    const parsed = try parseJson(arena, text, "OpenAI transcript", diag);
    const items: []Value = blk: {
        if (isObj(parsed)) {
            if (jarr(jget(parsed, "messages"))) |wrapped| break :blk wrapped;
        }
        if (jarr(parsed)) |bare| break :blk bare;
        return fail(diag, arena, "OpenAI transcript must be a messages[] array or an object with a \"messages\" array", .{});
    };
    var out = Messages.init(arena);
    for (items, 0..) |entry, i| {
        if (!isObj(entry)) return fail(diag, arena, "messages[{d}] is not an object", .{i});
        const where = try std.fmt.allocPrint(arena, "messages[{d}]", .{i}) catch return error.OutOfMemory;
        const role = jstr(jget(entry, "role")) orelse "";
        if (std.mem.eql(u8, role, "system") or std.mem.eql(u8, role, "developer")) {
            out.append(.{
                .role = "system",
                .content = try contentText(arena, jget(entry, "content"), where, warnings, diag),
            }) catch return error.OutOfMemory;
        } else if (std.mem.eql(u8, role, "user") or std.mem.eql(u8, role, "assistant") or std.mem.eql(u8, role, "tool")) {
            out.append(.{
                .role = role,
                .content = try contentText(arena, jget(entry, "content"), where, warnings, diag),
                .name = jstrOrNull(jget(entry, "name")),
                .tool_call_id = jstrOrNull(jget(entry, "tool_call_id")),
                .tool_calls = try parseOpenAiToolCalls(arena, jget(entry, "tool_calls"), diag),
            }) catch return error.OutOfMemory;
        } else {
            const rendered = if (jget(entry, "role")) |r| try compact(arena, r) else "null";
            return fail(diag, arena, "messages[{d}] has unsupported role: {s}", .{ i, rendered });
        }
    }
    return out.items;
}

/// OpenAI content → plain text (string, or text parts joined; other parts flagged).
fn contentText(arena: std.mem.Allocator, content: ?Value, where: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]const u8 {
    const c = content orelse return "";
    switch (c) {
        .null => return "",
        .string => |s| return s,
        .array => |parts| {
            var texts = std.ArrayList([]const u8).init(arena);
            var dropped: usize = 0;
            for (parts.items) |part| {
                const is_text = blk: {
                    const t = jstr(jget(part, "type")) orelse break :blk false;
                    break :blk std.mem.eql(u8, t, "text") and jstr(jget(part, "text")) != null;
                };
                if (is_text) {
                    texts.append(jstr(jget(part, "text")).?) catch return error.OutOfMemory;
                } else dropped += 1;
            }
            if (dropped > 0) {
                warnings.append(
                    std.fmt.allocPrint(arena, "{s}: dropped {d} non-text content part(s)", .{ where, dropped }) catch return error.OutOfMemory,
                ) catch return error.OutOfMemory;
            }
            return join(arena, texts.items, "");
        },
        else => return fail(diag, arena, "{s}: content must be a string, an array of parts, or null", .{where}),
    }
}

fn parseOpenAiToolCalls(arena: std.mem.Allocator, raw: ?Value, diag: *?[]const u8) Error!?[]const ToolCall {
    const r = raw orelse return null;
    switch (r) {
        .null => return null,
        .array => |items| {
            var calls = std.ArrayList(ToolCall).init(arena);
            for (items.items) |entry| {
                const fn_ = jget(entry, "function");
                calls.append(.{
                    .id = jstr(jget(entry, "id")) orelse "",
                    .name = if (fn_) |f| jstr(jget(f, "name")) orelse "" else "",
                    .args = if (fn_) |f| jstr(jget(f, "arguments")) orelse "" else "",
                }) catch return error.OutOfMemory;
            }
            return calls.items;
        },
        else => return fail(diag, arena, "tool_calls must be an array", .{}),
    }
}

/// Anthropic messages[] plus the top-level system field.
pub fn parseAnthropic(arena: std.mem.Allocator, text: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]Message {
    const root = try parseJson(arena, text, "Anthropic transcript", diag);
    const messages: []Value = blk: {
        if (isObj(root)) {
            if (jarr(jget(root, "messages"))) |items| break :blk items;
        }
        return fail(diag, arena, "Anthropic transcript must be an object with a \"messages\" array", .{});
    };
    var out = Messages.init(arena);
    if (has(root, "system")) {
        out.append(.{ .role = "system", .content = try blocksToText(arena, jget(root, "system").?, "system", warnings, diag) }) catch return error.OutOfMemory;
    }

    for (messages, 0..) |entry, i| {
        if (!isObj(entry)) return fail(diag, arena, "messages[{d}] is not an object", .{i});
        const where = try std.fmt.allocPrint(arena, "messages[{d}]", .{i}) catch return error.OutOfMemory;
        const role = jstr(jget(entry, "role")) orelse "";
        if (std.mem.eql(u8, role, "assistant")) {
            var texts = std.ArrayList([]const u8).init(arena);
            var calls = std.ArrayList(ToolCall).init(arena);
            var blocks = try contentBlocks(arena, jget(entry, "content") orelse .{ .null = {} }, where, diag);
            for (blocks.items) |block| {
                switch (block) {
                    .text => |t| texts.append(t) catch return error.OutOfMemory,
                    .tool_use => |raw| calls.append(try toolCallFromBlock(arena, raw)) catch return error.OutOfMemory,
                    .other => |raw| warnings.append(
                        std.fmt.allocPrint(arena, "{s}: dropped unsupported {s} block", .{ where, blockTypeName(raw) }) catch return error.OutOfMemory,
                    ) catch return error.OutOfMemory,
                    .tool_result => {},
                }
            }
            out.append(.{
                .role = "assistant",
                .content = try join(arena, texts.items, "\n"),
                .tool_calls = if (calls.items.len > 0) calls.items else null,
            }) catch return error.OutOfMemory;
        } else if (std.mem.eql(u8, role, "user")) {
            var texts = std.ArrayList([]const u8).init(arena);
            var blocks = try contentBlocks(arena, jget(entry, "content") orelse .{ .null = {} }, where, diag);
            for (blocks.items) |block| {
                switch (block) {
                    .text => |t| texts.append(t) catch return error.OutOfMemory,
                    .tool_use => |raw| {
                        warnings.append(
                            std.fmt.allocPrint(arena, "{s}: tool_use block inside a user message moved to an assistant tool call", .{where}) catch return error.OutOfMemory,
                        ) catch return error.OutOfMemory;
                        try flushUserTexts(arena, &out, &texts);
                        var one = arena.alloc(ToolCall, 1) catch return error.OutOfMemory;
                        one[0] = try toolCallFromBlock(arena, raw);
                        out.append(.{ .role = "assistant", .tool_calls = one }) catch return error.OutOfMemory;
                    },
                    .tool_result => |raw| {
                        try flushUserTexts(arena, &out, &texts);
                        const inner = jget(raw, "content") orelse strv("");
                        out.append(.{
                            .role = "tool",
                            .content = try blocksToText(arena, inner, where, warnings, diag),
                            .tool_call_id = jstr(jget(raw, "tool_use_id")) orelse "",
                        }) catch return error.OutOfMemory;
                    },
                    .other => |raw| warnings.append(
                        std.fmt.allocPrint(arena, "{s}: dropped unsupported {s} block", .{ where, blockTypeName(raw) }) catch return error.OutOfMemory,
                    ) catch return error.OutOfMemory,
                }
            }
            try flushUserTexts(arena, &out, &texts);
        } else {
            const rendered = if (jget(entry, "role")) |r| try compact(arena, r) else "null";
            return fail(diag, arena, "{s} has unsupported role: {s}", .{ where, rendered });
        }
    }
    return out.items;
}

fn flushUserTexts(arena: std.mem.Allocator, out: *Messages, texts: *std.ArrayList([]const u8)) Error!void {
    if (texts.items.len > 0) {
        out.append(.{ .role = "user", .content = try join(arena, texts.items, "\n") }) catch return error.OutOfMemory;
        texts.clearRetainingCapacity();
    }
}

/// Gemini systemInstruction (string or {parts}) → plain text.
fn geminiText(arena: std.mem.Allocator, v: Value, where: []const u8, warnings: *Warnings) Error![]const u8 {
    switch (v) {
        .string => |s| return s,
        .object => {
            if (jstr(jget(v, "text"))) |t| return t;
            if (jarr(jget(v, "parts"))) |parts| {
                var texts = std.ArrayList([]const u8).init(arena);
                for (parts) |part| {
                    texts.append(jstr(jget(part, "text")) orelse "") catch return error.OutOfMemory;
                }
                return join(arena, texts.items, "\n");
            }
        },
        else => {},
    }
    warnings.append(
        std.fmt.allocPrint(arena, "{s}: unsupported systemInstruction shape, treated as empty", .{where}) catch return error.OutOfMemory,
    ) catch return error.OutOfMemory;
    return "";
}

/// Gemini contents[] plus the optional systemInstruction.
pub fn parseGemini(arena: std.mem.Allocator, text: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]Message {
    const root = try parseJson(arena, text, "Gemini transcript", diag);
    const contents: []Value = blk: {
        if (isObj(root)) {
            if (jarr(jget(root, "contents"))) |items| break :blk items;
        }
        return fail(diag, arena, "Gemini transcript must be an object with a \"contents\" array", .{});
    };
    var out = Messages.init(arena);
    if (has(root, "systemInstruction")) {
        out.append(.{
            .role = "system",
            .content = try geminiText(arena, jget(root, "systemInstruction").?, "systemInstruction", warnings),
        }) catch return error.OutOfMemory;
    }

    for (contents, 0..) |entry, i| {
        if (!isObj(entry)) return fail(diag, arena, "contents[{d}] is not an object", .{i});
        const where = try std.fmt.allocPrint(arena, "contents[{d}]", .{i}) catch return error.OutOfMemory;
        const role = jstr(jget(entry, "role")) orelse "";
        if (!std.mem.eql(u8, role, "user") and !std.mem.eql(u8, role, "model")) {
            const rendered = if (jget(entry, "role")) |r| try compact(arena, r) else "null";
            return fail(diag, arena, "{s} has unsupported role: {s} (Gemini uses \"user\" or \"model\")", .{ where, rendered });
        }
        const parts = jarr(jget(entry, "parts")) orelse
            return fail(diag, arena, "{s}: parts must be an array", .{where});

        if (std.mem.eql(u8, role, "model")) {
            // A model turn keeps its text and function calls in ONE message,
            // mirroring an OpenAI assistant message with tool_calls.
            var texts = std.ArrayList([]const u8).init(arena);
            var calls = std.ArrayList(ToolCall).init(arena);
            for (parts) |part| {
                if (jstr(jget(part, "text"))) |t| {
                    texts.append(t) catch return error.OutOfMemory;
                    continue;
                }
                if (jget(part, "functionCall")) |fc| {
                    if (isObj(fc)) {
                        calls.append(.{
                            .name = jstr(jget(fc, "name")) orelse "",
                            .args = try argsOf(arena, jget(fc, "args")),
                        }) catch return error.OutOfMemory;
                        continue;
                    }
                }
                warnings.append(
                    std.fmt.allocPrint(arena, "{s}: dropped unsupported part (inlineData or similar)", .{where}) catch return error.OutOfMemory,
                ) catch return error.OutOfMemory;
            }
            out.append(.{
                .role = "assistant",
                .content = try join(arena, texts.items, "\n"),
                .tool_calls = if (calls.items.len > 0) calls.items else null,
            }) catch return error.OutOfMemory;
            continue;
        }

        var texts = std.ArrayList([]const u8).init(arena);
        for (parts) |part| {
            if (jstr(jget(part, "text"))) |t| {
                texts.append(t) catch return error.OutOfMemory;
                continue;
            }
            if (jget(part, "functionCall")) |fc| {
                if (isObj(fc)) {
                    try flushUserTexts(arena, &out, &texts);
                    var one = arena.alloc(ToolCall, 1) catch return error.OutOfMemory;
                    one[0] = .{
                        .name = jstr(jget(fc, "name")) orelse "",
                        .args = try argsOf(arena, jget(fc, "args")),
                    };
                    out.append(.{ .role = "assistant", .tool_calls = one }) catch return error.OutOfMemory;
                    continue;
                }
            }
            if (jget(part, "functionResponse")) |fr| {
                if (isObj(fr)) {
                    try flushUserTexts(arena, &out, &texts);
                    const name = jstr(jget(fr, "name")) orelse "";
                    out.append(.{
                        .role = "tool",
                        .content = try argsOf(arena, jget(fr, "response")),
                        .name = name,
                        .tool_call_id = name,
                    }) catch return error.OutOfMemory;
                    continue;
                }
            }
            warnings.append(
                std.fmt.allocPrint(arena, "{s}: dropped unsupported part (inlineData or similar)", .{where}) catch return error.OutOfMemory,
            ) catch return error.OutOfMemory;
        }
        try flushUserTexts(arena, &out, &texts);
    }
    return out.items;
}

/// A parsed Markdown header line: `**role**: text`.
fn markdownHeader(line: []const u8) ?struct { role: []const u8, first: []const u8 } {
    if (!std.mem.startsWith(u8, line, "**")) return null;
    const after_opening = line[2..];
    const end = std.mem.indexOf(u8, after_opening, "**:") orelse return null;
    const role = after_opening[0..end];
    const mapped: ?[]const u8 = blk: {
        if (std.mem.eql(u8, role, "system")) break :blk "system";
        if (std.mem.eql(u8, role, "user")) break :blk "user";
        if (std.mem.eql(u8, role, "assistant")) break :blk "assistant";
        if (std.mem.eql(u8, role, "model")) break :blk "assistant";
        if (std.mem.eql(u8, role, "tool")) break :blk "tool";
        break :blk null;
    };
    const m = mapped orelse return null;
    return .{ .role = m, .first = std.mem.trimLeft(u8, after_opening[end + 3 ..], " \t\r\n\x0b\x0c") };
}

fn isBlank(s: []const u8) bool {
    return std.mem.trim(u8, s, " \t\r\n\x0b\x0c").len == 0;
}

fn finishMessage(arena: std.mem.Allocator, role: []const u8, lines: *std.ArrayList([]const u8)) Error!Message {
    // Trim leading/trailing blank continuation lines but keep inner blank lines.
    while (lines.items.len > 1 and isBlank(lines.items[0])) _ = lines.orderedRemove(0);
    while (lines.items.len > 1 and isBlank(lines.items[lines.items.len - 1])) _ = lines.pop();
    return .{ .role = role, .content = try join(arena, lines.items, "\n") };
}

/// Markdown transcript: `**role**: text` header lines with continuation lines
/// belonging to the same message. `model` maps to assistant.
pub fn parseMarkdown(arena: std.mem.Allocator, text: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]Message {
    _ = warnings;
    var out = Messages.init(arena);
    var role: ?[]const u8 = null;
    var lines: std.ArrayList([]const u8) = std.ArrayList([]const u8).init(arena);
    var it = std.mem.splitScalar(u8, text, '\n');
    while (it.next()) |line| {
        if (markdownHeader(line)) |h| {
            if (role) |r| {
                out.append(try finishMessage(arena, r, &lines)) catch return error.OutOfMemory;
            }
            role = h.role;
            lines = std.ArrayList([]const u8).init(arena);
            lines.append(h.first) catch return error.OutOfMemory;
            continue;
        }
        if (role != null) {
            lines.append(line) catch return error.OutOfMemory;
        } else if (!isBlank(line)) {
            return fail(diag, arena, "Markdown transcript must start with a `**role**:` header line", .{});
        }
    }
    if (role) |r| {
        out.append(try finishMessage(arena, r, &lines)) catch return error.OutOfMemory;
    }
    return out.items;
}

// -------------------------------- serialize ---------------------------------

pub fn serializeOpenAi(arena: std.mem.Allocator, messages: []const Message, warnings: *Warnings) Error![]const u8 {
    _ = warnings;
    var arr = std.ArrayList(Value).init(arena);
    for (messages) |m| {
        var o = try newObj(arena);
        if (std.mem.eql(u8, m.role, "tool")) {
            try put(&o, "role", strv("tool"));
            try put(&o, "content", strv(m.content));
            try put(&o, "tool_call_id", strv(m.tool_call_id orelse ""));
            if (m.name) |n| try put(&o, "name", strv(n));
        } else if (std.mem.eql(u8, m.role, "assistant") and m.tool_calls != null) {
            try put(&o, "role", strv("assistant"));
            try put(&o, "content", if (m.content.len == 0) .null else strv(m.content));
            var calls = std.ArrayList(Value).init(arena);
            for (m.tool_calls.?) |tc| {
                var fn_ = try newObj(arena);
                try put(&fn_, "name", strv(tc.name));
                try put(&fn_, "arguments", strv(tc.args));
                var call = try newObj(arena);
                try put(&call, "id", strv(tc.id));
                try put(&call, "type", strv("function"));
                try put(&call, "function", .{ .object = fn_ });
                calls.append(.{ .object = call }) catch return error.OutOfMemory;
            }
            try put(&o, "tool_calls", try arrv(arena, calls.items));
            if (m.name) |n| try put(&o, "name", strv(n));
        } else {
            try put(&o, "role", strv(m.role));
            try put(&o, "content", strv(m.content));
            if (m.name) |n| try put(&o, "name", strv(n));
        }
        arr.append(.{ .object = o }) catch return error.OutOfMemory;
    }
    var root = try newObj(arena);
    try put(&root, "messages", try arrv(arena, arr.items));
    var buf = std.ArrayList(u8).init(arena);
    buf.appendSlice(try pretty(arena, .{ .object = root })) catch return error.OutOfMemory;
    buf.append('\n') catch return error.OutOfMemory;
    return buf.items;
}

pub fn serializeAnthropic(arena: std.mem.Allocator, messages: []const Message, warnings: *Warnings) Error![]const u8 {
    var system = std.ArrayList([]const u8).init(arena);
    var out = std.ArrayList(Value).init(arena);
    for (messages, 0..) |m, i| {
        if (std.mem.eql(u8, m.role, "system")) {
            system.append(m.content) catch return error.OutOfMemory;
            continue;
        }
        if (m.name != null and !std.mem.eql(u8, m.role, "tool")) {
            warnings.append(
                std.fmt.allocPrint(arena, "message {d}: \"name\" has no Anthropic equivalent and was dropped", .{i}) catch return error.OutOfMemory,
            ) catch return error.OutOfMemory;
        }
        var o = try newObj(arena);
        if (std.mem.eql(u8, m.role, "user")) {
            try put(&o, "role", strv("user"));
            try put(&o, "content", strv(m.content));
        } else if (std.mem.eql(u8, m.role, "assistant")) {
            try put(&o, "role", strv("assistant"));
            if (m.tool_calls) |calls| {
                var blocks = std.ArrayList(Value).init(arena);
                if (m.content.len > 0) {
                    var tb = try newObj(arena);
                    try put(&tb, "type", strv("text"));
                    try put(&tb, "text", strv(m.content));
                    blocks.append(.{ .object = tb }) catch return error.OutOfMemory;
                }
                for (calls) |tc| {
                    var ub = try newObj(arena);
                    try put(&ub, "type", strv("tool_use"));
                    try put(&ub, "id", strv(tc.id));
                    try put(&ub, "name", strv(tc.name));
                    try put(&ub, "input", try parseArgsOrEmpty(arena, tc.args));
                    blocks.append(.{ .object = ub }) catch return error.OutOfMemory;
                }
                try put(&o, "content", try arrv(arena, blocks.items));
            } else {
                try put(&o, "content", strv(m.content));
            }
        } else {
            try put(&o, "role", strv("user"));
            var rb = try newObj(arena);
            try put(&rb, "type", strv("tool_result"));
            try put(&rb, "tool_use_id", strv(m.tool_call_id orelse ""));
            try put(&rb, "content", strv(m.content));
            const wrapped = [1]Value{.{ .object = rb }};
            try put(&o, "content", try arrv(arena, &wrapped));
        }
        out.append(.{ .object = o }) catch return error.OutOfMemory;
    }
    var root = try newObj(arena);
    if (out.items.len > 0) try put(&root, "messages", try arrv(arena, out.items));
    if (system.items.len > 0) try put(&root, "system", strv(try join(arena, system.items, "\n\n")));
    var buf = std.ArrayList(u8).init(arena);
    buf.appendSlice(try pretty(arena, .{ .object = root })) catch return error.OutOfMemory;
    buf.append('\n') catch return error.OutOfMemory;
    return buf.items;
}

/// (role, parts) turns; consecutive same-role parts merge into one turn.
const Turn = struct { role: []const u8, parts: std.ArrayList(Value) };

fn pushTurn(turns: *std.ArrayList(Turn), role: []const u8, part: Value) Error!void {
    if (turns.items.len > 0) {
        var last = &turns.items[turns.items.len - 1];
        if (std.mem.eql(u8, last.role, role)) {
            last.parts.append(part) catch return error.OutOfMemory;
            return;
        }
    }
    var parts = std.ArrayList(Value).init(turns.allocator);
    parts.append(part) catch return error.OutOfMemory;
    turns.append(.{ .role = role, .parts = parts }) catch return error.OutOfMemory;
}

pub fn serializeGemini(arena: std.mem.Allocator, messages: []const Message, warnings: *Warnings) Error![]const u8 {
    var system = std.ArrayList([]const u8).init(arena);
    var turns = std.ArrayList(Turn).init(arena);
    for (messages, 0..) |m, i| {
        if (std.mem.eql(u8, m.role, "system")) {
            system.append(m.content) catch return error.OutOfMemory;
            continue;
        }
        if (m.name != null and !std.mem.eql(u8, m.role, "tool")) {
            warnings.append(
                std.fmt.allocPrint(arena, "message {d}: \"name\" has no Gemini equivalent and was dropped", .{i}) catch return error.OutOfMemory,
            ) catch return error.OutOfMemory;
        }
        if (std.mem.eql(u8, m.role, "user")) {
            var p = try newObj(arena);
            try put(&p, "text", strv(m.content));
            try pushTurn(&turns, "user", .{ .object = p });
        } else if (std.mem.eql(u8, m.role, "assistant")) {
            if (m.content.len > 0) {
                var p = try newObj(arena);
                try put(&p, "text", strv(m.content));
                try pushTurn(&turns, "model", .{ .object = p });
            }
            const calls: []const ToolCall = m.tool_calls orelse &.{};
            for (calls) |tc| {
                var fc = try newObj(arena);
                try put(&fc, "name", strv(tc.name));
                try put(&fc, "args", try parseArgsOrEmpty(arena, tc.args));
                var p = try newObj(arena);
                try put(&p, "functionCall", .{ .object = fc });
                try pushTurn(&turns, "model", .{ .object = p });
            }
        } else {
            const name = m.name orelse m.tool_call_id orelse "";
            var fr = try newObj(arena);
            try put(&fr, "name", strv(name));
            try put(&fr, "response", try parseArgsOrEmpty(arena, m.content));
            var p = try newObj(arena);
            try put(&p, "functionResponse", .{ .object = fr });
            try pushTurn(&turns, "user", .{ .object = p });
        }
    }
    var root = try newObj(arena);
    if (turns.items.len > 0) {
        var contents = std.ArrayList(Value).init(arena);
        for (turns.items) |turn| {
            var c = try newObj(arena);
            try put(&c, "role", strv(turn.role));
            try put(&c, "parts", try arrv(arena, turn.parts.items));
            contents.append(.{ .object = c }) catch return error.OutOfMemory;
        }
        try put(&root, "contents", try arrv(arena, contents.items));
    }
    if (system.items.len > 0) {
        var tp = try newObj(arena);
        try put(&tp, "text", strv(try join(arena, system.items, "\n\n")));
        const one = [1]Value{.{ .object = tp }};
        var si = try newObj(arena);
        try put(&si, "parts", try arrv(arena, &one));
        try put(&root, "systemInstruction", .{ .object = si });
    }
    var buf = std.ArrayList(u8).init(arena);
    buf.appendSlice(try pretty(arena, .{ .object = root })) catch return error.OutOfMemory;
    buf.append('\n') catch return error.OutOfMemory;
    return buf.items;
}

pub fn serializeMarkdown(arena: std.mem.Allocator, messages: []const Message, warnings: *Warnings) Error![]const u8 {
    var lines = std.ArrayList([]const u8).init(arena);
    for (messages, 0..) |m, i| {
        if (m.tool_calls) |calls| {
            if (calls.len > 0) {
                warnings.append(
                    std.fmt.allocPrint(arena, "message {d}: tool calls are not representable in Markdown and were dropped", .{i}) catch return error.OutOfMemory,
                ) catch return error.OutOfMemory;
            }
        }
        if (std.mem.eql(u8, m.role, "tool") and m.tool_call_id != null) {
            warnings.append(
                std.fmt.allocPrint(arena, "message {d}: tool result id is not representable in Markdown and was dropped", .{i}) catch return error.OutOfMemory,
            ) catch return error.OutOfMemory;
        }
        lines.append(
            std.fmt.allocPrint(arena, "**{s}**: {s}", .{ m.role, m.content }) catch return error.OutOfMemory,
        ) catch return error.OutOfMemory;
    }
    var buf = std.ArrayList(u8).init(arena);
    buf.appendSlice(try join(arena, lines.items, "\n\n")) catch return error.OutOfMemory;
    buf.append('\n') catch return error.OutOfMemory;
    return buf.items;
}

// --------------------------------- convert ----------------------------------

/// Convert a transcript between chat formats through the shared internal shape.
///
/// Returns error.Invalid (message in `diag`) when the transcript is empty, the
/// source format fails to parse, or `from`/`to` is not a known format. All
/// allocations come from `arena` — free everything by resetting the arena.
pub fn convert(
    arena: std.mem.Allocator,
    transcript: []const u8,
    from: []const u8,
    to: []const u8,
    diag: *?[]const u8,
) Error!ConversionResult {
    if (std.mem.trim(u8, transcript, " \t\r\n\x0b\x0c").len == 0) {
        return fail(diag, arena, "Transcript is empty — paste a transcript first", .{});
    }
    const Parser = *const fn (std.mem.Allocator, []const u8, *Warnings, *?[]const u8) Error![]Message;
    const parser: Parser = blk: {
        if (std.mem.eql(u8, from, "openai")) break :blk &parseOpenAi;
        if (std.mem.eql(u8, from, "anthropic")) break :blk &parseAnthropic;
        if (std.mem.eql(u8, from, "gemini")) break :blk &parseGemini;
        if (std.mem.eql(u8, from, "markdown")) break :blk &parseMarkdown;
        return fail(diag, arena, "Unknown source format: {s}", .{from});
    };
    const Serializer = *const fn (std.mem.Allocator, []const Message, *Warnings) Error![]const u8;
    const serializer: Serializer = blk: {
        if (std.mem.eql(u8, to, "openai")) break :blk &serializeOpenAi;
        if (std.mem.eql(u8, to, "anthropic")) break :blk &serializeAnthropic;
        if (std.mem.eql(u8, to, "gemini")) break :blk &serializeGemini;
        if (std.mem.eql(u8, to, "markdown")) break :blk &serializeMarkdown;
        return fail(diag, arena, "Unknown target format: {s}", .{to});
    };
    var warnings = Warnings.init(arena);
    const messages = try parser(arena, transcript, &warnings, diag);
    const output = try serializer(arena, messages, &warnings);
    return .{ .output = output, .warnings = warnings.items };
}

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 →