Chat Format Converter — C++ 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 C++ implementation — the same logic the interactive tool runs, in a shareable, citable form.
// chat-format-converter — C++ 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: C++ (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 C++
// standard library ships no JSON engine, so this file includes a small
// hand-written recursive-descent JSON parser + writer. Object keys are kept in
// a std::vector<std::pair<std::string, Json>> so insertion order survives a
// round-trip, exactly like the TypeScript original. Parse failures throw
// std::invalid_argument.
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <optional>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <utility>
#include <variant>
#include <vector>
namespace chatformat {
/* ------------------------------ JSON engine ------------------------------- */
// A JSON value. Objects preserve insertion order via a pair vector, matching
// JavaScript object semantics (JSON.stringify key order).
class Json {
public:
using Arr = std::vector<Json>;
using Obj = std::vector<std::pair<std::string, Json>>;
Json() : v_(nullptr) {}
Json(std::nullptr_t) : v_(nullptr) {}
Json(bool b) : v_(b) {}
Json(double n) : v_(n) {}
Json(const char* s) : v_(std::string(s)) {}
Json(std::string s) : v_(std::move(s)) {}
Json(Arr a) : v_(std::move(a)) {}
Json(Obj o) : v_(std::move(o)) {}
const Json* get(const std::string& key) const {
if (auto* obj = std::get_if<Obj>(&v_)) {
for (const auto& [k, val] : *obj) {
if (k == key) return &val;
}
}
return nullptr;
}
const std::string* asStr() const { return std::get_if<std::string>(&v_); }
const Arr* asArr() const { return std::get_if<Arr>(&v_); }
bool isObj() const { return std::holds_alternative<Obj>(v_); }
bool isNull() const { return std::holds_alternative<std::nullptr_t>(v_); }
// Compact encoding, the moral equivalent of TS `JSON.stringify`.
std::string toCompact() const {
std::string out;
write(out, false, 0);
return out;
}
// Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`.
std::string toPretty() const {
std::string out;
write(out, true, 0);
return out;
}
static void writeEscaped(std::string& out, const std::string& s) {
out += '"';
for (unsigned char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
default:
if (c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof buf, "\\u%04x", c);
out += buf;
} else {
out += static_cast<char>(c);
}
}
}
out += '"';
}
private:
void write(std::string& out, bool indent, size_t depth) const {
std::visit([&](const auto& val) { writeVal(out, val, indent, depth); }, v_);
}
template <typename T>
void writeVal(std::string& out, const T& val, bool indent, size_t depth) const {
if constexpr (std::is_same_v<T, std::nullptr_t>) {
out += "null";
} else if constexpr (std::is_same_v<T, bool>) {
out += val ? "true" : "false";
} else if constexpr (std::is_same_v<T, double>) {
out += formatF64(val);
} else if constexpr (std::is_same_v<T, std::string>) {
writeEscaped(out, val);
} else if constexpr (std::is_same_v<T, Arr>) {
if (val.empty()) { out += "[]"; return; }
out += '[';
for (size_t i = 0; i < val.size(); ++i) {
if (i > 0) out += ',';
if (indent) { out += '\n'; pushIndent(out, depth + 1); }
val[i].write(out, indent, depth + 1);
}
if (indent) { out += '\n'; pushIndent(out, depth); }
out += ']';
} else {
if (val.empty()) { out += "{}"; return; }
out += '{';
for (size_t i = 0; i < val.size(); ++i) {
if (i > 0) out += ',';
if (indent) { out += '\n'; pushIndent(out, depth + 1); }
writeEscaped(out, val[i].first);
out += indent ? ": " : ":";
val[i].second.write(out, indent, depth + 1);
}
if (indent) { out += '\n'; pushIndent(out, depth); }
out += '}';
}
}
static void pushIndent(std::string& out, size_t depth) {
for (size_t i = 0; i < depth; ++i) out += " ";
}
// Render a double the way JavaScript's number-to-string coercion does:
// whole values carry no trailing ".0".
static std::string formatF64(double n) {
if (n == std::trunc(n) && std::fabs(n) < 1e15) {
return std::to_string(static_cast<long long>(n));
}
char buf[32];
std::snprintf(buf, sizeof buf, "%.15g", n);
return buf;
}
std::variant<std::nullptr_t, bool, double, std::string, Arr, Obj> v_;
};
// Recursive-descent JSON parser; trailing garbage is an error.
class JsonParser {
public:
static Json parse(const std::string& input) {
JsonParser p(input);
p.skipWs();
Json v = p.value();
p.skipWs();
if (p.pos_ != p.src_.size()) {
throw Error("unexpected trailing characters at byte " + std::to_string(p.pos_));
}
return v;
}
struct Error : std::runtime_error {
explicit Error(const std::string& m) : std::runtime_error(m) {}
};
private:
explicit JsonParser(const std::string& src) : src_(src) {}
int peek() const { return pos_ < src_.size() ? static_cast<unsigned char>(src_[pos_]) : -1; }
void skipWs() {
while (peek() == ' ' || peek() == '\t' || peek() == '\n' || peek() == '\r') pos_++;
}
Json value() {
int c = peek();
if (c == -1) throw Error("unexpected end of input");
switch (c) {
case '{': return obj();
case '[': return arr();
case '"': return Json(string());
case 't': return literal("true", Json(true));
case 'f': return literal("false", Json(false));
case 'n': return literal("null", Json(nullptr));
default:
if (c == '-' || (c >= '0' && c <= '9')) return number();
throw Error(std::string("unexpected '") + static_cast<char>(c)
+ "' at byte " + std::to_string(pos_));
}
}
Json literal(const char* word, Json v) {
size_t n = strlenOf(word);
if (src_.compare(pos_, n, word) == 0) {
pos_ += n;
return v;
}
throw Error("invalid literal at byte " + std::to_string(pos_));
}
static size_t strlenOf(const char* s) {
size_t n = 0;
while (s[n]) n++;
return n;
}
Json number() {
size_t start = pos_;
while (true) {
int c = peek();
bool keep = c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E'
|| (c >= '0' && c <= '9');
if (keep) pos_++;
else break;
}
try {
return Json(std::stod(src_.substr(start, pos_ - start)));
} catch (const std::exception&) {
throw Error("invalid number \"" + src_.substr(start, pos_ - start)
+ "\" at byte " + std::to_string(start));
}
}
std::string string() {
pos_++; // opening quote
std::string out;
while (true) {
int ci = peek();
if (ci == -1) throw Error("unterminated string at byte " + std::to_string(pos_));
pos_++;
unsigned char c = static_cast<unsigned char>(ci);
if (c == '"') return out;
if (c == '\\') {
int ei = peek();
if (ei == -1) throw Error("unterminated escape");
pos_++;
switch (ei) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
uint32_t hi = hex4();
if (hi >= 0xD800 && hi <= 0xDBFF) {
// High surrogate: combine with a following \uXXXX low surrogate.
bool paired = peek() == '\\'
&& pos_ + 1 < src_.size() && src_[pos_ + 1] == 'u';
if (paired) {
pos_ += 2;
uint32_t lo = hex4();
appendUtf8(out, 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00));
} else {
appendUtf8(out, 0xFFFD);
}
} else {
appendUtf8(out, hi);
}
break;
}
default:
throw Error(std::string("invalid escape \\") + static_cast<char>(ei)
+ " at byte " + std::to_string(pos_ - 1));
}
} else {
out += static_cast<char>(c);
}
}
}
static void appendUtf8(std::string& out, uint32_t cp) {
if (cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) cp = 0xFFFD;
if (cp < 0x80) {
out += static_cast<char>(cp);
} else if (cp < 0x800) {
out += static_cast<char>(0xC0 | (cp >> 6));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
out += static_cast<char>(0xE0 | (cp >> 12));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else {
out += static_cast<char>(0xF0 | (cp >> 18));
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
}
}
uint32_t hex4() {
uint32_t v = 0;
for (int i = 0; i < 4; i++) {
int c = peek();
if (c == -1) throw Error("unterminated \\u escape");
int d = (c >= '0' && c <= '9') ? c - '0'
: (c >= 'a' && c <= 'f') ? c - 'a' + 10
: (c >= 'A' && c <= 'F') ? c - 'A' + 10 : -1;
if (d < 0) throw Error("invalid hex digit at byte " + std::to_string(pos_));
pos_++;
v = v * 16 + static_cast<uint32_t>(d);
}
return v;
}
Json obj() {
pos_++; // '{'
Json::Obj pairs;
skipWs();
if (peek() == '}') { pos_++; return Json(std::move(pairs)); }
while (true) {
skipWs();
if (peek() != '"') throw Error("expected object key at byte " + std::to_string(pos_));
std::string key = string();
skipWs();
if (peek() != ':') throw Error("expected ':' at byte " + std::to_string(pos_));
pos_++;
skipWs();
pairs.emplace_back(std::move(key), value());
skipWs();
if (peek() == ',') { pos_++; continue; }
if (peek() == '}') { pos_++; return Json(std::move(pairs)); }
throw Error("expected ',' or '}' at byte " + std::to_string(pos_));
}
}
Json arr() {
pos_++; // '['
Json::Arr items;
skipWs();
if (peek() == ']') { pos_++; return Json(std::move(items)); }
while (true) {
skipWs();
items.push_back(value());
skipWs();
if (peek() == ',') { pos_++; continue; }
if (peek() == ']') { pos_++; return Json(std::move(items)); }
throw Error("expected ',' or ']' at byte " + std::to_string(pos_));
}
}
const std::string& src_;
size_t pos_ = 0;
};
/* ------------------------------ domain types ------------------------------ */
// One assistant-emitted tool call, provider-agnostic. `args` is a JSON string.
struct ToolCall {
std::string id;
std::string name;
std::string args;
};
// The shared internal shape every format parses into and serializes from.
struct InternalMessage {
std::string role; // "system" | "user" | "assistant" | "tool"
std::string content; // "" when the message only carries tool calls/results
std::optional<std::string> name;
std::optional<std::vector<ToolCall>> tool_calls;
std::optional<std::string> tool_call_id;
InternalMessage(std::string role_, std::string content_ = "")
: role(std::move(role_)), content(std::move(content_)) {}
};
// The conversion output plus human-readable notes about unmapped fields.
struct ConversionResult {
std::string output;
std::vector<std::string> warnings;
};
inline const std::vector<std::string> CHAT_FORMATS = {"openai", "anthropic", "gemini", "markdown"};
/* -------------------------------- helpers --------------------------------- */
using Warnings = std::vector<std::string>;
inline Json parseTranscriptJson(const std::string& text, const std::string& label) {
try {
return JsonParser::parse(text);
} catch (const JsonParser::Error& e) {
throw std::invalid_argument(label + ": invalid JSON — " + e.what());
}
}
inline const Json* jget(const Json& el, const std::string& key) { return el.get(key); }
inline std::string jstr(const Json* el, const std::string& key) {
const Json* v = el ? el->get(key) : nullptr;
return v && v->asStr() ? *v->asStr() : "";
}
inline const std::string* jstrPtr(const Json* el, const std::string& key) {
const Json* v = el ? el->get(key) : nullptr;
return v ? v->asStr() : nullptr;
}
inline bool has(const Json& el, const std::string& key) { return el.get(key) != nullptr; }
// The TS `?? {}` capture: null/missing becomes {}, any other value passes
// through and stringifies as-is.
inline std::string argsOf(const Json* v) {
if (!v || v->isNull()) return "{}";
return v->toCompact();
}
// Parse a JSON-string argument back to a value, an empty object when it is
// not a JSON object (mirrors parseArgsOrEmpty).
inline Json parseArgsOrEmpty(const std::string& args) {
try {
Json v = JsonParser::parse(args);
if (v.isObj()) return v;
} catch (const JsonParser::Error&) {}
return Json::Obj{};
}
inline std::string blockTypeName(const Json& raw) {
const std::string* t = raw.get("type") ? raw.get("type")->asStr() : nullptr;
return t ? *t : "content";
}
// One normalized Anthropic content block.
struct Block {
enum Kind { Text, ToolUse, ToolResult, Other } kind;
std::string text; // Text only
Json raw; // ToolUse / ToolResult / Other
};
inline std::vector<Block> contentBlocks(const Json* content, const std::string& where) {
if (content && content->asStr()) return {{Block::Text, *content->asStr(), {}}};
const Json::Arr* items = content ? content->asArr() : nullptr;
if (!items) {
throw std::invalid_argument(where + ": content must be a string or an array of blocks");
}
std::vector<Block> blocks;
for (const Json& block : *items) {
const std::string* type = block.get("type") ? block.get("type")->asStr() : nullptr;
bool isText = type && *type == "text" && block.get("text") && block.get("text")->asStr();
if (isText) blocks.push_back({Block::Text, *block.get("text")->asStr(), {}});
else if (type && *type == "tool_use") blocks.push_back({Block::ToolUse, "", block});
else if (type && *type == "tool_result") blocks.push_back({Block::ToolResult, "", block});
else blocks.push_back({Block::Other, "", block});
}
return blocks;
}
inline std::string joinStrings(const std::vector<std::string>& parts, const char* sep) {
std::string out;
for (size_t i = 0; i < parts.size(); ++i) {
if (i > 0) out += sep;
out += parts[i];
}
return out;
}
// Anthropic string-or-block[] content → plain text.
inline std::string blocksToText(const Json* content, const std::string& where, Warnings& warnings) {
if (content && content->asStr()) return *content->asStr();
if (!content || content->isNull()) return "";
std::vector<std::string> texts;
for (const Block& block : contentBlocks(content, where)) {
switch (block.kind) {
case Block::Text:
texts.push_back(block.text);
break;
case Block::Other:
warnings.push_back(where + ": dropped unsupported " + blockTypeName(block.raw) + " block");
break;
case Block::ToolUse:
warnings.push_back(where + ": dropped tool_use block from text-only content");
break;
case Block::ToolResult:
warnings.push_back(where + ": dropped tool_result block from text-only content");
break;
}
}
return joinStrings(texts, "\n");
}
inline ToolCall toolCallFromBlock(const Json& raw) {
return ToolCall{jstr(&raw, "id"), jstr(&raw, "name"), argsOf(raw.get("input"))};
}
inline const Json NULL_JSON{nullptr};
inline const Json EMPTY_STR_JSON{std::string("")};
inline const std::vector<ToolCall> EMPTY_CALLS{};
/* ---------------------------------- parse --------------------------------- */
inline std::string trimBlank(const std::string& s) {
const char* ws = " \t\r\n\x0b\x0c";
size_t b = s.find_first_not_of(ws);
if (b == std::string::npos) return "";
size_t e = s.find_last_not_of(ws);
return s.substr(b, e - b + 1);
}
inline bool isBlankLine(const std::string& s) { return trimBlank(s).empty(); }
// OpenAI content → plain text (string, or text parts joined; other parts flagged).
inline std::string contentText(const Json* content, const std::string& where, Warnings& warnings) {
if (!content || content->isNull()) return "";
if (const std::string* s = content->asStr()) return *s;
if (const Json::Arr* parts = content->asArr()) {
std::vector<std::string> texts;
size_t dropped = 0;
for (const Json& part : *parts) {
const std::string* type = part.get("type") ? part.get("type")->asStr() : nullptr;
bool isText = type && *type == "text" && part.get("text") && part.get("text")->asStr();
if (isText) texts.push_back(*part.get("text")->asStr());
else dropped++;
}
if (dropped > 0) {
warnings.push_back(where + ": dropped " + std::to_string(dropped) + " non-text content part(s)");
}
return joinStrings(texts, "");
}
throw std::invalid_argument(where + ": content must be a string, an array of parts, or null");
}
inline std::optional<std::vector<ToolCall>> parseOpenAiToolCalls(const Json* raw) {
if (!raw || raw->isNull()) return std::nullopt;
const Json::Arr* items = raw->asArr();
if (!items) throw std::invalid_argument("tool_calls must be an array");
std::vector<ToolCall> calls;
for (const Json& entry : *items) {
const Json* fn = entry.get("function");
calls.push_back(ToolCall{
jstr(&entry, "id"),
fn ? jstr(fn, "name") : "",
fn ? jstr(fn, "arguments") : ""});
}
return calls;
}
// OpenAI messages[] (bare array, or wrapped in {"messages": [...]}).
inline std::vector<InternalMessage> parseOpenAi(const std::string& text, Warnings& warnings) {
Json parsed = parseTranscriptJson(text, "OpenAI transcript");
const Json::Arr* items = nullptr;
if (parsed.isObj()) {
if (const Json* wrapped = parsed.get("messages")) items = wrapped->asArr();
}
if (!items) items = parsed.asArr();
if (!items) {
throw std::invalid_argument(
"OpenAI transcript must be a messages[] array or an object with a \"messages\" array");
}
std::vector<InternalMessage> out;
for (size_t i = 0; i < items->size(); ++i) {
const Json& entry = (*items)[i];
if (!entry.isObj()) throw std::invalid_argument("messages[" + std::to_string(i) + "] is not an object");
std::string where = "messages[" + std::to_string(i) + "]";
std::string role = jstr(&entry, "role");
if (role == "system" || role == "developer") {
out.emplace_back("system", contentText(entry.get("content"), where, warnings));
} else if (role == "user" || role == "assistant" || role == "tool") {
InternalMessage msg(role, contentText(entry.get("content"), where, warnings));
if (const std::string* n = jstrPtr(&entry, "name")) msg.name = *n;
if (const std::string* t = jstrPtr(&entry, "tool_call_id")) msg.tool_call_id = *t;
msg.tool_calls = parseOpenAiToolCalls(entry.get("tool_calls"));
out.push_back(std::move(msg));
} else {
const Json* r = entry.get("role");
throw std::invalid_argument("messages[" + std::to_string(i) + "] has unsupported role: "
+ (r ? r->toCompact() : "null"));
}
}
return out;
}
inline void flushUserTexts(std::vector<InternalMessage>& out, std::vector<std::string>& texts) {
if (!texts.empty()) {
out.emplace_back("user", joinStrings(texts, "\n"));
texts.clear();
}
}
// Anthropic messages[] plus the top-level system field.
inline std::vector<InternalMessage> parseAnthropic(const std::string& text, Warnings& warnings) {
Json root = parseTranscriptJson(text, "Anthropic transcript");
const Json::Arr* messages = nullptr;
if (root.isObj()) {
if (const Json* m = root.get("messages")) messages = m->asArr();
}
if (!messages) {
throw std::invalid_argument("Anthropic transcript must be an object with a \"messages\" array");
}
std::vector<InternalMessage> out;
if (const Json* sys = root.get("system")) {
out.emplace_back("system", blocksToText(sys, "system", warnings));
}
for (size_t i = 0; i < messages->size(); ++i) {
const Json& entry = (*messages)[i];
if (!entry.isObj()) throw std::invalid_argument("messages[" + std::to_string(i) + "] is not an object");
std::string where = "messages[" + std::to_string(i) + "]";
std::string role = jstr(&entry, "role");
if (role == "assistant") {
std::vector<std::string> texts;
std::vector<ToolCall> calls;
const Json* content = entry.get("content");
for (const Block& block : contentBlocks(content ? content : &NULL_JSON, where)) {
switch (block.kind) {
case Block::Text: texts.push_back(block.text); break;
case Block::ToolUse: calls.push_back(toolCallFromBlock(block.raw)); break;
case Block::Other:
warnings.push_back(where + ": dropped unsupported " + blockTypeName(block.raw) + " block");
break;
case Block::ToolResult: break;
}
}
InternalMessage msg("assistant", joinStrings(texts, "\n"));
if (!calls.empty()) msg.tool_calls = std::move(calls);
out.push_back(std::move(msg));
} else if (role == "user") {
std::vector<std::string> texts;
const Json* content = entry.get("content");
for (const Block& block : contentBlocks(content ? content : &NULL_JSON, where)) {
switch (block.kind) {
case Block::Text:
texts.push_back(block.text);
break;
case Block::ToolUse: {
warnings.push_back(where
+ ": tool_use block inside a user message moved to an assistant tool call");
flushUserTexts(out, texts);
InternalMessage msg("assistant", "");
msg.tool_calls = std::vector<ToolCall>{toolCallFromBlock(block.raw)};
out.push_back(std::move(msg));
break;
}
case Block::ToolResult: {
flushUserTexts(out, texts);
const Json* inner = block.raw.get("content");
InternalMessage msg("tool",
blocksToText(inner ? inner : &EMPTY_STR_JSON, where, warnings));
msg.tool_call_id = jstr(&block.raw, "tool_use_id");
out.push_back(std::move(msg));
break;
}
case Block::Other:
warnings.push_back(where + ": dropped unsupported " + blockTypeName(block.raw) + " block");
break;
}
}
flushUserTexts(out, texts);
} else {
const Json* r = entry.get("role");
throw std::invalid_argument(where + " has unsupported role: " + (r ? r->toCompact() : "null"));
}
}
return out;
}
// Gemini systemInstruction (string or {parts}) → plain text.
inline std::string geminiText(const Json& v, const std::string& where, Warnings& warnings) {
if (const std::string* s = v.asStr()) return *s;
if (v.isObj()) {
if (const Json* t = v.get("text")) {
if (const std::string* ts = t->asStr()) return *ts;
}
if (const Json* p = v.get("parts")) {
if (const Json::Arr* parts = p->asArr()) {
std::vector<std::string> texts;
for (const Json& part : *parts) texts.push_back(jstr(&part, "text"));
return joinStrings(texts, "\n");
}
}
}
warnings.push_back(where + ": unsupported systemInstruction shape, treated as empty");
return "";
}
// Gemini contents[] plus the optional systemInstruction.
inline std::vector<InternalMessage> parseGemini(const std::string& text, Warnings& warnings) {
Json root = parseTranscriptJson(text, "Gemini transcript");
const Json::Arr* contents = nullptr;
if (root.isObj()) {
if (const Json* c = root.get("contents")) contents = c->asArr();
}
if (!contents) {
throw std::invalid_argument("Gemini transcript must be an object with a \"contents\" array");
}
std::vector<InternalMessage> out;
if (const Json* si = root.get("systemInstruction")) {
out.emplace_back("system", geminiText(*si, "systemInstruction", warnings));
}
for (size_t i = 0; i < contents->size(); ++i) {
const Json& entry = (*contents)[i];
if (!entry.isObj()) throw std::invalid_argument("contents[" + std::to_string(i) + "] is not an object");
std::string where = "contents[" + std::to_string(i) + "]";
std::string role = jstr(&entry, "role");
if (role != "user" && role != "model") {
const Json* r = entry.get("role");
throw std::invalid_argument(where + " has unsupported role: "
+ (r ? r->toCompact() : "null") + " (Gemini uses \"user\" or \"model\")");
}
const Json* partsJson = entry.get("parts");
const Json::Arr* parts = partsJson ? partsJson->asArr() : nullptr;
if (!parts) throw std::invalid_argument(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.
std::vector<std::string> texts;
std::vector<ToolCall> calls;
for (const Json& part : *parts) {
if (const Json* t = part.get("text")) {
if (const std::string* ts = t->asStr()) { texts.push_back(*ts); continue; }
}
if (const Json* fc = part.get("functionCall")) {
if (fc->isObj()) {
calls.push_back(ToolCall{"", jstr(fc, "name"), argsOf(fc->get("args"))});
continue;
}
}
warnings.push_back(where + ": dropped unsupported part (inlineData or similar)");
}
InternalMessage msg("assistant", joinStrings(texts, "\n"));
if (!calls.empty()) msg.tool_calls = std::move(calls);
out.push_back(std::move(msg));
continue;
}
std::vector<std::string> texts;
for (const Json& part : *parts) {
if (const Json* t = part.get("text")) {
if (const std::string* ts = t->asStr()) { texts.push_back(*ts); continue; }
}
if (const Json* fc = part.get("functionCall")) {
if (fc->isObj()) {
flushUserTexts(out, texts);
InternalMessage msg("assistant", "");
msg.tool_calls = std::vector<ToolCall>{{"", jstr(fc, "name"), argsOf(fc->get("args"))}};
out.push_back(std::move(msg));
continue;
}
}
if (const Json* fr = part.get("functionResponse")) {
if (fr->isObj()) {
flushUserTexts(out, texts);
std::string name = jstr(fr, "name");
InternalMessage msg("tool", argsOf(fr->get("response")));
msg.name = name;
msg.tool_call_id = name;
out.push_back(std::move(msg));
continue;
}
}
warnings.push_back(where + ": dropped unsupported part (inlineData or similar)");
}
flushUserTexts(out, texts);
}
return out;
}
// A parsed Markdown header line: `**role**: text`.
inline bool markdownHeader(const std::string& line, std::string& role, std::string& first) {
if (line.compare(0, 2, "**") != 0) return false;
size_t end = line.find("**:", 2);
if (end == std::string::npos) return false;
std::string raw = line.substr(2, end - 2);
if (raw == "system" || raw == "user" || raw == "assistant" || raw == "tool") role = raw;
else if (raw == "model") role = "assistant";
else return false;
size_t rest = end + 3;
while (rest < line.size() && (line[rest] == ' ' || line[rest] == '\t'
|| line[rest] == '\r' || line[rest] == '\n' || line[rest] == '\x0b' || line[rest] == '\x0c')) rest++;
first = line.substr(rest);
return true;
}
inline InternalMessage finishMessage(const std::string& role, std::vector<std::string>& lines) {
// Trim leading/trailing blank continuation lines but keep inner blank lines.
while (lines.size() > 1 && isBlankLine(lines.front())) lines.erase(lines.begin());
while (lines.size() > 1 && isBlankLine(lines.back())) lines.pop_back();
return InternalMessage(role, joinStrings(lines, "\n"));
}
// Markdown transcript: `**role**: text` header lines with continuation lines
// belonging to the same message. `model` maps to assistant.
inline std::vector<InternalMessage> parseMarkdown(const std::string& text, Warnings& /*warnings*/) {
std::vector<InternalMessage> out;
std::string role;
std::vector<std::string> lines;
bool inMessage = false;
size_t start = 0;
while (start <= text.size()) {
size_t nl = text.find('\n', start);
std::string line = text.substr(start, nl == std::string::npos ? std::string::npos : nl - start);
std::string hdrRole, hdrFirst;
if (markdownHeader(line, hdrRole, hdrFirst)) {
if (inMessage) out.push_back(finishMessage(role, lines));
role = hdrRole;
lines = {hdrFirst};
inMessage = true;
} else if (inMessage) {
lines.push_back(line);
} else if (!isBlankLine(line)) {
throw std::invalid_argument("Markdown transcript must start with a `**role**:` header line");
}
if (nl == std::string::npos) break;
start = nl + 1;
}
if (inMessage) out.push_back(finishMessage(role, lines));
return out;
}
/* -------------------------------- serialize ------------------------------- */
inline Json s(std::string v) { return Json(std::move(v)); }
inline Json kv(Json::Obj pairs) { return Json(std::move(pairs)); }
inline std::string serializeOpenAi(const std::vector<InternalMessage>& messages, Warnings& /*warnings*/) {
Json::Arr arr;
for (const InternalMessage& m : messages) {
Json::Obj o;
if (m.role == "tool") {
o.emplace_back("role", s("tool"));
o.emplace_back("content", s(m.content));
o.emplace_back("tool_call_id", s(m.tool_call_id.value_or("")));
if (m.name) o.emplace_back("name", s(*m.name));
} else if (m.role == "assistant" && m.tool_calls) {
o.emplace_back("role", s("assistant"));
o.emplace_back("content", m.content.empty() ? Json(nullptr) : s(m.content));
Json::Arr calls;
for (const ToolCall& tc : *m.tool_calls) {
Json::Obj fn;
fn.emplace_back("name", s(tc.name));
fn.emplace_back("arguments", s(tc.args));
Json::Obj call;
call.emplace_back("id", s(tc.id));
call.emplace_back("type", s("function"));
call.emplace_back("function", kv(std::move(fn)));
calls.push_back(kv(std::move(call)));
}
o.emplace_back("tool_calls", Json(std::move(calls)));
if (m.name) o.emplace_back("name", s(*m.name));
} else {
o.emplace_back("role", s(m.role));
o.emplace_back("content", s(m.content));
if (m.name) o.emplace_back("name", s(*m.name));
}
arr.push_back(kv(std::move(o)));
}
Json::Obj root;
root.emplace_back("messages", Json(std::move(arr)));
return kv(std::move(root)).toPretty() + "\n";
}
inline std::string serializeAnthropic(const std::vector<InternalMessage>& messages, Warnings& warnings) {
std::vector<std::string> system;
Json::Arr out;
for (size_t i = 0; i < messages.size(); ++i) {
const InternalMessage& m = messages[i];
if (m.role == "system") { system.push_back(m.content); continue; }
if (m.name && m.role != "tool") {
warnings.push_back("message " + std::to_string(i)
+ ": \"name\" has no Anthropic equivalent and was dropped");
}
Json::Obj o;
o.emplace_back("role", s(m.role == "user" || m.role == "assistant" ? m.role : "user"));
if (m.role == "user") {
o.emplace_back("content", s(m.content));
} else if (m.role == "assistant") {
if (m.tool_calls) {
Json::Arr blocks;
if (!m.content.empty()) {
Json::Obj tb;
tb.emplace_back("type", s("text"));
tb.emplace_back("text", s(m.content));
blocks.push_back(kv(std::move(tb)));
}
for (const ToolCall& tc : *m.tool_calls) {
Json::Obj ub;
ub.emplace_back("type", s("tool_use"));
ub.emplace_back("id", s(tc.id));
ub.emplace_back("name", s(tc.name));
ub.emplace_back("input", parseArgsOrEmpty(tc.args));
blocks.push_back(kv(std::move(ub)));
}
o.emplace_back("content", Json(std::move(blocks)));
} else {
o.emplace_back("content", s(m.content));
}
} else {
Json::Obj rb;
rb.emplace_back("type", s("tool_result"));
rb.emplace_back("tool_use_id", s(m.tool_call_id.value_or("")));
rb.emplace_back("content", s(m.content));
Json::Arr wrapped;
wrapped.push_back(kv(std::move(rb)));
o.emplace_back("content", Json(std::move(wrapped)));
}
out.push_back(kv(std::move(o)));
}
Json::Obj root;
if (!out.empty()) root.emplace_back("messages", Json(std::move(out)));
if (!system.empty()) root.emplace_back("system", s(joinStrings(system, "\n\n")));
return kv(std::move(root)).toPretty() + "\n";
}
// (role, parts) turns; consecutive same-role parts merge into one turn.
inline std::string serializeGemini(const std::vector<InternalMessage>& messages, Warnings& warnings) {
std::vector<std::string> system;
std::vector<std::pair<std::string, Json::Arr>> turns;
auto push = [&](const char* role, Json part) {
if (!turns.empty() && turns.back().first == role) {
turns.back().second.push_back(std::move(part));
} else {
turns.emplace_back(role, Json::Arr{std::move(part)});
}
};
for (size_t i = 0; i < messages.size(); ++i) {
const InternalMessage& m = messages[i];
if (m.role == "system") { system.push_back(m.content); continue; }
if (m.name && m.role != "tool") {
warnings.push_back("message " + std::to_string(i)
+ ": \"name\" has no Gemini equivalent and was dropped");
}
if (m.role == "user") {
Json::Obj p;
p.emplace_back("text", s(m.content));
push("user", kv(std::move(p)));
} else if (m.role == "assistant") {
if (!m.content.empty()) {
Json::Obj p;
p.emplace_back("text", s(m.content));
push("model", kv(std::move(p)));
}
const std::vector<ToolCall>& calls = m.tool_calls ? *m.tool_calls : EMPTY_CALLS;
for (const ToolCall& tc : calls) {
Json::Obj fc;
fc.emplace_back("name", s(tc.name));
fc.emplace_back("args", parseArgsOrEmpty(tc.args));
Json::Obj p;
p.emplace_back("functionCall", kv(std::move(fc)));
push("model", kv(std::move(p)));
}
} else {
std::string name = m.name ? *m.name : m.tool_call_id.value_or("");
Json::Obj fr;
fr.emplace_back("name", s(name));
fr.emplace_back("response", parseArgsOrEmpty(m.content));
Json::Obj p;
p.emplace_back("functionResponse", kv(std::move(fr)));
push("user", kv(std::move(p)));
}
}
Json::Obj root;
if (!turns.empty()) {
Json::Arr contents;
for (auto& [role, parts] : turns) {
Json::Obj c;
c.emplace_back("role", s(role));
c.emplace_back("parts", Json(std::move(parts)));
contents.push_back(kv(std::move(c)));
}
root.emplace_back("contents", Json(std::move(contents)));
}
if (!system.empty()) {
Json::Obj tp;
tp.emplace_back("text", s(joinStrings(system, "\n\n")));
Json::Arr one;
one.push_back(kv(std::move(tp)));
Json::Obj si;
si.emplace_back("parts", Json(std::move(one)));
root.emplace_back("systemInstruction", kv(std::move(si)));
}
return kv(std::move(root)).toPretty() + "\n";
}
inline std::string serializeMarkdown(const std::vector<InternalMessage>& messages, Warnings& warnings) {
std::vector<std::string> lines;
for (size_t i = 0; i < messages.size(); ++i) {
const InternalMessage& m = messages[i];
if (m.tool_calls && !m.tool_calls->empty()) {
warnings.push_back("message " + std::to_string(i)
+ ": tool calls are not representable in Markdown and were dropped");
}
if (m.role == "tool" && m.tool_call_id) {
warnings.push_back("message " + std::to_string(i)
+ ": tool result id is not representable in Markdown and was dropped");
}
lines.push_back("**" + m.role + "**: " + m.content);
}
return joinStrings(lines, "\n\n") + "\n";
}
/* --------------------------------- convert -------------------------------- */
inline const char* PARSERS[] = {"openai", "anthropic", "gemini", "markdown"};
// Convert a transcript between chat formats through the shared internal shape.
//
// Throws std::invalid_argument when the transcript is empty, the source format
// fails to parse, or from/to is not a known format.
inline ConversionResult convert(const std::string& transcript, const std::string& from, const std::string& to) {
if (trimBlank(transcript).empty()) {
throw std::invalid_argument("Transcript is empty — paste a transcript first");
}
std::vector<InternalMessage> messages;
Warnings warnings;
if (from == "openai") messages = parseOpenAi(transcript, warnings);
else if (from == "anthropic") messages = parseAnthropic(transcript, warnings);
else if (from == "gemini") messages = parseGemini(transcript, warnings);
else if (from == "markdown") messages = parseMarkdown(transcript, warnings);
else throw std::invalid_argument("Unknown source format: " + from);
std::string output;
if (to == "openai") output = serializeOpenAi(messages, warnings);
else if (to == "anthropic") output = serializeAnthropic(messages, warnings);
else if (to == "gemini") output = serializeGemini(messages, warnings);
else if (to == "markdown") output = serializeMarkdown(messages, warnings);
else throw std::invalid_argument("Unknown target format: " + to);
return ConversionResult{std::move(output), std::move(warnings)};
}
} // namespace chatformat
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 →