Chat Format Converter — Java source
Convert chat transcripts between OpenAI messages[], Anthropic system+messages, Gemini contents[], and plain Markdown. Roles map faithfully, tool calls are preserved where possible, and anything unmappable is flagged — never dropped silently. Runs entirely in your browser.
This is the Java implementation — the same logic the interactive tool runs, in a shareable, citable form.
// chat-format-converter — Java polyglot showcase port.
//
// Converts one chat transcript between provider shapes through a shared internal
// message list:
//
// - openai — {"messages": [{"role": "system|user|assistant|tool", ...}]}
// - anthropic — {"system": "...", "messages": [{"role": "user|assistant", ...}]}
// - gemini — {"contents": [{"role": "user|model", "parts": [...]}]}
// - markdown — a plain `**role**: text` transcript
//
// Language: Java (17, standard library only)
// Source: CosmoDev polyglot showcase port of the Chat Format Converter tool,
// ported from src/lib/chatFormatConverter.ts (the canonical
// TypeScript that powers the live tool).
// License: display source — part of CosmoDev's polyglot tool pages.
//
// Roles map faithfully (Gemini has no assistant — it is `model`; Anthropic
// system lives top-level). Fields a target format cannot represent (e.g. tool
// calls in Markdown) are flagged as warnings, never dropped silently. The JDK
// ships no JSON engine, so this file includes a small hand-written
// recursive-descent JSON parser + writer (java.std only). Object keys are kept
// in a List<Pair> so insertion order survives a round-trip, exactly like the
// TypeScript original. Parse failures throw IllegalArgumentException.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/* ------------------------------ JSON engine ------------------------------- */
/** A JSON value. Objects preserve insertion order via a pair list, matching
* JavaScript object semantics (JSON.stringify key order). */
sealed interface Json permits Json.Null, Json.Bool, Json.Num, Json.Str, Json.Arr, Json.Obj {
record Null() implements Json {}
record Bool(boolean value) implements Json {}
record Num(double value) implements Json {}
record Str(String value) implements Json {}
record Arr(List<Json> items) implements Json {}
record Obj(List<Map.Entry<String, Json>> pairs) implements Json {}
/** First value stored under `key`, null when absent. */
default Json get(String key) {
if (this instanceof Obj obj) {
for (var e : obj.pairs()) {
if (e.getKey().equals(key)) return e.getValue();
}
}
return null;
}
default String asStr() {
return this instanceof Str s ? s.value() : null;
}
default List<Json> asArr() {
return this instanceof Arr a ? a.items() : null;
}
default boolean isObject() {
return this instanceof Obj;
}
/** Compact encoding, the moral equivalent of TS `JSON.stringify`. */
default String toCompact() {
StringBuilder out = new StringBuilder();
write(out, null, 0);
return out.toString();
}
/** Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`. */
default String toPretty() {
StringBuilder out = new StringBuilder();
write(out, 2, 0);
return out.toString();
}
private void write(StringBuilder out, Integer indent, int depth) {
if (this instanceof Null) { out.append("null"); return; }
if (this instanceof Bool b) { out.append(b.value() ? "true" : "false"); return; }
if (this instanceof Num m) { out.append(formatF64(m.value())); return; }
if (this instanceof Str s) { writeEscaped(out, s.value()); return; }
if (this instanceof Arr a) {
if (a.items().isEmpty()) { out.append("[]"); return; }
out.append('[');
for (int i = 0; i < a.items().size(); i++) {
if (i > 0) out.append(',');
if (indent != null) { out.append('\n'); pushIndent(out, depth + 1); }
a.items().get(i).write(out, indent, depth + 1);
}
if (indent != null) { out.append('\n'); pushIndent(out, depth); }
out.append(']');
return;
}
Obj o = (Obj) this;
if (o.pairs().isEmpty()) { out.append("{}"); return; }
out.append('{');
for (int i = 0; i < o.pairs().size(); i++) {
if (i > 0) out.append(',');
if (indent != null) { out.append('\n'); pushIndent(out, depth + 1); }
var e = o.pairs().get(i);
writeEscaped(out, e.getKey());
out.append(indent != null ? ": " : ":");
e.getValue().write(out, indent, depth + 1);
}
if (indent != null) { out.append('\n'); pushIndent(out, depth); }
out.append('}');
}
private static void pushIndent(StringBuilder out, int depth) {
out.append(" ".repeat(depth));
}
/** Escape a string the way JSON.stringify does: the two mandatory
* escapes, the single-char shorthands, and u00XX-style for remaining control
* characters. */
static void writeEscaped(StringBuilder out, String s) {
out.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
case '\b' -> out.append("\\b");
case '\f' -> out.append("\\f");
default -> {
if (c < 0x20) out.append(String.format("\\u%04x", (int) c));
else out.append(c);
}
}
}
out.append('"');
}
/** Render a double the way JavaScript's number-to-string coercion does:
* whole values carry no trailing ".0". */
static String formatF64(double n) {
if (n == Math.rint(n) && Math.abs(n) < 1e15) return Long.toString((long) n);
return Double.toString(n);
}
}
/** Recursive-descent JSON parser; trailing garbage is an error. */
final class JsonParser {
private final String src;
private int pos = 0;
private JsonParser(String src) { this.src = src; }
static Json parse(String input) {
JsonParser p = new JsonParser(input);
p.skipWs();
Json v = p.value();
p.skipWs();
if (p.pos != p.src.length()) {
throw new JsonError("unexpected trailing characters at byte " + p.pos);
}
return v;
}
static final class JsonError extends RuntimeException {
JsonError(String message) { super(message); }
}
private int peek() { return pos < src.length() ? src.charAt(pos) : -1; }
private void skipWs() {
while (peek() == ' ' || peek() == '\t' || peek() == '\n' || peek() == '\r') pos++;
}
private Json value() {
int c = peek();
if (c == -1) throw new JsonError("unexpected end of input");
return switch (c) {
case '{' -> obj();
case '[' -> arr();
case '"' -> new Json.Str(string());
case 't' -> literal("true", new Json.Bool(true));
case 'f' -> literal("false", new Json.Bool(false));
case 'n' -> literal("null", new Json.Null());
default -> {
if (c == '-' || (c >= '0' && c <= '9')) yield number();
throw new JsonError("unexpected '" + (char) c + "' at byte " + pos);
}
};
}
private Json literal(String word, Json v) {
if (src.startsWith(word, pos)) {
pos += word.length();
return v;
}
throw new JsonError("invalid literal at byte " + pos);
}
private Json number() {
int start = pos;
scan:
while (true) {
int c = peek();
boolean keep = c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E' || (c >= '0' && c <= '9');
if (keep) pos++;
else break scan;
}
String text = src.substring(start, pos);
try {
return new Json.Num(Double.parseDouble(text));
} catch (NumberFormatException e) {
throw new JsonError("invalid number \"" + text + "\" at byte " + start);
}
}
private String string() {
pos++; // opening quote
StringBuilder out = new StringBuilder();
while (true) {
int c = peek();
if (c == -1) throw new JsonError("unterminated string at byte " + pos);
pos++;
if (c == '"') return out.toString();
if (c == '\\') {
int esc = peek();
if (esc == -1) throw new JsonError("unterminated escape");
pos++;
switch (esc) {
case '"' -> out.append('"');
case '\\' -> out.append('\\');
case '/' -> out.append('/');
case 'b' -> out.append('\b');
case 'f' -> out.append('\f');
case 'n' -> out.append('\n');
case 'r' -> out.append('\r');
case 't' -> out.append('\t');
case 'u' -> {
int hi = hex4();
if (hi >= 0xD800 && hi <= 0xDBFF) {
// High surrogate: combine with a following four-hex-digit escape.
boolean paired = peek() == '\\' && pos + 1 < src.length() && src.charAt(pos + 1) == 'u';
if (paired) {
pos += 2;
int lo = hex4();
out.append((char) (0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)));
} else {
out.append('�');
}
} else {
out.append((char) hi);
}
}
default -> throw new JsonError("invalid escape \\" + (char) esc + " at byte " + (pos - 1));
}
} else {
out.append((char) c);
}
}
}
private int hex4() {
int v = 0;
for (int i = 0; i < 4; i++) {
int c = peek();
if (c == -1) throw new JsonError("unterminated \\u escape");
int d = Character.digit(c, 16);
if (d < 0) throw new JsonError("invalid hex digit at byte " + pos);
pos++;
v = v * 16 + d;
}
return v;
}
private Json obj() {
pos++; // '{'
List<Map.Entry<String, Json>> pairs = new ArrayList<>();
skipWs();
if (peek() == '}') { pos++; return new Json.Obj(pairs); }
while (true) {
skipWs();
if (peek() != '"') throw new JsonError("expected object key at byte " + pos);
String key = string();
skipWs();
if (peek() != ':') throw new JsonError("expected ':' at byte " + pos);
pos++;
skipWs();
pairs.add(Map.entry(key, value()));
skipWs();
if (peek() == ',') { pos++; continue; }
if (peek() == '}') { pos++; return new Json.Obj(pairs); }
throw new JsonError("expected ',' or '}' at byte " + pos);
}
}
private Json arr() {
pos++; // '['
List<Json> items = new ArrayList<>();
skipWs();
if (peek() == ']') { pos++; return new Json.Arr(items); }
while (true) {
skipWs();
items.add(value());
skipWs();
if (peek() == ',') { pos++; continue; }
if (peek() == ']') { pos++; return new Json.Arr(items); }
throw new JsonError("expected ',' or ']' at byte " + pos);
}
}
}
/* ------------------------------ domain types ------------------------------ */
final class ChatFormatConverter {
public static final List<String> CHAT_FORMATS = List.of("openai", "anthropic", "gemini", "markdown");
/** One assistant-emitted tool call, provider-agnostic. `args` is a JSON string. */
record ToolCall(String id, String name, String args) {
ToolCall() { this("", "", ""); }
}
/** The shared internal shape every format parses into and serializes from. */
static final class InternalMessage {
final String role; // "system" | "user" | "assistant" | "tool"
String content = ""; // "" when the message only carries tool calls/results
String name; // nullable
List<ToolCall> toolCalls; // nullable
String toolCallId; // nullable
InternalMessage(String role, String content) {
this.role = role;
this.content = content;
}
InternalMessage(String role, String content, String name, List<ToolCall> toolCalls, String toolCallId) {
this.role = role;
this.content = content;
this.name = name;
this.toolCalls = toolCalls;
this.toolCallId = toolCallId;
}
}
/** The conversion output plus human-readable notes about unmapped fields. */
record ConversionResult(String output, List<String> warnings) {}
private ChatFormatConverter() {}
/* -------------------------------- helpers -------------------------------- */
private static Json parseTranscriptJson(String text, String label) {
try {
return JsonParser.parse(text);
} catch (JsonParser.JsonError e) {
throw new IllegalArgumentException(label + ": invalid JSON — " + e.getMessage());
}
}
/** The TS `?? {}` capture: null/missing becomes {}, any other value passes
* through and stringifies as-is. */
private static String argsOf(Json v) {
return v == null || v instanceof Json.Null ? "{}" : v.toCompact();
}
/** Parse a JSON-string argument back to a value, an empty object when it
* is not a JSON object (mirrors parseArgsOrEmpty). */
static Json parseArgsOrEmpty(String args) {
try {
Json v = JsonParser.parse(args);
return v instanceof Json.Obj ? v : new Json.Obj(List.of());
} catch (JsonParser.JsonError e) {
return new Json.Obj(List.of());
}
}
private static String str(Json el, String key) {
Json v = el == null ? null : el.get(key);
return v instanceof Json.Str s ? s.value() : "";
}
private static String strOrNull(Json el, String key) {
Json v = el == null ? null : el.get(key);
return v instanceof Json.Str s ? s.value() : null;
}
private static String blockTypeName(Json raw) {
String t = str(raw, "type");
return t.isEmpty() ? "content" : t;
}
/** One normalized Anthropic content block. */
private sealed interface Block permits Block.TextB, Block.ToolUseB, Block.ToolResultB, Block.OtherB {
record TextB(String text) implements Block {}
record ToolUseB(Json raw) implements Block {}
record ToolResultB(Json raw) implements Block {}
record OtherB(Json raw) implements Block {}
}
private static List<Block> contentBlocks(Json content, String where) {
if (content instanceof Json.Str s) return List.of(new Block.TextB(s.value()));
List<Json> items = content == null ? null : content.asArr();
if (items == null) {
throw new IllegalArgumentException(where + ": content must be a string or an array of blocks");
}
List<Block> out = new ArrayList<>(items.size());
for (Json block : items) {
boolean isText = str(block, "type").equals("text") && block.get("text") instanceof Json.Str;
if (isText) out.add(new Block.TextB(((Json.Str) block.get("text")).value()));
else if (str(block, "type").equals("tool_use")) out.add(new Block.ToolUseB(block));
else if (str(block, "type").equals("tool_result")) out.add(new Block.ToolResultB(block));
else out.add(new Block.OtherB(block));
}
return out;
}
/** Anthropic string-or-block[] content → plain text. */
private static String blocksToText(Json content, String where, List<String> warnings) {
if (content instanceof Json.Str s) return s.value();
if (content == null || content instanceof Json.Null) return "";
List<String> texts = new ArrayList<>();
for (Block block : contentBlocks(content, where)) {
if (block instanceof Block.TextB t) texts.add(t.text());
else if (block instanceof Block.OtherB o)
warnings.add(where + ": dropped unsupported " + blockTypeName(o.raw()) + " block");
else if (block instanceof Block.ToolUseB)
warnings.add(where + ": dropped tool_use block from text-only content");
else if (block instanceof Block.ToolResultB)
warnings.add(where + ": dropped tool_result block from text-only content");
}
return String.join("\n", texts);
}
private static ToolCall toolCallFromBlock(Json raw) {
return new ToolCall(str(raw, "id"), str(raw, "name"), argsOf(raw.get("input")));
}
/* ---------------------------------- parse -------------------------------- */
/** OpenAI messages[] (bare array, or wrapped in {"messages": [...]}). */
public static List<InternalMessage> parseOpenAi(String text, List<String> warnings) {
Json parsed = parseTranscriptJson(text, "OpenAI transcript");
Json wrapped = parsed.isObject() ? parsed.get("messages") : null;
List<Json> items = wrapped instanceof Json.Arr a ? a.items()
: parsed instanceof Json.Arr bare ? bare.items() : null;
if (items == null) {
throw new IllegalArgumentException(
"OpenAI transcript must be a messages[] array or an object with a \"messages\" array");
}
List<InternalMessage> out = new ArrayList<>();
for (int i = 0; i < items.size(); i++) {
Json entry = items.get(i);
if (!entry.isObject()) throw new IllegalArgumentException("messages[" + i + "] is not an object");
String where = "messages[" + i + "]";
String role = str(entry, "role");
switch (role) {
case "system", "developer" ->
out.add(new InternalMessage("system", contentText(entry.get("content"), where, warnings)));
case "user", "assistant", "tool" -> {
InternalMessage msg = new InternalMessage(role,
contentText(entry.get("content"), where, warnings));
msg.name = strOrNull(entry, "name");
msg.toolCallId = strOrNull(entry, "tool_call_id");
msg.toolCalls = parseOpenAiToolCalls(entry.get("tool_calls"));
out.add(msg);
}
default -> throw new IllegalArgumentException(
"messages[" + i + "] has unsupported role: "
+ (entry.get("role") == null ? "null" : entry.get("role").toCompact()));
}
}
return out;
}
/** OpenAI content → plain text (string, or text parts joined; other parts flagged). */
private static String contentText(Json content, String where, List<String> warnings) {
if (content == null || content instanceof Json.Null) return "";
if (content instanceof Json.Str s) return s.value();
if (content instanceof Json.Arr parts) {
List<String> texts = new ArrayList<>();
int dropped = 0;
for (Json part : parts.items()) {
boolean isText = str(part, "type").equals("text") && part.get("text") instanceof Json.Str;
if (isText) texts.add(((Json.Str) part.get("text")).value());
else dropped++;
}
if (dropped > 0) warnings.add(where + ": dropped " + dropped + " non-text content part(s)");
return String.join("", texts);
}
throw new IllegalArgumentException(where + ": content must be a string, an array of parts, or null");
}
private static List<ToolCall> parseOpenAiToolCalls(Json raw) {
if (raw == null || raw instanceof Json.Null) return null;
List<Json> items = raw.asArr();
if (items == null) throw new IllegalArgumentException("tool_calls must be an array");
List<ToolCall> calls = new ArrayList<>(items.size());
for (Json entry : items) {
Json fn = entry.get("function");
calls.add(new ToolCall(
str(entry, "id"),
fn == null ? "" : str(fn, "name"),
fn == null ? "" : str(fn, "arguments")));
}
return calls;
}
/** Anthropic messages[] plus the top-level system field. */
public static List<InternalMessage> parseAnthropic(String text, List<String> warnings) {
Json root = parseTranscriptJson(text, "Anthropic transcript");
List<Json> messages = root.isObject() && root.get("messages") instanceof Json.Arr a
? a.items() : null;
if (messages == null) {
throw new IllegalArgumentException(
"Anthropic transcript must be an object with a \"messages\" array");
}
List<InternalMessage> out = new ArrayList<>();
if (root.get("system") != null) {
out.add(new InternalMessage("system", blocksToText(root.get("system"), "system", warnings)));
}
for (int i = 0; i < messages.size(); i++) {
Json entry = messages.get(i);
if (!entry.isObject()) throw new IllegalArgumentException("messages[" + i + "] is not an object");
String where = "messages[" + i + "]";
String role = str(entry, "role");
switch (role) {
case "assistant" -> {
List<String> texts = new ArrayList<>();
List<ToolCall> calls = new ArrayList<>();
for (Block block : contentBlocks(orNull(entry.get("content")), where)) {
if (block instanceof Block.TextB t) texts.add(t.text());
else if (block instanceof Block.ToolUseB u) calls.add(toolCallFromBlock(u.raw()));
else if (block instanceof Block.OtherB o)
warnings.add(where + ": dropped unsupported " + blockTypeName(o.raw()) + " block");
}
InternalMessage msg = new InternalMessage("assistant", String.join("\n", texts));
if (!calls.isEmpty()) msg.toolCalls = calls;
out.add(msg);
}
case "user" -> {
List<String> texts = new ArrayList<>();
for (Block block : contentBlocks(orNull(entry.get("content")), where)) {
if (block instanceof Block.TextB t) {
texts.add(t.text());
} else if (block instanceof Block.ToolUseB u) {
warnings.add(where
+ ": tool_use block inside a user message moved to an assistant tool call");
flushUserTexts(out, texts);
InternalMessage msg = new InternalMessage("assistant", "");
msg.toolCalls = List.of(toolCallFromBlock(u.raw()));
out.add(msg);
} else if (block instanceof Block.ToolResultB r) {
flushUserTexts(out, texts);
Json content = r.raw().get("content");
InternalMessage msg = new InternalMessage("tool",
blocksToText(content == null ? new Json.Str("") : content, where, warnings));
msg.toolCallId = str(r.raw(), "tool_use_id");
out.add(msg);
} else if (block instanceof Block.OtherB o) {
warnings.add(where + ": dropped unsupported " + blockTypeName(o.raw()) + " block");
}
}
flushUserTexts(out, texts);
}
default -> throw new IllegalArgumentException(
where + " has unsupported role: "
+ (entry.get("role") == null ? "null" : entry.get("role").toCompact()));
}
}
return out;
}
private static Json orNull(Json v) { return v == null ? new Json.Null() : v; }
private static void flushUserTexts(List<InternalMessage> out, List<String> texts) {
if (!texts.isEmpty()) {
out.add(new InternalMessage("user", String.join("\n", texts)));
texts.clear();
}
}
/** Gemini systemInstruction (string or {parts}) → plain text. */
private static String geminiText(Json v, String where, List<String> warnings) {
if (v instanceof Json.Str s) return s.value();
if (v.isObject()) {
if (v.get("text") instanceof Json.Str t) return t.value();
if (v.get("parts") instanceof Json.Arr parts) {
List<String> texts = new ArrayList<>();
for (Json part : parts.items()) texts.add(str(part, "text"));
return String.join("\n", texts);
}
}
warnings.add(where + ": unsupported systemInstruction shape, treated as empty");
return "";
}
/** Gemini contents[] plus the optional systemInstruction. */
public static List<InternalMessage> parseGemini(String text, List<String> warnings) {
Json root = parseTranscriptJson(text, "Gemini transcript");
List<Json> contents = root.isObject() && root.get("contents") instanceof Json.Arr a
? a.items() : null;
if (contents == null) {
throw new IllegalArgumentException("Gemini transcript must be an object with a \"contents\" array");
}
List<InternalMessage> out = new ArrayList<>();
if (root.get("systemInstruction") != null) {
out.add(new InternalMessage("system",
geminiText(root.get("systemInstruction"), "systemInstruction", warnings)));
}
for (int i = 0; i < contents.size(); i++) {
Json entry = contents.get(i);
if (!entry.isObject()) throw new IllegalArgumentException("contents[" + i + "] is not an object");
String where = "contents[" + i + "]";
String role = str(entry, "role");
if (!role.equals("user") && !role.equals("model")) {
throw new IllegalArgumentException(where + " has unsupported role: "
+ (entry.get("role") == null ? "null" : entry.get("role").toCompact())
+ " (Gemini uses \"user\" or \"model\")");
}
Json partsJson = entry.get("parts");
if (!(partsJson instanceof Json.Arr parts)) {
throw new IllegalArgumentException(where + ": parts must be an array");
}
if (role.equals("model")) {
// A model turn keeps its text and function calls in ONE message,
// mirroring an OpenAI assistant message with tool_calls.
List<String> texts = new ArrayList<>();
List<ToolCall> calls = new ArrayList<>();
for (Json part : parts.items()) {
if (part.get("text") instanceof Json.Str t) { texts.add(t.value()); continue; }
if (part.get("functionCall") instanceof Json.Obj fc) {
calls.add(new ToolCall("", str(fc, "name"), argsOf(fc.get("args"))));
continue;
}
warnings.add(where + ": dropped unsupported part (inlineData or similar)");
}
InternalMessage msg = new InternalMessage("assistant", String.join("\n", texts));
if (!calls.isEmpty()) msg.toolCalls = calls;
out.add(msg);
continue;
}
List<String> texts = new ArrayList<>();
for (Json part : parts.items()) {
if (part.get("text") instanceof Json.Str t) { texts.add(t.value()); continue; }
if (part.get("functionCall") instanceof Json.Obj fc) {
flushUserTexts(out, texts);
InternalMessage msg = new InternalMessage("assistant", "");
msg.toolCalls = List.of(new ToolCall("", str(fc, "name"), argsOf(fc.get("args"))));
out.add(msg);
continue;
}
if (part.get("functionResponse") instanceof Json.Obj fr) {
flushUserTexts(out, texts);
String name = str(fr, "name");
out.add(new InternalMessage("tool", argsOf(fr.get("response")), name, null, name));
continue;
}
warnings.add(where + ": dropped unsupported part (inlineData or similar)");
}
flushUserTexts(out, texts);
}
return out;
}
private static final Pattern MARKDOWN_HEADER = Pattern.compile("^\\*\\*(system|user|assistant|model|tool)\\*\\*:\\s*(.*)$");
private static InternalMessage finishMessage(String role, List<String> lines) {
// Trim leading/trailing blank continuation lines but keep inner blank lines.
while (lines.size() > 1 && lines.get(0).isBlank()) lines.remove(0);
while (lines.size() > 1 && lines.get(lines.size() - 1).isBlank()) lines.remove(lines.size() - 1);
return new InternalMessage(role, String.join("\n", lines));
}
/** Markdown transcript: `**role**: text` header lines with continuation
* lines belonging to the same message. `model` maps to assistant. */
public static List<InternalMessage> parseMarkdown(String text, List<String> warnings) {
List<InternalMessage> out = new ArrayList<>();
String role = null;
List<String> lines = null;
for (String line : text.split("\n", -1)) {
Matcher match = MARKDOWN_HEADER.matcher(line);
if (match.matches()) {
if (role != null) out.add(finishMessage(role, lines));
role = match.group(1).equals("model") ? "assistant" : match.group(1);
lines = new ArrayList<>(List.of(match.group(2)));
continue;
}
if (lines != null) {
lines.add(line);
} else if (!line.isBlank()) {
throw new IllegalArgumentException("Markdown transcript must start with a `**role**:` header line");
}
}
if (role != null) out.add(finishMessage(role, lines));
return out;
}
/* -------------------------------- serialize ------------------------------ */
private static Json.Str s(String v) { return new Json.Str(v); }
private static Json.Obj kv(Object... pairs) {
List<Map.Entry<String, Json>> list = new ArrayList<>(pairs.length / 2);
for (int i = 0; i < pairs.length; i += 2) {
list.add(Map.entry((String) pairs[i], (Json) pairs[i + 1]));
}
return new Json.Obj(list);
}
public static String serializeOpenAi(List<InternalMessage> messages, List<String> warnings) {
List<Json> arr = new ArrayList<>(messages.size());
for (InternalMessage m : messages) {
List<Map.Entry<String, Json>> pairs = new ArrayList<>();
if (m.role.equals("tool")) {
pairs.add(Map.entry("role", s("tool")));
pairs.add(Map.entry("content", s(m.content)));
pairs.add(Map.entry("tool_call_id", s(m.toolCallId == null ? "" : m.toolCallId)));
if (m.name != null) pairs.add(Map.entry("name", s(m.name)));
} else if (m.role.equals("assistant") && m.toolCalls != null) {
pairs.add(Map.entry("role", s("assistant")));
pairs.add(Map.entry("content", m.content.isEmpty() ? new Json.Null() : s(m.content)));
List<Json> calls = new ArrayList<>(m.toolCalls.size());
for (ToolCall tc : m.toolCalls) {
calls.add(kv("id", s(tc.id()), "type", s("function"),
"function", kv("name", s(tc.name()), "arguments", s(tc.args()))));
}
pairs.add(Map.entry("tool_calls", new Json.Arr(calls)));
if (m.name != null) pairs.add(Map.entry("name", s(m.name)));
} else {
pairs.add(Map.entry("role", s(m.role)));
pairs.add(Map.entry("content", s(m.content)));
if (m.name != null) pairs.add(Map.entry("name", s(m.name)));
}
arr.add(new Json.Obj(pairs));
}
return new Json.Obj(List.of(Map.entry("messages", new Json.Arr(arr)))).toPretty() + "\n";
}
public static String serializeAnthropic(List<InternalMessage> messages, List<String> warnings) {
List<String> system = new ArrayList<>();
for (InternalMessage m : messages) {
if (m.role.equals("system")) system.add(m.content);
}
List<Json> out = new ArrayList<>();
for (int i = 0; i < messages.size(); i++) {
InternalMessage m = messages.get(i);
if (m.role.equals("system")) continue;
if (m.name != null && !m.role.equals("tool")) {
warnings.add("message " + i + ": \"name\" has no Anthropic equivalent and was dropped");
}
Json entry;
switch (m.role) {
case "user" -> entry = kv("role", s("user"), "content", s(m.content));
case "assistant" -> {
if (m.toolCalls != null) {
List<Json> blocks = new ArrayList<>();
if (!m.content.isEmpty()) {
blocks.add(kv("type", s("text"), "text", s(m.content)));
}
for (ToolCall tc : m.toolCalls) {
blocks.add(kv("type", s("tool_use"), "id", s(tc.id()), "name", s(tc.name()),
"input", parseArgsOrEmpty(tc.args())));
}
entry = kv("role", s("assistant"), "content", new Json.Arr(blocks));
} else {
entry = kv("role", s("assistant"), "content", s(m.content));
}
}
default -> entry = kv("role", s("user"), "content", new Json.Arr(List.of(
kv("type", s("tool_result"),
"tool_use_id", s(m.toolCallId == null ? "" : m.toolCallId),
"content", s(m.content)))));
}
out.add(entry);
}
List<Map.Entry<String, Json>> pairs = new ArrayList<>();
if (!out.isEmpty()) pairs.add(Map.entry("messages", new Json.Arr(out)));
if (!system.isEmpty()) pairs.add(Map.entry("system", s(String.join("\n\n", system))));
return new Json.Obj(pairs).toPretty() + "\n";
}
public static String serializeGemini(List<InternalMessage> messages, List<String> warnings) {
List<String> system = new ArrayList<>();
for (InternalMessage m : messages) {
if (m.role.equals("system")) system.add(m.content);
}
// (role, parts) turns; consecutive same-role parts merge into one turn.
List<Map.Entry<String, List<Json>>> turns = new ArrayList<>();
for (int i = 0; i < messages.size(); i++) {
InternalMessage m = messages.get(i);
if (m.role.equals("system")) continue;
if (m.name != null && !m.role.equals("tool")) {
warnings.add("message " + i + ": \"name\" has no Gemini equivalent and was dropped");
}
switch (m.role) {
case "user" -> pushTurn(turns, "user", kv("text", s(m.content)));
case "assistant" -> {
if (!m.content.isEmpty()) pushTurn(turns, "model", kv("text", s(m.content)));
List<ToolCall> calls = m.toolCalls == null ? List.of() : m.toolCalls;
for (ToolCall tc : calls) {
pushTurn(turns, "model",
kv("functionCall", kv("name", s(tc.name()), "args", parseArgsOrEmpty(tc.args()))));
}
}
default -> {
String name = m.name != null ? m.name : m.toolCallId != null ? m.toolCallId : "";
pushTurn(turns, "user",
kv("functionResponse",
kv("name", s(name), "response", parseArgsOrEmpty(m.content))));
}
}
}
List<Map.Entry<String, Json>> pairs = new ArrayList<>();
if (!turns.isEmpty()) {
List<Json> contents = new ArrayList<>(turns.size());
for (var turn : turns) {
contents.add(kv("role", s(turn.getKey()), "parts", new Json.Arr(turn.getValue())));
}
pairs.add(Map.entry("contents", new Json.Arr(contents)));
}
if (!system.isEmpty()) {
pairs.add(Map.entry("systemInstruction",
kv("parts", new Json.Arr(List.of(kv("text", s(String.join("\n\n", system))))))));
}
return new Json.Obj(pairs).toPretty() + "\n";
}
private static void pushTurn(List<Map.Entry<String, List<Json>>> turns, String role, Json part) {
Map.Entry<String, List<Json>> last = turns.isEmpty() ? null : turns.get(turns.size() - 1);
if (last != null && last.getKey().equals(role)) {
last.getValue().add(part);
} else {
List<Json> parts = new ArrayList<>();
parts.add(part);
turns.add(Map.entry(role, parts));
}
}
public static String serializeMarkdown(List<InternalMessage> messages, List<String> warnings) {
List<String> lines = new ArrayList<>(messages.size());
for (int i = 0; i < messages.size(); i++) {
InternalMessage m = messages.get(i);
if (m.toolCalls != null && !m.toolCalls.isEmpty()) {
warnings.add("message " + i + ": tool calls are not representable in Markdown and were dropped");
}
if (m.role.equals("tool") && m.toolCallId != null) {
warnings.add("message " + i + ": tool result id is not representable in Markdown and was dropped");
}
lines.add("**" + m.role + "**: " + m.content);
}
return String.join("\n\n", lines) + "\n";
}
/* --------------------------------- convert -------------------------------- */
/** Convert a transcript between chat formats through the shared internal shape.
*
* @throws IllegalArgumentException when the transcript is empty, the source
* format fails to parse, or from/to is not a known format. */
public static ConversionResult convert(String transcript, String from, String to) {
if (transcript == null || transcript.isBlank()) {
throw new IllegalArgumentException("Transcript is empty — paste a transcript first");
}
java.util.function.BiFunction<String, List<String>, List<InternalMessage>> parser = switch (from) {
case "openai" -> ChatFormatConverter::parseOpenAi;
case "anthropic" -> ChatFormatConverter::parseAnthropic;
case "gemini" -> ChatFormatConverter::parseGemini;
case "markdown" -> ChatFormatConverter::parseMarkdown;
default -> throw new IllegalArgumentException("Unknown source format: " + from);
};
java.util.function.BiFunction<List<InternalMessage>, List<String>, String> serializer = switch (to) {
case "openai" -> ChatFormatConverter::serializeOpenAi;
case "anthropic" -> ChatFormatConverter::serializeAnthropic;
case "gemini" -> ChatFormatConverter::serializeGemini;
case "markdown" -> ChatFormatConverter::serializeMarkdown;
default -> throw new IllegalArgumentException("Unknown target format: " + to);
};
List<String> warnings = new ArrayList<>();
List<InternalMessage> messages = parser.apply(transcript, warnings);
return new ConversionResult(serializer.apply(messages, warnings), warnings);
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →