or

module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 20, 2026 License: MIT

README

or

Choose the path from intent to action.

Setup

Create a Go application and install the library plus the .env loader used by the examples:

mkdir myapp
cd myapp
go mod init myapp
go get github.com/ktsoator/or/llm
go get github.com/joho/godotenv

Create a .env file in the application directory. Keep the keys for the providers you use and remove the rest:

# China and global providers
DEEPSEEK_API_KEY=your-deepseek-api-key
MINIMAX_API_KEY=your-minimax-global-api-key
MINIMAX_CN_API_KEY=your-minimax-cn-api-key
XIAOMI_API_KEY=your-xiaomi-mimo-api-key
ZAI_API_KEY=your-zai-global-api-key
ZAI_CODING_CN_API_KEY=your-zhipu-coding-cn-api-key
MOONSHOT_API_KEY=your-moonshot-api-key
KIMI_API_KEY=your-kimi-coding-api-key

# Additional catalog providers (not individually verified)
ANTHROPIC_API_KEY=your-anthropic-api-key
GROQ_API_KEY=your-groq-api-key
XAI_API_KEY=your-xai-api-key
OPENROUTER_API_KEY=your-openrouter-api-key
CEREBRAS_API_KEY=your-cerebras-api-key
FIREWORKS_API_KEY=your-fireworks-api-key

Xiaomi also accepts MIMO_API_KEY as an alternative to XIAOMI_API_KEY. Only the key for the provider selected by llm.GetModel is read.

Add .env to the application's .gitignore so the key is never committed:

.env

Copy one of the complete examples below into main.go, then run:

go mod tidy
go run .

In production, inject the selected provider's API key as a process environment variable instead of using a .env file.

Providers and models

The library currently implements two protocol adapters:

  • openai-completions
  • anthropic-messages

The catalog and compatibility layer explicitly configure these providers:

Provider Provider ID Protocol Environment variable
DeepSeek deepseek openai-completions DEEPSEEK_API_KEY
MiniMax Global minimax anthropic-messages MINIMAX_API_KEY
MiniMax China minimax-cn anthropic-messages MINIMAX_CN_API_KEY
Xiaomi MiMo xiaomi openai-completions XIAOMI_API_KEY or MIMO_API_KEY
Z.AI Global zai openai-completions ZAI_API_KEY
Zhipu Coding Plan China zai-coding-cn openai-completions ZAI_CODING_CN_API_KEY
Moonshot AI Global moonshotai openai-completions MOONSHOT_API_KEY
Moonshot AI China moonshotai-cn openai-completions MOONSHOT_API_KEY
Kimi Coding kimi-coding anthropic-messages KIMI_API_KEY

The catalog also contains metadata for additional compatible providers and models. Those entries can be queried and may work through one of the two protocol adapters, but they have not all been verified against live provider APIs and are not a support guarantee.

Automated tests exercise both protocol adapters with local mock servers. They do not currently run live integration tests against every provider listed above.

Query the catalog instead of hard-coding model IDs supplied by users:

Complete model discovery example
package main

import (
	"fmt"
	"log"

	"github.com/ktsoator/or/llm"
)

func main() {
	for _, provider := range llm.GetProviders() {
		fmt.Println(provider)
		for _, model := range llm.GetModels(provider) {
			fmt.Printf("  %s: %s\n", model.ID, model.Name)
		}
	}

	model, ok := llm.LookupModel("xiaomi", "mimo-v2-flash")
	if !ok {
		log.Fatal("model not found")
	}
	fmt.Printf("selected %s/%s via %s\n", model.Provider, model.ID, model.Protocol)
}

Use LookupModel for dynamic input. GetModel is convenient for known catalog entries and panics when the provider or model ID does not exist.

Quick start

The llm package includes the built-in OpenAI-compatible and Anthropic protocol adapters and can call Complete directly:

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/joho/godotenv"
	"github.com/ktsoator/or/llm"
)

func main() {
	_ = godotenv.Load()

	model := llm.GetModel("deepseek", "deepseek-v4-flash")
	response, err := llm.Complete(
		context.Background(),
		model,
		llm.Context{Messages: []llm.Message{
			&llm.UserMessage{Content: []llm.UserContent{
				&llm.TextContent{Text: "Explain Go channels briefly."},
			}},
		}},
		llm.StreamOptions{},
	)
	if err != nil {
		log.Fatal(err)
	}

	for _, block := range response.Content {
		if text, ok := block.(*llm.TextContent); ok {
			fmt.Println(text.Text)
		}
	}
}

Set the provider API key in the environment, for example DEEPSEEK_API_KEY. Use llm.NewClient when an isolated built-in client is needed.

Streaming

Use Stream to process text as it is generated:

Complete streaming example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/joho/godotenv"
	"github.com/ktsoator/or/llm"
)

func main() {
	_ = godotenv.Load()

	model := llm.GetModel("deepseek", "deepseek-v4-flash")
	events, err := llm.Stream(
		context.Background(),
		model,
		llm.Context{Messages: []llm.Message{
			&llm.UserMessage{Content: []llm.UserContent{
				&llm.TextContent{Text: "Explain Go channels briefly."},
			}},
		}},
		llm.StreamOptions{Reasoning: llm.ModelThinkingHigh},
	)
	if err != nil {
		log.Fatal(err)
	}

	thinkingStarted := false
	answerStarted := false
	var finalMessage *llm.AssistantMessage
	for event := range events {
		switch event.Type {
		case llm.EventThinkingDelta:
			if !thinkingStarted {
				fmt.Println("[thinking]")
				thinkingStarted = true
			}
			fmt.Print(event.Delta)
		case llm.EventTextDelta:
			if !answerStarted {
				if thinkingStarted {
					fmt.Print("\n\n")
				}
				fmt.Println("[answer]")
				answerStarted = true
			}
			fmt.Print(event.Delta)
		case llm.EventDone:
			finalMessage = event.Message
		case llm.EventError:
			log.Fatal(event.Err)
		}
	}
	if finalMessage == nil {
		log.Fatal("stream closed without a final message")
	}
	fmt.Printf(
		"\nstop=%s tokens=%d cost=$%.6f\n",
		finalMessage.StopReason,
		finalMessage.Usage.TotalTokens,
		finalMessage.Usage.Cost.Total,
	)
}

Thinking events are emitted only when the selected model and provider expose reasoning content.

Stream event reference
Event Meaning Main fields
EventStart The provider stream started Partial
EventTextStart A text block started ContentIndex, Partial
EventTextDelta A text fragment arrived ContentIndex, Delta, Partial
EventTextEnd A text block completed ContentIndex, Content, Partial
EventThinkingStart A reasoning block started ContentIndex, Partial
EventThinkingDelta A reasoning fragment arrived ContentIndex, Delta, Partial
EventThinkingEnd A reasoning block completed ContentIndex, Content, Partial
EventToolCallStart A tool call block started ContentIndex, ToolCall, Partial
EventToolCallDelta A raw tool-argument JSON fragment arrived ContentIndex, Delta, ToolCall, Partial
EventToolCallEnd A tool call finished streaming, arguments parsed best-effort ContentIndex, ToolCall, Partial
EventDone The request completed successfully Message
EventError The request failed or was cancelled Err, Message

EventDone.Message is the final assistant message and contains content, usage, cost, and stop reason. EventError.Message may contain partial content and usage. The channel emits exactly one terminal event and then closes.

Events from different content blocks may be interleaved. Use ContentIndex to associate deltas with their block. EventToolCallDelta.Delta is raw partial JSON. EventToolCallEnd carries the call with its arguments parsed best-effort: malformed or truncated JSON degrades to the fields received so far, or to an empty object, so validate arguments before use. Collect tool calls while streaming and execute them only after EventDone. On EventError, treat EventError.Message as partial content for display, logging, or retry only; do not execute any tool calls from that response.

When a tool call's arguments could not be parsed strictly, the response records a tool_arguments_recovered entry in Message.Diagnostics with the recovery mode (repaired, partial, or invalid). Inspect Diagnostics before executing a tool with side effects, and decline partial or invalid arguments — return them to the model as a tool error so it can retry.

Typed tools

Generate a provider-compatible JSON Schema from a Go struct instead of writing tool parameters by hand. The same type is used to validate, coerce, and decode the tool call returned by the model:

Complete typed tool example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/joho/godotenv"
	"github.com/ktsoator/or/llm"
)

type WeatherArgs struct {
	City  string `json:"city" jsonschema:"description=City name,minLength=1"`
	Units string `json:"units,omitempty" jsonschema:"enum=celsius,enum=fahrenheit"`
	Days  int    `json:"days" jsonschema:"minimum=1,maximum=10"`
}

func main() {
	_ = godotenv.Load()

	ctx := context.Background()
	model := llm.GetModel("deepseek", "deepseek-v4-flash")
	weatherTool := llm.MustTool[WeatherArgs](
		"get_weather",
		"Get a weather forecast",
	)

	messages := []llm.Message{
		&llm.UserMessage{Content: []llm.UserContent{
			&llm.TextContent{Text: "What's the weather in Shanghai for the next 3 days?"},
		}},
	}
	input := llm.Context{
		Messages: messages,
		Tools:    []llm.ToolDefinition{weatherTool},
	}

	response, err := llm.Complete(ctx, model, input, llm.StreamOptions{})
	if err != nil {
		log.Fatal(err)
	}
	messages = append(messages, &response)

	toolUsed := false
	for _, content := range response.Content {
		toolCall, ok := content.(*llm.ToolCall)
		if !ok || toolCall.Name != weatherTool.Name {
			continue
		}

		arguments, err := llm.DecodeToolCall[WeatherArgs](weatherTool, *toolCall)
		if err != nil {
			log.Fatal(err)
		}
		result := getWeather(arguments)
		messages = append(messages, &llm.ToolResultMessage{
			ToolCallID: toolCall.ID,
			ToolName:   toolCall.Name,
			Content: []llm.ToolResultContent{
				&llm.TextContent{Text: result},
			},
		})
		toolUsed = true
	}
	if !toolUsed {
		log.Fatal("model returned no weather tool call")
	}

	response, err = llm.Complete(ctx, model, llm.Context{
		Messages: messages,
		Tools:    []llm.ToolDefinition{weatherTool},
	}, llm.StreamOptions{})
	if err != nil {
		log.Fatal(err)
	}
	for _, content := range response.Content {
		if text, ok := content.(*llm.TextContent); ok {
			fmt.Println(text.Text)
		}
	}
}

func getWeather(arguments WeatherArgs) string {
	units := arguments.Units
	if units == "" {
		units = "celsius"
	}
	return fmt.Sprintf(
		"%s will be sunny for the next %d days, around 24 degrees %s.",
		arguments.City,
		arguments.Days,
		units,
	)
}

Fields without omitempty are required. The generated schema is fully inline and omits document metadata such as $schema, $id, $ref, and $defs.

Protocol-specific tool choice

Tool choice keeps each protocol's native vocabulary, matching the underlying APIs. Supply it through ProtocolOptions; the client validates that the option type matches the selected model protocol and that named tools exist in the request context.

OpenAI-compatible Chat Completions uses required and function choices:

options := llm.StreamOptions{
	ProtocolOptions: &llm.OpenAICompletionsStreamOptions{
		ToolChoice: llm.OpenAIToolChoiceRequired,
		// To force one function instead:
		// ToolChoice: llm.OpenAIToolChoiceFunction{Name: "get_weather"},
	},
}

Anthropic Messages uses any and tool choices:

options := llm.StreamOptions{
	ProtocolOptions: &llm.AnthropicStreamOptions{
		ToolChoice: llm.AnthropicToolChoiceAny,
		// To force one tool instead:
		// ToolChoice: llm.AnthropicToolChoiceTool{Name: "get_weather"},
	},
}

Both protocols also expose Auto and None mode constants. An explicit tool choice requires at least one tool in Context.Tools.

Reasoning and thinking

Reasoning is a provider-neutral effort level. Each adapter maps it to the target provider's native form — Anthropic adaptive or budget thinking, the OpenAI-compatible reasoning fields — and clamps it to the levels the model supports. Non-reasoning models ignore it.

options := llm.StreamOptions{Reasoning: llm.ModelThinkingHigh}

The accepted levels are ModelThinkingOff, ModelThinkingMinimal, ModelThinkingLow, ModelThinkingMedium, ModelThinkingHigh, and ModelThinkingXHigh. SupportedThinkingLevels reports the levels a model accepts and ClampThinkingLevel adjusts a requested level to the nearest supported one.

On the Anthropic protocol, ThinkingDisplay controls how the reasoning is returned without changing whether the model reasons. ThinkingDisplayOmitted withholds the thinking text while still returning the signature required for multi-turn tool use, which suits backends that never surface reasoning:

options := llm.StreamOptions{
	Reasoning: llm.ModelThinkingHigh,
	ProtocolOptions: &llm.AnthropicStreamOptions{
		ThinkingDisplay: llm.ThinkingDisplayOmitted,
	},
}

While streaming, reasoning arrives as EventThinkingDelta events before the answer text.

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 conversation that contains images is sent to a text-only model, the images are replaced with a short placeholder automatically, so the same history remains valid across models of differing capabilities.

Switching models between turns

The conversation history is provider-neutral, so the target model may change from one turn to the next — for example, drafting with an inexpensive model and reviewing with a stronger one. Before each request the library adapts the stored history for the target model: it downgrades images for text-only models, preserves reasoning signatures for the same model while downgrading or dropping them across models, and normalizes tool-call identifiers. No history rebuilding is required.

Complete model-switching example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/joho/godotenv"
	"github.com/ktsoator/or/llm"
)

func main() {
	_ = godotenv.Load()

	ctx := context.Background()
	draft := llm.GetModel("deepseek", "deepseek-v4-flash")
	review := llm.GetModel("anthropic", "claude-opus-4-8")

	messages := []llm.Message{
		&llm.UserMessage{Content: []llm.UserContent{
			&llm.TextContent{Text: "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.UserMessage{Content: []llm.UserContent{
		&llm.TextContent{Text: "Check the calculation above for mistakes."},
	}})

	second, err := llm.Complete(ctx, review, llm.Context{Messages: messages}, llm.StreamOptions{})
	if err != nil {
		log.Fatal(err)
	}
	for _, content := range second.Content {
		if text, ok := content.(*llm.TextContent); ok {
			fmt.Println(text.Text)
		}
	}
}

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

Saving and restoring conversations

A Context serializes to self-describing JSON: messages carry a role and content blocks carry a type, so the document round-trips back into concrete message and content types without manual dispatch. This makes it suitable for persisting chat history or passing a conversation between services.

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.

Discovering providers and models

The built-in catalog is queryable, which is useful for model pickers or capability filters:

for _, provider := range llm.GetProviders() {
	for _, model := range llm.GetModels(provider) {
		fmt.Printf("%s/%s reasoning=%t vision=%t context=%d\n",
			model.Provider, model.ID, model.Reasoning,
			slices.Contains(model.Input, llm.Image), model.ContextWindow)
	}
}

LookupModel returns a model and a found flag; GetModel returns it directly and panics when the model is unknown.

Custom and OpenAI-compatible endpoints

Any endpoint that implements one of the two protocols can be used by constructing a Model directly and pointing BaseURL at it. This covers local servers such as Ollama, vLLM, and LM Studio, as well as private model gateways:

model := llm.Model{
	ID:            "qwen2.5-coder:7b",
	Name:          "Qwen2.5 Coder 7B",
	Provider:      "ollama",
	Protocol:      llm.ProtocolOpenAICompletions,
	BaseURL:       "http://localhost:11434/v1",
	Input:         []llm.ModelInput{llm.Text},
	ContextWindow: 32768,
	MaxTokens:     4096,
}

events, err := llm.Stream(ctx, model, input, llm.StreamOptions{APIKey: "ollama"})

Endpoint-specific quirks — reasoning field names, cache-control support, and similar differences — are configured through Model.Compatibility with OpenAICompletionsCompatibility or AnthropicMessagesCompatibility.

Cancelling a request

Cancelling the request context stops an in-flight request. The stream emits a single EventError whose message reports StopReasonAborted, then closes.

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

events, err := llm.Stream(ctx, model, input, llm.StreamOptions{})
if err != nil {
	log.Fatal(err)
}
// Call cancel() from elsewhere, for example when the user presses Stop.
for event := range events {
	switch event.Type {
	case llm.EventTextDelta:
		fmt.Print(event.Delta)
	case llm.EventError:
		fmt.Printf("\nstopped: %s\n", event.Message.StopReason)
	}
}

Observing requests and responses

Two optional hooks expose the raw HTTP exchange for logging or debugging. Both fire once per attempt, so retries are observable. OnRequest receives the exact request body serialized for the provider, including protocol-specific fields that the neutral types do not show directly.

options := llm.StreamOptions{
	OnRequest: func(method, url string, body []byte) {
		log.Printf("→ %s %s\n%s", method, url, body)
	},
	OnResponse: func(status int, headers http.Header) {
		log.Printf("← %d", status)
	},
}

Custom protocol adapters

The two built-in protocols cover most needs, and any OpenAI- or Anthropic-compatible endpoint is reachable by pointing a Model at its BaseURL. To serve a genuinely different wire protocol, implement ProtocolAdapter and register it alongside the built-ins.

An adapter implements two methods: Protocol returns the registry key, and Stream translates the request and emits events. NewStreamWriter provides the event-stream machinery the built-in adapters use — a single EventStart, a Partial snapshot on every event, a single terminal event, and cancellation reported as StopReasonAborted — so the adapter only builds the message and emits deltas.

Minimal custom adapter
type myAdapter struct{ http *http.Client }

func (myAdapter) Protocol() llm.Protocol { return "my-protocol" }

func (a myAdapter) Stream(
	ctx context.Context, model llm.Model, input llm.Context, options llm.StreamOptions,
) (<-chan llm.Event, error) {
	events := make(chan llm.Event)
	go func() {
		defer close(events)

		message := llm.AssistantMessage{
			Protocol: model.Protocol, Provider: model.Provider, Model: model.ID,
		}
		writer := llm.NewStreamWriter(ctx, events, &message)

		// Translate input into the wire request, call the endpoint, and stream the
		// response. On any failure, writer.Fail(err) emits the terminal error.
		reply, usage, err := callMyEndpoint(ctx, a.http, model, input, options)
		if err != nil {
			writer.Fail(err)
			return
		}

		text := &llm.TextContent{}
		message.Content = append(message.Content, text)
		writer.Emit(llm.Event{Type: llm.EventTextStart, ContentIndex: 0})
		for chunk := range reply {
			text.Text += chunk
			writer.Emit(llm.Event{Type: llm.EventTextDelta, ContentIndex: 0, Delta: chunk})
		}
		writer.Emit(llm.Event{Type: llm.EventTextEnd, ContentIndex: 0, Content: text.Text})

		message.Usage = usage
		message.StopReason = llm.StopReasonStop
		writer.Done()
	}()
	return events, nil
}

Register it on a client that keeps the built-in protocols:

registry := llm.NewRegistry()
llm.RegisterBuiltins(registry)
if err := registry.Register(myAdapter{http: http.DefaultClient}); err != nil {
	log.Fatal(err)
}
client := llm.NewClientWithRegistry(registry)

model := llm.Model{ID: "x", Provider: "me", Protocol: "my-protocol", MaxTokens: 1024}
message, err := client.Complete(ctx, model, input, llm.StreamOptions{})

The adapter is responsible for the full translation in both directions — building the wire request from Context, framing the response, and emitting deltas. CloneToolCall deep-copies a tool call for an event's ToolCall field; ParseToolArgumentsMode recovers truncated argument JSON the same way the built-in adapters do.

Acknowledgements

This project is inspired by and partially adapted from earendil-works/pi, created by Mario Zechner.

Directories

Path Synopsis
internal
llm
llm/cmd/genmodels command
Command genmodels builds llm's checked-in model catalog from the same public catalogs used by pi-ai.
Command genmodels builds llm's checked-in model catalog from the same public catalogs used by pi-ai.
llm/providers/anthropic
Package anthropic implements the Anthropic Messages protocol on top of the official anthropic-sdk-go.
Package anthropic implements the Anthropic Messages protocol on top of the official anthropic-sdk-go.
Package llm is a unified, provider-neutral API for large language models.
Package llm is a unified, provider-neutral API for large language models.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL