Skip to content

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

// chat-format-converter — Go polyglot showcase port (core conversion logic).
//
// The authoritative, runnable Go source is the live CLI twin at
// cli/chat-format-converter/chatformatconverter.go (cosmodev chat-format-converter) —
// a full port with an order-preserving JSON encoder for byte-exact output.
// This excerpt carries the core conversion rules: parse one chat transcript
// (OpenAI / Anthropic / Gemini / Markdown) into a shared internal message
// list, then serialize it into any target shape. Fields a target format
// cannot represent are flagged as warnings, never dropped silently.
//
// Idiomatic Go condensed port: encoding/json maps are used for the JSON tree,
// so re-serialized keys come out sorted rather than in insertion order — the
// live twin carries the order-preserving writer when byte-exactness matters.
// Standard library only.
//
// This is the Go sibling of src/lib/chatFormatConverter.ts (the canonical
// TypeScript that powers the live tool).
// Display source — part of CosmoDev's polyglot tool pages (dev.cosmolabs.org).
package chatformatconverter

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"strings"
)

// ChatFormat is one transcript shape: openai | anthropic | gemini | markdown.
type ChatFormat = string

const (
	FormatOpenAI    ChatFormat = "openai"
	FormatAnthropic ChatFormat = "anthropic"
	FormatGemini    ChatFormat = "gemini"
	FormatMarkdown  ChatFormat = "markdown"
)

// ChatFormats lists every supported format.
var ChatFormats = []ChatFormat{FormatOpenAI, FormatAnthropic, FormatGemini, FormatMarkdown}

// ToolCall is one assistant-emitted tool call, provider-agnostic.
// Args is a JSON string.
type ToolCall struct {
	ID   string
	Name string
	Args string
}

// InternalMessage is the shared internal shape every format parses into and
// serializes from.
type InternalMessage struct {
	Role       string // "system" | "user" | "assistant" | "tool"
	Content    string // '' when the message only carries tool calls/results
	Name       string // OpenAI name, Anthropic tool_use, Gemini functionResponse
	ToolCalls  []ToolCall
	ToolCallID string // OpenAI tool_call_id, Anthropic tool_use_id, Gemini name
}

// ConversionResult carries the serialized output plus human-readable notes
// about fields that could not be mapped.
type ConversionResult struct {
	Output   string
	Warnings []string
}

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

func parseTranscriptJSON(text, label string) (any, error) {
	var v any
	if err := json.Unmarshal([]byte(text), &v); err != nil {
		return nil, fmt.Errorf("%s: invalid JSON — %s", label, err)
	}
	return v, nil
}

func asObject(v any) map[string]any {
	m, _ := v.(map[string]any)
	return m
}

// orEmpty mirrors the TS `?? {}` capture: null/missing becomes {}, any other
// value (string, number, ...) passes through untouched.
func orEmpty(v any) any {
	if v == nil {
		return map[string]any{}
	}
	return v
}

// parseArgsOrEmpty parses a JSON-string argument back to a value, an empty
// object when it is not a JSON object.
func parseArgsOrEmpty(args string) any {
	var v any
	if err := json.Unmarshal([]byte(args), &v); err == nil {
		if m, ok := v.(map[string]any); ok {
			return m
		}
	}
	return map[string]any{}
}

func encodeCompact(v any) string {
	var b bytes.Buffer
	enc := json.NewEncoder(&b)
	enc.SetEscapeHTML(false)
	_ = enc.Encode(v)
	return strings.TrimRight(b.String(), "\n")
}

func encodePretty(v any) string {
	var b bytes.Buffer
	enc := json.NewEncoder(&b)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "  ")
	_ = enc.Encode(v)
	return b.String() // Encoder.Encode already appends the trailing newline
}

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

// parseOpenAI reads OpenAI messages[] (bare array, or wrapped in
// {"messages": [...]}).
func parseOpenAI(text string, warnings *[]string) ([]InternalMessage, error) {
	v, err := parseTranscriptJSON(text, "OpenAI transcript")
	if err != nil {
		return nil, err
	}
	root := asObject(v)
	raw := v
	if root != nil {
		if msgs, ok := root["messages"].([]any); ok {
			raw = msgs
		}
	}
	arr, ok := raw.([]any)
	if !ok {
		return nil, errors.New(`OpenAI transcript must be a messages[] array or an object with a "messages" array`)
	}
	out := []InternalMessage{}
	for i, entry := range arr {
		m := asObject(entry)
		if m == nil {
			return nil, fmt.Errorf("messages[%d] is not an object", i)
		}
		role, _ := m["role"].(string)
		where := fmt.Sprintf("messages[%d]", i)
		switch role {
		case "system", "developer":
			content, err := contentText(m["content"], where, warnings)
			if err != nil {
				return nil, err
			}
			out = append(out, InternalMessage{Role: "system", Content: content})
		case "user", "assistant", "tool":
			content, err := contentText(m["content"], where, warnings)
			if err != nil {
				return nil, err
			}
			msg := InternalMessage{Role: role, Content: content}
			if name, ok := m["name"].(string); ok {
				msg.Name = name
			}
			if id, ok := m["tool_call_id"].(string); ok {
				msg.ToolCallID = id
			}
			calls, err := parseOpenAIToolCalls(m["tool_calls"])
			if err != nil {
				return nil, err
			}
			if calls != nil {
				msg.ToolCalls = calls
			}
			out = append(out, msg)
		default:
			return nil, fmt.Errorf("messages[%d] has unsupported role: %s", i, encodeCompact(m["role"]))
		}
	}
	return out, nil
}

// contentText flattens OpenAI content to plain text (string, or text parts
// joined; other parts flagged as warnings).
func contentText(content any, where string, warnings *[]string) (string, error) {
	switch c := content.(type) {
	case string:
		return c, nil
	case nil:
		return "", nil
	case []any:
		texts := []string{}
		dropped := 0
		for _, part := range c {
			p := asObject(part)
			if t, ok := p["text"].(string); ok && p["type"] == "text" && p != nil {
				texts = append(texts, t)
			} else {
				dropped++
			}
		}
		if dropped > 0 {
			*warnings = append(*warnings, fmt.Sprintf("%s: dropped %d non-text content part(s)", where, dropped))
		}
		return strings.Join(texts, ""), nil
	default:
		return "", fmt.Errorf("%s: content must be a string, an array of parts, or null", where)
	}
}

func parseOpenAIToolCalls(raw any) ([]ToolCall, error) {
	if raw == nil {
		return nil, nil
	}
	list, ok := raw.([]any)
	if !ok {
		return nil, errors.New("tool_calls must be an array")
	}
	calls := make([]ToolCall, 0, len(list))
	for _, entry := range list {
		tc := asObject(entry)
		fn := map[string]any{}
		if tc != nil {
			fn = asObject(tc["function"])
		}
		call := ToolCall{}
		if tc != nil {
			if id, ok := tc["id"].(string); ok {
				call.ID = id
			}
		}
		if fn != nil {
			if name, ok := fn["name"].(string); ok {
				call.Name = name
			}
			if args, ok := fn["arguments"].(string); ok {
				call.Args = args
			}
		}
		calls = append(calls, call)
	}
	return calls, nil
}

// anthBlock is one normalized Anthropic content block.
type anthBlock struct {
	kind string // "text" | "tool_use" | "tool_result" | "other"
	text string
	raw  map[string]any
}

func blockTypeName(raw map[string]any) string {
	if t, ok := raw["type"].(string); ok {
		return t
	}
	return "content"
}

func toolCallFromBlock(raw map[string]any) ToolCall {
	call := ToolCall{Args: encodeCompact(orEmpty(raw["input"]))}
	if id, ok := raw["id"].(string); ok {
		call.ID = id
	}
	if name, ok := raw["name"].(string); ok {
		call.Name = name
	}
	return call
}

// contentBlocks normalizes Anthropic content (string | block[]) into typed blocks.
func contentBlocks(content any, where string) ([]anthBlock, error) {
	if s, ok := content.(string); ok {
		return []anthBlock{{kind: "text", text: s}}, nil
	}
	list, ok := content.([]any)
	if !ok {
		return nil, fmt.Errorf("%s: content must be a string or an array of blocks", where)
	}
	blocks := make([]anthBlock, 0, len(list))
	for _, block := range list {
		b := asObject(block)
		if b != nil {
			if t, ok := b["text"].(string); ok && b["type"] == "text" {
				blocks = append(blocks, anthBlock{kind: "text", text: t})
				continue
			}
			if b["type"] == "tool_use" || b["type"] == "tool_result" {
				kind, _ := b["type"].(string)
				blocks = append(blocks, anthBlock{kind: kind, raw: b})
				continue
			}
		}
		blocks = append(blocks, anthBlock{kind: "other", raw: b}) // b may be nil; blockTypeName handles it
	}
	return blocks, nil
}

// blocksToText flattens Anthropic string-or-block[] content to plain text.
func blocksToText(content any, where string, warnings *[]string) (string, error) {
	if s, ok := content.(string); ok {
		return s, nil
	}
	blocks, err := contentBlocks(content, where)
	if err != nil {
		return "", err
	}
	texts := []string{}
	for _, b := range blocks {
		switch b.kind {
		case "text":
			texts = append(texts, b.text)
		case "other":
			*warnings = append(*warnings, fmt.Sprintf("%s: dropped unsupported %s block", where, blockTypeName(b.raw)))
		default:
			*warnings = append(*warnings, fmt.Sprintf("%s: dropped %s block from text-only content", where, b.kind))
		}
	}
	return strings.Join(texts, "\n"), nil
}

// parseAnthropic reads Anthropic messages[] plus the top-level system field.
func parseAnthropic(text string, warnings *[]string) ([]InternalMessage, error) {
	v, err := parseTranscriptJSON(text, "Anthropic transcript")
	if err != nil {
		return nil, err
	}
	root := asObject(v)
	if root == nil {
		return nil, errors.New(`Anthropic transcript must be an object with a "messages" array`)
	}
	msgList, ok := root["messages"].([]any)
	if !ok {
		return nil, errors.New(`Anthropic transcript must be an object with a "messages" array`)
	}
	out := []InternalMessage{}
	if sys, present := root["system"]; present {
		content, err := blocksToText(sys, "system", warnings)
		if err != nil {
			return nil, err
		}
		out = append(out, InternalMessage{Role: "system", Content: content})
	}

	for i, entry := range msgList {
		m := asObject(entry)
		if m == nil {
			return nil, fmt.Errorf("messages[%d] is not an object", i)
		}
		where := fmt.Sprintf("messages[%d]", i)
		switch m["role"] {
		case "assistant":
			texts := []string{}
			var calls []ToolCall
			blocks, err := contentBlocks(m["content"], where)
			if err != nil {
				return nil, err
			}
			for _, b := range blocks {
				switch b.kind {
				case "text":
					texts = append(texts, b.text)
				case "tool_use":
					calls = append(calls, toolCallFromBlock(b.raw))
				case "other":
					*warnings = append(*warnings, fmt.Sprintf("%s: dropped unsupported %s block", where, blockTypeName(b.raw)))
				}
			}
			msg := InternalMessage{Role: "assistant", Content: strings.Join(texts, "\n")}
			if len(calls) > 0 {
				msg.ToolCalls = calls
			}
			out = append(out, msg)
		case "user":
			texts := []string{}
			flush := func() {
				if len(texts) > 0 {
					out = append(out, InternalMessage{Role: "user", Content: strings.Join(texts, "\n")})
					texts = texts[:0]
				}
			}
			blocks, err := contentBlocks(m["content"], where)
			if err != nil {
				return nil, err
			}
			for _, b := range blocks {
				switch b.kind {
				case "text":
					texts = append(texts, b.text)
				case "tool_use":
					*warnings = append(*warnings, fmt.Sprintf("%s: tool_use block inside a user message moved to an assistant tool call", where))
					flush()
					out = append(out, InternalMessage{Role: "assistant", ToolCalls: []ToolCall{toolCallFromBlock(b.raw)}})
				case "tool_result":
					flush()
					content, err := blocksToText(orEmptyString(b.raw["content"]), where, warnings)
					if err != nil {
						return nil, err
					}
					msg := InternalMessage{Role: "tool", Content: content}
					if id, ok := b.raw["tool_use_id"].(string); ok {
						msg.ToolCallID = id
					}
					out = append(out, msg)
				default:
					*warnings = append(*warnings, fmt.Sprintf("%s: dropped unsupported %s block", where, blockTypeName(b.raw)))
				}
			}
			flush()
		default:
			return nil, fmt.Errorf("%s has unsupported role: %s", where, encodeCompact(m["role"]))
		}
	}
	return out, nil
}

func orEmptyString(v any) any {
	if v == nil {
		return ""
	}
	return v
}

// geminiText flattens a Gemini systemInstruction (string or {parts}) to text.
func geminiText(v any, where string, warnings *[]string) string {
	if s, ok := v.(string); ok {
		return s
	}
	if rec := asObject(v); rec != nil {
		if t, ok := rec["text"].(string); ok {
			return t
		}
		if parts, ok := rec["parts"].([]any); ok {
			texts := make([]string, 0, len(parts))
			for _, part := range parts {
				p := asObject(part)
				if t, ok := p["text"].(string); ok && p != nil {
					texts = append(texts, t)
				} else {
					texts = append(texts, "")
				}
			}
			return strings.Join(texts, "\n")
		}
	}
	*warnings = append(*warnings, fmt.Sprintf("%s: unsupported systemInstruction shape, treated as empty", where))
	return ""
}

// parseGemini reads Gemini contents[] plus the optional systemInstruction.
func parseGemini(text string, warnings *[]string) ([]InternalMessage, error) {
	v, err := parseTranscriptJSON(text, "Gemini transcript")
	if err != nil {
		return nil, err
	}
	root := asObject(v)
	if root == nil {
		return nil, errors.New(`Gemini transcript must be an object with a "contents" array`)
	}
	contents, ok := root["contents"].([]any)
	if !ok {
		return nil, errors.New(`Gemini transcript must be an object with a "contents" array`)
	}
	out := []InternalMessage{}
	if si, present := root["systemInstruction"]; present {
		out = append(out, InternalMessage{Role: "system", Content: geminiText(si, "systemInstruction", warnings)})
	}

	for i, entry := range contents {
		m := asObject(entry)
		if m == nil {
			return nil, fmt.Errorf("contents[%d] is not an object", i)
		}
		where := fmt.Sprintf("contents[%d]", i)
		role, _ := m["role"].(string)
		if role != "user" && role != "model" {
			return nil, fmt.Errorf("%s has unsupported role: %s (Gemini uses \"user\" or \"model\")", where, encodeCompact(m["role"]))
		}
		parts, ok := m["parts"].([]any)
		if !ok {
			return nil, fmt.Errorf("%s: parts must be an array", where)
		}

		if role == "model" {
			// A model turn keeps its text and function calls in ONE message,
			// mirroring an OpenAI assistant message with tool_calls.
			texts := []string{}
			var calls []ToolCall
			for _, part := range parts {
				p := asObject(part)
				if t, ok := p["text"].(string); ok && p != nil {
					texts = append(texts, t)
					continue
				}
				if fc := asObject(p["functionCall"]); fc != nil && p != nil {
					call := ToolCall{Args: encodeCompact(orEmpty(fc["args"]))}
					if name, ok := fc["name"].(string); ok {
						call.Name = name
					}
					calls = append(calls, call)
					continue
				}
				*warnings = append(*warnings, fmt.Sprintf("%s: dropped unsupported part (inlineData or similar)", where))
			}
			msg := InternalMessage{Role: "assistant", Content: strings.Join(texts, "\n")}
			if len(calls) > 0 {
				msg.ToolCalls = calls
			}
			out = append(out, msg)
			continue
		}

		texts := []string{}
		flushText := func() {
			if len(texts) > 0 {
				out = append(out, InternalMessage{Role: "user", Content: strings.Join(texts, "\n")})
				texts = texts[:0]
			}
		}
		for _, part := range parts {
			p := asObject(part)
			if t, ok := p["text"].(string); ok && p != nil {
				texts = append(texts, t)
				continue
			}
			if fc := asObject(p["functionCall"]); fc != nil && p != nil {
				flushText()
				call := ToolCall{Args: encodeCompact(orEmpty(fc["args"]))}
				if name, ok := fc["name"].(string); ok {
					call.Name = name
				}
				out = append(out, InternalMessage{Role: "assistant", ToolCalls: []ToolCall{call}})
				continue
			}
			if fr := asObject(p["functionResponse"]); fr != nil && p != nil {
				flushText()
				name, _ := fr["name"].(string)
				out = append(out, InternalMessage{
					Role:       "tool",
					Content:    encodeCompact(orEmpty(fr["response"])),
					Name:       name,
					ToolCallID: name,
				})
				continue
			}
			*warnings = append(*warnings, fmt.Sprintf("%s: dropped unsupported part (inlineData or similar)", where))
		}
		flushText()
	}
	return out, nil
}

var markdownHeader = regexp.MustCompile(`^\*\*(system|user|assistant|model|tool)\*\*:\s*(.*)$`)

// markdownPartial is the in-flight message while scanning a Markdown transcript.
type markdownPartial struct {
	role  string
	lines []string
}

func finishMarkdownMessage(current *markdownPartial) InternalMessage {
	// Trim leading/trailing blank continuation lines but keep inner blank lines.
	for len(current.lines) > 1 && strings.TrimSpace(current.lines[0]) == "" {
		current.lines = current.lines[1:]
	}
	for len(current.lines) > 1 && strings.TrimSpace(current.lines[len(current.lines)-1]) == "" {
		current.lines = current.lines[:len(current.lines)-1]
	}
	return InternalMessage{Role: current.role, Content: strings.Join(current.lines, "\n")}
}

// parseMarkdown reads a `**role**: text` transcript with continuation lines
// belonging to the same message. `model` maps to assistant.
func parseMarkdown(text string, _ *[]string) ([]InternalMessage, error) {
	out := []InternalMessage{}
	var current *markdownPartial
	for line := range strings.SplitSeq(text, "\n") {
		if m := markdownHeader.FindStringSubmatch(line); m != nil {
			if current != nil {
				out = append(out, finishMarkdownMessage(current))
			}
			role := m[1]
			if role == "model" {
				role = "assistant"
			}
			current = &markdownPartial{role: role, lines: []string{m[2]}}
			continue
		}
		if current != nil {
			current.lines = append(current.lines, line)
		} else if strings.TrimSpace(line) != "" {
			return nil, errors.New("Markdown transcript must start with a `**role**:` header line")
		}
	}
	if current != nil {
		out = append(out, finishMarkdownMessage(current))
	}
	return out, nil
}

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

func serializeOpenAI(messages []InternalMessage, _ *[]string) string {
	arr := make([]any, 0, len(messages))
	for _, m := range messages {
		switch {
		case m.Role == "tool":
			o := map[string]any{"role": "tool", "content": m.Content, "tool_call_id": m.ToolCallID}
			if m.Name != "" {
				o["name"] = m.Name
			}
			arr = append(arr, o)
		case m.Role == "assistant" && m.ToolCalls != nil:
			calls := make([]any, 0, len(m.ToolCalls))
			for _, tc := range m.ToolCalls {
				calls = append(calls, map[string]any{
					"id":       tc.ID,
					"type":     "function",
					"function": map[string]any{"name": tc.Name, "arguments": tc.Args},
				})
			}
			content := any(m.Content)
			if m.Content == "" {
				content = nil
			}
			o := map[string]any{"role": "assistant", "content": content, "tool_calls": calls}
			if m.Name != "" {
				o["name"] = m.Name
			}
			arr = append(arr, o)
		default:
			o := map[string]any{"role": m.Role, "content": m.Content}
			if m.Name != "" {
				o["name"] = m.Name
			}
			arr = append(arr, o)
		}
	}
	return encodePretty(map[string]any{"messages": arr})
}

func serializeAnthropic(messages []InternalMessage, warnings *[]string) string {
	system := []string{}
	for _, m := range messages {
		if m.Role == "system" {
			system = append(system, m.Content)
		}
	}
	out := []any{}
	for i, m := range messages {
		if m.Role == "system" {
			continue
		}
		if m.Name != "" && m.Role != "tool" {
			*warnings = append(*warnings, fmt.Sprintf("message %d: %q has no Anthropic equivalent and was dropped", i, "name"))
		}
		switch m.Role {
		case "user":
			out = append(out, map[string]any{"role": "user", "content": m.Content})
		case "assistant":
			if m.ToolCalls != nil {
				blocks := []any{}
				if m.Content != "" {
					blocks = append(blocks, map[string]any{"type": "text", "text": m.Content})
				}
				for _, tc := range m.ToolCalls {
					blocks = append(blocks, map[string]any{
						"type":  "tool_use",
						"id":    tc.ID,
						"name":  tc.Name,
						"input": parseArgsOrEmpty(tc.Args),
					})
				}
				out = append(out, map[string]any{"role": "assistant", "content": blocks})
			} else {
				out = append(out, map[string]any{"role": "assistant", "content": m.Content})
			}
		default: // tool result: Anthropic carries it in a user turn
			out = append(out, map[string]any{
				"role": "user",
				"content": []any{map[string]any{
					"type":        "tool_result",
					"tool_use_id": m.ToolCallID,
					"content":     m.Content,
				}},
			})
		}
	}
	root := map[string]any{}
	if len(out) > 0 {
		root["messages"] = out
	}
	if len(system) > 0 {
		root["system"] = strings.Join(system, "\n\n")
	}
	return encodePretty(root)
}

func serializeGemini(messages []InternalMessage, warnings *[]string) string {
	system := []string{}
	for _, m := range messages {
		if m.Role == "system" {
			system = append(system, m.Content)
		}
	}
	type turn struct {
		role  string
		parts []any
	}
	turns := []*turn{}
	push := func(role string, part any) {
		if n := len(turns); n > 0 && turns[n-1].role == role {
			turns[n-1].parts = append(turns[n-1].parts, part)
		} else {
			turns = append(turns, &turn{role: role, parts: []any{part}})
		}
	}
	for i, m := range messages {
		if m.Role == "system" {
			continue
		}
		if m.Name != "" && m.Role != "tool" {
			*warnings = append(*warnings, fmt.Sprintf("message %d: %q has no Gemini equivalent and was dropped", i, "name"))
		}
		switch m.Role {
		case "user":
			push("user", map[string]any{"text": m.Content})
		case "assistant":
			if m.Content != "" {
				push("model", map[string]any{"text": m.Content})
			}
			for _, tc := range m.ToolCalls {
				push("model", map[string]any{
					"functionCall": map[string]any{"name": tc.Name, "args": parseArgsOrEmpty(tc.Args)},
				})
			}
		default: // tool result: Gemini carries it as a user functionResponse
			name := m.ToolCallID
			if m.Name != "" {
				name = m.Name
			}
			push("user", map[string]any{
				"functionResponse": map[string]any{"name": name, "response": parseArgsOrEmpty(m.Content)},
			})
		}
	}
	root := map[string]any{}
	if len(turns) > 0 {
		contents := make([]any, 0, len(turns))
		for _, t := range turns {
			contents = append(contents, map[string]any{"role": t.role, "parts": t.parts})
		}
		root["contents"] = contents
	}
	if len(system) > 0 {
		root["systemInstruction"] = map[string]any{"parts": []any{map[string]any{"text": strings.Join(system, "\n\n")}}}
	}
	return encodePretty(root)
}

func serializeMarkdown(messages []InternalMessage, warnings *[]string) string {
	lines := make([]string, 0, len(messages))
	for i, m := range messages {
		if len(m.ToolCalls) > 0 {
			*warnings = append(*warnings, fmt.Sprintf("message %d: tool calls are not representable in Markdown and were dropped", i))
		}
		if m.Role == "tool" && m.ToolCallID != "" {
			*warnings = append(*warnings, fmt.Sprintf("message %d: tool result id is not representable in Markdown and was dropped", i))
		}
		lines = append(lines, fmt.Sprintf("**%s**: %s", m.Role, m.Content))
	}
	return strings.Join(lines, "\n\n") + "\n"
}

type parserFunc func(text string, warnings *[]string) ([]InternalMessage, error)
type serializerFunc func(messages []InternalMessage, warnings *[]string) string

var parsers = map[ChatFormat]parserFunc{
	FormatOpenAI:    parseOpenAI,
	FormatAnthropic: parseAnthropic,
	FormatGemini:    parseGemini,
	FormatMarkdown:  parseMarkdown,
}

var serializers = map[ChatFormat]serializerFunc{
	FormatOpenAI:    serializeOpenAI,
	FormatAnthropic: serializeAnthropic,
	FormatGemini:    serializeGemini,
	FormatMarkdown:  serializeMarkdown,
}

// Convert converts a transcript between chat formats through the shared
// internal shape. It errors when the transcript is empty, the source format
// fails to parse, or from/to is not a known format.
func Convert(transcript string, from, to ChatFormat) (ConversionResult, error) {
	if strings.TrimSpace(transcript) == "" {
		return ConversionResult{}, errors.New("Transcript is empty — paste a transcript first")
	}
	parser, ok := parsers[from]
	if !ok {
		return ConversionResult{}, fmt.Errorf("Unknown source format: %s", from)
	}
	serializer, ok := serializers[to]
	if !ok {
		return ConversionResult{}, fmt.Errorf("Unknown target format: %s", to)
	}
	warnings := []string{}
	messages, err := parser(transcript, &warnings)
	if err != nil {
		return ConversionResult{}, err
	}
	return ConversionResult{Output: serializer(messages, &warnings), Warnings: warnings}, nil
}

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 →