Skip to content

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

// chat-format-converter — Kotlin 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: Kotlin (1.9, 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. Kotlin's
// standard library ships no JSON engine, so this file includes a small
// hand-written recursive-descent JSON parser + writer (kotlin-stdlib only, no
// kotlinx.serialization). Object keys are kept in a List<Pair<String, Json>> so
// insertion order survives a round-trip, exactly like the TypeScript original.
// Parse failures throw IllegalArgumentException.

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

/** A JSON value. Objects preserve insertion order via a pair list, matching
 * JavaScript object semantics (JSON.stringify key order). */
sealed class Json {
    data object Null : Json()
    data class Bool(val value: Boolean) : Json()
    data class Num(val value: Double) : Json()
    data class Str(val value: String) : Json()
    data class Arr(val items: List<Json>) : Json()
    data class Obj(val pairs: List<Pair<String, Json>>) : Json()

    operator fun get(key: String): Json? = (this as? Obj)?.pairs?.firstOrNull { it.first == key }?.second

    val asStr: String? get() = (this as? Str)?.value
    val asArr: List<Json>? get() = (this as? Arr)?.items
    val isObject: Boolean get() = this is Obj

    /** Compact encoding, the moral equivalent of TS `JSON.stringify`. */
    fun toCompact(): String = buildString { writeJson(this, null, 0) }

    /** Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`. */
    fun toPretty(): String = buildString { writeJson(this, 2, 0) }

    fun writeJson(out: StringBuilder, indent: Int?, depth: Int) {
        when (this) {
            is Null -> out.append("null")
            is Bool -> out.append(if (value) "true" else "false")
            is Num -> out.append(formatF64(value))
            is Str -> writeEscaped(out, value)
            is Arr -> {
                if (items.isEmpty()) { out.append("[]"); return }
                out.append('[')
                items.forEachIndexed { i, item ->
                    if (i > 0) out.append(',')
                    if (indent != null) { out.append('\n'); pushIndent(out, depth + 1) }
                    item.writeJson(out, indent, depth + 1)
                }
                if (indent != null) { out.append('\n'); pushIndent(out, depth) }
                out.append(']')
            }
            is Obj -> {
                if (pairs.isEmpty()) { out.append("{}"); return }
                out.append('{')
                pairs.forEachIndexed { i, (k, v) ->
                    if (i > 0) out.append(',')
                    if (indent != null) { out.append('\n'); pushIndent(out, depth + 1) }
                    writeEscaped(out, k)
                    out.append(':')
                    if (indent != null) out.append(' ')
                    v.writeJson(out, indent, depth + 1)
                }
                if (indent != null) { out.append('\n'); pushIndent(out, depth) }
                out.append('}')
            }
        }
    }

    companion object {
        private fun pushIndent(out: StringBuilder, depth: Int) = repeat(depth) { out.append("  ") }

        /** Escape a string the way JSON.stringify does: the two mandatory
         * escapes, the single-char shorthands, and \u00XX for remaining
         * control characters. */
        fun writeEscaped(out: StringBuilder, s: String) {
            out.append('"')
            for (c in s) when (c) {
                '"' -> out.append("\\\"")
                '\\' -> out.append("\\\\")
                '\n' -> out.append("\\n")
                '\r' -> out.append("\\r")
                '\t' -> out.append("\\t")
                '\b' -> out.append("\\b")
                '\u000C' -> out.append("\\f")
                else -> if (c.code < 0x20) out.append("\\u%04x".format(c.code)) else out.append(c)
            }
            out.append('"')
        }

        /** Render a Double the way JavaScript's number-to-string coercion
         * does: whole values carry no trailing ".0". */
        fun formatF64(n: Double): String =
            if (n == n.toInt().toDouble() && kotlin.math.abs(n) < 1e15) n.toInt().toString() else n.toString()
    }
}

/** Recursive-descent JSON parser; trailing garbage is an error. */
class JsonParser private constructor(private val src: String, private var pos: Int = 0) {

    companion object {
        fun parse(input: String): Json {
            val p = JsonParser(input)
            p.skipWs()
            val v = p.value() ?: throw JsonError("unexpected end of input")
            p.skipWs()
            if (p.pos != p.src.length) throw JsonError("unexpected trailing characters at byte ${p.pos}")
            return v
        }
    }

    class JsonError(message: String) : Exception(message)

    private fun peek(): Char? = src.getOrNull(pos)

    private fun skipWs() {
        while (true) {
            val c = peek() ?: break
            if (c in " \t\n\r") pos++ else break
        }
    }

    private fun value(): Json? = when (val c = peek()) {
        '{' -> obj()
        '[' -> arr()
        '"' -> Json.Str(string())
        't' -> literal("true", Json.Bool(true))
        'f' -> literal("false", Json.Bool(false))
        'n' -> literal("null", Json.Null)
        null -> null
        else -> if (c == '-' || c.isDigit()) number() else throw JsonError("unexpected '$c' at byte $pos")
    }

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

    private fun number(): Json {
        val start = pos
        while (true) {
            val c = peek() ?: break
            if (c in "-+.eE" || c.isDigit()) pos++ else break
        }
        val text = src.substring(start, pos)
        return Json.Num(text.toDoubleOrNull() ?: throw JsonError("invalid number \"$text\" at byte $start"))
    }

    private fun string(): String {
        pos++ // opening quote
        val out = StringBuilder()
        while (true) {
            val c = peek() ?: throw JsonError("unterminated string at byte $pos")
            pos++
            when (c) {
                '"' -> return out.toString()
                '\\' -> {
                    when (val esc = peek() ?: throw JsonError("unterminated escape")) {
                        '"' -> out.append('"')
                        '\\' -> out.append('\\')
                        '/' -> out.append('/')
                        'b' -> out.append('\b')
                        'f' -> out.append('\u000C')
                        'n' -> out.append('\n')
                        'r' -> out.append('\r')
                        't' -> out.append('\t')
                        'u' -> {
                            val hi = hex4()
                            val ch = if (hi in 0xD800..0xDBFF) {
                                // High surrogate: combine with a following \uXXXX low surrogate.
                                val paired = src.getOrNull(pos) == '\\' && src.getOrNull(pos + 1) == 'u'
                                if (paired) {
                                    pos += 2
                                    val lo = hex4()
                                    (0x10000 + ((hi - 0xD800) shl 10) + (lo - 0xDC00)).toChar()
                                } else '�'
                            } else hi.toChar()
                            out.append(ch)
                        }
                        else -> throw JsonError("invalid escape \\$esc at byte ${pos - 1}")
                    }
                    pos++
                }
                else -> out.append(c)
            }
        }
    }

    private fun hex4(): Int {
        var v = 0
        repeat(4) {
            val c = peek() ?: throw JsonError("unterminated \\u escape")
            val d = c.digitToIntOrNull(16) ?: throw JsonError("invalid hex digit at byte $pos")
            pos++
            v = v * 16 + d
        }
        return v
    }

    private fun obj(): Json {
        pos++ // '{'
        val pairs = mutableListOf<Pair<String, Json>>()
        skipWs()
        if (peek() == '}') { pos++; return Json.Obj(pairs) }
        while (true) {
            skipWs()
            if (peek() != '"') throw JsonError("expected object key at byte $pos")
            val key = string()
            skipWs()
            if (peek() != ':') throw JsonError("expected ':' at byte $pos")
            pos++
            skipWs()
            pairs.add(key to (value() ?: throw JsonError("unexpected end of input")))
            skipWs()
            when (peek()) {
                ',' -> pos++
                '}' -> { pos++; return Json.Obj(pairs) }
                else -> throw JsonError("expected ',' or '}' at byte $pos")
            }
        }
    }

    private fun arr(): Json {
        pos++ // '['
        val items = mutableListOf<Json>()
        skipWs()
        if (peek() == ']') { pos++; return Json.Arr(items) }
        while (true) {
            skipWs()
            items.add(value() ?: throw JsonError("unexpected end of input"))
            skipWs()
            when (peek()) {
                ',' -> pos++
                ']' -> { pos++; return Json.Arr(items) }
                else -> throw JsonError("expected ',' or ']' at byte $pos")
            }
        }
    }
}

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

/** One assistant-emitted tool call, provider-agnostic. [args][ToolCall.args] is a JSON string. */
data class ToolCall(val id: String = "", val name: String = "", val args: String = "")

/** The shared internal shape every format parses into and serializes from. */
data class InternalMessage(
    val role: String,                        // "system" | "user" | "assistant" | "tool"
    val content: String = "",                // "" when the message only carries tool calls/results
    val name: String? = null,
    val toolCalls: List<ToolCall>? = null,
    val toolCallId: String? = null,
)

/** The conversion output plus human-readable notes about unmapped fields. */
data class ConversionResult(val output: String, val warnings: List<String>)

val CHAT_FORMATS = listOf("openai", "anthropic", "gemini", "markdown")

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

fun parseTranscriptJson(text: String, label: String): Json =
    try {
        JsonParser.parse(text)
    } catch (e: JsonParser.JsonError) {
        throw IllegalArgumentException("$label: invalid JSON — ${e.message}")
    }

/** The TS `?? {}` capture: null/missing becomes {}, any other value passes
 * through and stringifies as-is. */
fun argsOf(v: Json?): String = when (v) {
    null, Json.Null -> "{}"
    else -> v.toCompact()
}

/** Parse a JSON-string argument back to a value, an empty object when it is
 * not a JSON object (mirrors parseArgsOrEmpty). */
fun parseArgsOrEmpty(args: String): Json =
    try {
        JsonParser.parse(args).takeIf { it is Json.Obj } ?: Json.Obj(emptyList())
    } catch (_: JsonParser.JsonError) {
        Json.Obj(emptyList())
    }

fun blockTypeName(raw: Json): String = raw["type"]?.asStr ?: "content"

/** One normalized Anthropic content block. */
private sealed interface Block {
    data class Text(val text: String) : Block
    data class ToolUse(val raw: Json) : Block
    data class ToolResult(val raw: Json) : Block
    data class Other(val raw: Json) : Block
}

private fun contentBlocks(content: Json, where: String): List<Block> {
    if (content is Json.Str) return listOf(Block.Text(content.value))
    val items = (content as? Json.Arr)?.items
        ?: throw IllegalArgumentException("$where: content must be a string or an array of blocks")
    return items.map { block ->
        val isText = block["type"]?.asStr == "text" && block["text"]?.asStr != null
        when {
            isText -> Block.Text(block["text"]!!.asStr!!)
            block["type"]?.asStr == "tool_use" -> Block.ToolUse(block)
            block["type"]?.asStr == "tool_result" -> Block.ToolResult(block)
            else -> Block.Other(block)
        }
    }
}

/** Anthropic string-or-block[] content → plain text. */
private fun blocksToText(content: Json, where: String, warnings: MutableList<String>): String {
    if (content is Json.Str) return content.value
    val texts = mutableListOf<String>()
    for (block in contentBlocks(content, where)) {
        when (block) {
            is Block.Text -> texts.add(block.text)
            is Block.Other ->
                warnings.add("$where: dropped unsupported ${blockTypeName(block.raw)} block")
            is Block.ToolUse -> warnings.add("$where: dropped tool_use block from text-only content")
            is Block.ToolResult -> warnings.add("$where: dropped tool_result block from text-only content")
        }
    }
    return texts.joinToString("\n")
}

private fun toolCallFromBlock(raw: Json) = ToolCall(
    id = raw["id"]?.asStr ?: "",
    name = raw["name"]?.asStr ?: "",
    args = argsOf(raw["input"]),
)

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

/** OpenAI messages[] (bare array, or wrapped in {"messages": [...]}). */
fun parseOpenAi(text: String, warnings: MutableList<String>): List<InternalMessage> {
    val parsed = parseTranscriptJson(text, "OpenAI transcript")
    val items = (parsed["messages"] as? Json.Arr)?.items?.takeIf { parsed.isObject }
        ?: (parsed as? Json.Arr)?.items
        ?: throw IllegalArgumentException(
            """OpenAI transcript must be a messages[] array or an object with a "messages" array""")
    val out = mutableListOf<InternalMessage>()
    items.forEachIndexed { i, entry ->
        if (!entry.isObject) throw IllegalArgumentException("messages[$i] is not an object")
        val where = "messages[$i]"
        when (val role = entry["role"]?.asStr ?: "") {
            "system", "developer" ->
                out.add(InternalMessage("system", contentText(entry["content"], where, warnings)))
            "user", "assistant", "tool" -> out.add(InternalMessage(
                role,
                contentText(entry["content"], where, warnings),
                name = entry["name"]?.asStr,
                toolCalls = parseOpenAiToolCalls(entry["tool_calls"]),
                toolCallId = entry["tool_call_id"]?.asStr,
            ))
            else -> throw IllegalArgumentException(
                "messages[$i] has unsupported role: ${entry["role"]?.toCompact() ?: "null"}")
        }
    }
    return out
}

/** OpenAI content → plain text (string, or text parts joined; other parts flagged). */
private fun contentText(content: Json?, where: String, warnings: MutableList<String>): String = when (content) {
    null, Json.Null -> ""
    is Json.Str -> content.value
    is Json.Arr -> {
        val texts = mutableListOf<String>()
        var dropped = 0
        for (part in content.items) {
            val isText = part["type"]?.asStr == "text" && part["text"]?.asStr != null
            if (isText) texts.add(part["text"]!!.asStr!!) else dropped++
        }
        if (dropped > 0) warnings.add("$where: dropped $dropped non-text content part(s)")
        texts.joinToString("")
    }
    else -> throw IllegalArgumentException("$where: content must be a string, an array of parts, or null")
}

private fun parseOpenAiToolCalls(raw: Json?): List<ToolCall>? {
    if (raw == null || raw is Json.Null) return null
    if (raw !is Json.Arr) throw IllegalArgumentException("tool_calls must be an array")
    return raw.items.map { entry ->
        val fn = entry["function"]
        ToolCall(
            id = entry["id"]?.asStr ?: "",
            name = fn?.get("name")?.asStr ?: "",
            args = fn?.get("arguments")?.asStr ?: "",
        )
    }
}

/** Anthropic messages[] plus the top-level system field. */
fun parseAnthropic(text: String, warnings: MutableList<String>): List<InternalMessage> {
    val root = parseTranscriptJson(text, "Anthropic transcript")
    if (!root.isObject || root["messages"] !is Json.Arr)
        throw IllegalArgumentException("""Anthropic transcript must be an object with a "messages" array""")
    val out = mutableListOf<InternalMessage>()
    root["system"]?.let { out.add(InternalMessage("system", blocksToText(it, "system", warnings))) }

    (root["messages"] as Json.Arr).items.forEachIndexed { i, entry ->
        if (!entry.isObject) throw IllegalArgumentException("messages[$i] is not an object")
        val where = "messages[$i]"
        when (entry["role"]?.asStr ?: "") {
            "assistant" -> {
                val texts = mutableListOf<String>()
                val calls = mutableListOf<ToolCall>()
                for (block in contentBlocks(entry["content"] ?: Json.Null, where)) {
                    when (block) {
                        is Block.Text -> texts.add(block.text)
                        is Block.ToolUse -> calls.add(toolCallFromBlock(block.raw))
                        is Block.Other ->
                            warnings.add("$where: dropped unsupported ${blockTypeName(block.raw)} block")
                        is Block.ToolResult -> {}
                    }
                }
                out.add(InternalMessage("assistant", texts.joinToString("\n"),
                    toolCalls = calls.takeIf { it.isNotEmpty() }))
            }
            "user" -> {
                val texts = mutableListOf<String>()

                fun flush() {
                    if (texts.isNotEmpty()) {
                        out.add(InternalMessage("user", texts.joinToString("\n")))
                        texts.clear()
                    }
                }
                for (block in contentBlocks(entry["content"] ?: Json.Null, where)) {
                    when (block) {
                        is Block.Text -> texts.add(block.text)
                        is Block.ToolUse -> {
                            warnings.add("$where: tool_use block inside a user message moved to an assistant tool call")
                            flush()
                            out.add(InternalMessage("assistant", "", toolCalls = listOf(toolCallFromBlock(block.raw))))
                        }
                        is Block.ToolResult -> {
                            flush()
                            out.add(InternalMessage(
                                "tool",
                                blocksToText(block.raw["content"] ?: Json.Str(""), where, warnings),
                                toolCallId = block.raw["tool_use_id"]?.asStr ?: "",
                            ))
                        }
                        is Block.Other ->
                            warnings.add("$where: dropped unsupported ${blockTypeName(block.raw)} block")
                    }
                }
                flush()
            }
            else -> throw IllegalArgumentException(
                "$where has unsupported role: ${entry["role"]?.toCompact() ?: "null"}")
        }
    }
    return out
}

