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 (C11, 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. C's
 * standard library ships no JSON engine, so this file includes a small
 * hand-written recursive-descent JSON parser + writer. Object keys are kept in
 * an ordered pair array so insertion order survives a round-trip, exactly like
 * the TypeScript original. Parse failures return NULL with the message stored
 * in the caller's error buffer. Allocation failures abort() — this is display
 * source, not a service; OOM policy belongs to the embedder.
 */

#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* ------------------------------ allocation ------------------------------- */

static void *xmalloc(size_t n) {
    void *p = malloc(n ? n : 1);
    if (!p) { fprintf(stderr, "chat-format-converter: out of memory\n"); abort(); }
    return p;
}

static void *xrealloc(void *p, size_t n) {
    void *q = realloc(p, n ? n : 1);
    if (!q) { fprintf(stderr, "chat-format-converter: out of memory\n"); abort(); }
    return q;
}

static char *xstrdup(const char *s) {
    size_t n = strlen(s);
    char *p = xmalloc(n + 1);
    memcpy(p, s, n + 1);
    return p;
}

static char *xstrndup(const char *s, size_t n) {
    char *p = xmalloc(n + 1);
    memcpy(p, s, n);
    p[n] = '\0';
    return p;
}

/* ------------------------------ string buffer ----------------------------- */

typedef struct {
    char *data;
    size_t len, cap;
} StrBuf;

static void sb_init(StrBuf *sb) {
    sb->cap = 64;
    sb->len = 0;
    sb->data = xmalloc(sb->cap);
    sb->data[0] = '\0';
}

static void sb_grow(StrBuf *sb, size_t need) {
    if (sb->len + need + 1 > sb->cap) {
        while (sb->len + need + 1 > sb->cap) sb->cap *= 2;
        sb->data = xrealloc(sb->data, sb->cap);
    }
}

static void sb_append_n(StrBuf *sb, const char *s, size_t n) {
    sb_grow(sb, n);
    memcpy(sb->data + sb->len, s, n);
    sb->len += n;
    sb->data[sb->len] = '\0';
}

static void sb_append(StrBuf *sb, const char *s) { sb_append_n(sb, s, strlen(s)); }

static void sb_appendf(StrBuf *sb, const char *fmt, ...) {
    char tmp[256];
    va_list ap;
    va_start(ap, fmt);
    int n = vsnprintf(tmp, sizeof tmp, fmt, ap);
    va_end(ap);
    if (n < 0) return;
    if ((size_t) n < sizeof tmp) {
        sb_append_n(sb, tmp, (size_t) n);
    } else {
        char *big = xmalloc((size_t) n + 1);
        va_start(ap, fmt);
        vsnprintf(big, (size_t) n + 1, fmt, ap);
        va_end(ap);
        sb_append_n(sb, big, (size_t) n);
        free(big);
    }
}

/* ------------------------------ JSON engine ------------------------------- */

typedef enum { J_NULL, J_BOOL, J_NUM, J_STR, J_ARR, J_OBJ } JType;

typedef struct JValue JValue;

typedef struct {
    char *key;
    JValue *val;
} JPair;

struct JValue {
    JType type;
    int b;                                   /* J_BOOL */
    double num;                              /* J_NUM */
    char *str;                               /* J_STR */
    JValue **items;                          /* J_ARR */
    size_t n_items, cap_items;
    JPair *pairs;                            /* J_OBJ — ordered key/value array */
    size_t n_pairs, cap_pairs;
};

static JValue *jnew(JType t) {
    JValue *v = xmalloc(sizeof *v);
    memset(v, 0, sizeof *v);
    v->type = t;
    return v;
}

static void jfree(JValue *v) {
    if (!v) return;
    for (size_t i = 0; i < v->n_items; i++) jfree(v->items[i]);
    free(v->items);
    for (size_t i = 0; i < v->n_pairs; i++) {
        free(v->pairs[i].key);
        jfree(v->pairs[i].val);
    }
    free(v->pairs);
    free(v->str);
    free(v);
}

static void jarr_push(JValue *arr, JValue *item) {
    if (arr->n_items == arr->cap_items) {
        arr->cap_items = arr->cap_items ? arr->cap_items * 2 : 8;
        arr->items = xrealloc(arr->items, arr->cap_items * sizeof *arr->items);
    }
    arr->items[arr->n_items++] = item;
}

static void jobj_put(JValue *obj, const char *key, JValue *val) {
    if (obj->n_pairs == obj->cap_pairs) {
        obj->cap_pairs = obj->cap_pairs ? obj->cap_pairs * 2 : 8;
        obj->pairs = xrealloc(obj->pairs, obj->cap_pairs * sizeof *obj->pairs);
    }
    obj->pairs[obj->n_pairs].key = xstrdup(key);
    obj->pairs[obj->n_pairs].val = val;
    obj->n_pairs++;
}

static JValue *jstr_new(const char *s) {
    JValue *v = jnew(J_STR);
    v->str = xstrdup(s);
    return v;
}

/* First value stored under `key`, NULL when absent. */
static JValue *jget(JValue *v, const char *key) {
    if (!v || v->type != J_OBJ) return NULL;
    for (size_t i = 0; i < v->n_pairs; i++) {
        if (strcmp(v->pairs[i].key, key) == 0) return v->pairs[i].val;
    }
    return NULL;
}

/* The value of `key` when it is a string, NULL otherwise. */
static const char *jstr(JValue *v, const char *key) {
    JValue *r = jget(v, key);
    return r && r->type == J_STR ? r->str : NULL;
}

static int jis_obj(JValue *v) { return v && v->type == J_OBJ; }

/* Recursive-descent JSON parser; trailing garbage is an error. */
typedef struct {
    const char *s;
    size_t pos, len;
    char err[192];
} JParser;

static void jerr(JParser *p, const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    vsnprintf(p->err, sizeof p->err, fmt, ap);
    va_end(ap);
}

static int jpeek(JParser *p) { return p->pos < p->len ? (unsigned char) p->s[p->pos] : -1; }

static void jskip_ws(JParser *p) {
    int c = jpeek(p);
    while (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
        p->pos++;
        c = jpeek(p);
    }
}

static JValue *jvalue(JParser *p);

static JValue *jliteral(JParser *p, const char *word, JValue *v) {
    size_t n = strlen(word);
    if (p->pos + n <= p->len && memcmp(p->s + p->pos, word, n) == 0) {
        p->pos += n;
        return v;
    }
    jerr(p, "invalid literal at byte %zu", p->pos);
    return NULL;
}

/* Escape a string the way JSON.stringify does: the two mandatory escapes, the
 * single-char shorthands, and \u00XX for remaining control characters. */
static void write_escaped(StrBuf *out, const char *s) {
    sb_append_n(out, "\"", 1);
    for (const unsigned char *c = (const unsigned char *) s; *c; c++) {
        switch (*c) {
            case '"': sb_append(out, "\\\""); break;
            case '\\': sb_append(out, "\\\\"); break;
            case '\n': sb_append(out, "\\n"); break;
            case '\r': sb_append(out, "\\r"); break;
            case '\t': sb_append(out, "\\t"); break;
            case '\b': sb_append(out, "\\b"); break;
            case '\f': sb_append(out, "\\f"); break;
            default:
                if (*c < 0x20) sb_appendf(out, "\\u%04x", (unsigned) *c);
                else sb_append_n(out, (const char *) c, 1);
        }
    }
    sb_append_n(out, "\"", 1);
}

/* Decode one \uXXXX escape, pairing high surrogates with a following low one. */
static unsigned jhex4(JParser *p, int *ok) {
    unsigned v = 0;
    for (int i = 0; i < 4; i++) {
        int c = jpeek(p);
        int d;
        if (c >= '0' && c <= '9') d = c - '0';
        else if (c >= 'a' && c <= 'f') d = c - 'a' + 10;
        else if (c >= 'A' && c <= 'F') d = c - 'A' + 10;
        else {
            jerr(p, "invalid hex digit at byte %zu", p->pos);
            *ok = 0;
            return 0;
        }
        p->pos++;
        v = v * 16u + (unsigned) d;
    }
    return v;
}

static void append_utf8(StrBuf *out, unsigned cp) {
    if (cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) cp = 0xFFFD;
    if (cp < 0x80) {
        sb_append_n(out, (const char[]) { (char) cp }, 1);
    } else if (cp < 0x800) {
        sb_append_n(out, (const char[]) { (char) (0xC0 | (cp >> 6)), (char) (0x80 | (cp & 0x3F)) }, 2);
    } else if (cp < 0x10000) {
        sb_append_n(out, (const char[]) { (char) (0xE0 | (cp >> 12)),
                                          (char) (0x80 | ((cp >> 6) & 0x3F)),
                                          (char) (0x80 | (cp & 0x3F)) }, 3);
    } else {
        sb_append_n(out, (const char[]) { (char) (0xF0 | (cp >> 18)),
                                          (char) (0x80 | ((cp >> 12) & 0x3F)),
                                          (char) (0x80 | ((cp >> 6) & 0x3F)),
                                          (char) (0x80 | (cp & 0x3F)) }, 4);
    }
}

static char *jstring(JParser *p) {
    p->pos++; /* opening quote */
    StrBuf out;
    sb_init(&out);
    for (;;) {
        int c = jpeek(p);
        if (c == -1) {
            jerr(p, "unterminated string at byte %zu", p->pos);
            free(out.data);
            return NULL;
        }
        p->pos++;
        if (c == '"') return out.data;
        if (c == '\\') {
            int esc = jpeek(p);
            if (esc == -1) {
                jerr(p, "unterminated escape");
                free(out.data);
                return NULL;
            }
            p->pos++;
            switch (esc) {
                case '"': sb_append_n(&out, "\"", 1); break;
                case '\\': sb_append_n(&out, "\\", 1); break;
                case '/': sb_append_n(&out, "/", 1); break;
                case 'b': sb_append_n(&out, "\b", 1); break;
                case 'f': sb_append_n(&out, "\f", 1); break;
                case 'n': sb_append_n(&out, "\n", 1); break;
                case 'r': sb_append_n(&out, "\r", 1); break;
                case 't': sb_append_n(&out, "\t", 1); break;
                case 'u': {
                    int ok = 1;
                    unsigned hi = jhex4(p, &ok);
                    if (!ok) { free(out.data); return NULL; }
                    if (hi >= 0xD800 && hi <= 0xDBFF) {
                        /* High surrogate: combine with a following \uXXXX low surrogate. */
                        int paired = jpeek(p) == '\\'
                            && p->pos + 1 < p->len && p->s[p->pos + 1] == 'u';
                        if (paired) {
                            p->pos += 2;
                            unsigned lo = jhex4(p, &ok);
                            if (!ok) { free(out.data); return NULL; }
                            append_utf8(&out, 0x10000u + ((hi - 0xD800u) << 10) + (lo - 0xDC00u));
                        } else {
                            append_utf8(&out, 0xFFFD);
                        }
                    } else {
                        append_utf8(&out, hi);
                    }
                    break;
                }
                default:
                    jerr(p, "invalid escape \\%c at byte %zu", (char) esc, p->pos - 1);
                    free(out.data);
                    return NULL;
            }
        } else {
            sb_append_n(&out, (const char[]) { (char) c }, 1);
        }
    }
}

static JValue *jnumber(JParser *p) {
    size_t start = p->pos;
    for (;;) {
        int c = jpeek(p);
        int keep = c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E'
            || (c >= '0' && c <= '9');
        if (keep) p->pos++;
        else break;
    }
    char *text = xstrndup(p->s + start, p->pos - start);
    char *end = NULL;
    double d = strtod(text, &end);
    if (!end || *end != '\0' || end == text) {
        jerr(p, "invalid number \"%s\" at byte %zu", text, start);
        free(text);
        return NULL;
    }
    free(text);
    JValue *v = jnew(J_NUM);
    v->num = d;
    return v;
}

static JValue *jvalue(JParser *p) {
    int c = jpeek(p);
    if (c == -1) {
        jerr(p, "unexpected end of input");
        return NULL;
    }
    if (c == '{') {
        p->pos++;
        JValue *obj = jnew(J_OBJ);
        jskip_ws(p);
        if (jpeek(p) == '}') { p->pos++; return obj; }
        for (;;) {
            jskip_ws(p);
            if (jpeek(p) != '"') {
                jerr(p, "expected object key at byte %zu", p->pos);
                jfree(obj);
                return NULL;
            }
            char *key = jstring(p);
            if (!key) { jfree(obj); return NULL; }
            jskip_ws(p);
            if (jpeek(p) != ':') {
                jerr(p, "expected ':' at byte %zu", p->pos);
                free(key);
                jfree(obj);
                return NULL;
            }
            p->pos++;
            jskip_ws(p);
            JValue *val = jvalue(p);
            if (!val) { free(key); jfree(obj); return NULL; }
            if (obj->n_pairs == obj->cap_pairs) {
                obj->cap_pairs = obj->cap_pairs ? obj->cap_pairs * 2 : 8;
                obj->pairs = xrealloc(obj->pairs, obj->cap_pairs * sizeof *obj->pairs);
            }
            obj->pairs[obj->n_pairs].key = key;
            obj->pairs[obj->n_pairs].val = val;
            obj->n_pairs++;
            jskip_ws(p);
            if (jpeek(p) == ',') { p->pos++; continue; }
            if (jpeek(p) == '}') { p->pos++; return obj; }
            jerr(p, "expected ',' or '}' at byte %zu", p->pos);
            jfree(obj);
            return NULL;
        }
    }
    if (c == '[') {
        p->pos++;
        JValue *arr = jnew(J_ARR);
        jskip_ws(p);
        if (jpeek(p) == ']') { p->pos++; return arr; }
        for (;;) {
            jskip_ws(p);
            JValue *item = jvalue(p);
            if (!item) { jfree(arr); return NULL; }
            jarr_push(arr, item);
            jskip_ws(p);
            if (jpeek(p) == ',') { p->pos++; continue; }
            if (jpeek(p) == ']') { p->pos++; return arr; }
            jerr(p, "expected ',' or ']' at byte %zu", p->pos);
            jfree(arr);
            return NULL;
        }
    }
    if (c == '"') {
        char *s = jstring(p);
        if (!s) return NULL;
        JValue *v = jnew(J_STR);
        v->str = s;
        return v;
    }
    if (c == 't') {
        JValue *v = jnew(J_BOOL);
        v->b = 1;
        return jliteral(p, "true", v);
    }
    if (c == 'f') {
        JValue *v = jnew(J_BOOL);
        v->b = 0;
        return jliteral(p, "false", v);
    }
    if (c == 'n') return jliteral(p, "null", jnew(J_NULL));
    if (c == '-' || (c >= '0' && c <= '9')) return jnumber(p);
    jerr(p, "unexpected '%c' at byte %zu", (char) c, p->pos);
    return NULL;
}

static JValue *json_parse(const char *input, char *err, size_t errsz) {
    JParser p = { .s = input, .pos = 0, .len = strlen(input), .err = "" };
    jskip_ws(&p);
    JValue *v = jvalue(&p);
    if (!v) {
        snprintf(err, errsz, "%s", p.err);
        return NULL;
    }
    jskip_ws(&p);
    if (p.pos != p.len) {
        snprintf(err, errsz, "unexpected trailing characters at byte %zu", p.pos);
        jfree(v);
        return NULL;
    }
    return v;
}

/* Render a double the way JavaScript's number-to-string coercion does: whole
 * values carry no trailing ".0". */
static void format_f64(StrBuf *out, double n) {
    if (n == (double) (long long) n && (n < 0 ? -n : n) < 1e15) {
        sb_appendf(out, "%lld", (long long) n);
    } else {
        sb_appendf(out, "%.15g", n);
    }
}

static void push_indent(StrBuf *out, int depth) {
    for (int i = 0; i < depth; i++) sb_append(out, "  ");
}

static void write_json(StrBuf *out, JValue *v, int indent, int depth) {
    switch (v->type) {
        case J_NULL:
            sb_append(out, "null");
            break;
        case J_BOOL:
            sb_append(out, v->b ? "true" : "false");
            break;
        case J_NUM:
            format_f64(out, v->num);
            break;
        case J_STR:
            write_escaped(out, v->str);
            break;
        case J_ARR:
            if (v->n_items == 0) { sb_append(out, "[]"); break; }
            sb_append_n(out, "[", 1);
            for (size_t i = 0; i < v->n_items; i++) {
                if (i > 0) sb_append_n(out, ",", 1);
                if (indent) { sb_append_n(out, "\n", 1); push_indent(out, depth + 1); }
                write_json(out, v->items[i], indent, depth + 1);
            }
            if (indent) { sb_append_n(out, "\n", 1); push_indent(out, depth); }
            sb_append_n(out, "]", 1);
            break;
        case J_OBJ:
            if (v->n_pairs == 0) { sb_append(out, "{}"); break; }
            sb_append_n(out, "{", 1);
            for (size_t i = 0; i < v->n_pairs; i++) {
                if (i > 0) sb_append_n(out, ",", 1);
                if (indent) { sb_append_n(out, "\n", 1); push_indent(out, depth + 1); }
                write_escaped(out, v->pairs[i].key);
                sb_append(out, indent ? ": " : ":");
                write_json(out, v->pairs[i].val, indent, depth + 1);
            }
            if (indent) { sb_append_n(out, "\n", 1); push_indent(out, depth); }
            sb_append_n(out, "}", 1);
            break;
    }
}

/* Compact encoding, the moral equivalent of TS `JSON.stringify`. */
static char *jto_compact(JValue *v) {
    StrBuf out;
    sb_init(&out);
    write_json(&out, v, 0, 0);
    return out.data;
}

/* Pretty encoding with a 2-space indent, like `JSON.stringify(v, null, 2)`. */
static char *jto_pretty(JValue *v) {
    StrBuf out;
    sb_init(&out);
    write_json(&out, v, 2, 0);
    return out.data;
}

/* ------------------------------ domain types ------------------------------ */

/* One assistant-emitted tool call, provider-agnostic. `args` is a JSON string. */
typedef struct {
    char *id;
    char *name;
    char *args;
} ToolCall;

/* The shared internal shape every format parses into and serializes from. */
typedef struct {
    char *role;      /* "system" | "user" | "assistant" | "tool" */
    char *content;   /* "" when the message only carries tool calls/results */
    char *name;      /* nullable */
    ToolCall *tool_calls;
    size_t n_tool_calls;
    int has_tool_calls; /* distinguishes "no field" from "empty list" */
    char *tool_call_id; /* nullable */
} Message;

/* The conversion output plus human-readable notes about unmapped fields. */
typedef struct {
    char *output;
    char **warnings;
    size_t n_warnings;
} ConversionResult;

static const char *const CHAT_FORMATS[] = { "openai", "anthropic", "gemini", "markdown" };

/* Shared context: error message + warning accumulator. */
typedef struct {
    char err[512];
    char **warnings;
    size_t n_warnings, cap_warnings;
} Ctx;

static void ctx_warn(Ctx *ctx, const char *fmt, ...) {
    if (ctx->n_warnings == ctx->cap_warnings) {
        ctx->cap_warnings = ctx->cap_warnings ? ctx->cap_warnings * 2 : 8;
        ctx->warnings = xrealloc(ctx->warnings, ctx->cap_warnings * sizeof *ctx->warnings);
    }
    char buf[512];
    va_list ap;
    va_start(ap, fmt);
    vsnprintf(buf, sizeof buf, fmt, ap);
    va_end(ap);
    ctx->warnings[ctx->n_warnings++] = xstrdup(buf);
}

static void *ctx_fail(Ctx *ctx, const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    vsnprintf(ctx->err, sizeof ctx->err, fmt, ap);
    va_end(ap);
    return NULL;
}

/* ------------------------------ str vectors ------------------------------- */

typedef struct {
    char **items;
    size_t n, cap;
} StrList;

static void sl_init(StrList *l) { l->items = NULL; l->n = l->cap = 0; }

static void sl_push(StrList *l, char *s) {
    if (l->n == l->cap) {
        l->cap = l->cap ? l->cap * 2 : 8;
        l->items = xrealloc(l->items, l->cap * sizeof *l->items);
    }
    l->items[l->n++] = s;
}

static void sl_free(StrList *l) {
    for (size_t i = 0; i < l->n; i++) free(l->items[i]);
    free(l->items);
}

static char *sl_join(StrList *l, const char *sep) {
    StrBuf out;
    sb_init(&out);
    for (size_t i = 0; i < l->n; i++) {
        if (i > 0) sb_append(&out, sep);
        sb_append(&out, l->items[i]);
    }
    return out.data;
}

/* -------------------------------- messages -------------------------------- */

typedef struct {
    Message *items;
    size_t n, cap;
} MsgList;

static void ml_init(MsgList *l) { l->items = NULL; l->n = l->cap = 0; }

static Message *ml_push(MsgList *l, Message m) {
    if (l->n == l->cap) {
        l->cap = l->cap ? l->cap * 2 : 8;
        l->items = xrealloc(l->items, l->cap * sizeof *l->items);
    }
    l->items[l->n] = m;
    return &l->items[l->n++];
}

static void ml_free(MsgList *l) {
    for (size_t i = 0; i < l->n; i++) {
        Message *m = &l->items[i];
        free(m->role);
        free(m->content);
        free(m->name);
        for (size_t k = 0; k < m->n_tool_calls; k++) {
            free(m->tool_calls[k].id);
            free(m->tool_calls[k].name);
            free(m->tool_calls[k].args);
        }
        free(m->tool_calls);
        free(m->tool_call_id);
    }
    free(l->items);
}

static Message msg_new(const char *role, char *content) {
    Message m;
    memset(&m, 0, sizeof m);
    m.role = xstrdup(role);
    m.content = content;
    return m;
}

/* -------------------------------- helpers --------------------------------- */

static JValue *parse_transcript_json(Ctx *ctx, const char *text, const char *label) {
    char jerr[192];
    JValue *v = json_parse(text, jerr, sizeof jerr);
    if (!v) return ctx_fail(ctx, "%s: invalid JSON — %s", label, jerr);
    return v;
}

/* The TS `?? {}` capture: null/missing becomes {}, any other value passes
 * through and stringifies as-is. */
static char *args_of(JValue *v) {
    if (!v || v->type == J_NULL) return xstrdup("{}");
    return jto_compact(v);
}

/* Parse a JSON-string argument back to a value, an empty object when it is
 * not a JSON object (mirrors parseArgsOrEmpty). */
static JValue *parse_args_or_empty(const char *args) {
    char jerr[192];
    JValue *v = json_parse(args, jerr, sizeof jerr);
    if (v && v->type == J_OBJ) return v;
    jfree(v);
    return jnew(J_OBJ);
}

static const char *block_type_name(JValue *raw) {
    const char *t = jstr(raw, "type");
    return t ? t : "content";
}

/* One normalized Anthropic content block. */
typedef enum { B_TEXT, B_TOOL_USE, B_TOOL_RESULT, B_OTHER } BKind;

typedef struct {
    BKind kind;
    char *text;   /* B_TEXT */
    JValue *raw;  /* B_TOOL_USE | B_TOOL_RESULT | B_OTHER */
} Block;

typedef struct {
    Block *items;
    size_t n, cap;
} BlockList;

static BlockList content_blocks(Ctx *ctx, JValue *content, const char *where) {
    BlockList blocks = { NULL, 0, 0 };
    if (!content) {
        ctx_fail(ctx, "%s: content must be a string or an array of blocks", where);
        return blocks;
    }
    if (content->type == J_STR) {
        blocks.items = xmalloc(sizeof *blocks.items);
        blocks.items[0] = (Block) { .kind = B_TEXT, .text = xstrdup(content->str) };
        blocks.n = 1;
        return blocks;
    }
    if (content->type != J_ARR) {
        ctx_fail(ctx, "%s: content must be a string or an array of blocks", where);
        return blocks;
    }
    for (size_t i = 0; i < content->n_items; i++) {
        JValue *block = content->items[i];
        const char *type = jstr(block, "type");
        JValue *textv = jget(block, "text");
        int is_text = type && strcmp(type, "text") == 0 && textv && textv->type == J_STR;
        Block b;
        if (is_text) {
            b = (Block) { .kind = B_TEXT, .text = xstrdup(textv->str) };
        } else if (type && strcmp(type, "tool_use") == 0) {
            b = (Block) { .kind = B_TOOL_USE, .raw = block };
        } else if (type && strcmp(type, "tool_result") == 0) {
            b = (Block) { .kind = B_TOOL_RESULT, .raw = block };
        } else {
            b = (Block) { .kind = B_OTHER, .raw = block };
        }
        if (blocks.n == blocks.cap) {
            blocks.cap = blocks.cap ? blocks.cap * 2 : 8;
            blocks.items = xrealloc(blocks.items, blocks.cap * sizeof *blocks.items);
        }
        blocks.items[blocks.n++] = b;
    }
    return blocks;
}

static void blocks_free(BlockList *blocks) {
    for (size_t i = 0; i < blocks->n; i++) free(blocks->items[i].text);
    free(blocks->items);
}

/* Anthropic string-or-block[] content → plain text. */
static char *blocks_to_text(Ctx *ctx, JValue *content, const char *where) {
    if (!content) return xstrdup("");
    if (content->type == J_STR) return xstrdup(content->str);
    if (content->type == J_NULL) return xstrdup("");
    StrList texts;
    sl_init(&texts);
    BlockList blocks = content_blocks(ctx, content, where);
    if (ctx->err[0]) return NULL;
    for (size_t i = 0; i < blocks.n; i++) {
        Block b = blocks.items[i];
        switch (b.kind) {
            case B_TEXT:
                sl_push(&texts, xstrdup(b.text));
                break;
            case B_OTHER:
                ctx_warn(ctx, "%s: dropped unsupported %s block", where, block_type_name(b.raw));
                break;
            case B_TOOL_USE:
                ctx_warn(ctx, "%s: dropped tool_use block from text-only content", where);
                break;
            case B_TOOL_RESULT:
                ctx_warn(ctx, "%s: dropped tool_result block from text-only content", where);
                break;
        }
    }
    blocks_free(&blocks);
    char *joined = sl_join(&texts, "\n");
    sl_free(&texts);
    return joined;
}

static ToolCall tool_call_from_block(JValue *raw) {
    const char *id = jstr(raw, "id");
    const char *name = jstr(raw, "name");
    return (ToolCall) {
        .id = xstrdup(id ? id : ""),
        .name = xstrdup(name ? name : ""),
        .args = args_of(jget(raw, "input")),
    };
}

static int is_blank(const char *s) {
    for (; *s; s++) {
        if (*s != ' ' && *s != '\t' && *s != '\r' && *s != '\n' && *s != '\x0b' && *s != '\x0c') return 0;
    }
    return 1;
}

static char *trim_dup(const char *s) {
    while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n' || *s == '\x0b' || *s == '\x0c') s++;
    size_t n = strlen(s);
    while (n > 0 && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r'
        || s[n - 1] == '\n' || s[n - 1] == '\x0b' || s[n - 1] == '\x0c')) n--;
    return xstrndup(s, n);
}

/* ---------------------------------- parse --------------------------------- */

/* OpenAI content → plain text (string, or text parts joined; other parts flagged). */
static char *content_text(Ctx *ctx, JValue *content, const char *where) {
    if (!content || content->type == J_NULL) return xstrdup("");
    if (content->type == J_STR) return xstrdup(content->str);
    if (content->type == J_ARR) {
        StrList texts;
        sl_init(&texts);
        size_t dropped = 0;
        for (size_t i = 0; i < content->n_items; i++) {
            JValue *part = content->items[i];
            const char *type = jstr(part, "type");
            JValue *textv = jget(part, "text");
            if (type && strcmp(type, "text") == 0 && textv && textv->type == J_STR) {
                sl_push(&texts, xstrdup(textv->str));
            } else {
                dropped++;
            }
        }
        if (dropped > 0) ctx_warn(ctx, "%s: dropped %zu non-text content part(s)", where, dropped);
        StrBuf joined;
        sb_init(&joined);
        for (size_t i = 0; i < texts.n; i++) sb_append(&joined, texts.items[i]);
        sl_free(&texts);
        return joined.data;
    }
    return ctx_fail(ctx, "%s: content must be a string, an array of parts, or null", where);
}

static int parse_openai_tool_calls(Ctx *ctx, JValue *raw, ToolCall **out, size_t *n_out,
                                   int *has_out) {
    *out = NULL;
    *n_out = 0;
    if (!raw || raw->type == J_NULL) {
        *has_out = 0;
        return 0;
    }
    if (raw->type != J_ARR) {
        ctx_fail(ctx, "tool_calls must be an array");
        return -1;
    }
    *has_out = 1;
    if (raw->n_items == 0) return 0;
    *out = xmalloc(raw->n_items * sizeof **out);
    for (size_t i = 0; i < raw->n_items; i++) {
        JValue *entry = raw->items[i];
        JValue *fn = jget(entry, "function");
        const char *id = jstr(entry, "id");
        const char *name = fn ? jstr(fn, "name") : NULL;
        const char *arguments = fn ? jstr(fn, "arguments") : NULL;
        (*out)[i] = (ToolCall) {
            .id = xstrdup(id ? id : ""),
            .name = xstrdup(name ? name : ""),
            .args = xstrdup(arguments ? arguments : ""),
        };
    }
    *n_out = raw->n_items;
    return 0;
}

/* OpenAI messages[] (bare array, or wrapped in {"messages": [...]}). */
static int parse_openai(Ctx *ctx, const char *text, MsgList *out) {
    JValue *parsed = parse_transcript_json(ctx, text, "OpenAI transcript");
    if (!parsed) return -1;
    JValue *messages = jis_obj(parsed) ? jget(parsed, "messages") : NULL;
    JValue *items = messages && messages->type == J_ARR ? messages
        : parsed->type == J_ARR ? parsed : NULL;
    if (!items) {
        jfree(parsed);
        ctx_fail(ctx,
            "OpenAI transcript must be a messages[] array or an object with a \"messages\" array");
        return -1;
    }
    int rc = -1;
    for (size_t i = 0; i < items->n_items; i++) {
        JValue *entry = items->items[i];
        char where[64];
        snprintf(where, sizeof where, "messages[%zu]", i);
        if (!jis_obj(entry)) {
            ctx_fail(ctx, "messages[%zu] is not an object", i);
            goto done;
        }
        const char *role = jstr(entry, "role");
        char *content = NULL;
        if (role && (strcmp(role, "system") == 0 || strcmp(role, "developer") == 0)) {
            content = content_text(ctx, jget(entry, "content"), where);
            if (!content) goto done;
            ml_push(out, msg_new("system", content));
            continue;
        }
        if (role && (strcmp(role, "user") == 0 || strcmp(role, "assistant") == 0
            || strcmp(role, "tool") == 0)) {
            content = content_text(ctx, jget(entry, "content"), where);
            if (!content) goto done;
            Message m = msg_new(role, content);
            const char *name = jstr(entry, "name");
            const char *tcid = jstr(entry, "tool_call_id");
            if (name) m.name = xstrdup(name);
            if (tcid) m.tool_call_id = xstrdup(tcid);
            if (parse_openai_tool_calls(ctx, jget(entry, "tool_calls"),
                    &m.tool_calls, &m.n_tool_calls, &m.has_tool_calls) != 0) {
                free(m.role);
                free(m.content);
                goto done;
            }
            ml_push(out, m);
            continue;
        }
        JValue *rv = jget(entry, "role");
        char *rendered = rv ? jto_compact(rv) : NULL;
        ctx_fail(ctx, "messages[%zu] has unsupported role: %s", i, rendered ? rendered : "null");
        free(rendered);
        goto done;
    }
    rc = 0;
done:
    jfree(parsed);
    return rc;
}

static void flush_user_texts(MsgList *out, StrList *texts) {
    if (texts->n > 0) {
        char *joined = sl_join(texts, "\n");
        ml_push(out, msg_new("user", joined));
        for (size_t i = 0; i < texts->n; i++) free(texts->items[i]);
        texts->n = 0;
    }
}

/* Anthropic messages[] plus the top-level system field. */
static int parse_anthropic(Ctx *ctx, const char *text, MsgList *out) {
    JValue *root = parse_transcript_json(ctx, text, "Anthropic transcript");
    if (!root) return -1;
    int rc = -1;
    JValue *messages = jis_obj(root) ? jget(root, "messages") : NULL;
    if (!messages || messages->type != J_ARR) {
        ctx_fail(ctx, "Anthropic transcript must be an object with a \"messages\" array");
        goto done;
    }
    if (jget(root, "system")) {
        char *sys = blocks_to_text(ctx, jget(root, "system"), "system");
        if (!sys) goto done;
        ml_push(out, msg_new("system", sys));
    }
    for (size_t i = 0; i < messages->n_items; i++) {
        JValue *entry = messages->items[i];
        char where[64];
        snprintf(where, sizeof where, "messages[%zu]", i);
        if (!jis_obj(entry)) {
            ctx_fail(ctx, "messages[%zu] is not an object", i);
            goto done;
        }
        const char *role = jstr(entry, "role");
        if (role && strcmp(role, "assistant") == 0) {
            StrList texts;
            sl_init(&texts);
            ToolCall *calls = NULL;
            size_t n_calls = 0;
            BlockList blocks = content_blocks(ctx, jget(entry, "content"), where);
            if (ctx->err[0]) { blocks_free(&blocks); sl_free(&texts); goto done; }
            for (size_t k = 0; k < blocks.n; k++) {
                Block b = blocks.items[k];
                if (b.kind == B_TEXT) {
                    sl_push(&texts, xstrdup(b.text));
                } else if (b.kind == B_TOOL_USE) {
                    calls = xrealloc(calls, (n_calls + 1) * sizeof *calls);
                    calls[n_calls++] = tool_call_from_block(b.raw);
                } else if (b.kind == B_OTHER) {
                    ctx_warn(ctx, "%s: dropped unsupported %s block", where, block_type_name(b.raw));
                } /* B_TOOL_RESULT: ignored inside an assistant message */
            }
            blocks_free(&blocks);
            char *joined = sl_join(&texts, "\n");
            Message m = msg_new("assistant", joined);
            sl_free(&texts);
            if (n_calls > 0) {
                m.tool_calls = calls;
                m.n_tool_calls = n_calls;
                m.has_tool_calls = 1;
            } else {
                free(calls);
            }
            ml_push(out, m);
            continue;
        }
        if (role && strcmp(role, "user") == 0) {
            StrList texts;
            sl_init(&texts);
            BlockList blocks = content_blocks(ctx, jget(entry, "content"), where);
            if (ctx->err[0]) { blocks_free(&blocks); sl_free(&texts); goto done; }
            for (size_t k = 0; k < blocks.n; k++) {
                Block b = blocks.items[k];
                if (b.kind == B_TEXT) {
                    sl_push(&texts, xstrdup(b.text));
                    continue;
                }
                if (b.kind == B_TOOL_USE) {
                    ctx_warn(ctx, "%s: tool_use block inside a user message moved to an assistant tool call", where);
                    flush_user_texts(out, &texts);
                    Message m = msg_new("assistant", xstrdup(""));
                    m.tool_calls = xmalloc(sizeof *m.tool_calls);
                    m.tool_calls[0] = tool_call_from_block(b.raw);
                    m.n_tool_calls = 1;
                    m.has_tool_calls = 1;
                    ml_push(out, m);
                    continue;
                }
                if (b.kind == B_TOOL_RESULT) {
                    flush_user_texts(out, &texts);
                    char *ctext = blocks_to_text(ctx, jget(b.raw, "content"), where);
                    if (!ctext) { blocks_free(&blocks); sl_free(&texts); goto done; }
                    Message m = msg_new("tool", ctext);
                    const char *tuid = jstr(b.raw, "tool_use_id");
                    m.tool_call_id = xstrdup(tuid ? tuid : "");
                    ml_push(out, m);
                    continue;
                }
                ctx_warn(ctx, "%s: dropped unsupported %s block", where, block_type_name(b.raw));
            }
            blocks_free(&blocks);
            flush_user_texts(out, &texts);
            sl_free(&texts);
            continue;
        }
        JValue *rv = jget(entry, "role");
        char *rendered = rv ? jto_compact(rv) : NULL;
        ctx_fail(ctx, "%s has unsupported role: %s", where, rendered ? rendered : "null");
        free(rendered);
        goto done;
    }
    rc = 0;
done:
    jfree(root);
    return rc;
}

/* Gemini systemInstruction (string or {parts}) → plain text. */
static char *gemini_text(Ctx *ctx, JValue *v, const char *where) {
    if (v->type == J_STR) return xstrdup(v->str);
    if (v->type == J_OBJ) {
        const char *t = jstr(v, "text");
        if (t) return xstrdup(t);
        JValue *parts = jget(v, "parts");
        if (parts && parts->type == J_ARR) {
            StrList texts;
            sl_init(&texts);
            for (size_t i = 0; i < parts->n_items; i++) {
                const char *pt = jstr(parts->items[i], "text");
                sl_push(&texts, xstrdup(pt ? pt : ""));
            }
            char *joined = sl_join(&texts, "\n");
            sl_free(&texts);
            return joined;
        }
    }
    ctx_warn(ctx, "%s: unsupported systemInstruction shape, treated as empty", where);
    return xstrdup("");
}

/* Gemini contents[] plus the optional systemInstruction. */
static int parse_gemini(Ctx *ctx, const char *text, MsgList *out) {
    JValue *root = parse_transcript_json(ctx, text, "Gemini transcript");
    if (!root) return -1;
    int rc = -1;
    JValue *contents = jis_obj(root) ? jget(root, "contents") : NULL;
    if (!contents || contents->type != J_ARR) {
        ctx_fail(ctx, "Gemini transcript must be an object with a \"contents\" array");
        goto done;
    }
    if (jget(root, "systemInstruction")) {
        char *sys = gemini_text(ctx, jget(root, "systemInstruction"), "systemInstruction");
        if (!sys) goto done;
        ml_push(out, msg_new("system", sys));
    }
    for (size_t i = 0; i < contents->n_items; i++) {
        JValue *entry = contents->items[i];
        char where[64];
        snprintf(where, sizeof where, "contents[%zu]", i);
        if (!jis_obj(entry)) {
            ctx_fail(ctx, "contents[%zu] is not an object", i);
            goto done;
        }
        const char *role = jstr(entry, "role");
        if (!role || (strcmp(role, "user") != 0 && strcmp(role, "model") != 0)) {
            JValue *rv = jget(entry, "role");
            char *rendered = rv ? jto_compact(rv) : NULL;
            ctx_fail(ctx, "%s has unsupported role: %s (Gemini uses \"user\" or \"model\")",
                where, rendered ? rendered : "null");
            free(rendered);
            goto done;
        }
        JValue *parts = jget(entry, "parts");
        if (!parts || parts->type != J_ARR) {
            ctx_fail(ctx, "%s: parts must be an array", where);
            goto done;
        }
        if (strcmp(role, "model") == 0) {
            /* A model turn keeps its text and function calls in ONE message,
             * mirroring an OpenAI assistant message with tool_calls. */
            StrList texts;
            sl_init(&texts);
            ToolCall *calls = NULL;
            size_t n_calls = 0;
            for (size_t k = 0; k < parts->n_items; k++) {
                JValue *part = parts->items[k];
                const char *t = jstr(part, "text");
                if (t) {
                    sl_push(&texts, xstrdup(t));
                    continue;
                }
                JValue *fc = jget(part, "functionCall");
                if (fc && fc->type == J_OBJ) {
                    const char *name = jstr(fc, "name");
                    calls = xrealloc(calls, (n_calls + 1) * sizeof *calls);
                    calls[n_calls].id = xstrdup("");
                    calls[n_calls].name = xstrdup(name ? name : "");
                    calls[n_calls].args = args_of(jget(fc, "args"));
                    n_calls++;
                    continue;
                }
                ctx_warn(ctx, "%s: dropped unsupported part (inlineData or similar)", where);
            }
            char *joined = sl_join(&texts, "\n");
            Message m = msg_new("assistant", joined);
            sl_free(&texts);
            if (n_calls > 0) {
                m.tool_calls = calls;
                m.n_tool_calls = n_calls;
                m.has_tool_calls = 1;
            } else {
                free(calls);
            }
            ml_push(out, m);
            continue;
        }
        StrList texts;
        sl_init(&texts);
        for (size_t k = 0; k < parts->n_items; k++) {
            JValue *part = parts->items[k];
            const char *t = jstr(part, "text");
            if (t) {
                sl_push(&texts, xstrdup(t));
                continue;
            }
            JValue *fc = jget(part, "functionCall");
            if (fc && fc->type == J_OBJ) {
                flush_user_texts(out, &texts);
                Message m = msg_new("assistant", xstrdup(""));
                const char *name = jstr(fc, "name");
                m.tool_calls = xmalloc(sizeof *m.tool_calls);
                m.tool_calls[0].id = xstrdup("");
                m.tool_calls[0].name = xstrdup(name ? name : "");
                m.tool_calls[0].args = args_of(jget(fc, "args"));
                m.n_tool_calls = 1;
                m.has_tool_calls = 1;
                ml_push(out, m);
                continue;
            }
            JValue *fr = jget(part, "functionResponse");
            if (fr && fr->type == J_OBJ) {
                flush_user_texts(out, &texts);
                const char *name = jstr(fr, "name");
                Message m = msg_new("tool", args_of(jget(fr, "response")));
                m.name = xstrdup(name ? name : "");
                m.tool_call_id = xstrdup(name ? name : "");
                ml_push(out, m);
                continue;
            }
            ctx_warn(ctx, "%s: dropped unsupported part (inlineData or similar)", where);
        }
        flush_user_texts(out, &texts);
        sl_free(&texts);
    }
    rc = 0;
done:
    jfree(root);
    return rc;
}

/* A parsed Markdown header line: `**role**: text`. Returns 0 on no match. */
static int markdown_header(const char *line, char role[16], char **first) {
    if (strncmp(line, "**", 2) != 0) return 0;
    const char *rest = line + 2;
    const char *end = strstr(rest, "**:");
    if (!end) return 0;
    size_t role_len = (size_t) (end - rest);
    const char *mapped = NULL;
    if (role_len == 6 && strncmp(rest, "system", 6) == 0) mapped = "system";
    else if (role_len == 4 && strncmp(rest, "user", 4) == 0) mapped = "user";
    else if (role_len == 9 && strncmp(rest, "assistant", 9) == 0) mapped = "assistant";
    else if (role_len == 5 && strncmp(rest, "model", 5) == 0) mapped = "assistant";
    else if (role_len == 4 && strncmp(rest, "tool", 4) == 0) mapped = "tool";
    if (!mapped) return 0;
    snprintf(role, 16, "%s", mapped);
    const char *p = end + 3;
    while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' || *p == '\x0b' || *p == '\x0c') p++;
    *first = xstrdup(p);
    return 1;
}

static Message finish_markdown_message(const char *role, StrList *lines) {
    /* Trim leading/trailing blank continuation lines but keep inner blank lines. */
    while (lines->n > 1 && is_blank(lines->items[0])) {
        free(lines->items[0]);
        memmove(lines->items, lines->items + 1, (lines->n - 1) * sizeof *lines->items);
        lines->n--;
    }
    while (lines->n > 1 && is_blank(lines->items[lines->n - 1])) {
        free(lines->items[lines->n - 1]);
        lines->n--;
    }
    char *joined = sl_join(lines, "\n");
    return msg_new(role, joined);
}

/* Markdown transcript: `**role**: text` header lines with continuation lines
 * belonging to the same message. `model` maps to assistant. */
static int parse_markdown(Ctx *ctx, const char *text, MsgList *out) {
    StrList lines;
    sl_init(&lines);
    char role[16] = "";
    int in_message = 0;
    size_t start = 0;
    for (;;) {
        const char *nl = strchr(text + start, '\n');
        size_t len = nl ? (size_t) (nl - (text + start)) : strlen(text + start);
        char *line = xstrndup(text + start, len);
        char hdr_role[16], *hdr_first = NULL;
        if (markdown_header(line, hdr_role, &hdr_first)) {
            if (in_message) {
                ml_push(out, finish_markdown_message(role, &lines));
                sl_free(&lines);
            }
            snprintf(role, sizeof role, "%s", hdr_role);
            sl_init(&lines);
            sl_push(&lines, hdr_first);
            in_message = 1;
        } else if (in_message) {
            sl_push(&lines, line);
        } else if (!is_blank(line)) {
            free(line);
            sl_free(&lines);
            ctx_fail(ctx, "Markdown transcript must start with a `**role**:` header line");
            return -1;
        } else {
            free(line);
        }
        if (!nl) break;
        start = (size_t) (nl - text) + 1;
    }
    if (in_message) ml_push(out, finish_markdown_message(role, &lines));
    sl_free(&lines);
    return 0;
}

/* -------------------------------- serialize ------------------------------- */

static char *serialize_openai(MsgList *msgs, Ctx *ctx) {
    (void) ctx;
    JValue *root = jnew(J_OBJ);
    JValue *arr = jnew(J_ARR);
    for (size_t i = 0; i < msgs->n; i++) {
        Message *m = &msgs->items[i];
        JValue *o = jnew(J_OBJ);
        if (strcmp(m->role, "tool") == 0) {
            jobj_put(o, "role", jstr_new("tool"));
            jobj_put(o, "content", jstr_new(m->content));
            jobj_put(o, "tool_call_id", jstr_new(m->tool_call_id ? m->tool_call_id : ""));
            if (m->name) jobj_put(o, "name", jstr_new(m->name));
        } else if (strcmp(m->role, "assistant") == 0 && m->has_tool_calls) {
            jobj_put(o, "role", jstr_new("assistant"));
            if (m->content[0] == '\0') jobj_put(o, "content", jnew(J_NULL));
            else jobj_put(o, "content", jstr_new(m->content));
            JValue *calls = jnew(J_ARR);
            for (size_t k = 0; k < m->n_tool_calls; k++) {
                ToolCall *tc = &m->tool_calls[k];
                JValue *call = jnew(J_OBJ);
                jobj_put(call, "id", jstr_new(tc->id));
                jobj_put(call, "type", jstr_new("function"));
                JValue *fn = jnew(J_OBJ);
                jobj_put(fn, "name", jstr_new(tc->name));
                jobj_put(fn, "arguments", jstr_new(tc->args));
                jobj_put(call, "function", fn);
                jarr_push(calls, call);
            }
            jobj_put(o, "tool_calls", calls);
            if (m->name) jobj_put(o, "name", jstr_new(m->name));
        } else {
            jobj_put(o, "role", jstr_new(m->role));
            jobj_put(o, "content", jstr_new(m->content));
            if (m->name) jobj_put(o, "name", jstr_new(m->name));
        }
        jarr_push(arr, o);
    }
    jobj_put(root, "messages", arr);
    StrBuf out;
    sb_init(&out);
    char *pretty = jto_pretty(root);
    sb_append(&out, pretty);
    sb_append_n(&out, "\n", 1);
    free(pretty);
    jfree(root);
    return out.data;
}

static char *serialize_anthropic(MsgList *msgs, Ctx *ctx) {
    StrList system;
    sl_init(&system);
    JValue *arr = jnew(J_ARR);
    for (size_t i = 0; i < msgs->n; i++) {
        Message *m = &msgs->items[i];
        if (strcmp(m->role, "system") == 0) {
            sl_push(&system, xstrdup(m->content));
            continue;
        }
        if (m->name && strcmp(m->role, "tool") != 0) {
            ctx_warn(ctx, "message %zu: \"name\" has no Anthropic equivalent and was dropped", i);
        }
        JValue *o = jnew(J_OBJ);
        if (strcmp(m->role, "user") == 0) {
            jobj_put(o, "role", jstr_new("user"));
            jobj_put(o, "content", jstr_new(m->content));
        } else if (strcmp(m->role, "assistant") == 0) {
            jobj_put(o, "role", jstr_new("assistant"));
            if (m->has_tool_calls) {
                JValue *blocks = jnew(J_ARR);
                if (m->content[0] != '\0') {
                    JValue *tb = jnew(J_OBJ);
                    jobj_put(tb, "type", jstr_new("text"));
                    jobj_put(tb, "text", jstr_new(m->content));
                    jarr_push(blocks, tb);
                }
                for (size_t k = 0; k < m->n_tool_calls; k++) {
                    ToolCall *tc = &m->tool_calls[k];
                    JValue *ub = jnew(J_OBJ);
                    jobj_put(ub, "type", jstr_new("tool_use"));
                    jobj_put(ub, "id", jstr_new(tc->id));
                    jobj_put(ub, "name", jstr_new(tc->name));
                    jobj_put(ub, "input", parse_args_or_empty(tc->args));
                    jarr_push(blocks, ub);
                }
                jobj_put(o, "content", blocks);
            } else {
                jobj_put(o, "content", jstr_new(m->content));
            }
        } else {
            jobj_put(o, "role", jstr_new("user"));
            JValue *rb = jnew(J_OBJ);
            jobj_put(rb, "type", jstr_new("tool_result"));
            jobj_put(rb, "tool_use_id", jstr_new(m->tool_call_id ? m->tool_call_id : ""));
            jobj_put(rb, "content", jstr_new(m->content));
            JValue *wrapped = jnew(J_ARR);
            jarr_push(wrapped, rb);
            jobj_put(o, "content", wrapped);
        }
        jarr_push(arr, o);
    }
    JValue *root = jnew(J_OBJ);
    if (arr->n_items > 0) jobj_put(root, "messages", arr);
    else jfree(arr);
    if (system.n > 0) {
        char *joined = sl_join(&system, "\n\n");
        jobj_put(root, "system", jstr_new(joined));
        free(joined);
    }
    sl_free(&system);
    StrBuf out;
    sb_init(&out);
    char *pretty = jto_pretty(root);
    sb_append(&out, pretty);
    sb_append_n(&out, "\n", 1);
    free(pretty);
    jfree(root);
    return out.data;
}

