Skip to content

Chat Format Converter — PHP 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 PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
/**
 * chat-format-converter — PHP 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
 *
 * 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.
 *
 * This is the PHP sibling of src/lib/chatFormatConverter.ts (the canonical
 * TypeScript that powers the live tool). Standard library only; parse failures
 * throw InvalidArgumentException. JSON objects decode as stdClass and arrays as
 * lists, so the two stay distinguishable.
 *
 * Display source — part of CosmoDev's polyglot tool pages (dev.cosmolabs.org).
 */

declare(strict_types=1);

const CHAT_FORMAT_OPENAI = 'openai';
const CHAT_FORMAT_ANTHROPIC = 'anthropic';
const CHAT_FORMAT_GEMINI = 'gemini';
const CHAT_FORMAT_MARKDOWN = 'markdown';

/** One assistant-emitted tool call, provider-agnostic. `args` is a JSON string. */
final class ToolCall
{
    public function __construct(
        public readonly string $id = '',
        public readonly string $name = '',
        public readonly string $args = '',
    ) {
    }
}

/** The shared internal shape every format parses into and serializes from. */
final class InternalMessage
{
    public function __construct(
        public string $role, // 'system' | 'user' | 'assistant' | 'tool'
        public string $content = '',
        public ?string $name = null,
        /** @var list<ToolCall>|null */
        public ?array $toolCalls = null,
        public ?string $toolCallId = null,
    ) {
    }
}

final class ConversionResult
{
    /** @param list<string> $warnings human-readable notes about unmapped fields */
    public function __construct(
        public readonly string $output,
        public readonly array $warnings = [],
    ) {
    }
}

/** Parse a JSON-string argument back to a value, `{}` when it is not a JSON object. */
function cfcParseArgsOrEmpty(string $args): mixed
{
    try {
        $v = json_decode($args, false, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        return new stdClass();
    }
    return $v instanceof stdClass ? $v : new stdClass();
}

/** Substitute `{}` for null/missing (mirrors TS `?? {}`); other values pass through. */
function cfcOrEmptyObject(mixed $v): mixed
{
    return $v === null ? new stdClass() : $v;
}

function cfcAsRecord(mixed $v): ?stdClass
{
    return $v instanceof stdClass ? $v : null;
}

/** @return list<mixed> */
function cfcAsArray(mixed $v): ?array
{
    return is_array($v) ? $v : null;
}

/** Compact JSON encoding, mirroring TS JSON.stringify for captured args. */
function cfcStringify(mixed $v): string
{
    return json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

/** Pretty JSON encoding for serialized transcripts — a 2-space writer matching
 *  JSON.stringify(v, null, 2), since PHP's JSON_PRETTY_PRINT hardcodes 4 spaces.
 *  Objects (stdClass or string-keyed arrays) keep insertion order. */
function cfcPretty(mixed $v): string
{
    return cfcWriteValue($v, 0) . "\n";
}

function cfcWriteValue(mixed $v, int $depth): string
{
    if ($v === null) {
        return 'null';
    }
    if (is_bool($v)) {
        return $v ? 'true' : 'false';
    }
    if (is_int($v) || is_float($v)) {
        return cfcFormatNumber($v);
    }
    if (is_string($v)) {
        return cfcEscapeString($v);
    }
    $pad = str_repeat('  ', $depth + 1);
    $close = str_repeat('  ', $depth);
    if ($v instanceof stdClass) {
        $props = get_object_vars($v);
        if ($props === []) {
            return '{}';
        }
        $parts = [];
        foreach ($props as $k => $val) {
            $parts[] = $pad . cfcEscapeString((string) $k) . ': ' . cfcWriteValue($val, $depth + 1);
        }
        return "{\n" . implode(",\n", $parts) . "\n" . $close . '}';
    }
    if (is_array($v)) {
        if ($v === []) {
            return '[]';
        }
        if (!array_is_list($v)) {
            // A string-keyed array is an object in the making.
            $parts = [];
            foreach ($v as $k => $val) {
                $parts[] = $pad . cfcEscapeString((string) $k) . ': ' . cfcWriteValue($val, $depth + 1);
            }
            return "{\n" . implode(",\n", $parts) . "\n" . $close . '}';
        }
        $parts = [];
        foreach ($v as $val) {
            $parts[] = $pad . cfcWriteValue($val, $depth + 1);
        }
        return "[\n" . implode(",\n", $parts) . "\n" . $close . ']';
    }
    return 'null';
}

function cfcFormatNumber(int|float $n): string
{
    if (is_int($n)) {
        return (string) $n;
    }
    // Whole floats render without a trailing ".0", the way JS coerces them.
    if ($n == floor($n) && abs($n) < 1e15) {
        return sprintf('%.0f', $n);
    }
    return (string) $n;
}

function cfcEscapeString(string $s): string
{
    $map = ['"' => '\\"', '\\' => '\\\\', "\n" => '\\n', "\r" => '\\r', "\t" => '\\t',
        "\x08" => '\\b', "\x0c" => '\\f'];
    $out = '"';
    foreach (preg_split('//u', $s, -1, PREG_SPLIT_NO_EMPTY) as $c) {
        if (isset($map[$c])) {
            $out .= $map[$c];
        } elseif (strlen($c) === 1 && ord($c) < 0x20) {
            $out .= sprintf('\\u%04x', ord($c));
        } else {
            $out .= $c;
        }
    }
    return $out . '"';
}

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

/** OpenAI messages[] (bare array, or wrapped in {"messages": [...]}). */
function cfcParseOpenAI(string $text, array &$warnings): array
{
    try {
        $parsed = json_decode($text, false, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        throw new InvalidArgumentException('OpenAI transcript: invalid JSON — ' . $e->getMessage());
    }
    $root = cfcAsRecord($parsed);
    $raw = ($root !== null && ($msgs = cfcAsArray($root->messages ?? null)) !== null) ? $msgs : $parsed;
    if (!is_array($raw)) {
        throw new InvalidArgumentException('OpenAI transcript must be a messages[] array or an object with a "messages" array');
    }
    $out = [];
    foreach ($raw as $i => $entry) {
        $m = cfcAsRecord($entry);
        if ($m === null) {
            throw new InvalidArgumentException("messages[{$i}] is not an object");
        }
        $role = $m->role ?? null;
        if ($role === 'system' || $role === 'developer') {
            $out[] = new InternalMessage('system', cfcContentText($m->content ?? null, "messages[{$i}]", $warnings));
            continue;
        }
        if ($role === 'user' || $role === 'assistant' || $role === 'tool') {
            $msg = new InternalMessage($role, cfcContentText($m->content ?? null, "messages[{$i}]", $warnings));
            if (is_string($m->name ?? null)) {
                $msg->name = $m->name;
            }
            if (is_string($m->tool_call_id ?? null)) {
                $msg->toolCallId = $m->tool_call_id;
            }
            $calls = cfcParseOpenAIToolCalls($m->tool_calls ?? null);
            if ($calls !== null) {
                $msg->toolCalls = $calls;
            }
            $out[] = $msg;
            continue;
        }
        throw new InvalidArgumentException("messages[{$i}] has unsupported role: " . json_encode($role));
    }
    return $out;
}

/** OpenAI content → plain text (string, or text parts joined; other parts flagged). */
function cfcContentText(mixed $content, string $where, array &$warnings): string
{
    if (is_string($content)) {
        return $content;
    }
    if ($content === null) {
        return '';
    }
    if (is_array($content)) {
        $texts = [];
        $dropped = 0;
        foreach ($content as $part) {
            $p = cfcAsRecord($part);
            if ($p !== null && ($p->type ?? null) === 'text' && is_string($p->text ?? null)) {
                $texts[] = $p->text;
            } else {
                $dropped++;
            }
        }
        if ($dropped > 0) {
            $warnings[] = "{$where}: dropped {$dropped} non-text content part(s)";
        }
        return implode('', $texts);
    }
    throw new InvalidArgumentException("{$where}: content must be a string, an array of parts, or null");
}

/** @return list<ToolCall>|null */
function cfcParseOpenAIToolCalls(mixed $raw): ?array
{
    if ($raw === null) {
        return null;
    }
    if (!is_array($raw)) {
        throw new InvalidArgumentException('tool_calls must be an array');
    }
    $calls = [];
    foreach ($raw as $entry) {
        $tc = cfcAsRecord($entry);
        $fn = $tc !== null ? cfcAsRecord($tc->function ?? null) : null;
        $calls[] = new ToolCall(
            id: ($tc !== null && is_string($tc->id ?? null)) ? $tc->id : '',
            name: ($fn !== null && is_string($fn->name ?? null)) ? $fn->name : '',
            args: ($fn !== null && is_string($fn->arguments ?? null)) ? $fn->arguments : '',
        );
    }
    return $calls;
}

/** Anthropic messages[] (+ top-level `system`). */
function cfcParseAnthropic(string $text, array &$warnings): array
{
    try {
        $root = cfcAsRecord(json_decode($text, false, 512, JSON_THROW_ON_ERROR));
    } catch (JsonException $e) {
        throw new InvalidArgumentException('Anthropic transcript: invalid JSON — ' . $e->getMessage());
    }
    if ($root === null || !is_array($root->messages ?? null)) {
        throw new InvalidArgumentException('Anthropic transcript must be an object with a "messages" array');
    }
    $out = [];
    if (property_exists($root, 'system')) {
        $out[] = new InternalMessage('system', cfcBlocksToText($root->system, 'system', $warnings));
    }

    foreach ($root->messages as $i => $entry) {
        $m = cfcAsRecord($entry);
        if ($m === null) {
            throw new InvalidArgumentException("messages[{$i}] is not an object");
        }
        $where = "messages[{$i}]";
        if (($m->role ?? null) === 'assistant') {
            $texts = [];
            $calls = [];
            foreach (cfcContentBlocks($m->content ?? null, $where) as [$kind, $raw]) {
                if ($kind === 'text') {
                    $texts[] = $raw;
                } elseif ($kind === 'tool_use') {
                    $calls[] = new ToolCall(
                        id: is_string($raw->id ?? null) ? $raw->id : '',
                        name: is_string($raw->name ?? null) ? $raw->name : '',
                        args: cfcStringify(cfcOrEmptyObject($raw->input ?? null)),
                    );
                } elseif ($kind === 'other') {
                    $warnings[] = "{$where}: dropped unsupported " . cfcBlockTypeName($raw) . ' block';
                }
            }
            $msg = new InternalMessage('assistant', implode("\n", $texts));
            if ($calls !== []) {
                $msg->toolCalls = $calls;
            }
            $out[] = $msg;
            continue;
        }
        if (($m->role ?? null) === 'user') {
            $texts = [];
            $flush = function () use (&$out, &$texts): void {
                if ($texts !== []) {
                    $out[] = new InternalMessage('user', implode("\n", $texts));
                    $texts = [];
                }
            };
            foreach (cfcContentBlocks($m->content ?? null, $where) as [$kind, $raw]) {
                if ($kind === 'text') {
                    $texts[] = $raw;
                } elseif ($kind === 'tool_use') {
                    $warnings[] = "{$where}: tool_use block inside a user message moved to an assistant tool call";
                    $flush();
                    $out[] = new InternalMessage('assistant', '', toolCalls: [new ToolCall(
                        id: is_string($raw->id ?? null) ? $raw->id : '',
                        name: is_string($raw->name ?? null) ? $raw->name : '',
                        args: cfcStringify(cfcOrEmptyObject($raw->input ?? null)),
                    )]);
                } elseif ($kind === 'tool_result') {
                    $flush();
                    $out[] = new InternalMessage(
                        'tool',
                        cfcBlocksToText($raw->content ?? '', $where, $warnings),
                        toolCallId: is_string($raw->tool_use_id ?? null) ? $raw->tool_use_id : '',
                    );
                } else {
                    $warnings[] = "{$where}: dropped unsupported " . cfcBlockTypeName($raw) . ' block';
                }
            }
            $flush();
            continue;
        }
        throw new InvalidArgumentException("{$where} has unsupported role: " . json_encode($m->role ?? null));
    }
    return $out;
}

/**
 * Normalize Anthropic content (string | block list) into typed pairs:
 * ['text', string] | ['tool_use'|'tool_result'|'other', stdClass].
 *
 * @return list<array{0: string, 1: mixed}>
 */
function cfcContentBlocks(mixed $content, string $where): array
{
    if (is_string($content)) {
        return [['text', $content]];
    }
    if (!is_array($content)) {
        throw new InvalidArgumentException("{$where}: content must be a string or an array of blocks");
    }
    $blocks = [];
    foreach ($content as $block) {
        $b = cfcAsRecord($block);
        if ($b !== null && ($b->type ?? null) === 'text' && is_string($b->text ?? null)) {
            $blocks[] = ['text', $b->text];
        } elseif ($b !== null && in_array($b->type ?? null, ['tool_use', 'tool_result'], true)) {
            $blocks[] = [$b->type, $b];
        } else {
            $blocks[] = ['other', $b ?? new stdClass()];
        }
    }
    return $blocks;
}

function cfcBlockTypeName(stdClass $raw): string
{
    $t = $raw->type ?? null;
    return is_string($t) ? $t : 'content';
}

/** Anthropic string-or-block-list content → plain text. */
function cfcBlocksToText(mixed $content, string $where, array &$warnings): string
{
    if (is_string($content)) {
        return $content;
    }
    $texts = [];
    foreach (cfcContentBlocks($content, $where) as [$kind, $raw]) {
        if ($kind === 'text') {
            $texts[] = $raw;
        } elseif ($kind === 'other') {
            $warnings[] = "{$where}: dropped unsupported " . cfcBlockTypeName($raw) . ' block';
        } else {
            $warnings[] = "{$where}: dropped {$kind} block from text-only content";
        }
    }
    return implode("\n", $texts);
}

/** Gemini contents[] (+ optional systemInstruction). */
function cfcParseGemini(string $text, array &$warnings): array
{
    try {
        $root = cfcAsRecord(json_decode($text, false, 512, JSON_THROW_ON_ERROR));
    } catch (JsonException $e) {
        throw new InvalidArgumentException('Gemini transcript: invalid JSON — ' . $e->getMessage());
    }
    if ($root === null || !is_array($root->contents ?? null)) {
        throw new InvalidArgumentException('Gemini transcript must be an object with a "contents" array');
    }
    $out = [];
    if (property_exists($root, 'systemInstruction')) {
        $out[] = new InternalMessage('system', cfcGeminiText($root->systemInstruction, 'systemInstruction', $warnings));
    }

    foreach ($root->contents as $i => $entry) {
        $m = cfcAsRecord($entry);
        if ($m === null) {
            throw new InvalidArgumentException("contents[{$i}] is not an object");
        }
        $where = "contents[{$i}]";
        if (($m->role ?? null) !== 'user' && ($m->role ?? null) !== 'model') {
            throw new InvalidArgumentException(
                "{$where} has unsupported role: " . json_encode($m->role ?? null) . ' (Gemini uses "user" or "model")'
            );
        }
        $role = $m->role === 'model' ? 'assistant' : 'user';
        $parts = cfcAsArray($m->parts ?? null);
        if ($parts === null) {
            throw new InvalidArgumentException("{$where}: parts must be an array");
        }

        if ($role === 'assistant') {
            // A model turn keeps its text and function calls in ONE message,
            // mirroring an OpenAI assistant message with tool_calls.
            $texts = [];
            $calls = [];
            foreach ($parts as $part) {
                $p = cfcAsRecord($part);
                if ($p !== null && is_string($p->text ?? null)) {
                    $texts[] = $p->text;
                    continue;
                }
                $fc = $p !== null ? cfcAsRecord($p->functionCall ?? null) : null;
                if ($fc !== null) {
                    $calls[] = new ToolCall(
                        name: is_string($fc->name ?? null) ? $fc->name : '',
                        args: cfcStringify(cfcOrEmptyObject($fc->args ?? null)),
                    );
                    continue;
                }
                $warnings[] = "{$where}: dropped unsupported part (inlineData or similar)";
            }
            $msg = new InternalMessage('assistant', implode("\n", $texts));
            if ($calls !== []) {
                $msg->toolCalls = $calls;
            }
            $out[] = $msg;
            continue;
        }

        $texts = [];
        $flushText = function () use (&$out, &$texts): void {
            if ($texts !== []) {
                $out[] = new InternalMessage('user', implode("\n", $texts));
                $texts = [];
            }
        };
        foreach ($parts as $part) {
            $p = cfcAsRecord($part);
            if ($p !== null && is_string($p->text ?? null)) {
                $texts[] = $p->text;
                continue;
            }
            $fc = $p !== null ? cfcAsRecord($p->functionCall ?? null) : null;
            if ($fc !== null) {
                $flushText();
                $out[] = new InternalMessage('assistant', '', toolCalls: [new ToolCall(
                    name: is_string($fc->name ?? null) ? $fc->name : '',
                    args: cfcStringify(cfcOrEmptyObject($fc->args ?? null)),
                )]);
                continue;
            }
            $fr = $p !== null ? cfcAsRecord($p->functionResponse ?? null) : null;
            if ($fr !== null) {
                $flushText();
                $name = is_string($fr->name ?? null) ? $fr->name : '';
                $out[] = new InternalMessage(
                    'tool',
                    cfcStringify(cfcOrEmptyObject($fr->response ?? null)),
                    $name,
                    null,
                    $name,
                );
                continue;
            }
            $warnings[] = "{$where}: dropped unsupported part (inlineData or similar)";
        }
        $flushText();
    }
    return $out;
}

/** Gemini systemInstruction (string or {parts}) → plain text. */
function cfcGeminiText(mixed $v, string $where, array &$warnings): string
{
    if (is_string($v)) {
        return $v;
    }
    $rec = cfcAsRecord($v);
    if ($rec !== null) {
        if (is_string($rec->text ?? null)) {
            return $rec->text;
        }
        if (is_array($rec->parts ?? null)) {
            $texts = [];
            foreach ($rec->parts as $part) {
                $p = cfcAsRecord($part);
                $texts[] = ($p !== null && is_string($p->text ?? null)) ? $p->text : '';
            }
            return implode("\n", $texts);
        }
    }
    $warnings[] = "{$where}: unsupported systemInstruction shape, treated as empty";
    return '';
}

/**
 * Markdown transcript: `**role**: text` header lines with continuation lines
 * belonging to the same message. `model` maps to assistant.
 */
function cfcParseMarkdown(string $text, array &$warnings): array
{
    $out = [];
    $current = null; // [string $role, list<string> $lines]
    foreach (explode("\n", $text) as $line) {
        if (preg_match('/^\*\*(system|user|assistant|model|tool)\*\*:\s*(.*)$/', $line, $m) === 1) {
            if ($current !== null) {
                $out[] = cfcFinishMessage($current);
            }
            $current = [$m[1] === 'model' ? 'assistant' : $m[1], [$m[2]]];
            continue;
        }
        if ($current !== null) {
            $current[1][] = $line;
        } elseif (trim($line) !== '') {
            throw new InvalidArgumentException('Markdown transcript must start with a `**role**:` header line');
        }
    }
    if ($current !== null) {
        $out[] = cfcFinishMessage($current);
    }
    return $out;
}

/** @param array{0: string, 1: list<string>} $current */
function cfcFinishMessage(array $current): InternalMessage
{
    // Trim leading/trailing blank continuation lines but keep inner blank lines.
    $lines = $current[1];
    while (count($lines) > 1 && trim($lines[0]) === '') {
        array_shift($lines);
    }
    while (count($lines) > 1 && trim($lines[count($lines) - 1]) === '') {
        array_pop($lines);
    }
    return new InternalMessage($current[0], implode("\n", $lines));
}

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

function cfcSerializeOpenAI(array $messages): string
{
    $arr = [];
    foreach ($messages as $m) {
        if ($m->role === 'tool') {
            $o = ['role' => 'tool', 'content' => $m->content, 'tool_call_id' => $m->toolCallId ?? ''];
            if ($m->name !== null) {
                $o['name'] = $m->name;
            }
            $arr[] = $o;
            continue;
        }
        if ($m->role === 'assistant' && $m->toolCalls !== null) {
            $o = [
                'role' => 'assistant',
                'content' => $m->content === '' ? null : $m->content,
                'tool_calls' => array_map(
                    static fn (ToolCall $tc) => [
                        'id' => $tc->id,
                        'type' => 'function',
                        'function' => ['name' => $tc->name, 'arguments' => $tc->args],
                    ],
                    $m->toolCalls,
                ),
            ];
            if ($m->name !== null) {
                $o['name'] = $m->name;
            }
            $arr[] = $o;
            continue;
        }
        $o = ['role' => $m->role, 'content' => $m->content];
        if ($m->name !== null) {
            $o['name'] = $m->name;
        }
        $arr[] = $o;
    }
    return cfcPretty(['messages' => $arr]);
}

function cfcSerializeAnthropic(array $messages, array &$warnings): string
{
    $system = [];
    $out = [];
    foreach ($messages as $m) {
        if ($m->role === 'system') {
            $system[] = $m->content;
        }
    }
    foreach ($messages as $i => $m) {
        if ($m->role === 'system') {
            continue;
        }
        if ($m->name !== null && $m->role !== 'tool') {
            $warnings[] = "message {$i}: \"name\" has no Anthropic equivalent and was dropped";
        }
        if ($m->role === 'user') {
            $out[] = ['role' => 'user', 'content' => $m->content];
        } elseif ($m->role === 'assistant') {
            if ($m->toolCalls !== null) {
                $blocks = [];
                if ($m->content !== '') {
                    $blocks[] = ['type' => 'text', 'text' => $m->content];
                }
                foreach ($m->toolCalls as $tc) {
                    $blocks[] = [
                        'type' => 'tool_use',
                        'id' => $tc->id,
                        'name' => $tc->name,
                        'input' => cfcParseArgsOrEmpty($tc->args),
                    ];
                }
                $out[] = ['role' => 'assistant', 'content' => $blocks];
            } else {
                $out[] = ['role' => 'assistant', 'content' => $m->content];
            }
        } else {
            $out[] = [
                'role' => 'user',
                'content' => [['type' => 'tool_result', 'tool_use_id' => $m->toolCallId ?? '', 'content' => $m->content]],
            ];
        }
    }
    $root = $out !== [] ? ['messages' => $out] : [];
    if ($system !== []) {
        $root['system'] = implode("\n\n", $system);
    }
    return cfcPretty($root ?: new stdClass());
}

function cfcSerializeGemini(array $messages, array &$warnings): string
{
    $system = [];
    foreach ($messages as $m) {
        if ($m->role === 'system') {
            $system[] = $m->content;
        }
    }
    $contents = [];
    $push = function (string $role, array $part) use (&$contents): void {
        $last = count($contents) - 1;
        if ($last >= 0 && $contents[$last]['role'] === $role) {
            $contents[$last]['parts'][] = $part;
        } else {
            $contents[] = ['role' => $role, 'parts' => [$part]];
        }
    };
    foreach ($messages as $i => $m) {
        if ($m->role === 'system') {
            continue;
        }
        if ($m->name !== null && $m->role !== 'tool') {
            $warnings[] = "message {$i}: \"name\" has no Gemini equivalent and was dropped";
        }
        if ($m->role === 'user') {
            $push('user', ['text' => $m->content]);
        } elseif ($m->role === 'assistant') {
            if ($m->content !== '') {
                $push('model', ['text' => $m->content]);
            }
            foreach ($m->toolCalls ?? [] as $tc) {
                $push('model', ['functionCall' => ['name' => $tc->name, 'args' => cfcParseArgsOrEmpty($tc->args)]]);
            }
        } else {
            $push('user', [
                'functionResponse' => [
                    'name' => $m->name ?? $m->toolCallId ?? '',
                    'response' => cfcParseArgsOrEmpty($m->content),
                ],
            ]);
        }
    }
    $root = $contents !== [] ? ['contents' => $contents] : [];
    if ($system !== []) {
        $root['systemInstruction'] = ['parts' => [['text' => implode("\n\n", $system)]]];
    }
    return cfcPretty($root ?: new stdClass());
}

function cfcSerializeMarkdown(array $messages, array &$warnings): string
{
    foreach ($messages as $i => $m) {
        if ($m->toolCalls !== null && $m->toolCalls !== []) {
            $warnings[] = "message {$i}: tool calls are not representable in Markdown and were dropped";
        }
        if ($m->role === 'tool' && $m->toolCallId !== null) {
            $warnings[] = "message {$i}: tool result id is not representable in Markdown and was dropped";
        }
    }
    return implode("\n\n", array_map(
        static fn (InternalMessage $m) => "**{$m->role}**: {$m->content}",
        $messages,
    )) . "\n";
}

function cfcParsers(): array
{
    return [
        'openai' => 'cfcParseOpenAI',
        'anthropic' => 'cfcParseAnthropic',
        'gemini' => 'cfcParseGemini',
        'markdown' => 'cfcParseMarkdown',
    ];
}

function cfcSerializers(): array
{
    return [
        'openai' => 'cfcSerializeOpenAI',
        'anthropic' => 'cfcSerializeAnthropic',
        'gemini' => 'cfcSerializeGemini',
        'markdown' => 'cfcSerializeMarkdown',
    ];
}

/**
 * Convert a transcript between chat formats through the shared internal shape.
 *
 * @param list<string> $warnings populated with notes about unmapped fields.
 *
 * @throws InvalidArgumentException when the transcript is empty, the source
 *         format fails to parse, or $from/$to is not a known format.
 */
function cfcConvert(string $transcript, string $from, string $to, ?array &$warnings = null): ConversionResult
{
    $warnings = $warnings ?? [];
    if (trim($transcript) === '') {
        throw new InvalidArgumentException('Transcript is empty — paste a transcript first');
    }
    $parsers = cfcParsers();
    if (!isset($parsers[$from])) {
        throw new InvalidArgumentException("Unknown source format: {$from}");
    }
    $serializers = cfcSerializers();
    if (!isset($serializers[$to])) {
        throw new InvalidArgumentException("Unknown target format: {$to}");
    }
    $messages = $parsers[$from]($transcript, $warnings);
    $output = $serializers[$to]($messages, $warnings);
    return new ConversionResult($output, $warnings);
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →