Skip to content

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

// chat-format-converter — Java 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: Java (17, 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. The JDK
// ships no JSON engine, so this file includes a small hand-written
// recursive-descent JSON parser + writer (java.std only). Object keys are kept
// in a List<Pair> so insertion order survives a round-trip, exactly like the
// TypeScript original. Parse failures throw IllegalArgumentException.

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/* ------------------------------ JSON engine ------------------------------- */

/** A JSON value. Objects preserve insertion order via a pair list, matching
 * JavaScript object semantics (JSON.stringify key order). */
sealed interface Json permits Json.Null, Json.Bool, Json.Num, Json.Str, Json.Arr, Json.Obj {
    record Null() implements Json {}
    record Bool(boolean value) implements Json {}
    record Num(double value) implements Json {}
    record Str(String value) implements Json {}
    record Arr(List<Json> items) implements Json {}
    record Obj(List<Map.Entry<String, Json>> pairs) implements Json {}

    /** First value stored under `key`, null when absent. */
    default Json get(String key) {
        if (this instanceof Obj obj) {
            for (var e : obj.pairs()) {
                if (e.getKey().equals(key)) return e.getValue();
            }
        }
        return null;
    }

    default String asStr() {
        return this instanceof Str s ? s.value() : null;
    }

    default List<Json> asArr() {
        return this instanceof Arr a ? a.items() : null;
    }

    default boolean isObject() {
        return this instanceof Obj;
    }

    /** Compact encoding, the moral equivalent of TS `JSON.stringify`. */
    default String toCompact() {
        StringBuilder out = new StringBuilder();
        write(out, null, 0);
        return out.toString();
    }

    /** Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`. */
    default String toPretty() {
        StringBuilder out = new StringBuilder();
        write(out, 2, 0);
        return out.toString();
    }

    private void write(StringBuilder out, Integer indent, int depth) {
        if (this instanceof Null) { out.append("null"); return; }
        if (this instanceof Bool b) { out.append(b.value() ? "true" : "false"); return; }
        if (this instanceof Num m) { out.append(formatF64(m.value())); return; }
        if (this instanceof Str s) { writeEscaped(out, s.value()); return; }
        if (this instanceof Arr a) {
            if (a.items().isEmpty()) { out.append("[]"); return; }
            out.append('[');
            for (int i = 0; i < a.items().size(); i++) {
                if (i > 0) out.append(',');
                if (indent != null) { out.append('\n'); pushIndent(out, depth + 1); }
                a.items().get(i).write(out, indent, depth + 1);
            }
            if (indent != null) { out.append('\n'); pushIndent(out, depth); }
            out.append(']');
            return;
        }
        Obj o = (Obj) this;
        if (o.pairs().isEmpty()) { out.append("{}"); return; }
        out.append('{');
        for (int i = 0; i < o.pairs().size(); i++) {
            if (i > 0) out.append(',');
            if (indent != null) { out.append('\n'); pushIndent(out, depth + 1); }
            var e = o.pairs().get(i);
            writeEscaped(out, e.getKey());
            out.append(indent != null ? ": " : ":");
            e.getValue().write(out, indent, depth + 1);
        }
        if (indent != null) { out.append('\n'); pushIndent(out, depth); }
        out.append('}');
    }

    private static void pushIndent(StringBuilder out, int depth) {
        out.append("  ".repeat(depth));
    }

    /** Escape a string the way JSON.stringify does: the two mandatory
     * escapes, the single-char shorthands, and u00XX-style for remaining control
     * characters. */
    static void writeEscaped(StringBuilder out, String s) {
        out.append('"');
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            switch (c) {
                case '"' -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                case '\b' -> out.append("\\b");
                case '\f' -> out.append("\\f");
                default -> {
                    if (c < 0x20) out.append(String.format("\\u%04x", (int) c));
                    else out.append(c);
                }
            }
        }
        out.append('"');
    }

    /** Render a double the way JavaScript's number-to-string coercion does:
     * whole values carry no trailing ".0". */
    static String formatF64(double n) {
        if (n == Math.rint(n) && Math.abs(n) < 1e15) return Long.toString((long) n);
        return Double.toString(n);
    }
}

/** Recursive-descent JSON parser; trailing garbage is an error. */
final class JsonParser {
    private final String src;
    private int pos = 0;

    private JsonParser(String src) { this.src = src; }

    static Json parse(String input) {
        JsonParser p = new JsonParser(input);
        p.skipWs();
        Json v = p.value();
        p.skipWs();
        if (p.pos != p.src.length()) {
            throw new JsonError("unexpected trailing characters at byte " + p.pos);
        }
        return v;
    }

    static final class JsonError extends RuntimeException {
        JsonError(String message) { super(message); }
    }

    private int peek() { return pos < src.length() ? src.charAt(pos) : -1; }

    private void skipWs() {
        while (peek() == ' ' || peek() == '\t' || peek() == '\n' || peek() == '\r') pos++;
    }

    private Json value() {
        int c = peek();
        if (c == -1) throw new JsonError("unexpected end of input");
        return switch (c) {
            case '{' -> obj();
            case '[' -> arr();
            case '"' -> new Json.Str(string());
            case 't' -> literal("true", new Json.Bool(true));
            case 'f' -> literal("false", new Json.Bool(false));
            case 'n' -> literal("null", new Json.Null());
            default -> {
                if (c == '-' || (c >= '0' && c <= '9')) yield number();
                throw new JsonError("unexpected '" + (char) c + "' at byte " + pos);
            }
        };
    }

    private Json literal(String word, Json v) {
        if (src.startsWith(word, pos)) {
            pos += word.length();
            return v;
        }
        throw new JsonError("invalid literal at byte " + pos);
    }

    private Json number() {
        int start = pos;
        scan:
        while (true) {
            int c = peek();
            boolean keep = c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E' || (c >= '0' && c <= '9');
            if (keep) pos++;
            else break scan;
        }
        String text = src.substring(start, pos);
        try {
            return new Json.Num(Double.parseDouble(text));
        } catch (NumberFormatException e) {
            throw new JsonError("invalid number \"" + text + "\" at byte " + start);
        }
    }

    private String string() {
        pos++; // opening quote
        StringBuilder out = new StringBuilder();
        while (true) {
            int c = peek();
            if (c == -1) throw new JsonError("unterminated string at byte " + pos);
            pos++;
            if (c == '"') return out.toString();
            if (c == '\\') {
                int esc = peek();
                if (esc == -1) throw new JsonError("unterminated escape");
                pos++;
                switch (esc) {
                    case '"' -> out.append('"');
                    case '\\' -> out.append('\\');
                    case '/' -> out.append('/');
                    case 'b' -> out.append('\b');
                    case 'f' -> out.append('\f');
                    case 'n' -> out.append('\n');
                    case 'r' -> out.append('\r');
                    case 't' -> out.append('\t');
                    case 'u' -> {
                        int hi = hex4();
                        if (hi >= 0xD800 && hi <= 0xDBFF) {
                            // High surrogate: combine with a following four-hex-digit escape.
                            boolean paired = peek() == '\\' && pos + 1 < src.length() && src.charAt(pos + 1) == 'u';
                            if (paired) {
                                pos += 2;
                                int lo = hex4();
                                out.append((char) (0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)));
                            } else {
                                out.append('�');
                            }
                        } else {
                            out.append((char) hi);
                        }
                    }
                    default -> throw new JsonError("invalid escape \\" + (char) esc + " at byte " + (pos - 1));
                }
            } else {
                out.append((char) c);
            }
        }
    }

    private int hex4() {
        int v = 0;
        for (int i = 0; i < 4; i++) {
            int c = peek();
            if (c == -1) throw new JsonError("unterminated \\u escape");
            int d = Character.digit(c, 16);
            if (d < 0) throw new JsonError("invalid hex digit at byte " + pos);
            pos++;
            v = v * 16 + d;
        }
        return v;
    }

    private Json obj() {
        pos++; // '{'
        List<Map.Entry<String, Json>> pairs = new ArrayList<>();
        skipWs();
        if (peek() == '}') { pos++; return new Json.Obj(pairs); }
        while (true) {
            skipWs();
            if (peek() != '"') throw new JsonError("expected object key at byte " + pos);
            String key = string();
            skipWs();
            if (peek() != ':') throw new JsonError("expected ':' at byte " + pos);
            pos++;
            skipWs();
            pairs.add(Map.entry(key, value()));
            skipWs();
            if (peek() == ',') { pos++; continue; }
            if (peek() == '}') { pos++; return new Json.Obj(pairs); }
            throw new JsonError("expected ',' or '}' at byte " + pos);
        }
    }

    private Json arr() {
        pos++; // '['
        List<Json> items = new ArrayList<>();
        skipWs();
        if (peek() == ']') { pos++; return new Json.Arr(items); }
        while (true) {
            skipWs();
            items.add(value());
            skipWs();
            if (peek() == ',') { pos++; continue; }
            if (peek() == ']') { pos++; return new Json.Arr(items); }
            throw new JsonError("expected ',' or ']' at byte " + pos);
        }
    }
}

/* ------------------------------ domain types ------------------------------ */

final class ChatFormatConverter {
    public static final List<String> CHAT_FORMATS = List.of("openai", "anthropic", "gemini", "markdown");

    /** One assistant-emitted tool call, provider-agnostic. `args` is a JSON string. */
    record ToolCall(String id, String name, String args) {
        ToolCall() { this("", "", ""); }
    }

    /** The shared internal shape every format parses into and serializes from. */
    static final class InternalMessage {
        final String role;          // "system" | "user" | "assistant" | "tool"
        String content = "";        // "" when the message only carries tool calls/results
        String name;                // nullable
        List<ToolCall> toolCalls;   // nullable
        String toolCallId;          // nullable

        InternalMessage(String role, String content) {
            this.role = role;
            this.content = content;
        }

        InternalMessage(String role, String content, String name, List<ToolCall> toolCalls, String toolCallId) {
            this.role = role;
            this.content = content;
            this.name = name;
            this.toolCalls = toolCalls;
            this.toolCallId = toolCallId;
        }
    }

    /** The conversion output plus human-readable notes about unmapped fields. */
    record ConversionResult(String output, List<String> warnings) {}

    private ChatFormatConverter() {}

    /* -------------------------------- helpers -------------------------------- */

    private static Json parseTranscriptJson(String text, String label) {
        try {
            return JsonParser.parse(text);
        } catch (JsonParser.JsonError e) {
            throw new IllegalArgumentException(label + ": invalid JSON — " + e.getMessage());
        }
    }

    /** The TS `?? {}` capture: null/missing becomes {}, any other value passes
     * through and stringifies as-is. */
    private static String argsOf(Json v) {
        return v == null || v instanceof Json.Null ? "{}" : v.toCompact();
    }

    /** Parse a JSON-string argument back to a value, an empty object when it
     * is not a JSON object (mirrors parseArgsOrEmpty). */
    static Json parseArgsOrEmpty(String args) {
        try {
            Json v = JsonParser.parse(args);
            return v instanceof Json.Obj ? v : new Json.Obj(List.of());
        } catch (JsonParser.JsonError e) {
            return new Json.Obj(List.of());
        }
    }

    private static String str(Json el, String key) {
        Json v = el == null ? null : el.get(key);
        return v instanceof Json.Str s ? s.value() : "";
    }

    private static String strOrNull(Json el, String key) {
        Json v = el == null ? null : el.get(key);
        return v instanceof Json.Str s ? s.value() : null;
    }

    private static String blockTypeName(Json raw) {
        String t = str(raw, "type");
        return t.isEmpty() ? "content" : t;
    }

    /** One normalized Anthropic content block. */
    private sealed interface Block permits Block.TextB, Block.ToolUseB, Block.ToolResultB, Block.OtherB {
        record TextB(String text) implements Block {}
        record ToolUseB(Json raw) implements Block {}
        record ToolResultB(Json raw) implements Block {}
        record OtherB(Json raw) implements Block {}
    }

    private static List<Block> contentBlocks(Json content, String where) {
        if (content instanceof Json.Str s) return List.of(new Block.TextB(s.value()));
        List<Json> items = content == null ? null : content.asArr();
        if (items == null) {
            throw new IllegalArgumentException(where + ": content must be a string or an array of blocks");
        }
        List<Block> out = new ArrayList<>(items.size());
        for (Json block : items) {
            boolean isText = str(block, "type").equals("text") && block.get("text") instanceof Json.Str;
            if (isText) out.add(new Block.TextB(((Json.Str) block.get("text")).value()));
            else if (str(block, "type").equals("tool_use")) out.add(new Block.ToolUseB(block));
            else if (str(block, "type").equals("tool_result")) out.add(new Block.ToolResultB(block));
            else out.add(new Block.OtherB(block));
        }
        return out;
    }

    /** Anthropic string-or-block[] content → plain text. */
    private static String blocksToText(Json content, String where, List<String> warnings) {
        if (content instanceof Json.Str s) return s.value();
        if (content == null || content instanceof Json.Null) return "";
        List<String> texts = new ArrayList<>();
        for (Block block : contentBlocks(content, where)) {
            if (block instanceof Block.TextB t) texts.add(t.text());
            else if (block instanceof Block.OtherB o)
                warnings.add(where + ": dropped unsupported " + blockTypeName(o.raw()) + " block");
            else if (block instanceof Block.ToolUseB)
                warnings.add(where + ": dropped tool_use block from text-only content");
            else if (block instanceof Block.ToolResultB)
                warnings.add(where + ": dropped tool_result block from text-only content");
        }
        return String.join("\n", texts);
    }

    private static ToolCall toolCallFromBlock(Json raw) {
        return new ToolCall(str(raw, "id"), str(raw, "name"), argsOf(raw.get("input")));
    }

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

    /** OpenAI messages[] (bare array, or wrapped in {"messages": [...]}). */
    public static List<InternalMessage> parseOpenAi(String text, List<String> warnings) {
        Json parsed = parseTranscriptJson(text, "OpenAI transcript");
        Json wrapped = parsed.isObject() ? parsed.get("messages") : null;
        List<Json> items = wrapped instanceof Json.Arr a ? a.items()
            : parsed instanceof Json.Arr bare ? bare.items() : null;
        if (items == null) {
            throw new IllegalArgumentException(
                "OpenAI transcript must be a messages[] array or an object with a \"messages\" array");
        }
        List<InternalMessage> out = new ArrayList<>();
        for (int i = 0; i < items.size(); i++) {
            Json entry = items.get(i);
            if (!entry.isObject()) throw new IllegalArgumentException("messages[" + i + "] is not an object");
            String where = "messages[" + i + "]";
            String role = str(entry, "role");
            switch (role) {
                case "system", "developer" ->
                    out.add(new InternalMessage("system", contentText(entry.get("content"), where, warnings)));
                case "user", "assistant", "tool" -> {
                    InternalMessage msg = new InternalMessage(role,
                        contentText(entry.get("content"), where, warnings));
                    msg.name = strOrNull(entry, "name");
                    msg.toolCallId = strOrNull(entry, "tool_call_id");
                    msg.toolCalls = parseOpenAiToolCalls(entry.get("tool_calls"));
                    out.add(msg);
                }
                default -> throw new IllegalArgumentException(
                    "messages[" + i + "] has unsupported role: "
                        + (entry.get("role") == null ? "null" : entry.get("role").toCompact()));
            }
        }
        return out;
    }

    /** OpenAI content → plain text (string, or text parts joined; other parts flagged). */
    private static String contentText(Json content, String where, List<String> warnings) {
        if (content == null || content instanceof Json.Null) return "";
        if (content instanceof Json.Str s) return s.value();
        if (content instanceof Json.Arr parts) {
            List<String> texts = new ArrayList<>();
            int dropped = 0;
            for (Json part : parts.items()) {
                boolean isText = str(part, "type").equals("text") && part.get("text") instanceof Json.Str;
                if (isText) texts.add(((Json.Str) part.get("text")).value());
                else dropped++;
            }
            if (dropped > 0) warnings.add(where + ": dropped " + dropped + " non-text content part(s)");
            return String.join("", texts);
        }
        throw new IllegalArgumentException(where + ": content must be a string, an array of parts, or null");
    }

    private static List<ToolCall> parseOpenAiToolCalls(Json raw) {
        if (raw == null || raw instanceof Json.Null) return null;
        List<Json> items = raw.asArr();
        if (items == null) throw new IllegalArgumentException("tool_calls must be an array");
        List<ToolCall> calls = new ArrayList<>(items.size());
        for (Json entry : items) {
            Json fn = entry.get("function");
            calls.add(new ToolCall(
                str(entry, "id"),
                fn == null ? "" : str(fn, "name"),
                fn == null ? "" : str(fn, "arguments")));
        }
        return calls;
    }

    /** Anthropic messages[] plus the top-level system field. */
    public static List<InternalMessage> parseAnthropic(String text, List<String> warnings) {
        Json root = parseTranscriptJson(text, "Anthropic transcript");
        List<Json> messages = root.isObject() && root.get("messages") instanceof Json.Arr a
            ? a.items() : null;
        if (messages == null) {
            throw new IllegalArgumentException(
                "Anthropic transcript must be an object with a \"messages\" array");
        }
        List<InternalMessage> out = new ArrayList<>();
        if (root.get("system") != null) {
            out.add(new InternalMessage("system", blocksToText(root.get("system"), "system", warnings)));
        }
        for (int i = 0; i < messages.size(); i++) {
            Json entry = messages.get(i);
            if (!entry.isObject()) throw new IllegalArgumentException("messages[" + i + "] is not an object");
            String where = "messages[" + i + "]";
            String role = str(entry, "role");
            switch (role) {
                case "assistant" -> {
                    List<String> texts = new ArrayList<>();
                    List<ToolCall> calls = new ArrayList<>();
                    for (Block block : contentBlocks(orNull(entry.get("content")), where)) {
                        if (block instanceof Block.TextB t) texts.add(t.text());
                        else if (block instanceof Block.ToolUseB u) calls.add(toolCallFromBlock(u.raw()));
                        else if (block instanceof Block.OtherB o)
                            warnings.add(where + ": dropped unsupported " + blockTypeName(o.raw()) + " block");
                    }
                    InternalMessage msg = new InternalMessage("assistant", String.join("\n", texts));
                    if (!calls.isEmpty()) msg.toolCalls = calls;
                    out.add(msg);
                }
                case "user" -> {
                    List<String> texts = new ArrayList<>();
                    for (Block block : contentBlocks(orNull(entry.get("content")), where)) {
                        if (block instanceof Block.TextB t) {
                            texts.add(t.text());
                        } else if (block instanceof Block.ToolUseB u) {
                            warnings.add(where
                                + ": tool_use block inside a user message moved to an assistant tool call");
                            flushUserTexts(out, texts);
                            InternalMessage msg = new InternalMessage("assistant", "");
                            msg.toolCalls = List.of(toolCallFromBlock(u.raw()));
                            out.add(msg);
                        } else if (block instanceof Block.ToolResultB r) {
                            flushUserTexts(out, texts);
                            Json content = r.raw().get("content");
                            InternalMessage msg = new InternalMessage("tool",
                                blocksToText(content == null ? new Json.Str("") : content, where, warnings));
                            msg.toolCallId = str(r.raw(), "tool_use_id");
                            out.add(msg);
                        } else if (block instanceof Block.OtherB o) {
                            warnings.add(where + ": dropped unsupported " + blockTypeName(o.raw()) + " block");
                        }
                    }
                    flushUserTexts(out, texts);
                }
                default -> throw new IllegalArgumentException(
                    where + " has unsupported role: "
                        + (entry.get("role") == null ? "null" : entry.get("role").toCompact()));
            }
        }
        return out;
    }

    private static Json orNull(Json v) { return v == null ? new Json.Null() : v; }

    private static void flushUserTexts(List<InternalMessage> out, List<String> texts) {
        if (!texts.isEmpty()) {
            out.add(new InternalMessage("user", String.join("\n", texts)));
            texts.clear();
        }
    }

    /** Gemini systemInstruction (string or {parts}) → plain text. */
    private static String geminiText(Json v, String where, List<String> warnings) {
        if (v instanceof Json.Str s) return s.value();
        if (v.isObject()) {
            if (v.get("text") instanceof Json.Str t) return t.value();
            if (v.get("parts") instanceof Json.Arr parts) {
                List<String> texts = new ArrayList<>();
                for (Json part : parts.items()) texts.add(str(part, "text"));
                return String.join("\n", texts);
            }
        }
        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) {
        Json root = parseTranscriptJson(text, "Gemini transcript");
        List<Json> contents = root.isObject() && root.get("contents") instanceof Json.Arr a
            ? a.items() : null;
        if (contents == null) {
            throw new IllegalArgumentException("Gemini transcript must be an object with a \"contents\" array");
        }
        List<InternalMessage> out = new ArrayList<>();
        if (root.get("systemInstruction") != null) {
            out.add(new InternalMessage("system",
                geminiText(root.get("systemInstruction"), "systemInstruction", warnings)));
        }
        for (int i = 0; i < contents.size(); i++) {
            Json entry = contents.get(i);
            if (!entry.isObject()) throw new IllegalArgumentException("contents[" + i + "] is not an object");
            String where = "contents[" + i + "]";
            String role = str(entry, "role");
            if (!role.equals("user") && !role.equals("model")) {
                throw new IllegalArgumentException(where + " has unsupported role: "
                    + (entry.get("role") == null ? "null" : entry.get("role").toCompact())
                    + " (Gemini uses \"user\" or \"model\")");
            }
            Json partsJson = entry.get("parts");
            if (!(partsJson instanceof Json.Arr parts)) {
                throw new IllegalArgumentException(where + ": parts must be an array");
            }

            if (role.equals("model")) {
                // A model turn keeps its text and function calls in ONE message,
                // mirroring an OpenAI assistant message with tool_calls.
                List<String> texts = new ArrayList<>();
                List<ToolCall> calls = new ArrayList<>();
                for (Json part : parts.items()) {
                    if (part.get("text") instanceof Json.Str t) { texts.add(t.value()); continue; }
                    if (part.get("functionCall") instanceof Json.Obj fc) {
                        calls.add(new ToolCall("", str(fc, "name"), argsOf(fc.get("args"))));
                        continue;
                    }
                    warnings.add(where + ": dropped unsupported part (inlineData or similar)");
                }
                InternalMessage msg = new InternalMessage("assistant", String.join("\n", texts));
                if (!calls.isEmpty()) msg.toolCalls = calls;
                out.add(msg);
                continue;
            }

            List<String> texts = new ArrayList<>();
            for (Json part : parts.items()) {
                if (part.get("text") instanceof Json.Str t) { texts.add(t.value()); continue; }
                if (part.get("functionCall") instanceof Json.Obj fc) {
                    flushUserTexts(out, texts);
                    InternalMessage msg = new InternalMessage("assistant", "");
                    msg.toolCalls = List.of(new ToolCall("", str(fc, "name"), argsOf(fc.get("args"))));
                    out.add(msg);
                    continue;
                }
                if (part.get("functionResponse") instanceof Json.Obj fr) {
                    flushUserTexts(out, texts);
                    String name = str(fr, "name");
                    out.add(new InternalMessage("tool", argsOf(fr.get("response")), name, null, name));
                    continue;
                }
                warnings.add(where + ": dropped unsupported part (inlineData or similar)");
            }
            flushUserTexts(out, texts);
        }
        return out;
    }

    private static final Pattern MARKDOWN_HEADER = Pattern.compile("^\\*\\*(system|user|assistant|model|tool)\\*\\*:\\s*(.*)$");

    private static InternalMessage finishMessage(String role, List<String> lines) {
        // Trim leading/trailing blank continuation lines but keep inner blank lines.
        while (lines.size() > 1 && lines.get(0).isBlank()) lines.remove(0);
        while (lines.size() > 1 && lines.get(lines.size() - 1).isBlank()) lines.remove(lines.size() - 1);
        return new InternalMessage(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> warnings) {
        List<InternalMessage> out = new ArrayList<>();
        String role = null;
        List<String> lines = null;
        for (String line : text.split("\n", -1)) {
            Matcher match = MARKDOWN_HEADER.matcher(line);
            if (match.matches()) {
                if (role != null) out.add(finishMessage(role, lines));
                role = match.group(1).equals("model") ? "assistant" : match.group(1);
                lines = new ArrayList<>(List.of(match.group(2)));
                continue;
            }
            if (lines != null) {
                lines.add(line);
            } else if (!line.isBlank()) {
                throw new IllegalArgumentException("Markdown transcript must start with a `**role**:` header line");
            }
        }
        if (role != null) out.add(finishMessage(role, lines));
        return out;
    }

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

    private static Json.Str s(String v) { return new Json.Str(v); }

    private static Json.Obj kv(Object... pairs) {
        List<Map.Entry<String, Json>> list = new ArrayList<>(pairs.length / 2);
        for (int i = 0; i < pairs.length; i += 2) {
            list.add(Map.entry((String) pairs[i], (Json) pairs[i + 1]));
        }
        return new Json.Obj(list);
    }

    public static String serializeOpenAi(List<InternalMessage> messages, List<String> warnings) {
        List<Json> arr = new ArrayList<>(messages.size());
        for (InternalMessage m : messages) {
            List<Map.Entry<String, Json>> pairs = new ArrayList<>();
            if (m.role.equals("tool")) {
                pairs.add(Map.entry("role", s("tool")));
                pairs.add(Map.entry("content", s(m.content)));
                pairs.add(Map.entry("tool_call_id", s(m.toolCallId == null ? "" : m.toolCallId)));
                if (m.name != null) pairs.add(Map.entry("name", s(m.name)));
            } else if (m.role.equals("assistant") && m.toolCalls != null) {
                pairs.add(Map.entry("role", s("assistant")));
                pairs.add(Map.entry("content", m.content.isEmpty() ? new Json.Null() : s(m.content)));
                List<Json> calls = new ArrayList<>(m.toolCalls.size());
                for (ToolCall tc : m.toolCalls) {
                    calls.add(kv("id", s(tc.id()), "type", s("function"),
                        "function", kv("name", s(tc.name()), "arguments", s(tc.args()))));
                }
                pairs.add(Map.entry("tool_calls", new Json.Arr(calls)));
                if (m.name != null) pairs.add(Map.entry("name", s(m.name)));
            } else {
                pairs.add(Map.entry("role", s(m.role)));
                pairs.add(Map.entry("content", s(m.content)));
                if (m.name != null) pairs.add(Map.entry("name", s(m.name)));
            }
            arr.add(new Json.Obj(pairs));
        }
        return new Json.Obj(List.of(Map.entry("messages", new Json.Arr(arr)))).toPretty() + "\n";
    }

    public static String serializeAnthropic(List<InternalMessage> messages, List<String> warnings) {
        List<String> system = new ArrayList<>();
        for (InternalMessage m : messages) {
            if (m.role.equals("system")) system.add(m.content);
        }
        List<Json> out = new ArrayList<>();
        for (int i = 0; i < messages.size(); i++) {
            InternalMessage m = messages.get(i);
            if (m.role.equals("system")) continue;
            if (m.name != null && !m.role.equals("tool")) {
                warnings.add("message " + i + ": \"name\" has no Anthropic equivalent and was dropped");
            }
            Json entry;
            switch (m.role) {
                case "user" -> entry = kv("role", s("user"), "content", s(m.content));
                case "assistant" -> {
                    if (m.toolCalls != null) {
                        List<Json> blocks = new ArrayList<>();
                        if (!m.content.isEmpty()) {
                            blocks.add(kv("type", s("text"), "text", s(m.content)));
                        }
                        for (ToolCall tc : m.toolCalls) {
                            blocks.add(kv("type", s("tool_use"), "id", s(tc.id()), "name", s(tc.name()),
                                "input", parseArgsOrEmpty(tc.args())));
                        }
                        entry = kv("role", s("assistant"), "content", new Json.Arr(blocks));
                    } else {
                        entry = kv("role", s("assistant"), "content", s(m.content));
                    }
                }
                default -> entry = kv("role", s("user"), "content", new Json.Arr(List.of(
                    kv("type", s("tool_result"),
                       "tool_use_id", s(m.toolCallId == null ? "" : m.toolCallId),
                       "content", s(m.content)))));
            }
            out.add(entry);
        }
        List<Map.Entry<String, Json>> pairs = new ArrayList<>();
        if (!out.isEmpty()) pairs.add(Map.entry("messages", new Json.Arr(out)));
        if (!system.isEmpty()) pairs.add(Map.entry("system", s(String.join("\n\n", system))));
        return new Json.Obj(pairs).toPretty() + "\n";
    }

    public static String serializeGemini(List<InternalMessage> messages, List<String> warnings) {
        List<String> system = new ArrayList<>();
        for (InternalMessage m : messages) {
            if (m.role.equals("system")) system.add(m.content);
        }
        // (role, parts) turns; consecutive same-role parts merge into one turn.
        List<Map.Entry<String, List<Json>>> turns = new ArrayList<>();
        for (int i = 0; i < messages.size(); i++) {
            InternalMessage m = messages.get(i);
            if (m.role.equals("system")) continue;
            if (m.name != null && !m.role.equals("tool")) {
                warnings.add("message " + i + ": \"name\" has no Gemini equivalent and was dropped");
            }
            switch (m.role) {
                case "user" -> pushTurn(turns, "user", kv("text", s(m.content)));
                case "assistant" -> {
                    if (!m.content.isEmpty()) pushTurn(turns, "model", kv("text", s(m.content)));
                    List<ToolCall> calls = m.toolCalls == null ? List.of() : m.toolCalls;
                    for (ToolCall tc : calls) {
                        pushTurn(turns, "model",
                            kv("functionCall", kv("name", s(tc.name()), "args", parseArgsOrEmpty(tc.args()))));
                    }
                }
                default -> {
                    String name = m.name != null ? m.name : m.toolCallId != null ? m.toolCallId : "";
                    pushTurn(turns, "user",
                        kv("functionResponse",
                            kv("name", s(name), "response", parseArgsOrEmpty(m.content))));
                }
            }
        }
        List<Map.Entry<String, Json>> pairs = new ArrayList<>();
        if (!turns.isEmpty()) {
            List<Json> contents = new ArrayList<>(turns.size());
            for (var turn : turns) {
                contents.add(kv("role", s(turn.getKey()), "parts", new Json.Arr(turn.getValue())));
            }
            pairs.add(Map.entry("contents", new Json.Arr(contents)));
        }
        if (!system.isEmpty()) {
            pairs.add(Map.entry("systemInstruction",
                kv("parts", new Json.Arr(List.of(kv("text", s(String.join("\n\n", system))))))));
        }
        return new Json.Obj(pairs).toPretty() + "\n";
    }

    private static void pushTurn(List<Map.Entry<String, List<Json>>> turns, String role, Json part) {
        Map.Entry<String, List<Json>> last = turns.isEmpty() ? null : turns.get(turns.size() - 1);
        if (last != null && last.getKey().equals(role)) {
            last.getValue().add(part);
        } else {
            List<Json> parts = new ArrayList<>();
            parts.add(part);
            turns.add(Map.entry(role, parts));
        }
    }

    public static String serializeMarkdown(List<InternalMessage> messages, List<String> warnings) {
        List<String> lines = new ArrayList<>(messages.size());
        for (int i = 0; i < messages.size(); i++) {
            InternalMessage m = messages.get(i);
            if (m.toolCalls != null && !m.toolCalls.isEmpty()) {
                warnings.add("message " + i + ": tool calls are not representable in Markdown and were dropped");
            }
            if (m.role.equals("tool") && m.toolCallId != null) {
                warnings.add("message " + i + ": tool result id is not representable in Markdown and was dropped");
            }
            lines.add("**" + m.role + "**: " + m.content);
        }
        return String.join("\n\n", lines) + "\n";
    }

    /* --------------------------------- convert -------------------------------- */

    /** Convert a transcript between chat formats through the shared internal shape.
     *
     * @throws IllegalArgumentException 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 (transcript == null || transcript.isBlank()) {
            throw new IllegalArgumentException("Transcript is empty — paste a transcript first");
        }
        java.util.function.BiFunction<String, List<String>, List<InternalMessage>> parser = switch (from) {
            case "openai" -> ChatFormatConverter::parseOpenAi;
            case "anthropic" -> ChatFormatConverter::parseAnthropic;
            case "gemini" -> ChatFormatConverter::parseGemini;
            case "markdown" -> ChatFormatConverter::parseMarkdown;
            default -> throw new IllegalArgumentException("Unknown source format: " + from);
        };
        java.util.function.BiFunction<List<InternalMessage>, List<String>, String> serializer = switch (to) {
            case "openai" -> ChatFormatConverter::serializeOpenAi;
            case "anthropic" -> ChatFormatConverter::serializeAnthropic;
            case "gemini" -> ChatFormatConverter::serializeGemini;
            case "markdown" -> ChatFormatConverter::serializeMarkdown;
            default -> throw new IllegalArgumentException("Unknown target format: " + to);
        };
        List<String> warnings = new ArrayList<>();
        List<InternalMessage> messages = parser.apply(transcript, warnings);
        return new ConversionResult(serializer.apply(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 →