Chat Format Converter — Zig source
Convert chat transcripts between OpenAI messages[], Anthropic system+messages, Gemini contents[], and plain Markdown. Roles map faithfully, tool calls are preserved where possible, and anything unmappable is flagged — never dropped silently. Runs entirely in your browser.
This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! chat-format-converter — Zig polyglot showcase port.
//!
//! Converts one chat transcript between provider shapes through a shared internal
//! message list:
//!
//! - openai — {"messages": [{"role": "system|user|assistant|tool", ...}]}
//! - anthropic — {"system": "...", "messages": [{"role": "user|assistant", ...}]}
//! - gemini — {"contents": [{"role": "user|model", "parts": [...]}]}
//! - markdown — a plain `**role**: text` transcript
//!
//! Language: Zig (0.13, standard library only)
//! Source: CosmoDev polyglot showcase port of the Chat Format Converter tool,
//! ported from src/lib/chatFormatConverter.ts (the canonical
//! TypeScript that powers the live tool).
//! License: display source — part of CosmoDev's polyglot tool pages.
//!
//! Roles map faithfully (Gemini has no assistant — it is `model`; Anthropic
//! system lives top-level). Fields a target format cannot represent (e.g. tool
//! calls in Markdown) are flagged as warnings, never dropped silently.
//! std.json.Value's ObjectMap is an array hash map, so key insertion order
//! survives a round-trip exactly like the TypeScript original. Everything is
//! allocated from one caller-provided arena; parse failures return
//! error.Invalid with the human-readable message stored in `diag`.
const std = @import("std");
const Value = std.json.Value;
pub const Error = error{ Invalid, OutOfMemory };
pub const chat_formats = [_][]const u8{ "openai", "anthropic", "gemini", "markdown" };
/// One assistant-emitted tool call, provider-agnostic. `args` is a JSON string.
pub const ToolCall = struct {
id: []const u8 = "",
name: []const u8 = "",
args: []const u8 = "",
};
/// The shared internal shape every format parses into and serializes from.
pub const Message = struct {
role: []const u8, // "system" | "user" | "assistant" | "tool"
content: []const u8 = "", // "" when the message only carries tool calls/results
name: ?[]const u8 = null,
tool_calls: ?[]const ToolCall = null,
tool_call_id: ?[]const u8 = null,
};
/// The conversion output plus human-readable notes about unmapped fields.
pub const ConversionResult = struct {
output: []const u8,
warnings: []const []const u8,
};
const Warnings = std.ArrayList([]const u8);
const Messages = std.ArrayList(Message);
/// Record the human-readable failure message and return error.Invalid.
fn fail(diag: *?[]const u8, arena: std.mem.Allocator, comptime fmt: []const u8, args: anytype) Error {
diag.* = std.fmt.allocPrint(arena, fmt, args) catch null;
return error.Invalid;
}
fn join(arena: std.mem.Allocator, parts: []const []const u8, sep: []const u8) Error![]const u8 {
return std.mem.join(arena, sep, parts) catch return error.OutOfMemory;
}
// ------------------------------ JSON helpers --------------------------------
fn parseJson(arena: std.mem.Allocator, text: []const u8, label: []const u8, diag: *?[]const u8) Error!Value {
return std.json.parseFromSliceLeaky(Value, arena, text, .{}) catch |e| switch (e) {
error.OutOfMemory => error.OutOfMemory,
else => fail(diag, arena, "{s}: invalid JSON — {s}", .{ label, @errorName(e) }),
}
}
fn jget(v: Value, key: []const u8) ?Value {
switch (v) {
.object => |map| return map.get(key),
else => return null,
}
}
fn jstr(v: ?Value) ?[]const u8 {
const val = v orelse return null;
switch (val) {
.string => |s| return s,
else => return null,
}
}
/// str-or-null semantics: a JSON string value (even "") is present as ?[]const u8.
fn jstrOrNull(v: ?Value) ?[]const u8 {
return jstr(v);
}
fn jarr(v: ?Value) ?[]Value {
const val = v orelse return null;
switch (val) {
.array => |items| return items.items,
else => return null,
}
}
fn isObj(v: Value) bool {
return v == .object;
}
fn has(v: Value, key: []const u8) bool {
return jget(v, key) != null;
}
/// Compact JSON encoding of a value, the moral equivalent of TS `JSON.stringify`.
fn compact(arena: std.mem.Allocator, v: Value) Error![]const u8 {
return std.json.stringifyAlloc(arena, v, .{}) catch return error.OutOfMemory;
}
/// Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`.
fn pretty(arena: std.mem.Allocator, v: Value) Error![]const u8 {
return std.json.stringifyAlloc(arena, v, .{ .whitespace = .indent_2 }) catch return error.OutOfMemory;
}
/// The TS `?? {}` capture: null/missing becomes {}, any other value passes
/// through and stringifies as-is.
fn argsOf(arena: std.mem.Allocator, v: ?Value) Error![]const u8 {
const val = v orelse return "{}";
switch (val) {
.null => return "{}",
else => return compact(arena, val),
}
}
/// Parse a JSON-string argument back to a value, an empty object when it is
/// not a JSON object (mirrors parseArgsOrEmpty).
fn parseArgsOrEmpty(arena: std.mem.Allocator, args: []const u8) Error!Value {
const parsed = std.json.parseFromSliceLeaky(Value, arena, args, .{}) catch {
return emptyObj(arena);
};
if (parsed == .object) return parsed;
return emptyObj(arena);
}
fn emptyObj(arena: std.mem.Allocator) Error!Value {
const map = std.json.ObjectMap.init(arena);
return .{ .object = map };
}
fn newObj(arena: std.mem.Allocator) Error!std.json.ObjectMap {
return std.json.ObjectMap.init(arena);
}
fn put(map: *std.json.ObjectMap, key: []const u8, v: Value) Error!void {
map.put(key, v) catch return error.OutOfMemory;
}
fn strv(s: []const u8) Value {
return .{ .string = s };
}
fn arrv(arena: std.mem.Allocator, items: []const Value) Error!Value {
var list = std.json.Array.init(arena);
list.appendSlice(items) catch return error.OutOfMemory;
return .{ .array = list };
}
// ------------------------------ domain helpers ------------------------------
fn blockTypeName(raw: Value) []const u8 {
return jstr(jget(raw, "type")) orelse "content";
}
/// One normalized Anthropic content block.
const Block = union(enum) {
text: []const u8,
tool_use: Value,
tool_result: Value,
other: Value,
};
const Blocks = std.ArrayList(Block);
fn contentBlocks(arena: std.mem.Allocator, content: Value, where: []const u8, diag: *?[]const u8) Error!Blocks {
var blocks = Blocks.init(arena);
switch (content) {
.string => |s| {
blocks.append(.{ .text = s }) catch return error.OutOfMemory;
return blocks;
},
.array => |items| {
for (items.items) |block| {
const is_text = blk: {
const t = jstr(jget(block, "type")) orelse break :blk false;
break :blk std.mem.eql(u8, t, "text") and jstr(jget(block, "text")) != null;
};
const kind = jstr(jget(block, "type"));
if (is_text) {
blocks.append(.{ .text = jstr(jget(block, "text")).? }) catch return error.OutOfMemory;
} else if (kind != null and std.mem.eql(u8, kind.?, "tool_use")) {
blocks.append(.{ .tool_use = block }) catch return error.OutOfMemory;
} else if (kind != null and std.mem.eql(u8, kind.?, "tool_result")) {
blocks.append(.{ .tool_result = block }) catch return error.OutOfMemory;
} else {
blocks.append(.{ .other = block }) catch return error.OutOfMemory;
}
}
return blocks;
},
else => return fail(diag, arena, "{s}: content must be a string or an array of blocks", .{where}),
}
}
/// Anthropic string-or-block[] content → plain text.
fn blocksToText(arena: std.mem.Allocator, content: Value, where: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]const u8 {
switch (content) {
.string => |s| return s,
.null => return "",
else => {},
}
var texts = std.ArrayList([]const u8).init(arena);
var blocks = try contentBlocks(arena, content, where, diag);
for (blocks.items) |block| {
switch (block) {
.text => |t| texts.append(t) catch return error.OutOfMemory,
.other => |raw| warnings.append(
std.fmt.allocPrint(arena, "{s}: dropped unsupported {s} block", .{ where, blockTypeName(raw) }) catch return error.OutOfMemory,
) catch return error.OutOfMemory,
.tool_use => warnings.append(
std.fmt.allocPrint(arena, "{s}: dropped tool_use block from text-only content", .{where}) catch return error.OutOfMemory,
) catch return error.OutOfMemory,
.tool_result => warnings.append(
std.fmt.allocPrint(arena, "{s}: dropped tool_result block from text-only content", .{where}) catch return error.OutOfMemory,
) catch return error.OutOfMemory,
}
}
return join(arena, texts.items, "\n");
}
fn toolCallFromBlock(arena: std.mem.Allocator, raw: Value) Error!ToolCall {
return .{
.id = jstr(jget(raw, "id")) orelse "",
.name = jstr(jget(raw, "name")) orelse "",
.args = try argsOf(arena, jget(raw, "input")),
};
}
// ---------------------------------- parse -----------------------------------
/// OpenAI messages[] (bare array, or wrapped in {"messages": [...]}).
pub fn parseOpenAi(arena: std.mem.Allocator, text: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]Message {
const parsed = try parseJson(arena, text, "OpenAI transcript", diag);
const items: []Value = blk: {
if (isObj(parsed)) {
if (jarr(jget(parsed, "messages"))) |wrapped| break :blk wrapped;
}
if (jarr(parsed)) |bare| break :blk bare;
return fail(diag, arena, "OpenAI transcript must be a messages[] array or an object with a \"messages\" array", .{});
};
var out = Messages.init(arena);
for (items, 0..) |entry, i| {
if (!isObj(entry)) return fail(diag, arena, "messages[{d}] is not an object", .{i});
const where = try std.fmt.allocPrint(arena, "messages[{d}]", .{i}) catch return error.OutOfMemory;
const role = jstr(jget(entry, "role")) orelse "";
if (std.mem.eql(u8, role, "system") or std.mem.eql(u8, role, "developer")) {
out.append(.{
.role = "system",
.content = try contentText(arena, jget(entry, "content"), where, warnings, diag),
}) catch return error.OutOfMemory;
} else if (std.mem.eql(u8, role, "user") or std.mem.eql(u8, role, "assistant") or std.mem.eql(u8, role, "tool")) {
out.append(.{
.role = role,
.content = try contentText(arena, jget(entry, "content"), where, warnings, diag),
.name = jstrOrNull(jget(entry, "name")),
.tool_call_id = jstrOrNull(jget(entry, "tool_call_id")),
.tool_calls = try parseOpenAiToolCalls(arena, jget(entry, "tool_calls"), diag),
}) catch return error.OutOfMemory;
} else {
const rendered = if (jget(entry, "role")) |r| try compact(arena, r) else "null";
return fail(diag, arena, "messages[{d}] has unsupported role: {s}", .{ i, rendered });
}
}
return out.items;
}
/// OpenAI content → plain text (string, or text parts joined; other parts flagged).
fn contentText(arena: std.mem.Allocator, content: ?Value, where: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]const u8 {
const c = content orelse return "";
switch (c) {
.null => return "",
.string => |s| return s,
.array => |parts| {
var texts = std.ArrayList([]const u8).init(arena);
var dropped: usize = 0;
for (parts.items) |part| {
const is_text = blk: {
const t = jstr(jget(part, "type")) orelse break :blk false;
break :blk std.mem.eql(u8, t, "text") and jstr(jget(part, "text")) != null;
};
if (is_text) {
texts.append(jstr(jget(part, "text")).?) catch return error.OutOfMemory;
} else dropped += 1;
}
if (dropped > 0) {
warnings.append(
std.fmt.allocPrint(arena, "{s}: dropped {d} non-text content part(s)", .{ where, dropped }) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
}
return join(arena, texts.items, "");
},
else => return fail(diag, arena, "{s}: content must be a string, an array of parts, or null", .{where}),
}
}
fn parseOpenAiToolCalls(arena: std.mem.Allocator, raw: ?Value, diag: *?[]const u8) Error!?[]const ToolCall {
const r = raw orelse return null;
switch (r) {
.null => return null,
.array => |items| {
var calls = std.ArrayList(ToolCall).init(arena);
for (items.items) |entry| {
const fn_ = jget(entry, "function");
calls.append(.{
.id = jstr(jget(entry, "id")) orelse "",
.name = if (fn_) |f| jstr(jget(f, "name")) orelse "" else "",
.args = if (fn_) |f| jstr(jget(f, "arguments")) orelse "" else "",
}) catch return error.OutOfMemory;
}
return calls.items;
},
else => return fail(diag, arena, "tool_calls must be an array", .{}),
}
}
/// Anthropic messages[] plus the top-level system field.
pub fn parseAnthropic(arena: std.mem.Allocator, text: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]Message {
const root = try parseJson(arena, text, "Anthropic transcript", diag);
const messages: []Value = blk: {
if (isObj(root)) {
if (jarr(jget(root, "messages"))) |items| break :blk items;
}
return fail(diag, arena, "Anthropic transcript must be an object with a \"messages\" array", .{});
};
var out = Messages.init(arena);
if (has(root, "system")) {
out.append(.{ .role = "system", .content = try blocksToText(arena, jget(root, "system").?, "system", warnings, diag) }) catch return error.OutOfMemory;
}
for (messages, 0..) |entry, i| {
if (!isObj(entry)) return fail(diag, arena, "messages[{d}] is not an object", .{i});
const where = try std.fmt.allocPrint(arena, "messages[{d}]", .{i}) catch return error.OutOfMemory;
const role = jstr(jget(entry, "role")) orelse "";
if (std.mem.eql(u8, role, "assistant")) {
var texts = std.ArrayList([]const u8).init(arena);
var calls = std.ArrayList(ToolCall).init(arena);
var blocks = try contentBlocks(arena, jget(entry, "content") orelse .{ .null = {} }, where, diag);
for (blocks.items) |block| {
switch (block) {
.text => |t| texts.append(t) catch return error.OutOfMemory,
.tool_use => |raw| calls.append(try toolCallFromBlock(arena, raw)) catch return error.OutOfMemory,
.other => |raw| warnings.append(
std.fmt.allocPrint(arena, "{s}: dropped unsupported {s} block", .{ where, blockTypeName(raw) }) catch return error.OutOfMemory,
) catch return error.OutOfMemory,
.tool_result => {},
}
}
out.append(.{
.role = "assistant",
.content = try join(arena, texts.items, "\n"),
.tool_calls = if (calls.items.len > 0) calls.items else null,
}) catch return error.OutOfMemory;
} else if (std.mem.eql(u8, role, "user")) {
var texts = std.ArrayList([]const u8).init(arena);
var blocks = try contentBlocks(arena, jget(entry, "content") orelse .{ .null = {} }, where, diag);
for (blocks.items) |block| {
switch (block) {
.text => |t| texts.append(t) catch return error.OutOfMemory,
.tool_use => |raw| {
warnings.append(
std.fmt.allocPrint(arena, "{s}: tool_use block inside a user message moved to an assistant tool call", .{where}) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
try flushUserTexts(arena, &out, &texts);
var one = arena.alloc(ToolCall, 1) catch return error.OutOfMemory;
one[0] = try toolCallFromBlock(arena, raw);
out.append(.{ .role = "assistant", .tool_calls = one }) catch return error.OutOfMemory;
},
.tool_result => |raw| {
try flushUserTexts(arena, &out, &texts);
const inner = jget(raw, "content") orelse strv("");
out.append(.{
.role = "tool",
.content = try blocksToText(arena, inner, where, warnings, diag),
.tool_call_id = jstr(jget(raw, "tool_use_id")) orelse "",
}) catch return error.OutOfMemory;
},
.other => |raw| warnings.append(
std.fmt.allocPrint(arena, "{s}: dropped unsupported {s} block", .{ where, blockTypeName(raw) }) catch return error.OutOfMemory,
) catch return error.OutOfMemory,
}
}
try flushUserTexts(arena, &out, &texts);
} else {
const rendered = if (jget(entry, "role")) |r| try compact(arena, r) else "null";
return fail(diag, arena, "{s} has unsupported role: {s}", .{ where, rendered });
}
}
return out.items;
}
fn flushUserTexts(arena: std.mem.Allocator, out: *Messages, texts: *std.ArrayList([]const u8)) Error!void {
if (texts.items.len > 0) {
out.append(.{ .role = "user", .content = try join(arena, texts.items, "\n") }) catch return error.OutOfMemory;
texts.clearRetainingCapacity();
}
}
/// Gemini systemInstruction (string or {parts}) → plain text.
fn geminiText(arena: std.mem.Allocator, v: Value, where: []const u8, warnings: *Warnings) Error![]const u8 {
switch (v) {
.string => |s| return s,
.object => {
if (jstr(jget(v, "text"))) |t| return t;
if (jarr(jget(v, "parts"))) |parts| {
var texts = std.ArrayList([]const u8).init(arena);
for (parts) |part| {
texts.append(jstr(jget(part, "text")) orelse "") catch return error.OutOfMemory;
}
return join(arena, texts.items, "\n");
}
},
else => {},
}
warnings.append(
std.fmt.allocPrint(arena, "{s}: unsupported systemInstruction shape, treated as empty", .{where}) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
return "";
}
/// Gemini contents[] plus the optional systemInstruction.
pub fn parseGemini(arena: std.mem.Allocator, text: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]Message {
const root = try parseJson(arena, text, "Gemini transcript", diag);
const contents: []Value = blk: {
if (isObj(root)) {
if (jarr(jget(root, "contents"))) |items| break :blk items;
}
return fail(diag, arena, "Gemini transcript must be an object with a \"contents\" array", .{});
};
var out = Messages.init(arena);
if (has(root, "systemInstruction")) {
out.append(.{
.role = "system",
.content = try geminiText(arena, jget(root, "systemInstruction").?, "systemInstruction", warnings),
}) catch return error.OutOfMemory;
}
for (contents, 0..) |entry, i| {
if (!isObj(entry)) return fail(diag, arena, "contents[{d}] is not an object", .{i});
const where = try std.fmt.allocPrint(arena, "contents[{d}]", .{i}) catch return error.OutOfMemory;
const role = jstr(jget(entry, "role")) orelse "";
if (!std.mem.eql(u8, role, "user") and !std.mem.eql(u8, role, "model")) {
const rendered = if (jget(entry, "role")) |r| try compact(arena, r) else "null";
return fail(diag, arena, "{s} has unsupported role: {s} (Gemini uses \"user\" or \"model\")", .{ where, rendered });
}
const parts = jarr(jget(entry, "parts")) orelse
return fail(diag, arena, "{s}: parts must be an array", .{where});
if (std.mem.eql(u8, role, "model")) {
// A model turn keeps its text and function calls in ONE message,
// mirroring an OpenAI assistant message with tool_calls.
var texts = std.ArrayList([]const u8).init(arena);
var calls = std.ArrayList(ToolCall).init(arena);
for (parts) |part| {
if (jstr(jget(part, "text"))) |t| {
texts.append(t) catch return error.OutOfMemory;
continue;
}
if (jget(part, "functionCall")) |fc| {
if (isObj(fc)) {
calls.append(.{
.name = jstr(jget(fc, "name")) orelse "",
.args = try argsOf(arena, jget(fc, "args")),
}) catch return error.OutOfMemory;
continue;
}
}
warnings.append(
std.fmt.allocPrint(arena, "{s}: dropped unsupported part (inlineData or similar)", .{where}) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
}
out.append(.{
.role = "assistant",
.content = try join(arena, texts.items, "\n"),
.tool_calls = if (calls.items.len > 0) calls.items else null,
}) catch return error.OutOfMemory;
continue;
}
var texts = std.ArrayList([]const u8).init(arena);
for (parts) |part| {
if (jstr(jget(part, "text"))) |t| {
texts.append(t) catch return error.OutOfMemory;
continue;
}
if (jget(part, "functionCall")) |fc| {
if (isObj(fc)) {
try flushUserTexts(arena, &out, &texts);
var one = arena.alloc(ToolCall, 1) catch return error.OutOfMemory;
one[0] = .{
.name = jstr(jget(fc, "name")) orelse "",
.args = try argsOf(arena, jget(fc, "args")),
};
out.append(.{ .role = "assistant", .tool_calls = one }) catch return error.OutOfMemory;
continue;
}
}
if (jget(part, "functionResponse")) |fr| {
if (isObj(fr)) {
try flushUserTexts(arena, &out, &texts);
const name = jstr(jget(fr, "name")) orelse "";
out.append(.{
.role = "tool",
.content = try argsOf(arena, jget(fr, "response")),
.name = name,
.tool_call_id = name,
}) catch return error.OutOfMemory;
continue;
}
}
warnings.append(
std.fmt.allocPrint(arena, "{s}: dropped unsupported part (inlineData or similar)", .{where}) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
}
try flushUserTexts(arena, &out, &texts);
}
return out.items;
}
/// A parsed Markdown header line: `**role**: text`.
fn markdownHeader(line: []const u8) ?struct { role: []const u8, first: []const u8 } {
if (!std.mem.startsWith(u8, line, "**")) return null;
const after_opening = line[2..];
const end = std.mem.indexOf(u8, after_opening, "**:") orelse return null;
const role = after_opening[0..end];
const mapped: ?[]const u8 = blk: {
if (std.mem.eql(u8, role, "system")) break :blk "system";
if (std.mem.eql(u8, role, "user")) break :blk "user";
if (std.mem.eql(u8, role, "assistant")) break :blk "assistant";
if (std.mem.eql(u8, role, "model")) break :blk "assistant";
if (std.mem.eql(u8, role, "tool")) break :blk "tool";
break :blk null;
};
const m = mapped orelse return null;
return .{ .role = m, .first = std.mem.trimLeft(u8, after_opening[end + 3 ..], " \t\r\n\x0b\x0c") };
}
fn isBlank(s: []const u8) bool {
return std.mem.trim(u8, s, " \t\r\n\x0b\x0c").len == 0;
}
fn finishMessage(arena: std.mem.Allocator, role: []const u8, lines: *std.ArrayList([]const u8)) Error!Message {
// Trim leading/trailing blank continuation lines but keep inner blank lines.
while (lines.items.len > 1 and isBlank(lines.items[0])) _ = lines.orderedRemove(0);
while (lines.items.len > 1 and isBlank(lines.items[lines.items.len - 1])) _ = lines.pop();
return .{ .role = role, .content = try join(arena, lines.items, "\n") };
}
/// Markdown transcript: `**role**: text` header lines with continuation lines
/// belonging to the same message. `model` maps to assistant.
pub fn parseMarkdown(arena: std.mem.Allocator, text: []const u8, warnings: *Warnings, diag: *?[]const u8) Error![]Message {
_ = warnings;
var out = Messages.init(arena);
var role: ?[]const u8 = null;
var lines: std.ArrayList([]const u8) = std.ArrayList([]const u8).init(arena);
var it = std.mem.splitScalar(u8, text, '\n');
while (it.next()) |line| {
if (markdownHeader(line)) |h| {
if (role) |r| {
out.append(try finishMessage(arena, r, &lines)) catch return error.OutOfMemory;
}
role = h.role;
lines = std.ArrayList([]const u8).init(arena);
lines.append(h.first) catch return error.OutOfMemory;
continue;
}
if (role != null) {
lines.append(line) catch return error.OutOfMemory;
} else if (!isBlank(line)) {
return fail(diag, arena, "Markdown transcript must start with a `**role**:` header line", .{});
}
}
if (role) |r| {
out.append(try finishMessage(arena, r, &lines)) catch return error.OutOfMemory;
}
return out.items;
}
// -------------------------------- serialize ---------------------------------
pub fn serializeOpenAi(arena: std.mem.Allocator, messages: []const Message, warnings: *Warnings) Error![]const u8 {
_ = warnings;
var arr = std.ArrayList(Value).init(arena);
for (messages) |m| {
var o = try newObj(arena);
if (std.mem.eql(u8, m.role, "tool")) {
try put(&o, "role", strv("tool"));
try put(&o, "content", strv(m.content));
try put(&o, "tool_call_id", strv(m.tool_call_id orelse ""));
if (m.name) |n| try put(&o, "name", strv(n));
} else if (std.mem.eql(u8, m.role, "assistant") and m.tool_calls != null) {
try put(&o, "role", strv("assistant"));
try put(&o, "content", if (m.content.len == 0) .null else strv(m.content));
var calls = std.ArrayList(Value).init(arena);
for (m.tool_calls.?) |tc| {
var fn_ = try newObj(arena);
try put(&fn_, "name", strv(tc.name));
try put(&fn_, "arguments", strv(tc.args));
var call = try newObj(arena);
try put(&call, "id", strv(tc.id));
try put(&call, "type", strv("function"));
try put(&call, "function", .{ .object = fn_ });
calls.append(.{ .object = call }) catch return error.OutOfMemory;
}
try put(&o, "tool_calls", try arrv(arena, calls.items));
if (m.name) |n| try put(&o, "name", strv(n));
} else {
try put(&o, "role", strv(m.role));
try put(&o, "content", strv(m.content));
if (m.name) |n| try put(&o, "name", strv(n));
}
arr.append(.{ .object = o }) catch return error.OutOfMemory;
}
var root = try newObj(arena);
try put(&root, "messages", try arrv(arena, arr.items));
var buf = std.ArrayList(u8).init(arena);
buf.appendSlice(try pretty(arena, .{ .object = root })) catch return error.OutOfMemory;
buf.append('\n') catch return error.OutOfMemory;
return buf.items;
}
pub fn serializeAnthropic(arena: std.mem.Allocator, messages: []const Message, warnings: *Warnings) Error![]const u8 {
var system = std.ArrayList([]const u8).init(arena);
var out = std.ArrayList(Value).init(arena);
for (messages, 0..) |m, i| {
if (std.mem.eql(u8, m.role, "system")) {
system.append(m.content) catch return error.OutOfMemory;
continue;
}
if (m.name != null and !std.mem.eql(u8, m.role, "tool")) {
warnings.append(
std.fmt.allocPrint(arena, "message {d}: \"name\" has no Anthropic equivalent and was dropped", .{i}) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
}
var o = try newObj(arena);
if (std.mem.eql(u8, m.role, "user")) {
try put(&o, "role", strv("user"));
try put(&o, "content", strv(m.content));
} else if (std.mem.eql(u8, m.role, "assistant")) {
try put(&o, "role", strv("assistant"));
if (m.tool_calls) |calls| {
var blocks = std.ArrayList(Value).init(arena);
if (m.content.len > 0) {
var tb = try newObj(arena);
try put(&tb, "type", strv("text"));
try put(&tb, "text", strv(m.content));
blocks.append(.{ .object = tb }) catch return error.OutOfMemory;
}
for (calls) |tc| {
var ub = try newObj(arena);
try put(&ub, "type", strv("tool_use"));
try put(&ub, "id", strv(tc.id));
try put(&ub, "name", strv(tc.name));
try put(&ub, "input", try parseArgsOrEmpty(arena, tc.args));
blocks.append(.{ .object = ub }) catch return error.OutOfMemory;
}
try put(&o, "content", try arrv(arena, blocks.items));
} else {
try put(&o, "content", strv(m.content));
}
} else {
try put(&o, "role", strv("user"));
var rb = try newObj(arena);
try put(&rb, "type", strv("tool_result"));
try put(&rb, "tool_use_id", strv(m.tool_call_id orelse ""));
try put(&rb, "content", strv(m.content));
const wrapped = [1]Value{.{ .object = rb }};
try put(&o, "content", try arrv(arena, &wrapped));
}
out.append(.{ .object = o }) catch return error.OutOfMemory;
}
var root = try newObj(arena);
if (out.items.len > 0) try put(&root, "messages", try arrv(arena, out.items));
if (system.items.len > 0) try put(&root, "system", strv(try join(arena, system.items, "\n\n")));
var buf = std.ArrayList(u8).init(arena);
buf.appendSlice(try pretty(arena, .{ .object = root })) catch return error.OutOfMemory;
buf.append('\n') catch return error.OutOfMemory;
return buf.items;
}
/// (role, parts) turns; consecutive same-role parts merge into one turn.
const Turn = struct { role: []const u8, parts: std.ArrayList(Value) };
fn pushTurn(turns: *std.ArrayList(Turn), role: []const u8, part: Value) Error!void {
if (turns.items.len > 0) {
var last = &turns.items[turns.items.len - 1];
if (std.mem.eql(u8, last.role, role)) {
last.parts.append(part) catch return error.OutOfMemory;
return;
}
}
var parts = std.ArrayList(Value).init(turns.allocator);
parts.append(part) catch return error.OutOfMemory;
turns.append(.{ .role = role, .parts = parts }) catch return error.OutOfMemory;
}
pub fn serializeGemini(arena: std.mem.Allocator, messages: []const Message, warnings: *Warnings) Error![]const u8 {
var system = std.ArrayList([]const u8).init(arena);
var turns = std.ArrayList(Turn).init(arena);
for (messages, 0..) |m, i| {
if (std.mem.eql(u8, m.role, "system")) {
system.append(m.content) catch return error.OutOfMemory;
continue;
}
if (m.name != null and !std.mem.eql(u8, m.role, "tool")) {
warnings.append(
std.fmt.allocPrint(arena, "message {d}: \"name\" has no Gemini equivalent and was dropped", .{i}) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
}
if (std.mem.eql(u8, m.role, "user")) {
var p = try newObj(arena);
try put(&p, "text", strv(m.content));
try pushTurn(&turns, "user", .{ .object = p });
} else if (std.mem.eql(u8, m.role, "assistant")) {
if (m.content.len > 0) {
var p = try newObj(arena);
try put(&p, "text", strv(m.content));
try pushTurn(&turns, "model", .{ .object = p });
}
const calls: []const ToolCall = m.tool_calls orelse &.{};
for (calls) |tc| {
var fc = try newObj(arena);
try put(&fc, "name", strv(tc.name));
try put(&fc, "args", try parseArgsOrEmpty(arena, tc.args));
var p = try newObj(arena);
try put(&p, "functionCall", .{ .object = fc });
try pushTurn(&turns, "model", .{ .object = p });
}
} else {
const name = m.name orelse m.tool_call_id orelse "";
var fr = try newObj(arena);
try put(&fr, "name", strv(name));
try put(&fr, "response", try parseArgsOrEmpty(arena, m.content));
var p = try newObj(arena);
try put(&p, "functionResponse", .{ .object = fr });
try pushTurn(&turns, "user", .{ .object = p });
}
}
var root = try newObj(arena);
if (turns.items.len > 0) {
var contents = std.ArrayList(Value).init(arena);
for (turns.items) |turn| {
var c = try newObj(arena);
try put(&c, "role", strv(turn.role));
try put(&c, "parts", try arrv(arena, turn.parts.items));
contents.append(.{ .object = c }) catch return error.OutOfMemory;
}
try put(&root, "contents", try arrv(arena, contents.items));
}
if (system.items.len > 0) {
var tp = try newObj(arena);
try put(&tp, "text", strv(try join(arena, system.items, "\n\n")));
const one = [1]Value{.{ .object = tp }};
var si = try newObj(arena);
try put(&si, "parts", try arrv(arena, &one));
try put(&root, "systemInstruction", .{ .object = si });
}
var buf = std.ArrayList(u8).init(arena);
buf.appendSlice(try pretty(arena, .{ .object = root })) catch return error.OutOfMemory;
buf.append('\n') catch return error.OutOfMemory;
return buf.items;
}
pub fn serializeMarkdown(arena: std.mem.Allocator, messages: []const Message, warnings: *Warnings) Error![]const u8 {
var lines = std.ArrayList([]const u8).init(arena);
for (messages, 0..) |m, i| {
if (m.tool_calls) |calls| {
if (calls.len > 0) {
warnings.append(
std.fmt.allocPrint(arena, "message {d}: tool calls are not representable in Markdown and were dropped", .{i}) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
}
}
if (std.mem.eql(u8, m.role, "tool") and m.tool_call_id != null) {
warnings.append(
std.fmt.allocPrint(arena, "message {d}: tool result id is not representable in Markdown and was dropped", .{i}) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
}
lines.append(
std.fmt.allocPrint(arena, "**{s}**: {s}", .{ m.role, m.content }) catch return error.OutOfMemory,
) catch return error.OutOfMemory;
}
var buf = std.ArrayList(u8).init(arena);
buf.appendSlice(try join(arena, lines.items, "\n\n")) catch return error.OutOfMemory;
buf.append('\n') catch return error.OutOfMemory;
return buf.items;
}
// --------------------------------- convert ----------------------------------
/// Convert a transcript between chat formats through the shared internal shape.
///
/// Returns error.Invalid (message in `diag`) when the transcript is empty, the
/// source format fails to parse, or `from`/`to` is not a known format. All
/// allocations come from `arena` — free everything by resetting the arena.
pub fn convert(
arena: std.mem.Allocator,
transcript: []const u8,
from: []const u8,
to: []const u8,
diag: *?[]const u8,
) Error!ConversionResult {
if (std.mem.trim(u8, transcript, " \t\r\n\x0b\x0c").len == 0) {
return fail(diag, arena, "Transcript is empty — paste a transcript first", .{});
}
const Parser = *const fn (std.mem.Allocator, []const u8, *Warnings, *?[]const u8) Error![]Message;
const parser: Parser = blk: {
if (std.mem.eql(u8, from, "openai")) break :blk &parseOpenAi;
if (std.mem.eql(u8, from, "anthropic")) break :blk &parseAnthropic;
if (std.mem.eql(u8, from, "gemini")) break :blk &parseGemini;
if (std.mem.eql(u8, from, "markdown")) break :blk &parseMarkdown;
return fail(diag, arena, "Unknown source format: {s}", .{from});
};
const Serializer = *const fn (std.mem.Allocator, []const Message, *Warnings) Error![]const u8;
const serializer: Serializer = blk: {
if (std.mem.eql(u8, to, "openai")) break :blk &serializeOpenAi;
if (std.mem.eql(u8, to, "anthropic")) break :blk &serializeAnthropic;
if (std.mem.eql(u8, to, "gemini")) break :blk &serializeGemini;
if (std.mem.eql(u8, to, "markdown")) break :blk &serializeMarkdown;
return fail(diag, arena, "Unknown target format: {s}", .{to});
};
var warnings = Warnings.init(arena);
const messages = try parser(arena, transcript, &warnings, diag);
const output = try serializer(arena, messages, &warnings);
return .{ .output = output, .warnings = warnings.items };
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →