Skip to content

Conversations

Conversation messages are provider-neutral. The same history can be persisted, extended, and sent to another compatible model without rebuilding it.

Message and content model

A history is a []llm.Message. Message is an interface with three implementations, one per role. Each holds a slice of content blocks, and the role constrains which block types are allowed:

Message Role Allowed content blocks
UserMessage user input TextContent, ImageContent
AssistantMessage model output TextContent, ThinkingContent, ToolCall
ToolResultMessage a tool's result TextContent, ImageContent

The content blocks are the leaf types you read and write:

Block Carries
TextContent plain text (valid in any message)
ImageContent base64 image data plus a MIME type
ThinkingContent reasoning text and its provider signature (assistant only)
ToolCall a tool name, an ID, and decoded arguments (assistant only)

Because both the message and the blocks are typed, a stored conversation round-trips through JSON without manual dispatch — see Save and restore.

For the common "just send text" case, reach for the convenience constructors below. Build the struct literals by hand only when you need content a constructor does not cover — for example mixing text and an image in one user message (see Image input), or seeding an assistant turn that carries a tool call.

Build messages

Context, Message, and the content blocks are fully general, but most calls just send some text. Convenience constructors remove the nesting for that path:

llm.Prompt("Explain Go channels briefly.")        // Context with one user text message
llm.PromptWithSystem("Be concise.", "Explain...") // ...with a system prompt
llm.UserText("hello")                             // *UserMessage
llm.AssistantText("hi there")                     // *AssistantMessage (seed history)
llm.UserImage(data, "image/png")                  // *UserMessage with one image
llm.ToolResult(callID, name, "result text")       // *ToolResultMessage
llm.NewContext(msg1, msg2, ...)                   // Context from messages

Read a response back with the matching accessors on AssistantMessage:

response.Text()      // all text blocks joined
response.ToolCalls() // every tool call, in order

The longhand struct literals below remain valid; reach for them when you need content a constructor does not cover, such as mixing text and images in one message.

Continue a conversation

A multi-turn conversation is a growing []llm.Message. Append the assistant's reply, then the next user message, and send the whole slice again. The library is stateless, so the history you keep is the conversation.

messages := []llm.Message{
    llm.UserText("Name a Go web framework."),
}

for _, turn := range []string{"And one for CLIs?", "Which is older?"} {
    reply, err := llm.Complete(ctx, model,
        llm.Context{Messages: messages}, llm.StreamOptions{})
    if err != nil {
        log.Fatal(err)
    }
    messages = append(messages, &reply)            // record the answer
    messages = append(messages, llm.UserText(turn)) // ask the follow-up
}

Append &reply (a pointer) so the assistant turn keeps the type the library needs to replay it. A SystemPrompt set on the Context applies to every turn without being stored in the message history.

Image input

Multimodal models accept images alongside text in a user message. Provide the raw bytes as base64 with their MIME type:

raw, err := os.ReadFile("screenshot.png")
if err != nil {
    log.Fatal(err)
}
input := llm.Context{Messages: []llm.Message{
    &llm.UserMessage{Content: []llm.UserContent{
        &llm.TextContent{Text: "Describe the problem shown in this screenshot."},
        &llm.ImageContent{
            MIMEType: "image/png",
            Data:     base64.StdEncoding.EncodeToString(raw),
        },
    }},
}}

A model declares image support through Model.Input. When a history containing images is sent to a text-only model, images are replaced with a short placeholder automatically.

Switch models between turns

Before each request, the library adapts stored history for the target model. It downgrades images for text-only models, preserves reasoning signatures where compatible, downgrades or removes incompatible reasoning, and normalizes tool call identifiers.

The two models below even speak different wire protocols — DeepSeek is OpenAI-compatible, MiniMax CN is Anthropic-compatible — yet the history slice is reused as-is. Register both provider packages, since each protocol has its own adapter:

import (
    "github.com/ktsoator/or/llm"
    _ "github.com/ktsoator/or/llm/anthropic" // MiniMax CN (Anthropic-compatible)
    _ "github.com/ktsoator/or/llm/openai"    // DeepSeek (OpenAI-compatible)
)

ctx := context.Background()
draft := llm.GetModel("deepseek", "deepseek-v4-flash")   // openai-completions
review := llm.GetModel("minimax-cn", "MiniMax-M2.7")      // anthropic-messages

messages := []llm.Message{
    llm.UserText("Compute 25 * 18 and explain the steps."),
}

first, err := llm.Complete(ctx, draft,
    llm.Context{Messages: messages}, llm.StreamOptions{})
if err != nil {
    log.Fatal(err)
}
messages = append(messages, &first)
messages = append(messages, llm.UserText("Check the calculation above for mistakes."))

second, err := llm.Complete(ctx, review,
    llm.Context{Messages: messages}, llm.StreamOptions{})
if err != nil {
    log.Fatal(err)
}

This needs DEEPSEEK_API_KEY and MINIMAX_CN_API_KEY in the environment. See the runnable model_switch example for the complete program.

TransformMessages performs this adaptation and is exported for callers that need to inspect the exact history a model would receive.

Save and restore conversations

Context serializes to self-describing JSON: messages carry a role and content blocks carry a type, so JSON round-trips into concrete message and content types without manual dispatch.

data, err := json.MarshalIndent(llm.Context{Messages: messages}, "", "  ")
if err != nil {
    log.Fatal(err)
}
if err := os.WriteFile("conversation.json", data, 0o644); err != nil {
    log.Fatal(err)
}

raw, err := os.ReadFile("conversation.json")
if err != nil {
    log.Fatal(err)
}
var restored llm.Context
if err := json.Unmarshal(raw, &restored); err != nil {
    log.Fatal(err)
}

restored.Messages is ready to extend and replay against any model.

Serialized history is sensitive data

A serialized Context can contain user input, tool results (which may embed fetched documents or credentials), and provider reasoning signatures. Treat the JSON as sensitive: do not log it wholesale, and store or transmit it with the same care as the underlying data.