Chat Format Converter — Swift 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 Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.
// chat-format-converter — Swift 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: Swift (5.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.
// Foundation's JSONSerialization alphabetizes object keys, which would break
// the round-trip key order the TypeScript original guarantees — so this file
// includes a small hand-written recursive-descent JSON parser + writer
// (stdlib only). Object keys are kept in an [(String, Json)] array so
// insertion order survives a round-trip. Parse failures throw ChatFormatError.
// MARK: - JSON engine
/// A JSON value. Objects preserve insertion order via a pair array, matching
/// JavaScript object semantics (JSON.stringify key order).
enum Json {
case null
case bool(Bool)
case num(Double)
case str(String)
case arr([Json])
case obj([(String, Json)])
subscript(key: String) -> Json? {
guard case .obj(let pairs) = self else { return nil }
return pairs.first(where: { $0.0 == key })?.1
}
var asStr: String? { if case .str(let s) = self { return s }; return nil }
var asArr: [Json]? { if case .arr(let items) = self { return items }; return nil }
var isObject: Bool { if case .obj = self { return true }; return false }
/// Compact encoding, the moral equivalent of TS `JSON.stringify`.
var toCompact: String {
var out = ""
write(into: &out, indent: nil, depth: 0)
return out
}
/// Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`.
var toPretty: String {
var out = ""
write(into: &out, indent: 2, depth: 0)
return out
}
private func write(into out: inout String, indent: Int?, depth: Int) {
switch self {
case .null: out += "null"
case .bool(let b): out += b ? "true" : "false"
case .num(let n): out += Json.formatF64(n)
case .str(let s): Json.writeEscaped(into: &out, s)
case .arr(let items):
if items.isEmpty { out += "[]"; return }
out += "["
for (i, item) in items.enumerated() {
if i > 0 { out += "," }
if indent != nil { out += "\n"; Json.pushIndent(into: &out, depth + 1) }
item.write(into: &out, indent: indent, depth: depth + 1)
}
if indent != nil { out += "\n"; Json.pushIndent(into: &out, depth) }
out += "]"
case .obj(let pairs):
if pairs.isEmpty { out += "{}"; return }
out += "{"
for (i, (k, v)) in pairs.enumerated() {
if i > 0 { out += "," }
if indent != nil { out += "\n"; Json.pushIndent(into: &out, depth + 1) }
Json.writeEscaped(into: &out, k)
out += indent != nil ? ": " : ":"
v.write(into: &out, indent: indent, depth: depth + 1)
}
if indent != nil { out += "\n"; Json.pushIndent(into: &out, depth) }
out += "}"
}
}
private static func pushIndent(into out: inout String, _ depth: Int) {
for _ in 0..<depth { out += " " }
}
/// Escape a string the way JSON.stringify does: the two mandatory escapes,
/// the single-char shorthands, and \u00XX for remaining control characters.
static func writeEscaped(into out: inout String, _ s: String) {
out += "\""
for c in s {
switch c {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
case "\u{0008}": out += "\\b"
case "\u{000C}": out += "\\f"
default:
let b = c.utf8.first!
if b < 0x20 { out += Json.hex4Escape(b) } else { out.append(c) }
}
}
out += "\""
}
/// \u00XX for a control byte, lowercase hex, exactly four digits.
private static func hex4Escape(_ b: UInt8) -> String {
let digits = Array("0123456789abcdef")
return "\\u" + String(digits[Int(b >> 4)]) + String(digits[Int(b & 0xF)])
}
/// Render a Double the way JavaScript's number-to-string coercion does:
/// whole values carry no trailing ".0".
static func formatF64(_ n: Double) -> String {
if n == n.rounded() && abs(n) < 1e15 { return String(Int64(n)) }
return String(n)
}
}
/// One parse/convert failure, carrying the same human-readable message the
/// TypeScript original surfaces in the UI.
struct ChatFormatError: Error, CustomStringConvertible {
let message: String
var description: String { message }
}
/// Recursive-descent JSON parser; trailing garbage is an error.
struct JsonParser {
private let bytes: [UInt8]
private var pos = 0
static func parse(_ input: String) throws -> Json {
var p = JsonParser(bytes: Array(input.utf8))
p.skipWs()
let v = try p.value()
p.skipWs()
if p.pos != p.bytes.count {
throw ChatFormatError(message: "unexpected trailing characters at byte \(p.pos)")
}
return v
}
private mutating func skipWs() {
while let c = peek(), c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D { pos += 1 }
}
private func peek() -> UInt8? { pos < bytes.count ? bytes[pos] : nil }
private mutating func value() throws -> Json {
guard let c = peek() else { throw ChatFormatError(message: "unexpected end of input") }
switch c {
case UInt8(ascii: "{"): return try obj()
case UInt8(ascii: "["): return try arr()
case UInt8(ascii: "\""): return .str(try string())
case UInt8(ascii: "t"): return try literal("true", .bool(true))
case UInt8(ascii: "f"): return try literal("false", .bool(false))
case UInt8(ascii: "n"): return try literal("null", .null)
case UInt8(ascii: "-"), 0x30...0x39: return try number()
default:
throw ChatFormatError(message: "unexpected '\(Character(UnicodeScalar(c)))' at byte \(pos)")
}
}
private mutating func literal(_ word: String, _ val: Json) throws -> Json {
let target = Array(word.utf8)
if bytes[pos..<min(pos + target.count, bytes.count)].elementsEqual(target) {
pos += target.count
return val
}
throw ChatFormatError(message: "invalid literal at byte \(pos)")
}
private mutating func number() throws -> Json {
let start = pos
loop: while let c = peek() {
switch c {
case UInt8(ascii: "-"), UInt8(ascii: "+"), UInt8(ascii: "."),
UInt8(ascii: "e"), UInt8(ascii: "E"), 0x30...0x39:
pos += 1
default: break loop
}
}
let text = String(decoding: bytes[start..<pos], as: UTF8.self)
guard let d = Double(text) else {
throw ChatFormatError(message: "invalid number \"\(text)\" at byte \(start)")
}
return .num(d)
}
private mutating func string() throws -> String {
pos += 1 // opening quote
var scalars = [UInt8]()
while true {
guard let c = peek() else {
throw ChatFormatError(message: "unterminated string at byte \(pos)")
}
pos += 1
switch c {
case UInt8(ascii: "\""):
return String(decoding: scalars, as: UTF8.self)
case UInt8(ascii: "\\"):
guard let esc = peek() else { throw ChatFormatError(message: "unterminated escape") }
pos += 1
switch esc {
case UInt8(ascii: "\""): scalars.append(0x22)
case UInt8(ascii: "\\"): scalars.append(0x5C)
case UInt8(ascii: "/"): scalars.append(0x2F)
case UInt8(ascii: "b"): scalars.append(0x08)
case UInt8(ascii: "f"): scalars.append(0x0C)
case UInt8(ascii: "n"): scalars.append(0x0A)
case UInt8(ascii: "r"): scalars.append(0x0D)
case UInt8(ascii: "t"): scalars.append(0x09)
case UInt8(ascii: "u"):
let hi = try hex4()
if hi >= 0xD800 && hi <= 0xDBFF {
// High surrogate: combine with a following \uXXXX low surrogate.
let paired = peek() == UInt8(ascii: "\\")
&& bytes.count > pos + 1 && bytes[pos + 1] == UInt8(ascii: "u")
if paired {
pos += 2
let lo = try hex4()
let cp = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)
if let s = String(scalar: cp) { scalars.append(contentsOf: Array(s.utf8)) }
else { scalars.append(contentsOf: [0xEF, 0xBF, 0xBD]) }
} else {
scalars.append(contentsOf: Array("\u{FFFD}".utf8))
}
} else if let s = String(scalar: hi) {
scalars.append(contentsOf: Array(s.utf8))
} else {
scalars.append(contentsOf: Array("\u{FFFD}".utf8))
}
default:
throw ChatFormatError(message: "invalid escape \\\(Character(UnicodeScalar(esc))) at byte \(pos - 1)")
}
default:
// Copy the full UTF-8 sequence as-is; `pos` already advanced one byte.
scalars.append(c)
}
}
}
private mutating func hex4() throws -> Int {
var v = 0
for _ in 0..<4 {
guard let c = peek() else { throw ChatFormatError(message: "unterminated \\u escape") }
guard let d = Character(UnicodeScalar(c)).hexDigitValue else {
throw ChatFormatError(message: "invalid hex digit at byte \(pos)")
}
pos += 1
v = v * 16 + d
}
return v
}
private mutating func obj() throws -> Json {
pos += 1 // '{'
var pairs: [(String, Json)] = []
skipWs()
if peek() == UInt8(ascii: "}") { pos += 1; return .obj(pairs) }
while true {
skipWs()
guard peek() == UInt8(ascii: "\"") else {
throw ChatFormatError(message: "expected object key at byte \(pos)")
}
let key = try string()
skipWs()
guard peek() == UInt8(ascii: ":") else {
throw ChatFormatError(message: "expected ':' at byte \(pos)")
}
pos += 1
skipWs()
pairs.append((key, try value()))
skipWs()
switch peek() {
case UInt8(ascii: ","): pos += 1
case UInt8(ascii: "}"): pos += 1; return .obj(pairs)
default: throw ChatFormatError(message: "expected ',' or '}' at byte \(pos)")
}
}
}
private mutating func arr() throws -> Json {
pos += 1 // '['
var items: [Json] = []
skipWs()
if peek() == UInt8(ascii: "]") { pos += 1; return .arr(items) }
while true {
skipWs()
items.append(try value())
skipWs()
switch peek() {
case UInt8(ascii: ","): pos += 1
case UInt8(ascii: "]"): pos += 1; return .arr(items)
default: throw ChatFormatError(message: "expected ',' or ']' at byte \(pos)")
}
}
}
}
extension String {
/// Build a String from a Unicode scalar value, nil when it is a surrogate.
init?(scalar v: Int) {
guard let us = UnicodeScalar(v), !(0xD800...0xDFFF).contains(v) else { return nil }
self = String(Character(us))
}
}
// MARK: - Domain types
let CHAT_FORMATS = ["openai", "anthropic", "gemini", "markdown"]
/// One assistant-emitted tool call, provider-agnostic. `args` is a JSON string.
struct ToolCall: Equatable {
var id = ""
var name = ""
var args = ""
}
/// The shared internal shape every format parses into and serializes from.
struct InternalMessage: Equatable {
var role: String // "system" | "user" | "assistant" | "tool"
var content = "" // "" when the message only carries tool calls/results
var name: String? = nil
var toolCalls: [ToolCall]? = nil
var toolCallId: String? = nil
init(_ role: String, _ content: String = "") {
self.role = role
self.content = content
}
}
/// The conversion output plus human-readable notes about unmapped fields.
struct ConversionResult: Equatable {
var output: String
var warnings: [String]
}
// MARK: - Helpers
func parseTranscriptJson(_ text: String, _ label: String) throws -> Json {
do { return try JsonParser.parse(text) }
catch let e as ChatFormatError { throw ChatFormatError(message: "\(label): invalid JSON — \(e.message)") }
}
/// The TS `?? {}` capture: null/missing becomes {}, any other value passes
/// through and stringifies as-is.
func argsOf(_ v: Json?) -> String {
switch v {
case nil, .some(.null): return "{}"
case .some(let v): return v.toCompact
}
}
/// Parse a JSON-string argument back to a value, an empty object when it is
/// not a JSON object (mirrors parseArgsOrEmpty).
func parseArgsOrEmpty(_ args: String) -> Json {
if let v = try? JsonParser.parse(args), v.isObject { return v }
return .obj([])
}
func blockTypeName(_ raw: Json) -> String {
raw["type"]?.asStr ?? "content"
}
/// One normalized Anthropic content block.
enum Block {
case text(String)
case toolUse(Json)
case toolResult(Json)
case other(Json)
}
func contentBlocks(_ content: Json, _ where_: String) throws -> [Block] {
if case .str(let s) = content { return [.text(s)] }
guard case .arr(let items) = content else {
throw ChatFormatError(message: "\(where_): content must be a string or an array of blocks")
}
return items.map { block in
let isText = block["type"]?.asStr == "text" && block["text"]?.asStr != nil
switch block["type"]?.asStr {
case "text" where isText: return .text(block["text"]!.asStr!)
case "tool_use": return .toolUse(block)
case "tool_result": return .toolResult(block)
default: return .other(block)
}
}
}
/// Anthropic string-or-block[] content → plain text.
func blocksToText(_ content: Json, _ where_: String, _ warnings: inout [String]) throws -> String {
if case .str(let s) = content { return s }
var texts: [String] = []
for block in try contentBlocks(content, where_) {
switch block {
case .text(let t): texts.append(t)
case .other(let raw):
warnings.append("\(where_): dropped unsupported \(blockTypeName(raw)) block")
case .toolUse:
warnings.append("\(where_): dropped tool_use block from text-only content")
case .toolResult:
warnings.append("\(where_): dropped tool_result block from text-only content")
}
}
return texts.joined(separator: "\n")
}
func toolCallFromBlock(_ raw: Json) -> ToolCall {
ToolCall(id: raw["id"]?.asStr ?? "", name: raw["name"]?.asStr ?? "", args: argsOf(raw["input"]))
}
// MARK: - Parse
/// OpenAI messages[] (bare array, or wrapped in {"messages": [...]}).
func parseOpenAi(_ text: String, _ warnings: inout [String]) throws -> [InternalMessage] {
let parsed = try parseTranscriptJson(text, "OpenAI transcript")
let items: [Json]
if case .arr(let wrapped)? = parsed.isObject ? parsed["messages"] : nil {
items = wrapped
} else if case .arr(let bare) = parsed {
items = bare
} else {
throw ChatFormatError(
message: "OpenAI transcript must be a messages[] array or an object with a \"messages\" array")
}
var out: [InternalMessage] = []
for (i, entry) in items.enumerated() {
guard entry.isObject else { throw ChatFormatError(message: "messages[\(i)] is not an object") }
let where_ = "messages[\(i)]"
let role = entry["role"]?.asStr ?? ""
switch role {
case "system", "developer":
out.append(InternalMessage("system", try contentText(entry["content"], where_, &warnings)))
case "user", "assistant", "tool":
var msg = InternalMessage(role, try contentText(entry["content"], where_, &warnings))
msg.name = entry["name"]?.asStr
msg.toolCallId = entry["tool_call_id"]?.asStr
msg.toolCalls = try parseOpenAiToolCalls(entry["tool_calls"])
out.append(msg)
default:
throw ChatFormatError(message: "messages[\(i)] has unsupported role: \(entry["role"]?.toCompact ?? "null")")
}
}
return out
}
/// OpenAI content → plain text (string, or text parts joined; other parts flagged).
func contentText(_ content: Json?, _ where_: String, _ warnings: inout [String]) throws -> String {
switch content {
case nil, .some(.null): return ""
case .some(.str(let s)): return s
case .some(.arr(let parts)):
var texts: [String] = []
var dropped = 0
for part in parts {
let isText = part["type"]?.asStr == "text" && part["text"]?.asStr != nil
if isText { texts.append(part["text"]!.asStr!) } else { dropped += 1 }
}
if dropped > 0 { warnings.append("\(where_): dropped \(dropped) non-text content part(s)") }
return texts.joined()
default:
throw ChatFormatError(message: "\(where_): content must be a string, an array of parts, or null")
}
}
func parseOpenAiToolCalls(_ raw: Json?) throws -> [ToolCall]? {
switch raw {
case nil, .some(.null):
return nil
case .some(.arr(let items)):
return items.map { entry in
let fn = entry["function"]
return ToolCall(
id: entry["id"]?.asStr ?? "",
name: fn?["name"]?.asStr ?? "",
args: fn?["arguments"]?.asStr ?? "")
}
default:
throw ChatFormatError(message: "tool_calls must be an array")
}
}
/// Anthropic messages[] plus the top-level system field.
func parseAnthropic(_ text: String, _ warnings: inout [String]) throws -> [InternalMessage] {
let root = try parseTranscriptJson(text, "Anthropic transcript")
guard root.isObject, case .arr(let messages)? = root["messages"] else {
throw ChatFormatError(message: "Anthropic transcript must be an object with a \"messages\" array")
}
var out: [InternalMessage] = []
if let sys = root["system"] {
out.append(InternalMessage("system", try blocksToText(sys, "system", &warnings)))
}
for (i, entry) in messages.enumerated() {
guard entry.isObject else { throw ChatFormatError(message: "messages[\(i)] is not an object") }
let where_ = "messages[\(i)]"
switch entry["role"]?.asStr ?? "" {
case "assistant":
var texts: [String] = []
var calls: [ToolCall] = []
for block in try contentBlocks(entry["content"] ?? .null, where_) {
switch block {
case .text(let t): texts.append(t)
case .toolUse(let raw): calls.append(toolCallFromBlock(raw))
case .other(let raw):
warnings.append("\(where_): dropped unsupported \(blockTypeName(raw)) block")
case .toolResult: break
}
}
var msg = InternalMessage("assistant", texts.joined(separator: "\n"))
if !calls.isEmpty { msg.toolCalls = calls }
out.append(msg)
case "user":
var texts: [String] = []
func flush() {
if !texts.isEmpty {
out.append(InternalMessage("user", texts.joined(separator: "\n")))
texts.removeAll()
}
}
for block in try contentBlocks(entry["content"] ?? .null, where_) {
switch block {
case .text(let t):
texts.append(t)
case .toolUse(let raw):
warnings.append("\(where_): tool_use block inside a user message moved to an assistant tool call")
flush()
var msg = InternalMessage("assistant")
msg.toolCalls = [toolCallFromBlock(raw)]
out.append(msg)
case .toolResult(let raw):
flush()
var msg = InternalMessage("tool",
try blocksToText(raw["content"] ?? .str(""), where_, &warnings))
msg.toolCallId = raw["tool_use_id"]?.asStr ?? ""
out.append(msg)
case .other(let raw):
warnings.append("\(where_): dropped unsupported \(blockTypeName(raw)) block")
}
}
flush()
default:
throw ChatFormatError(message: "\(where_) has unsupported role: \(entry["role"]?.toCompact ?? "null")")
}
}
return out
}
/// Gemini systemInstruction (string or {parts}) → plain text.
func geminiText(_ v: Json, _ where_: String, _ warnings: inout [String]) -> String {
if case .str(let s) = v { return s }
if v.isObject {
if let t = v["text"]?.asStr { return t }
if case .arr(let parts)? = v["parts"] {
return parts.map { $0["text"]?.asStr ?? "" }.joined(separator: "\n")
}
}
warnings.append("\(where_): unsupported systemInstruction shape, treated as empty")
return ""
}
/// Gemini contents[] plus the optional systemInstruction.
func parseGemini(_ text: String, _ warnings: inout [String]) throws -> [InternalMessage] {
let root = try parseTranscriptJson(text, "Gemini transcript")
guard root.isObject, case .arr(let contents)? = root["contents"] else {
throw ChatFormatError(message: "Gemini transcript must be an object with a \"contents\" array")
}
var out: [InternalMessage] = []
if let si = root["systemInstruction"] {
out.append(InternalMessage("system", geminiText(si, "systemInstruction", &warnings)))
}
for (i, entry) in contents.enumerated() {
guard entry.isObject else { throw ChatFormatError(message: "contents[\(i)] is not an object") }
let where_ = "contents[\(i)]"
let role = entry["role"]?.asStr ?? ""
guard role == "user" || role == "model" else {
let roleJson = entry["role"]?.toCompact ?? "null"
throw ChatFormatError(message: "\(where_) has unsupported role: \(roleJson) (Gemini uses \"user\" or \"model\")")
}
guard case .arr(let parts)? = entry["parts"] else {
throw ChatFormatError(message: "\(where_): parts must be an array")
}
if role == "model" {
// A model turn keeps its text and function calls in ONE message,
// mirroring an OpenAI assistant message with tool_calls.
var texts: [String] = []
var calls: [ToolCall] = []
for part in parts {
if let t = part["text"]?.asStr { texts.append(t); continue }
if case .obj(let fc)? = part["functionCall"] {
calls.append(ToolCall(name: fc["name"]?.asStr ?? "", args: argsOf(fc["args"])))
continue
}
warnings.append("\(where_): dropped unsupported part (inlineData or similar)")
}
var msg = InternalMessage("assistant", texts.joined(separator: "\n"))
if !calls.isEmpty { msg.toolCalls = calls }
out.append(msg)
continue
}
var texts: [String] = []
func flushText() {
if !texts.isEmpty {
out.append(InternalMessage("user", texts.joined(separator: "\n")))
texts.removeAll()
}
}
for part in parts {
if let t = part["text"]?.asStr { texts.append(t); continue }
if part["functionCall"]?.isObject == true {
let fc = part["functionCall"]!
flushText()
var msg = InternalMessage("assistant")
msg.toolCalls = [ToolCall(name: fc["name"]?.asStr ?? "", args: argsOf(fc["args"]))]
out.append(msg)
continue
}
if part["functionResponse"]?.isObject == true {
let fr = part["functionResponse"]!
flushText()
let name = fr["name"]?.asStr ?? ""
var msg = InternalMessage("tool", argsOf(fr["response"]))
msg.name = name
msg.toolCallId = name
out.append(msg)
continue
}
warnings.append("\(where_): dropped unsupported part (inlineData or similar)")
}
flushText()
}
return out
}
/// A parsed Markdown header line: `**role**: text`.
func markdownHeader(_ line: String) -> (role: String, first: String)? {
guard line.hasPrefix("**") else { return nil }
let afterOpening = line.index(line.startIndex, offsetBy: 2)
// Substring indices are shared with the parent string, so the found range
// maps straight back onto `line`.
guard let afterRole = line[afterOpening...].firstRange(of: "**:") else { return nil }
let role = String(line[afterOpening..<afterRole.lowerBound])
let mapped: String?
switch role {
case "system", "user", "assistant", "tool": mapped = role
case "model": mapped = "assistant"
default: mapped = nil
}
guard let mapped else { return nil }
let rest = line[afterRole.upperBound...]
return (mapped, String(rest.drop(while: { $0.isWhitespace })))
}
func finishMarkdownMessage(_ role: String, _ lines: inout [String]) -> InternalMessage {
// Trim leading/trailing blank continuation lines but keep inner blank lines.
while lines.count > 1 && lines[0].allSatisfy(\.isWhitespace) { lines.removeFirst() }
while lines.count > 1 && lines[lines.count - 1].allSatisfy(\.isWhitespace) {
lines.removeLast()
}
return InternalMessage(role, lines.joined(separator: "\n"))
}
/// Markdown transcript: `**role**: text` header lines with continuation lines
/// belonging to the same message. `model` maps to assistant.
func parseMarkdown(_ text: String, _ warnings: inout [String]) throws -> [InternalMessage] {
var out: [InternalMessage] = []
var current: (role: String, lines: [String])? = nil
for line in text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if let (role, first) = markdownHeader(line) {
if var cur = current { out.append(finishMarkdownMessage(cur.role, &cur.lines)) }
current = (role, [first])
continue
}
if var cur = current {
cur.lines.append(line)
current = (cur.role, cur.lines)
} else if !line.allSatisfy(\.isWhitespace) {
throw ChatFormatError(message: "Markdown transcript must start with a `**role**:` header line")
}
}
if var cur = current { out.append(finishMarkdownMessage(cur.role, &cur.lines)) }
return out
}
// MARK: - Serialize
func kv(_ pairs: [(String, Json)]) -> Json { .obj(pairs) }
func s(_ v: String) -> Json { .str(v) }
func serializeOpenAi(_ messages: [InternalMessage], _ warnings: inout [String]) -> String {
let arr = messages.map { m -> Json in
var pairs: [(String, Json)] = []
if m.role == "tool" {
pairs.append(("role", s("tool")))
pairs.append(("content", s(m.content)))
pairs.append(("tool_call_id", s(m.toolCallId ?? "")))
if let name = m.name { pairs.append(("name", s(name))) }
} else if m.role == "assistant" && m.toolCalls != nil {
pairs.append(("role", s("assistant")))
pairs.append(("content", m.content.isEmpty ? .null : s(m.content)))
pairs.append(("tool_calls", .arr(m.toolCalls!.map { tc in
kv([("id", s(tc.id)), ("type", s("function")),
("function", kv([("name", s(tc.name)), ("arguments", s(tc.args))]))])
})))
if let name = m.name { pairs.append(("name", s(name))) }
} else {
pairs.append(("role", s(m.role)))
pairs.append(("content", s(m.content)))
if let name = m.name { pairs.append(("name", s(name))) }
}
return kv(pairs)
}
return kv([("messages", .arr(arr))]).toPretty + "\n"
}
func serializeAnthropic(_ messages: [InternalMessage], _ warnings: inout [String]) -> String {
let system = messages.filter { $0.role == "system" }.map(\.content)
var out: [Json] = []
for (i, m) in messages.enumerated() {
if m.role == "system" { continue }
if m.name != nil && m.role != "tool" {
warnings.append("message \(i): \"name\" has no Anthropic equivalent and was dropped")
}
switch m.role {
case "user":
out.append(kv([("role", s("user")), ("content", s(m.content))]))
case "assistant":
if let calls = m.toolCalls {
var blocks: [Json] = []
if !m.content.isEmpty { blocks.append(kv([("type", s("text")), ("text", s(m.content))])) }
for tc in calls {
blocks.append(kv([("type", s("tool_use")), ("id", s(tc.id)), ("name", s(tc.name)),
("input", parseArgsOrEmpty(tc.args))]))
}
out.append(kv([("role", s("assistant")), ("content", .arr(blocks))]))
} else {
out.append(kv([("role", s("assistant")), ("content", s(m.content))]))
}
default:
out.append(kv([
("role", s("user")),
("content", .arr([kv([
("type", s("tool_result")),
("tool_use_id", s(m.toolCallId ?? "")),
("content", s(m.content)),
])])),
]))
}
}
var pairs: [(String, Json)] = []
if !out.isEmpty { pairs.append(("messages", .arr(out))) }
if !system.isEmpty { pairs.append(("system", s(system.joined(separator: "\n\n")))) }
return Json.obj(pairs).toPretty + "\n"
}
func serializeGemini(_ messages: [InternalMessage], _ warnings: inout [String]) -> String {
let system = messages.filter { $0.role == "system" }.map(\.content)
// (role, parts) turns; consecutive same-role parts merge into one turn.
var turns: [(String, [Json])] = []
func push(_ role: String, _ part: Json) {
if var last = turns.last, last.0 == role {
last.1.append(part)
turns[turns.count - 1] = last
} else {
turns.append((role, [part]))
}
}
for (i, m) in messages.enumerated() {
if m.role == "system" { continue }
if m.name != nil && m.role != "tool" {
warnings.append("message \(i): \"name\" has no Gemini equivalent and was dropped")
}
switch m.role {
case "user":
push("user", kv([("text", s(m.content))]))
case "assistant":
if !m.content.isEmpty { push("model", kv([("text", s(m.content))])) }
for tc in m.toolCalls ?? [] {
push("model", kv([("functionCall", kv([("name", s(tc.name)),
("args", parseArgsOrEmpty(tc.args))]))]))
}
default:
let name = m.name ?? m.toolCallId ?? ""
push("user", kv([("functionResponse", kv([("name", s(name)),
("response", parseArgsOrEmpty(m.content))]))]))
}
}
var pairs: [(String, Json)] = []
if !turns.isEmpty {
pairs.append(("contents", .arr(turns.map { (role, parts) in
kv([("role", s(role)), ("parts", .arr(parts))])
})))
}
if !system.isEmpty {
pairs.append(("systemInstruction",
kv([("parts", .arr([kv([("text", s(system.joined(separator: "\n\n")))])]))])))
}
return Json.obj(pairs).toPretty + "\n"
}
func serializeMarkdown(_ messages: [InternalMessage], _ warnings: inout [String]) -> String {
for (i, m) in messages.enumerated() {
if let calls = m.toolCalls, !calls.isEmpty {
warnings.append("message \(i): tool calls are not representable in Markdown and were dropped")
}
if m.role == "tool" && m.toolCallId != nil {
warnings.append("message \(i): tool result id is not representable in Markdown and was dropped")
}
}
return messages.map { "**\($0.role)**: \($0.content)" }.joined(separator: "\n\n") + "\n"
}
let PARSERS: [String: (String, inout [String]) throws -> [InternalMessage]] = [
"openai": parseOpenAi,
"anthropic": parseAnthropic,
"gemini": parseGemini,
"markdown": parseMarkdown,
]
let SERIALIZERS: [String: ([InternalMessage], inout [String]) -> String] = [
"openai": serializeOpenAi,
"anthropic": serializeAnthropic,
"gemini": serializeGemini,
"markdown": serializeMarkdown,
]
/// Convert a transcript between chat formats through the shared internal shape.
///
/// Throws ChatFormatError when the transcript is empty, the source format
/// fails to parse, or from/to is not a known format.
func convert(_ transcript: String, _ from: String, _ to: String) throws -> ConversionResult {
if transcript.allSatisfy(\.isWhitespace) {
throw ChatFormatError(message: "Transcript is empty — paste a transcript first")
}
guard let parser = PARSERS[from] else {
throw ChatFormatError(message: "Unknown source format: \(from)")
}
guard let serializer = SERIALIZERS[to] else {
throw ChatFormatError(message: "Unknown target format: \(to)")
}
var warnings: [String] = []
let messages = try parser(transcript, &warnings)
return ConversionResult(output: serializer(messages, &warnings), 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 →