/** Gemini systemInstruction (string or {parts}) → plain text. */
private fun geminiText(v: Json, where: String, warnings: MutableList<String>): String {
    if (v is Json.Str) return v.value
    if (v.isObject) {
        v["text"]?.asStr?.let { return it }
        (v["parts"] as? Json.Arr)?.let { parts ->
            return parts.items.joinToString("\n") { it["text"]?.asStr ?: "" }
        }
    }
    warnings.add("$where: unsupported systemInstruction shape, treated as empty")
    return ""
}

/** Gemini contents[] plus the optional systemInstruction. */
fun parseGemini(text: String, warnings: MutableList<String>): List<InternalMessage> {
    val root = parseTranscriptJson(text, "Gemini transcript")
    if (!root.isObject || root["contents"] !is Json.Arr)
        throw IllegalArgumentException("""Gemini transcript must be an object with a "contents" array""")
    val out = mutableListOf<InternalMessage>()
    root["systemInstruction"]?.let {
        out.add(InternalMessage("system", geminiText(it, "systemInstruction", warnings)))
    }

    (root["contents"] as Json.Arr).items.forEachIndexed { i, entry ->
        if (!entry.isObject) throw IllegalArgumentException("contents[$i] is not an object")
        val where = "contents[$i]"
        val role = entry["role"]?.asStr ?: ""
        if (role != "user" && role != "model") {
            val roleJson = entry["role"]?.toCompact() ?: "null"
            throw IllegalArgumentException(
                "$where has unsupported role: $roleJson (Gemini uses \"user\" or \"model\")")
        }
        val parts = (entry["parts"] as? Json.Arr)?.items
            ?: throw IllegalArgumentException("$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.
            val texts = mutableListOf<String>()
            val calls = mutableListOf<ToolCall>()
            for (part in parts) {
                val t = part["text"]?.asStr
                if (t != null) { texts.add(t); continue }
                val fc = part["functionCall"] as? Json.Obj
                if (fc != null) {
                    calls.add(ToolCall(name = fc["name"]?.asStr ?: "", args = argsOf(fc["args"])))
                    continue
                }
                warnings.add("$where: dropped unsupported part (inlineData or similar)")
            }
            out.add(InternalMessage("assistant", texts.joinToString("\n"),
                toolCalls = calls.takeIf { it.isNotEmpty() }))
            return@forEachIndexed
        }

        val texts = mutableListOf<String>()

        fun flushText() {
            if (texts.isNotEmpty()) {
                out.add(InternalMessage("user", texts.joinToString("\n")))
                texts.clear()
            }
        }
        for (part in parts) {
            val t = part["text"]?.asStr
            if (t != null) { texts.add(t); continue }
            val fc = part["functionCall"] as? Json.Obj
            if (fc != null) {
                flushText()
                out.add(InternalMessage("assistant", "",
                    toolCalls = listOf(ToolCall(name = fc["name"]?.asStr ?: "", args = argsOf(fc["args"])))))
                continue
            }
            val fr = part["functionResponse"] as? Json.Obj
            if (fr != null) {
                flushText()
                val name = fr["name"]?.asStr ?: ""
                out.add(InternalMessage("tool", argsOf(fr["response"]), name, null, name))
                continue
            }
            warnings.add("$where: dropped unsupported part (inlineData or similar)")
        }
        flushText()
    }
    return out
}

private val MARKDOWN_HEADER = Regex("""^\*\*(system|user|assistant|model|tool)\*\*:\s*(.*)$""")

