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 →