Skip to content

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 →