A powerful and flexible Go library for interacting with Large Language Models (LLMs) with built-in support for function calling and streaming responses. Currently supports Anthropic, Google, OpenAI compatible providers, and TypeSafe System One models.
- Supports Anthropic, Google (Gemini + Vertex), and OpenAI (Chat Completions + Responses + WebSocket)
- Also supports using custom endpoints compatible with any of the APIs above
- TypeSafe System One models (Jev): typed judgments with probabilities, driven by a JSON output schema
- Streaming responses (including thinking) for real-time interaction
- Built-in tool calling with Go generics to generate JSON schemas automatically
- Structured output (JSON schema based) for model response
- OpenAI only: Custom grammars like Lark / Regex for structured output
- Prompt cache hints
- Image inputs and image generation / editing
- Usage tracking
- Text diffusion model support (Inception, Google)
- Realtime bidirectional text/audio streaming (WebRTC)
go get github.com/flitsinc/go-llmsHere’s a simple example that creates an LLM instance and has a conversation with it:
package main
import (
"fmt"
"os"
"github.com/flitsinc/go-llms/content"
"github.com/flitsinc/go-llms/llms"
"github.com/flitsinc/go-llms/openai"
"github.com/flitsinc/go-llms/tools"
)
func main() {
// Create a new LLM instance with OpenAI's gpt-5.5 model
llm := llms.New(
openai.NewResponsesAPI(os.Getenv("OPENAI_API_KEY"), "gpt-5.5"),
)
// Optional: Set a system prompt
llm.SystemPrompt = func() content.Content {
return content.FromText("You are a helpful assistant.")
}
// Start a chat conversation
for update := range llm.Chat("What's the capital of France?") {
switch update := update.(type) {
case llms.TextUpdate:
fmt.Print(update.Text)
}
}
// Check for errors after the chat completes
if err := llm.Err(); err != nil {
panic(err)
}
}You must specify modalities for this model to work (and you cannot use WithThinking):
func main() {
provider := google.New("gemini-3.1-flash-image-preview").
WithGeminiAPI(os.Getenv("GEMINI_API_KEY")).
WithModalities("TEXT", "IMAGE")
llm := llms.New(provider)
for update := range llm.Chat("Draw a sketch of a happy banana") {
switch update := update.(type) {
case llms.TextUpdate:
fmt.Print(update.Text)
case llms.ImageUpdate:
path := fmt.Sprintf("image-%d.png", time.Now().Unix())
// We assume we always get a base64-encoded PNG image -- don't do this in prod!
b64, _ := strings.CutPrefix(update.URL, "data:image/png;base64,")
data, _ := base64.StdEncoding.DecodeString(b64)
os.WriteFile(path, data, 0644)
fmt.Printf("\nImage written to: %s\n", path)
}
}
}Here’s an example showing how to use tools (function calling):
package main
import (
"fmt"
"os"
"github.com/flitsinc/go-llms/anthropic"
"github.com/flitsinc/go-llms/llms"
"github.com/flitsinc/go-llms/tools"
)
// Define tool parameters
type CommandParams struct {
Command string `json:"command" description:"The shell command to run"`
}
// Create a shell command tool
var RunCommand = tools.Func(
"Run Command",
"Run a shell command and return the output",
"run_command",
func(r tools.Runner, p CommandParams) tools.Result {
return tools.SuccessWithLabel(p.Command, map[string]any{
"output": fmt.Sprintf("Simulated output for: %s", p.Command),
})
},
)
func main() {
// Create a new LLM instance using Anthropic's Claude with tools
llm := llms.New(
anthropic.New(os.Getenv("ANTHROPIC_API_KEY"), "claude-sonnet-4-6"),
RunCommand,
)
// Chat with tool usage
for update := range llm.Chat("List files in the current directory") {
switch update := update.(type) {
case llms.TextUpdate:
fmt.Print(update.Text)
case llms.ToolStartUpdate:
fmt.Printf("(Using tool: %s)\n", update.Tool.Label()) // Shows "Run Command"
case llms.ToolDoneUpdate:
// Shows the label specific to this execution, e.g., "ls -l"
fmt.Printf("(Tool result: %s)\n", update.Result.Label())
}
}
// Check for errors after the chat completes
if err := llm.Err(); err != nil {
panic(err)
}
}Models sometimes call a tool that isn't in the toolbox — a mangled name, or a
real tool that was removed while the conversation history still mentions it.
This doesn't end the chat: the call produces a tool "name" not found error
result, which the model sees on its next turn so it can pick a different tool.
The ToolStartUpdate and ToolDoneUpdate for such a call carry a placeholder
tool. Nothing is executed for it, so if you do anything at tool_start
(spawn a worker, kick off a workflow), check for the placeholder first:
case llms.ToolStartUpdate:
if tools.IsUnknown(update.Tool) {
// There is no tool to run; the result is already an error.
break
}
startExecuting(update.Tool)Use errors.As with *tools.NotFoundError to tell this apart from an error a
real tool returned.
A model that keeps calling tools that don't exist gets no rule of its own: like
any other unproductive loop, it runs until WithMaxTurns stops it. That limit
is unlimited by default, so set one if you don't already — otherwise a model
that never stops inventing tool names never stops.
Sometimes, you might have a set of predefined tool schemas (perhaps from an external source or another system) that you want the LLM to be able to use. AddExternalTools allows you to provide these schemas along with a single handler function.
This is useful when the logic for handling multiple tools is centralized, or when you need to dynamically add tools based on external definitions.
The handler function receives the tools.Runner and the raw JSON parameters for the called tool. You can use llms.GetToolCall(r.Context()) within the handler to retrieve the specific ToolCall instance, which includes the function name (tc.Name) and unique call ID (tc.ID), allowing you to dispatch to the correct logic.
// Example external tool schemas (could come from a config file, API, etc.)
var externalToolSchemas = []tools.FunctionSchema{
{
Name: "get_stock_price",
/* ... */
},
{
Name: "get_weather",
/* ... */
},
}
func main() {
llm := llms.New(anthropic.New(os.Getenv("ANTHROPIC_API_KEY"), "claude-sonnet-4-6"))
// Add external tools and their handler
llm.AddExternalTools(externalToolSchemas, handleExternalTool)
// Now the LLM can use "get_stock_price" and "get_weather"
for update := range llm.Chat("What's the weather in London?") {
switch update := update.(type) {
case llms.TextUpdate:
fmt.Print(update.Text)
case llms.ToolStartUpdate:
fmt.Printf("(Using tool: %s)\n", update.Tool.Label())
case llms.ToolDoneUpdate:
fmt.Printf("(Tool result: %s - %s)\n", update.Tool.Label(), update.Result.Label())
}
}
if err := llm.Err(); err != nil {
panic(err)
}
}
// Single handler that forwards external tool calls.
func handleExternalTool(r tools.Runner, params json.RawMessage) tools.Result {
// Get the specific tool call details from the context
toolCall, ok := llms.GetToolCall(r.Context())
if !ok {
return tools.Errorf("Could not get tool call details from context")
}
// Typically, you would now:
// 1. Construct a request to your external API endpoint (or send it to a browser client)
targetURL := fmt.Sprintf("https://api.example.com/tool?name=%s", toolCall.Name)
req, err := http.NewRequestWithContext(r.Context(), "POST", targetURL, bytes.NewReader(params))
// ... set headers, handle error ...
// 2. Execute the request.
resp, err := httpClient.Do(req)
// ... handle error ...
// 3. Process the response.
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Use Errorf or ErrorWithLabel for tool errors
bodyBytes, _ := io.ReadAll(resp.Body)
return tools.Errorf("External tool API failed (%s): %s", resp.Status, string(bodyBytes))
}
bodyBytes, err := io.ReadAll(resp.Body)
// ... handle read error ...
// 4. Return the result based on the response body.
return tools.Success(json.RawMessage(bodyBytes))
}OpenAI supports custom tools that can enforce specific input formats using grammars. This allows you to constrain the model's output to follow precise patterns, which is useful for structured data extraction, validation, or parsing tasks.
The library supports three grammar types:
- Lark Grammar: For complex parsing using Lark parser syntax
- Regex Grammar: For pattern matching using regular expressions
- Text Grammar: For free-form text input
Here are examples of grammar-based tools:
package main
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"github.com/flitsinc/go-llms/llms"
"github.com/flitsinc/go-llms/openai"
"github.com/flitsinc/go-llms/tools"
)
// Example 1: Lark Grammar for Math Expressions
var mathGrammar = tools.Lark(`
start: expr
expr: term (SP ADD SP term)* -> add
| term
term: factor (SP MUL SP factor)* -> mul
| factor
factor: INT
SP: " "
ADD: "+"
MUL: "*"
%import common.INT
`)
var MathTool = tools.FuncGrammar(
mathGrammar,
"Math Calculator",
"Evaluate simple math expressions with addition and multiplication",
"calculate_math",
func(r tools.Runner, expression string) tools.Result {
// Simple evaluation for demo (in practice, use a proper parser)
expression = strings.ReplaceAll(expression, " ", "")
// Handle simple cases for demonstration
if strings.Contains(expression, "+") {
parts := strings.Split(expression, "+")
if len(parts) == 2 {
a, err1 := strconv.Atoi(parts[0])
b, err2 := strconv.Atoi(parts[1])
if err1 == nil && err2 == nil {
result := a + b
return tools.SuccessWithLabel(expression, map[string]any{
"result": result,
"expression": expression,
})
}
}
}
// Fallback for single numbers
if num, err := strconv.Atoi(expression); err == nil {
return tools.SuccessWithLabel(expression, map[string]any{
"result": num,
"expression": expression,
})
}
return tools.ErrorWithLabel("Invalid expression", fmt.Errorf("could not parse: %s", expression))
},
)
// Example 2: Regex Grammar for Email Validation
var emailGrammar = tools.Regex(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
var EmailValidator = tools.FuncGrammar(
emailGrammar,
"Email Validator",
"Validate and extract information from email addresses",
"validate_email",
func(r tools.Runner, email string) tools.Result {
emailRegex := regexp.MustCompile(`^([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$`)
matches := emailRegex.FindStringSubmatch(email)
if len(matches) != 3 {
return tools.ErrorWithLabel("Invalid email", fmt.Errorf("email format is invalid: %s", email))
}
username := matches[1]
domain := matches[2]
return tools.SuccessWithLabel(email, map[string]any{
"email": email,
"username": username,
"domain": domain,
"is_valid": true,
})
},
)
func main() {
// Create LLM with grammar-based tools (OpenAI only)
llm := llms.New(
openai.New(os.Getenv("OPENAI_API_KEY"), "gpt-5.5"),
MathTool,
EmailValidator,
)
// The model can now use structured tools with grammar constraints
for update := range llm.Chat("Calculate 15 + 27 and validate the email user@example.com") {
switch update := update.(type) {
case llms.TextUpdate:
fmt.Print(update.Text)
case llms.ToolStartUpdate:
fmt.Printf("(Using %s: ", update.Tool.Label())
case llms.ToolDoneUpdate:
fmt.Printf("%s)\n", update.Result.Label())
}
}
if err := llm.Err(); err != nil {
panic(err)
}
}Note: Grammar-based tools are currently only supported by OpenAI's API. Other providers currently do not support grammar-based tools.
The library currently supports:
- Anthropic
- Google (Gemini API and Vertex AI)
- OpenAI and all compatible providers (you can customize the endpoint)
- OpenAI’s newer Responses API (HTTP and WebSocket)
- TypeSafe System One models (see TypeSafe below)
Each provider can be initialized with their respective configuration:
// Anthropic
llm := llms.New(anthropic.New(os.Getenv("ANTHROPIC_API_KEY"), "claude-sonnet-4-6"))
// Google Gemini
llm := llms.New(google.New("gemini-3-flash-preview").WithGeminiAPI(os.Getenv("GEMINI_API_KEY")))
// Google Vertex AI
ts, err := googleoauth.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/cloud-platform")
llm := llms.New(google.New("gemini-3-flash-preview").WithVertexAI(ts, projectID, "global"))
// OpenAI (Responses API)
llm := llms.New(openai.NewResponsesAPI(os.Getenv("OPENAI_API_KEY"), "gpt-5.5"))
// OpenAI (Responses API over WebSocket — persistent connection, faster tool loops)
provider := openai.NewWebSocketResponsesAPI(os.Getenv("OPENAI_API_KEY"), "gpt-5.5")
defer provider.Close()
llm := llms.New(provider)
// OpenAI (Chat Completions API)
llm := llms.New(openai.New(os.Getenv("OPENAI_API_KEY"), "gpt-5.5"))
// OpenAI-compatible endpoint (e.g., xAI)
// You can use the OpenAI provider with compatible APIs by configuring the endpoint.
llm := llms.New(
openai.New(os.Getenv("XAI_API_KEY"), "grok-3-latest").
WithEndpoint("https://api.x.ai/v1/chat/completions", "xAI"),
)
// TypeSafe (System One)
llm := llms.New(typesafe.New(os.Getenv("TYPESAFE_API_KEY"), "jev-latest"))TypeSafe models such as Jev do not generate text. They read a
state and answer typed questions with calibrated probabilities. The typesafe provider
maps the regular go-llms call onto that API:
- The system prompt and messages become the request state, as a JSON object with a
systemstring and amessagesarray. JSON content items are embedded as JSON so the model sees named fields. - Images are lifted out of the content and sent before that object, so the state becomes an array of image parts followed by the conversation (or of the image parts alone, when the content was only images). That is the one place the API reads an image. A multimodal decision model sees the images first and the text after; a text-only model answers from the text. An image loses its place between the texts around it and its message's role, so word the text accordingly ("the screenshot shows...").
- The JSON output schema becomes the questions, one per property, with the property's
descriptionas the question. The property name is not sent to the model, so the description must carry the full meaning. - The answers come back as the JSON object the schema describes, in one text chunk, so structured-output callers work unchanged.
Some System One models refuse that conversation object and read only a state of their
own shape; OpenRouter's Respan Span-01, for example, takes a transcript whose last
assistant message is judged. WithState replaces the default state with whatever a
builder returns from the system prompt and messages. The builder owns the conversion,
including tool messages, which reach it instead of being refused, and its error fails
the request before anything is sent.
Supported property types:
| Property type | Question | Rendered answer |
|---|---|---|
boolean |
Noul | true when the probability is at least 0.5 |
number |
Noul | the probability that the description holds, 0 to 1 |
string with enum |
Choice | the chosen enum member |
A number property is read as the probability that its description holds, so its description has to be a yes/no question. The rendered value is that probability, not a quantity the model counted or estimated.
A property listed in the schema's required must be answered or the request fails; an
unanswered optional property is simply left out of the rendered object.
Nested objects, arrays, free strings, and tools are rejected with a typed error rather
than approximated, because the model cannot produce them; audio, video, and compaction
content are rejected because the state cannot hold them. Callers that need the full
probability distribution or confidence behind an answer can read it from the stream.
That means calling the provider's Generate directly, since llms.LLM does not expose
provider streams:
provider := typesafe.New(os.Getenv("TYPESAFE_API_KEY"), "jev-latest")
stream := provider.Generate(ctx, systemPrompt, messages, nil, schema)
if err := stream.Err(); err != nil {
panic(err)
}
if response := stream.(*typesafe.Stream).Response(); response != nil {
fmt.Println(response.Answers["severity"].Probabilities)
}The WebSocket provider keeps a persistent connection to OpenAI's Responses API. Within multi-turn tool loops it automatically chains responses via previous_response_id, sending only new tool results instead of the full conversation history. This can significantly reduce latency in tool-heavy workflows.
provider := openai.NewWebSocketResponsesAPI(os.Getenv("OPENAI_API_KEY"), "gpt-5.5")
defer provider.Close() // closes the WebSocket when doneThe connection is established lazily on the first call. All the same With* configuration methods from ResponsesAPI are available.
Warmup pre-loads tools and instructions on the server before the first user message, making the first turn faster:
_, err := provider.Warmup(ctx, "You are a helpful assistant.", toolbox)Debugging — when calling Warmup or Generate directly on the provider (not through LLM), use WithDebugger to attach a debugger:
provider.WithDebugger(llms.StdOutDebugger)Alternatively, pass the debugger via context: llms.WithDebugger(ctx, llms.StdOutDebugger).
External connections let you manage the WebSocket lifecycle yourself:
conn, err := openai.DialResponsesWebSocket(ctx, accessToken)
provider := openai.NewWebSocketResponsesAPI(accessToken, "gpt-5.5").WithConn(conn)
// provider.Close() is a no-op — you own the connectionResetChain clears the chaining state when starting a new conversation on the same connection:
provider.ResetChain()You can easily implement new providers by implementing the Provider interface:
type Provider interface {
Company() string
Model() string
// Generate takes a system prompt, message history, and optional toolbox,
// returning a stream for the LLM's response. The provided context should
// be respected for cancellation.
//
// Providers should check for a Debugger on the context via
// llms.GetDebugger(ctx) to support request/response logging.
Generate(
ctx context.Context,
systemPrompt content.Content,
messages []Message,
toolbox *tools.Toolbox,
jsonOutputSchema *tools.ValueSchema,
) ProviderStream
}
type ProviderStream interface {
Err() error
Iter() func(yield func(StreamStatus) bool)
Message() Message
Text() string
Image() (string, string)
Audio() (string, string)
Thought() content.Thought
ToolCall() ToolCall
Usage() Usage
}A stream can also implement optional capabilities, which the turn loop discovers with a type assertion: ContextUsageStream (usage of the final sampling pass, after any compaction), SearchStream (provider-run searches), CompactionStream (native compaction checkpoints) and ToolArgumentFinalizationStream (a final snapshot of tool arguments).
To wrap a stream (for fallback, metering and the like), embed llms.StreamWrapper rather than llms.ProviderStream, so the wrapped stream's optional capabilities keep reaching the turn loop, and assert llms.StreamCapabilities to catch a regression at compile time. A wrapper that overrides Usage must override ContextUsage too:
type meteredStream struct {
llms.StreamWrapper
}
var _ llms.StreamCapabilities = (*meteredStream)(nil)
stream := &meteredStream{StreamWrapper: llms.StreamWrapper{ProviderStream: inner}}Track the usage of your LLM interactions:
usage := llm.TotalUsage
fmt.Printf("Cached Input Tokens: %d, Tokens Written to Input Cache: %d, Input Tokens: %d, Output Tokens: %d\n",
usage.CachedInputTokens, usage.CacheCreationInputTokens, usage.InputTokens, usage.OutputTokens)As patterns emerge between providers with regards to cache tokens, speculative tokens, etc. these will be added too.
When you want to make providers easily swappable and a simplified API that focuses on hekoing you implement the most common types of agentic flows.
Since each LLM provider has its own quirks, especially around reasoning, streaming, and tool calling, we’ve done our best to smooth those over, but expect some differences still.
Google doesn’t allow the additionalProperties field for JSON schemas (probably a bug), while OpenAI’s new Responses API requires it for tool calls! It’s also commonly required for models with strict JSON outputs since it helps with speculative decoding.
Because of this, we strip out the additionalProperties field before sending it to Google, so it shouldn’t be a problem for you, just keep it in mind.
OpenAI’s strict mode (Responses API function tools, and JSON outputs on both OpenAI APIs) rejects a schema unless every property is listed in required, so optional properties have to be sent as required-but-nullable (anyOf: [<schema>, {"type": "null"}]). The types that carry the strict flag pad their schema when they are encoded, including a FunctionTool you pass to WithTool, so write optional properties normally and expect null for the ones the model leaves out on those requests (tools.Func validation treats null for an optional property as left out). An object whose additionalProperties is the any-value schema {} is closed (false), because strict mode needs a type on every schema; strict mode also needs additionalProperties: false on every object, which padding does not add, so close your objects yourself. Padding is idempotent, so schemas you already pad yourself are sent unchanged.
Everywhere else the schema goes out as written, including Chat Completions function tools, which are not sent strict unless you call WithStrictTools(). Do call it for Chat Completions endpoints that serve OpenAI models (including OpenRouter’s openai/* models): OpenAI treats function tools as strict even without the flag, so an unpadded schema makes the model invent a value for every optional field. Don’t pad schemas yourself for other providers: forced to fill an optional field it wants to leave out, Claude sometimes writes an empty value, which Anthropic streams as invalid argument JSON ("field": ,).
Anthropic’s structured outputs reject maxItems and any minItems other than 0 or 1, so the anthropic package drops maxItems from a JSON output schema and lowers a larger minItems to 1. Tool schemas go out with their limits, which Claude reads as hints. Other providers receive the limits as written but enforce them unevenly, so also state a limit in the field description or the prompt: schema enforcement is a backstop, not a replacement.
OpenAI resolves a reasoning item replayed by ID alone only in the organization that stored it, so that history fails once the API key belongs to another organization. The Responses API provider therefore asks for each reasoning item’s encrypted content and keeps it on the content.Thought (Encrypted, next to the item’s ID), both in the streamed thought and in the message. Only the endpoint that produced the content can read it, so the thought’s Metadata records its openai:reasoning_format (openai-responses-v1) and openai:reasoning_endpoint (the API host, such as api.openai.com or api.x.ai). The Responses provider replays the content only to that endpoint and replays other thoughts by ID, and Chat Completions forwards the format so OpenRouter can drop reasoning its upstream cannot read. Keep Metadata when you persist thoughts.
A thought recorded before this content was requested has only its ID and can still be refused after a change of organization, as can content OpenAI can no longer verify. (*llms.HTTPError).IsReplayRejected() reports these refusals, and those of compaction checkpoints; the caller should then send the conversation without the replayed reasoning and checkpoints. The WebSocket provider resends the whole conversation once, without previous_response_id, when the server no longer holds the response it chained to.
The streaming API of Anthropic only sends complete string values when streaming tool calls, so if you have a tool call like edit_file which produces very long fields nothing will update until that field has completely finished generating.
To fix this, use fine-grained tool streaming which is currently in beta, by calling .WithBeta("fine-grained-tool-streaming-2025-05-14") on the Anthropic provider instance.
MIT License - See LICENSE file for details.