private fun finishMessage(role: String, lines: MutableList<String>): InternalMessage {
    // Trim leading/trailing blank continuation lines but keep inner blank lines.
    while (lines.size > 1 && lines.first().isBlank()) lines.removeAt(0)
    while (lines.size > 1 && lines.last().isBlank()) lines.removeAt(lines.size - 1)
    return InternalMessage(role, lines.joinToString("\n"))
}

/** Markdown transcript: `**role**: text` header lines with continuation lines
 * belonging to the same message. `model` maps to assistant. */
fun parseMarkdown(text: String, @Suppress("UNUSED_PARAMETER") warnings: MutableList<String>): List<InternalMessage> {
    val out = mutableListOf<InternalMessage>()
    var current: Pair<String, MutableList<String>>? = null
    for (line in text.split('\n')) {
        val match = MARKDOWN_HEADER.matchEntire(line)
        if (match != null) {
            current?.let { (r, ls) -> out.add(finishMessage(r, ls)) }
            current = (if (match.groupValues[1] == "model") "assistant" else match.groupValues[1]) to
                mutableListOf(match.groupValues[2])
            continue
        }
        val cur = current
        if (cur != null) cur.second.add(line)
        else if (line.isNotBlank())
            throw IllegalArgumentException("Markdown transcript must start with a `**role**:` header line")
    }
    current?.let { (r, ls) -> out.add(finishMessage(r, ls)) }
    return out
}

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

private fun kv(pairs: List<Pair<String, Json>>) = Json.Obj(pairs)
private fun s(v: String) = Json.Str(v)

fun serializeOpenAi(messages: List<InternalMessage>, @Suppress("UNUSED_PARAMETER") warnings: MutableList<String>): String {
    val arr = messages.map { m ->
        val pairs = mutableListOf<Pair<String, Json>>()
        when {
            m.role == "tool" -> {
                pairs.add("role" to s("tool")); pairs.add("content" to s(m.content))
                pairs.add("tool_call_id" to s(m.toolCallId ?: ""))
                m.name?.let { pairs.add("name" to s(it)) }
            }
            m.role == "assistant" && m.toolCalls != null -> {
                pairs.add("role" to s("assistant"))
                pairs.add("content" to if (m.content.isEmpty()) Json.Null else s(m.content))
                pairs.add("tool_calls" to Json.Arr(m.toolCalls.map { tc ->
                    kv(listOf(
                        "id" to s(tc.id), "type" to s("function"),
                        "function" to kv(listOf("name" to s(tc.name), "arguments" to s(tc.args))),
                    ))
                }))
                m.name?.let { pairs.add("name" to s(it)) }
            }
            else -> {
                pairs.add("role" to s(m.role)); pairs.add("content" to s(m.content))
                m.name?.let { pairs.add("name" to s(it)) }
            }
        }
        kv(pairs)
    }
    return kv(listOf("messages" to Json.Arr(arr))).toPretty() + "\n"
}

fun serializeAnthropic(messages: List<InternalMessage>, warnings: MutableList<String>): String {
    val system = messages.filter { it.role == "system" }.map { it.content }
    val out = mutableListOf<Json>()
    messages.forEachIndexed { i, m ->
        if (m.role == "system") return@forEachIndexed
        if (m.name != null && m.role != "tool")
            warnings.add("message $i: \"name\" has no Anthropic equivalent and was dropped")
        out.add(when (m.role) {
            "user" -> kv(listOf("role" to s("user"), "content" to s(m.content)))
            "assistant" -> if (m.toolCalls != null) {
                val blocks = mutableListOf<Json>()
                if (m.content.isNotEmpty()) blocks.add(kv(listOf("type" to s("text"), "text" to s(m.content))))
                for (tc in m.toolCalls) blocks.add(kv(listOf(
                    "type" to s("tool_use"), "id" to s(tc.id), "name" to s(tc.name),
                    "input" to parseArgsOrEmpty(tc.args),
                )))
                kv(listOf("role" to s("assistant"), "content" to Json.Arr(blocks)))
            } else kv(listOf("role" to s("assistant"), "content" to s(m.content)))
            else -> kv(listOf(
                "role" to s("user"),
                "content" to Json.Arr(listOf(kv(listOf(
                    "type" to s("tool_result"),
                    "tool_use_id" to s(m.toolCallId ?: ""),
                    "content" to s(m.content),
                )))),
            ))
        })
    }
    val pairs = mutableListOf<Pair<String, Json>>()
    if (out.isNotEmpty()) pairs.add("messages" to Json.Arr(out))
    if (system.isNotEmpty()) pairs.add("system" to s(system.joinToString("\n\n")))
    return Json.Obj(pairs).toPretty() + "\n"
}

fun serializeGemini(messages: List<InternalMessage>, warnings: MutableList<String>): String {
    val system = messages.filter { it.role == "system" }.map { it.content }
    // (role, parts) turns; consecutive same-role parts merge into one turn.
    val turns = mutableListOf<Pair<String, MutableList<Json>>>()

    fun push(role: String, part: Json) {
        val last = turns.lastOrNull()
        if (last != null && last.first == role) last.second.add(part)
        else turns.add(role to mutableListOf(part))
    }
    messages.forEachIndexed { i, m ->
        if (m.role == "system") return@forEachIndexed
        if (m.name != null && m.role != "tool")
            warnings.add("message $i: \"name\" has no Gemini equivalent and was dropped")
        when (m.role) {
            "user" -> push("user", kv(listOf("text" to s(m.content))))
            "assistant" -> {
                if (m.content.isNotEmpty()) push("model", kv(listOf("text" to s(m.content))))
                for (tc in m.toolCalls ?: emptyList())
                    push("model", kv(listOf("functionCall" to kv(listOf(
                        "name" to s(tc.name), "args" to parseArgsOrEmpty(tc.args))))))
            }
            else -> {
                val name = m.name ?: m.toolCallId ?: ""
                push("user", kv(listOf("functionResponse" to kv(listOf(
                    "name" to s(name), "response" to parseArgsOrEmpty(m.content))))))
            }
        }
    }
    val pairs = mutableListOf<Pair<String, Json>>()
    if (turns.isNotEmpty()) pairs.add("contents" to Json.Arr(turns.map { (role, parts) ->
        kv(listOf("role" to s(role), "parts" to Json.Arr(parts)))
    }))
    if (system.isNotEmpty()) pairs.add("systemInstruction" to kv(listOf(
        "parts" to Json.Arr(listOf(kv(listOf("text" to s(system.joinToString("\n\n")))))))))
    return Json.Obj(pairs).toPretty() + "\n"
}

fun serializeMarkdown(messages: List<InternalMessage>, warnings: MutableList<String>): String {
    messages.forEachIndexed { i, m ->
        if (!m.toolCalls.isNullOrEmpty())
            warnings.add("message $i: tool calls are not representable in Markdown and were dropped")
        if (m.role == "tool" && m.toolCallId != null)
            warnings.add("message $i: tool result id is not representable in Markdown and was dropped")
    }
    return messages.joinToString("\n\n") { "**${it.role}**: ${it.content}" } + "\n"
}

private val PARSERS: Map<String, (String, MutableList<String>) -> List<InternalMessage>> = mapOf(
    "openai" to ::parseOpenAi,
    "anthropic" to ::parseAnthropic,
    "gemini" to ::parseGemini,
    "markdown" to ::parseMarkdown,
)

private val SERIALIZERS: Map<String, (List<InternalMessage>, MutableList<String>) -> String> = mapOf(
    "openai" to ::serializeOpenAi,
    "anthropic" to ::serializeAnthropic,
    "gemini" to ::serializeGemini,
    "markdown" to ::serializeMarkdown,
)

/** 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. */
fun convert(transcript: String, fromFormat: String, toFormat: String): ConversionResult {
    if (transcript.isBlank())
        throw IllegalArgumentException("Transcript is empty — paste a transcript first")
    val parser = PARSERS[fromFormat]
        ?: throw IllegalArgumentException("Unknown source format: $fromFormat")
    val serializer = SERIALIZERS[toFormat]
        ?: throw IllegalArgumentException("Unknown target format: $toFormat")
    val warnings = mutableListOf<String>()
    val messages = parser(transcript, warnings)
    return 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 →