/* (role, parts) turns; consecutive same-role parts merge into one turn. */
typedef struct {
    const char *role;
    JValue *parts; /* J_ARR */
} Turn;

static void push_turn(Turn **turns, size_t *n, size_t *cap, const char *role, JValue *part) {
    if (*n > 0 && strcmp((*turns)[*n - 1].role, role) == 0) {
        jarr_push((*turns)[*n - 1].parts, part);
        return;
    }
    if (*n == *cap) {
        *cap = *cap ? *cap * 2 : 8;
        *turns = xrealloc(*turns, *cap * sizeof **turns);
    }
    JValue *parts = jnew(J_ARR);
    jarr_push(parts, part);
    (*turns)[*n].role = role;
    (*turns)[*n].parts = parts;
    (*n)++;
}

static char *serialize_gemini(MsgList *msgs, Ctx *ctx) {
    StrList system;
    sl_init(&system);
    Turn *turns = NULL;
    size_t n_turns = 0, cap_turns = 0;
    for (size_t i = 0; i < msgs->n; i++) {
        Message *m = &msgs->items[i];
        if (strcmp(m->role, "system") == 0) {
            sl_push(&system, xstrdup(m->content));
            continue;
        }
        if (m->name && strcmp(m->role, "tool") != 0) {
            ctx_warn(ctx, "message %zu: \"name\" has no Gemini equivalent and was dropped", i);
        }
        if (strcmp(m->role, "user") == 0) {
            JValue *p = jnew(J_OBJ);
            jobj_put(p, "text", jstr_new(m->content));
            push_turn(&turns, &n_turns, &cap_turns, "user", p);
        } else if (strcmp(m->role, "assistant") == 0) {
            if (m->content[0] != '\0') {
                JValue *p = jnew(J_OBJ);
                jobj_put(p, "text", jstr_new(m->content));
                push_turn(&turns, &n_turns, &cap_turns, "model", p);
            }
            for (size_t k = 0; k < m->n_tool_calls; k++) {
                ToolCall *tc = &m->tool_calls[k];
                JValue *fc = jnew(J_OBJ);
                jobj_put(fc, "name", jstr_new(tc->name));
                jobj_put(fc, "args", parse_args_or_empty(tc->args));
                JValue *p = jnew(J_OBJ);
                jobj_put(p, "functionCall", fc);
                push_turn(&turns, &n_turns, &cap_turns, "model", p);
            }
        } else {
            const char *name = m->name ? m->name : (m->tool_call_id ? m->tool_call_id : "");
            JValue *fr = jnew(J_OBJ);
            jobj_put(fr, "name", jstr_new(name));
            jobj_put(fr, "response", parse_args_or_empty(m->content));
            JValue *p = jnew(J_OBJ);
            jobj_put(p, "functionResponse", fr);
            push_turn(&turns, &n_turns, &cap_turns, "user", p);
        }
    }
    JValue *root = jnew(J_OBJ);
    if (n_turns > 0) {
        JValue *contents = jnew(J_ARR);
        for (size_t i = 0; i < n_turns; i++) {
            JValue *c = jnew(J_OBJ);
            jobj_put(c, "role", jstr_new(turns[i].role));
            jobj_put(c, "parts", turns[i].parts);
            jarr_push(contents, c);
        }
        jobj_put(root, "contents", contents);
    }
    if (system.n > 0) {
        char *joined = sl_join(&system, "\n\n");
        JValue *tp = jnew(J_OBJ);
        jobj_put(tp, "text", jstr_new(joined));
        free(joined);
        JValue *one = jnew(J_ARR);
        jarr_push(one, tp);
        JValue *si = jnew(J_OBJ);
        jobj_put(si, "parts", one);
        jobj_put(root, "systemInstruction", si);
    }
    sl_free(&system);
    free(turns);
    StrBuf out;
    sb_init(&out);
    char *pretty = jto_pretty(root);
    sb_append(&out, pretty);
    sb_append_n(&out, "\n", 1);
    free(pretty);
    jfree(root);
    return out.data;
}

static char *serialize_markdown(MsgList *msgs, Ctx *ctx) {
    StrBuf out;
    sb_init(&out);
    for (size_t i = 0; i < msgs->n; i++) {
        Message *m = &msgs->items[i];
        if (m->n_tool_calls > 0) {
            ctx_warn(ctx, "message %zu: tool calls are not representable in Markdown and were dropped", i);
        }
        if (strcmp(m->role, "tool") == 0 && m->tool_call_id) {
            ctx_warn(ctx, "message %zu: tool result id is not representable in Markdown and was dropped", i);
        }
        if (i > 0) sb_append_n(&out, "\n\n", 2);
        sb_appendf(&out, "**%s**: %s", m->role, m->content);
    }
    sb_append_n(&out, "\n", 1);
    return out.data;
}

/* --------------------------------- convert -------------------------------- */

typedef int (*ParserFn)(Ctx *, const char *, MsgList *);
typedef char *(*SerializerFn)(MsgList *, Ctx *);

/* Convert a transcript between chat formats through the shared internal shape.
 *
 * Returns a malloc'd ConversionResult, or NULL with the message in `err`
 * (errsz bytes) when the transcript is empty, the source format fails to
 * parse, or from/to is not a known format. Free with conversion_result_free. */
static ConversionResult *convert(const char *transcript, const char *from, const char *to,
                                 char *err, size_t errsz) {
    char *trimmed = trim_dup(transcript);
    int empty = trimmed[0] == '\0';
    free(trimmed);
    if (empty) {
        snprintf(err, errsz, "Transcript is empty — paste a transcript first");
        return NULL;
    }
    ParserFn parser = strcmp(from, "openai") == 0 ? parse_openai
        : strcmp(from, "anthropic") == 0 ? parse_anthropic
        : strcmp(from, "gemini") == 0 ? parse_gemini
        : strcmp(from, "markdown") == 0 ? parse_markdown : NULL;
    if (!parser) {
        snprintf(err, errsz, "Unknown source format: %s", from);
        return NULL;
    }
    SerializerFn serializer = strcmp(to, "openai") == 0 ? serialize_openai
        : strcmp(to, "anthropic") == 0 ? serialize_anthropic
        : strcmp(to, "gemini") == 0 ? serialize_gemini
        : strcmp(to, "markdown") == 0 ? serialize_markdown : NULL;
    if (!serializer) {
        snprintf(err, errsz, "Unknown target format: %s", to);
        return NULL;
    }
    Ctx ctx;
    memset(&ctx, 0, sizeof ctx);
    MsgList msgs;
    ml_init(&msgs);
    if (parser(&ctx, transcript, &msgs) != 0) {
        snprintf(err, errsz, "%s", ctx.err);
        ml_free(&msgs);
        for (size_t i = 0; i < ctx.n_warnings; i++) free(ctx.warnings[i]);
        free(ctx.warnings);
        return NULL;
    }
    char *output = serializer(&msgs, &ctx);
    ml_free(&msgs);
    ConversionResult *r = xmalloc(sizeof *r);
    r->output = output;
    r->warnings = ctx.warnings;
    r->n_warnings = ctx.n_warnings;
    return r;
}

static void conversion_result_free(ConversionResult *r) {
    if (!r) return;
    free(r->output);
    for (size_t i = 0; i < r->n_warnings; i++) free(r->warnings[i]);
    free(r->warnings);
    free(r);
}

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 →