Skip to content

Sending a Chat Request

This recipe shows how a Go program sends a chat request to a large language model with the pgEdge Go LLM Library. The library gives Anthropic, OpenAI, Gemini, Ollama, Voyage, and TypeSafe the same interface, so the program chooses its provider from the environment and the rest of the code stays the same. By default the example talks to a local Ollama server, which needs no account and no API key.

The example sends a system message and a user message, and then prints the text of the reply with the number of tokens that the request used. A language model produces different text on each run, so the output varies; the output has the following shape:

Provider: ollama
Model: qwen2.5:1.5b
Reply:
A primary key is a column, or set of columns, that uniquely identifies
each row in a table.
Tokens: 32 prompt, 26 completion

Preparing the Ingredients

The example needs Go and the library, which the go.mod file pins at version 0.6.0. The following snippet shows the dependency declaration from the example project:

// The pgEdge Go LLM Library has no dependencies of its own.
require github.com/pgEdge/pgedge-go-llm-lib v0.6.0

To run the example against a local model, install Ollama, and then pull the chat model that the example uses by default. The following commands pull the model and confirm that the server is listening on its default port:

ollama pull qwen2.5:1.5b
curl http://localhost:11434/api/tags

Importing the Library

The program imports the llm package, which defines the client interface and the request types. The blank import of the llm/all package registers all six providers, so that llm.NewClient() can find the provider by name; a program that uses only one provider can import that provider's package under llm/provider instead, to keep the binary small. The following snippet shows the imports:

import (
    "context"
    "fmt"
    "os"
    "strings"
    "time"

    "github.com/pgEdge/pgedge-go-llm-lib/llm"
    // Importing the llm/all package registers every provider; import
    // individual packages under llm/provider to keep the binary small.
    _ "github.com/pgEdge/pgedge-go-llm-lib/llm/all"
)

Configuring the Provider

The program reads its settings from environment variables, so the same code runs against a local model or a cloud service. Like the other recipes in this cookbook, the program treats an empty variable as unset, and uses the default instead. The following table describes the environment variables that this example reads:

Variable Default Description
LLM_PROVIDER ollama The provider, such as openai.
LLM_MODEL qwen2.5:1.5b The name of the chat model.
LLM_API_KEY None The API key, if the provider needs one.
LLM_BASE_URL None Overrides the provider endpoint.

When LLM_BASE_URL is unset or empty, the program passes no base URL, and the library uses the default endpoint of the chosen provider, which is http://localhost:11434 for Ollama. The following snippet shows how the program reads the variables:

// envOr returns the value of an environment variable, or a fallback if
// the variable is unset or empty.
func envOr(name, fallback string) string {
    if value := os.Getenv(name); value != "" {
        return value
    }
    return fallback
}

// config holds the settings that choose the provider and the model.
type config struct {
    provider string
    model    string
    apiKey   string
    baseURL  string
}

// loadConfig reads the LLM_* environment variables. An empty
// LLM_BASE_URL leaves the field empty, so the library uses the default
// endpoint of the chosen provider.
func loadConfig() config {
    return config{
        provider: envOr("LLM_PROVIDER", "ollama"),
        model:    envOr("LLM_MODEL", "qwen2.5:1.5b"),
        apiKey:   os.Getenv("LLM_API_KEY"),
        baseURL:  os.Getenv("LLM_BASE_URL"),
    }
}

Switching Providers

Changing the provider needs no code change, only different values for the variables. The following commands run the same program against OpenAI and against Anthropic; replace the placeholders with a model that your account can use and your API key:

LLM_PROVIDER=openai LLM_MODEL=<model> LLM_API_KEY=<key> go run .
LLM_PROVIDER=anthropic LLM_MODEL=<model> LLM_API_KEY=<key> go run .

Set LLM_BASE_URL to use a server that speaks the same protocol as a provider but lives elsewhere, such as a gateway in front of OpenAI or a remote Ollama server.

Note

Production applications should not store API keys in source code or shell history. Use a secrets manager, or at least keep the key in an environment variable that the deployment sets.

Creating the Client

The program passes the provider name and an llm.Options value to llm.NewClient(), which returns a client that implements the llm.Client interface. The options set the model, the API key, and the base URL. They also cap the reply at 64 tokens, which keeps the example fast, and limit the retry policy to two retries, so that the program gives up quickly if the server is unreachable. The following snippet shows how the program creates the client:

// newClient builds a client for the provider named in the configuration.
// Every provider implements the same llm.Client interface, so the rest
// of the program does not depend on which one is in use.
func newClient(cfg config) (llm.Client, error) {
    client, err := llm.NewClient(cfg.provider, llm.Options{
        APIKey:  cfg.apiKey,
        Model:   cfg.model,
        BaseURL: cfg.baseURL,
        // Cap the length of the reply; this also keeps the example fast.
        MaxTokens: llm.Int(64),
        // Retry transient failures twice, which gives up sooner than the
        // default of five retries with a two second initial backoff.
        Retry: llm.RetryConfig{
            MaxRetries:     2,
            InitialBackoff: 200 * time.Millisecond,
        },
    })
    if err != nil {
        return nil, fmt.Errorf("creating the client failed: %w", err)
    }
    return client, nil
}

Sending the Request

The program calls Chat() with an llm.ChatRequest. The SystemPrompt field sets the behaviour of the model, and the Messages field holds the conversation, built here with the llm.UserText() helper. The call returns once the model has produced the whole reply. The following snippet shows the request:

// ask sends a system message and a user message, and returns the
// response, which holds the content blocks and the token usage.
func ask(ctx context.Context, client llm.Client) (*llm.ChatResponse, error) {
    resp, err := client.Chat(ctx, llm.ChatRequest{
        // The system prompt sets the behaviour of the model, and the
        // messages hold the conversation so far.
        SystemPrompt: "You are a concise assistant for PostgreSQL users.",
        Messages: []llm.Message{
            llm.UserText("In one sentence, what is a primary key?"),
        },
    })
    if err != nil {
        return nil, fmt.Errorf("chat request failed: %w", err)
    }
    return resp, nil
}

Reading the Reply

A response holds a list of content blocks rather than a single string, because a model can return text, tool calls, and other kinds of content in the same reply. The program prints each block whose type is llm.BlockText, and then prints the prompt and completion token counts from the Usage field. The following snippet shows how the program reads the response:

// printResponse prints the text blocks of the response, followed by the
// token usage that the provider reported.
func printResponse(resp *llm.ChatResponse) {
    fmt.Println("Reply:")
    for _, block := range resp.Content {
        // A response can hold other kinds of block (tool calls, for
        // example), so print only the text.
        if block.Type == llm.BlockText {
            fmt.Println(strings.TrimSpace(block.Text))
        }
    }
    fmt.Printf("Tokens: %d prompt, %d completion\n",
        resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
}

Handling Errors

The main part of the program builds the client, sends the request, and prints the result. It returns any error to main(), which reports the error on stderr and exits with a non-zero status. The following snippet shows the main part of the program:

// run builds the client, sends the request, and prints the response,
// returning any error to the caller.
func run(ctx context.Context) error {
    cfg := loadConfig()
    client, err := newClient(cfg)
    if err != nil {
        return err
    }

    fmt.Printf("Provider: %s\n", client.Provider())
    fmt.Printf("Model: %s\n", client.Model())

    resp, err := ask(ctx, client)
    if err != nil {
        return err
    }
    printResponse(resp)
    return nil
}

// main reports any error returned by run() and exits with a non-zero
// status.
func main() {
    if err := run(context.Background()); err != nil {
        fmt.Fprintf(os.Stderr, "LLM error: %v\n", err)
        os.Exit(1)
    }
}

Running the Example

The complete project is available in the cookbook repository. With Ollama running, run the following command from the go project directory:

go run .

Viewing the Complete Program

The following snippet shows the complete program:

// Ellie's Postgres Cookbook
// Sending a chat request with the pgEdge Go LLM Library.
//
// Copyright (c) 2026, pgEdge, Inc.
// This code is released under the PostgreSQL License.

package main

import (
    "context"
    "fmt"
    "os"
    "strings"
    "time"

    "github.com/pgEdge/pgedge-go-llm-lib/llm"
    // Importing the llm/all package registers every provider; import
    // individual packages under llm/provider to keep the binary small.
    _ "github.com/pgEdge/pgedge-go-llm-lib/llm/all"
)


// envOr returns the value of an environment variable, or a fallback if
// the variable is unset or empty.
func envOr(name, fallback string) string {
    if value := os.Getenv(name); value != "" {
        return value
    }
    return fallback
}

// config holds the settings that choose the provider and the model.
type config struct {
    provider string
    model    string
    apiKey   string
    baseURL  string
}

// loadConfig reads the LLM_* environment variables. An empty
// LLM_BASE_URL leaves the field empty, so the library uses the default
// endpoint of the chosen provider.
func loadConfig() config {
    return config{
        provider: envOr("LLM_PROVIDER", "ollama"),
        model:    envOr("LLM_MODEL", "qwen2.5:1.5b"),
        apiKey:   os.Getenv("LLM_API_KEY"),
        baseURL:  os.Getenv("LLM_BASE_URL"),
    }
}


// newClient builds a client for the provider named in the configuration.
// Every provider implements the same llm.Client interface, so the rest
// of the program does not depend on which one is in use.
func newClient(cfg config) (llm.Client, error) {
    client, err := llm.NewClient(cfg.provider, llm.Options{
        APIKey:  cfg.apiKey,
        Model:   cfg.model,
        BaseURL: cfg.baseURL,
        // Cap the length of the reply; this also keeps the example fast.
        MaxTokens: llm.Int(64),
        // Retry transient failures twice, which gives up sooner than the
        // default of five retries with a two second initial backoff.
        Retry: llm.RetryConfig{
            MaxRetries:     2,
            InitialBackoff: 200 * time.Millisecond,
        },
    })
    if err != nil {
        return nil, fmt.Errorf("creating the client failed: %w", err)
    }
    return client, nil
}


// ask sends a system message and a user message, and returns the
// response, which holds the content blocks and the token usage.
func ask(ctx context.Context, client llm.Client) (*llm.ChatResponse, error) {
    resp, err := client.Chat(ctx, llm.ChatRequest{
        // The system prompt sets the behaviour of the model, and the
        // messages hold the conversation so far.
        SystemPrompt: "You are a concise assistant for PostgreSQL users.",
        Messages: []llm.Message{
            llm.UserText("In one sentence, what is a primary key?"),
        },
    })
    if err != nil {
        return nil, fmt.Errorf("chat request failed: %w", err)
    }
    return resp, nil
}


// printResponse prints the text blocks of the response, followed by the
// token usage that the provider reported.
func printResponse(resp *llm.ChatResponse) {
    fmt.Println("Reply:")
    for _, block := range resp.Content {
        // A response can hold other kinds of block (tool calls, for
        // example), so print only the text.
        if block.Type == llm.BlockText {
            fmt.Println(strings.TrimSpace(block.Text))
        }
    }
    fmt.Printf("Tokens: %d prompt, %d completion\n",
        resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
}


// run builds the client, sends the request, and prints the response,
// returning any error to the caller.
func run(ctx context.Context) error {
    cfg := loadConfig()
    client, err := newClient(cfg)
    if err != nil {
        return err
    }

    fmt.Printf("Provider: %s\n", client.Provider())
    fmt.Printf("Model: %s\n", client.Model())

    resp, err := ask(ctx, client)
    if err != nil {
        return err
    }
    printResponse(resp)
    return nil
}

// main reports any error returned by run() and exits with a non-zero
// status.
func main() {
    if err := run(context.Background()); err != nil {
        fmt.Fprintf(os.Stderr, "LLM error: %v\n", err)
        os.Exit(1)
    }
}

Next Steps

The following resources provide more information about the topics in this recipe: