3 Commits
Author SHA1 Message Date
konradandCursor f90719583e Document every exported symbol and how callers use the library.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 19:25:43 +02:00
konradandCursor 7b63c8db58 Remove a trailing blank line from the client.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 14:03:53 +02:00
Konrad NeitzelandCursor be4f54890b Raise the module Go version to 1.27.0.
Match the Workspace pin so language and stdlib features through 1.27 are allowed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 17:16:20 +02:00
6 changed files with 622 additions and 18 deletions
+36 -5
View File
@@ -1,10 +1,41 @@
# go-llm # go-llm
Go Library for chat LLM calls (`gitea.neitzel.de/konrad/go-llm`). Domain language is in `CONTEXT.md`. Decisions live in the Workspace at `docs/adr/go-llm/`. Go library for chat LLM calls. One client speaks either the Ollama native protocol or an OpenAI-compatible protocol. Callers pass messages. The library does not load files or build product prompts.
v1: Chat, Stream, List Models, temperature, and max tokens. Adapters: Ollama native (`/api/chat`, `/api/tags`) and OpenAI-compatible (`/v1/chat/completions`, `/v1/models`). No embeddings, tools, raw generate, Nextcloud types, or story types. Import: `gitea.neitzel.de/konrad/go-llm` (package `gollm`).
```go ```bash
client, err := gollm.NewClient(gollm.AdapterOpenAI, "http://localhost:8000", "") go get gitea.neitzel.de/konrad/go-llm
text, err := client.Chat(ctx, []gollm.Message{{Role: gollm.RoleUser, Content: "Hello"}}, gollm.Options{Model: "llama"})
``` ```
- [Guide](docs/guide.md) — how to call each capability
- [API](docs/api.md) — every exported symbol
- [Domain language](CONTEXT.md)
## Included
- [Adapters](docs/guide.md#adapters) — Ollama native and OpenAI-compatible
- [Chat](docs/guide.md#chat) — one assistant reply, including temperature and max tokens
- [Stream](docs/guide.md#stream) — the same call, delivered as text chunks
- [List Models](docs/guide.md#list-models) — names the server reports
## Excluded
- Embeddings, tools, and raw generate
- Nextcloud types and story types
## Testing
Unit tests use `httptest`. No live model server is required.
`go test ./...` fails when [`docs/api.md`](docs/api.md) does not match the exported API. Regenerate it with:
```bash
UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1
```
## Related
- **Tales** — ExApp that consumes this library
- Workspace ADR `docs/adr/go-llm/0001-native-adapters.md`
- Workspace ADR `docs/adr/go-llm/0002-wire-only.md`
+278
View File
@@ -0,0 +1,278 @@
package gollm_test
import (
"bytes"
"fmt"
"go/ast"
"go/doc"
"go/doc/comment"
"go/parser"
"go/printer"
"go/token"
"os"
"strings"
"testing"
)
const apiModulePath = "gitea.neitzel.de/konrad/go-llm"
func TestAPIDoc(t *testing.T) {
got, err := renderAPIDoc(".", apiModulePath)
if err != nil {
t.Fatal(err)
}
const path = "docs/api.md"
if os.Getenv("UPDATE_API_DOCS") == "1" {
if err := os.MkdirAll("docs", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if string(want) != got {
t.Fatalf("docs/api.md is stale; regenerate with UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1\n%s", firstDiff(string(want), got))
}
}
func renderAPIDoc(dir, modulePath string) (string, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, parser.ParseComments)
if err != nil {
return "", err
}
if len(pkgs) != 1 {
return "", fmt.Errorf("expected 1 package in %s, found %d", dir, len(pkgs))
}
var astPkg *ast.Package
for _, p := range pkgs {
astPkg = p
}
pkg := doc.New(astPkg, modulePath, 0)
lookup := symLookup(pkg)
var b strings.Builder
b.WriteString("# API\n\n")
b.WriteString("Generated from the package comment and every exported declaration. Do not edit.\n")
b.WriteString("Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.\n\n")
fmt.Fprintf(&b, "## Package %s\n\n", pkg.Name)
writeDoc(&b, pkg.Doc, lookup)
writeValues(&b, fset, "Constants", pkg.Consts, "### ", lookup)
writeValues(&b, fset, "Variables", pkg.Vars, "### ", lookup)
writeFuncs(&b, fset, "Functions", pkg.Funcs, "### ", lookup)
if len(pkg.Types) > 0 {
b.WriteString("## Types\n\n")
for _, typ := range pkg.Types {
writeType(&b, fset, typ, lookup)
}
}
return b.String(), nil
}
func writeType(b *strings.Builder, fset *token.FileSet, typ *doc.Type, lookup func(string, string) bool) {
fmt.Fprintf(b, "### %s\n\n", typ.Name)
sig, err := formatGenDecl(fset, typ.Decl)
if err != nil {
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
} else {
writeCode(b, sig)
}
writeDoc(b, typ.Doc, lookup)
writeValues(b, fset, "", typ.Consts, "#### ", lookup)
writeValues(b, fset, "", typ.Vars, "#### ", lookup)
writeFuncs(b, fset, "", typ.Funcs, "#### ", lookup)
if !isInterface(typ) {
writeFuncs(b, fset, "", typ.Methods, "#### ", lookup)
}
}
func writeValues(b *strings.Builder, fset *token.FileSet, title string, vals []*doc.Value, heading string, lookup func(string, string) bool) {
if len(vals) == 0 {
return
}
if title != "" {
fmt.Fprintf(b, "## %s\n\n", title)
}
for _, v := range vals {
for _, name := range v.Names {
fmt.Fprintf(b, "%s%s\n\n", heading, name)
}
sig, err := formatGenDecl(fset, v.Decl)
if err != nil {
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
} else {
writeCode(b, sig)
}
writeDoc(b, v.Doc, lookup)
}
}
func writeFuncs(b *strings.Builder, fset *token.FileSet, title string, fns []*doc.Func, heading string, lookup func(string, string) bool) {
if len(fns) == 0 {
return
}
if title != "" {
fmt.Fprintf(b, "## %s\n\n", title)
}
for _, fn := range fns {
name := fn.Name
if fn.Recv != "" {
name = strings.TrimPrefix(fn.Recv, "*") + "." + fn.Name
}
fmt.Fprintf(b, "%s%s\n\n", heading, name)
sig, err := formatFunc(fset, fn.Decl)
if err != nil {
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
} else {
writeCode(b, sig)
}
writeDoc(b, fn.Doc, lookup)
}
}
func writeCode(b *strings.Builder, sig string) {
b.WriteString("```go\n")
b.WriteString(sig)
b.WriteString("\n```\n\n")
}
func writeDoc(b *strings.Builder, raw string, lookup func(string, string) bool) {
text := renderComment(raw, lookup)
if text == "" {
return
}
b.WriteString(text)
if !strings.HasSuffix(text, "\n") {
b.WriteByte('\n')
}
b.WriteByte('\n')
}
func symLookup(pkg *doc.Package) func(string, string) bool {
syms := map[string]bool{}
methods := map[string]bool{}
addNames := func(vals []*doc.Value) {
for _, v := range vals {
for _, name := range v.Names {
syms[name] = true
}
}
}
addFuncs := func(fns []*doc.Func) {
for _, fn := range fns {
syms[fn.Name] = true
if fn.Recv != "" {
recv := strings.TrimPrefix(fn.Recv, "*")
methods[recv+"."+fn.Name] = true
}
}
}
addNames(pkg.Consts)
addNames(pkg.Vars)
addFuncs(pkg.Funcs)
for _, typ := range pkg.Types {
syms[typ.Name] = true
addNames(typ.Consts)
addNames(typ.Vars)
addFuncs(typ.Funcs)
addFuncs(typ.Methods)
}
return func(recv, name string) bool {
if recv == "" {
return syms[name]
}
return methods[recv+"."+name]
}
}
func renderComment(raw string, lookup func(string, string) bool) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var parser comment.Parser
parser.LookupSym = lookup
var printer comment.Printer
printer.HeadingLevel = 4
printer.DocLinkURL = func(link *comment.DocLink) string {
if link.ImportPath != "" {
return link.DefaultURL("https://pkg.go.dev")
}
name := link.Name
if link.Recv != "" {
name = link.Recv + "." + link.Name
}
return "#" + githubAnchor(name)
}
return strings.TrimSpace(string(printer.Markdown(parser.Parse(raw))))
}
// githubAnchor matches GitHub heading slugs: lowercase, drop punctuation, keep
// letters, digits, hyphens, and underscores. "Client.Chat" becomes "clientchat".
func githubAnchor(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(name) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
}
}
return b.String()
}
func formatGenDecl(fset *token.FileSet, decl *ast.GenDecl) (string, error) {
if decl == nil {
return "", fmt.Errorf("nil decl")
}
copyDecl := *decl
copyDecl.Doc = nil
return formatNode(fset, &copyDecl)
}
func formatFunc(fset *token.FileSet, decl *ast.FuncDecl) (string, error) {
if decl == nil {
return "", fmt.Errorf("nil decl")
}
copyDecl := *decl
copyDecl.Doc = nil
copyDecl.Body = nil
return formatNode(fset, &copyDecl)
}
func formatNode(fset *token.FileSet, node ast.Node) (string, error) {
var buf bytes.Buffer
if err := printer.Fprint(&buf, fset, node); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
func isInterface(typ *doc.Type) bool {
if typ.Decl == nil || len(typ.Decl.Specs) != 1 {
return false
}
spec, ok := typ.Decl.Specs[0].(*ast.TypeSpec)
if !ok {
return false
}
_, ok = spec.Type.(*ast.InterfaceType)
return ok
}
func firstDiff(want, got string) string {
w := strings.Split(want, "\n")
g := strings.Split(got, "\n")
n := min(len(g), len(w))
for i := range n {
if w[i] != g[i] {
return fmt.Sprintf("line %d:\n got: %s\nwant: %s", i+1, g[i], w[i])
}
}
return fmt.Sprintf("length got %d lines, want %d lines", len(g), len(w))
}
+38 -10
View File
@@ -13,37 +13,50 @@ import (
"strings" "strings"
) )
// Adapter is which native protocol the Client speaks. // Adapter selects the wire protocol a Client speaks.
// [AdapterOpenAI] uses POST /v1/chat/completions and GET /v1/models.
// [AdapterOllama] uses POST /api/chat and GET /api/tags.
type Adapter string type Adapter string
const ( const (
// AdapterOpenAI selects the OpenAI-compatible protocol.
AdapterOpenAI Adapter = "openai" AdapterOpenAI Adapter = "openai"
// AdapterOllama selects the Ollama native protocol.
AdapterOllama Adapter = "ollama" AdapterOllama Adapter = "ollama"
) )
// Role is a Message role. // Role is the speaker of one Message.
type Role string type Role string
const ( const (
// RoleSystem is a system instruction.
RoleSystem Role = "system" RoleSystem Role = "system"
// RoleUser is the caller's turn.
RoleUser Role = "user" RoleUser Role = "user"
// RoleAssistant is a previous model turn.
RoleAssistant Role = "assistant" RoleAssistant Role = "assistant"
) )
// Message is one chat turn. // Message is one chat turn. Role and Content are sent unchanged.
type Message struct { type Message struct {
Role Role Role Role
Content string Content string
} }
// Options are Chat/Stream parameters. Temperature and MaxTokens are omitted when nil. // Options configures one Chat or Stream call.
// Model is the server-side model name. The Client does not check that it is
// set; the server rejects an empty model.
// A nil Temperature or MaxTokens is left out of the request.
// On [AdapterOllama], MaxTokens is sent as num_predict.
type Options struct { type Options struct {
Model string Model string
Temperature *float64 Temperature *float64
MaxTokens *int MaxTokens *int
} }
// Client is a configured caller bound to one Adapter, base URL, and optional API key. // Client calls one Adapter at one base URL.
// An empty API key sends no Authorization header.
// A non-empty key is sent as a Bearer token on every request.
type Client struct { type Client struct {
adapter Adapter adapter Adapter
baseURL string baseURL string
@@ -51,7 +64,10 @@ type Client struct {
http *http.Client http *http.Client
} }
// NewClient validates Adapter and base URL. apiKey may be empty. // NewClient checks adapter and baseURL.
// It returns an error when adapter is not [AdapterOpenAI] or [AdapterOllama],
// or when baseURL is empty or only whitespace.
// A trailing slash on baseURL is removed. apiKey may be empty.
func NewClient(adapter Adapter, baseURL, apiKey string) (*Client, error) { func NewClient(adapter Adapter, baseURL, apiKey string) (*Client, error) {
switch adapter { switch adapter {
case AdapterOpenAI, AdapterOllama: case AdapterOpenAI, AdapterOllama:
@@ -65,7 +81,10 @@ func NewClient(adapter Adapter, baseURL, apiKey string) (*Client, error) {
return &Client{adapter: adapter, baseURL: base, apiKey: apiKey, http: http.DefaultClient}, nil return &Client{adapter: adapter, baseURL: base, apiKey: apiKey, http: http.DefaultClient}, nil
} }
// Chat returns the assistant text for messages. // Chat sends messages and returns the assistant text.
// It returns an error when ctx is canceled, the HTTP status is not 2xx,
// the body cannot be decoded, or an OpenAI response contains no choices.
// An HTTP failure includes a short prefix of the response body.
func (c *Client) Chat(ctx context.Context, messages []Message, opt Options) (string, error) { func (c *Client) Chat(ctx context.Context, messages []Message, opt Options) (string, error) {
switch c.adapter { switch c.adapter {
case AdapterOpenAI: case AdapterOpenAI:
@@ -77,7 +96,13 @@ func (c *Client) Chat(ctx context.Context, messages []Message, opt Options) (str
} }
} }
// Stream yields assistant text chunks for the same Chat request. // Stream sends the same request as Chat with streaming enabled and calls
// yield for each non-empty text chunk, in order.
// It returns the error from yield.
// It also returns an error when ctx is canceled, the HTTP status is not 2xx,
// or a stream line cannot be decoded.
// [AdapterOpenAI] ends the stream when a data line is the token DONE.
// [AdapterOllama] reads one JSON object per line and yields message content.
func (c *Client) Stream(ctx context.Context, messages []Message, opt Options, yield func(chunk string) error) error { func (c *Client) Stream(ctx context.Context, messages []Message, opt Options, yield func(chunk string) error) error {
switch c.adapter { switch c.adapter {
case AdapterOpenAI: case AdapterOpenAI:
@@ -89,7 +114,11 @@ func (c *Client) Stream(ctx context.Context, messages []Message, opt Options, yi
} }
} }
// ListModels returns server-side Model names. // ListModels returns model names reported by the server.
// [AdapterOpenAI] reads data[].id from GET /v1/models.
// [AdapterOllama] reads models[].name from GET /api/tags.
// It returns an error when ctx is canceled, the HTTP status is not 2xx,
// or the body cannot be decoded. Empty names are omitted.
func (c *Client) ListModels(ctx context.Context) ([]string, error) { func (c *Client) ListModels(ctx context.Context) ([]string, error) {
switch c.adapter { switch c.adapter {
case AdapterOpenAI: case AdapterOpenAI:
@@ -276,4 +305,3 @@ func truncate(b []byte) string {
} }
return s return s
} }
+122
View File
@@ -0,0 +1,122 @@
# API
Generated from the package comment and every exported declaration. Do not edit.
Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.
## Package gollm
Package gollm is a chat LLM wire client: Chat, Stream, and List Models through an Ollama-native or OpenAI-compatible Adapter.
## Types
### Adapter
```go
type Adapter string
```
Adapter selects the wire protocol a Client speaks. [AdapterOpenAI](#adapteropenai) uses POST /v1/chat/completions and GET /v1/models. [AdapterOllama](#adapterollama) uses POST /api/chat and GET /api/tags.
#### AdapterOpenAI
#### AdapterOllama
```go
const (
// AdapterOpenAI selects the OpenAI-compatible protocol.
AdapterOpenAI Adapter = "openai"
// AdapterOllama selects the Ollama native protocol.
AdapterOllama Adapter = "ollama"
)
```
### Client
```go
type Client struct {
// contains filtered or unexported fields
}
```
Client calls one Adapter at one base URL. An empty API key sends no Authorization header. A non-empty key is sent as a Bearer token on every request.
#### NewClient
```go
func NewClient(adapter Adapter, baseURL, apiKey string) (*Client, error)
```
NewClient checks adapter and baseURL. It returns an error when adapter is not [AdapterOpenAI](#adapteropenai) or [AdapterOllama](#adapterollama), or when baseURL is empty or only whitespace. A trailing slash on baseURL is removed. apiKey may be empty.
#### Client.Chat
```go
func (c *Client) Chat(ctx context.Context, messages []Message, opt Options) (string, error)
```
Chat sends messages and returns the assistant text. It returns an error when ctx is canceled, the HTTP status is not 2xx, the body cannot be decoded, or an OpenAI response contains no choices. An HTTP failure includes a short prefix of the response body.
#### Client.ListModels
```go
func (c *Client) ListModels(ctx context.Context) ([]string, error)
```
ListModels returns model names reported by the server. [AdapterOpenAI](#adapteropenai) reads data\[].id from GET /v1/models. [AdapterOllama](#adapterollama) reads models\[].name from GET /api/tags. It returns an error when ctx is canceled, the HTTP status is not 2xx, or the body cannot be decoded. Empty names are omitted.
#### Client.Stream
```go
func (c *Client) Stream(ctx context.Context, messages []Message, opt Options, yield func(chunk string) error) error
```
Stream sends the same request as Chat with streaming enabled and calls yield for each non-empty text chunk, in order. It returns the error from yield. It also returns an error when ctx is canceled, the HTTP status is not 2xx, or a stream line cannot be decoded. [AdapterOpenAI](#adapteropenai) ends the stream when a data line is the token DONE. [AdapterOllama](#adapterollama) reads one JSON object per line and yields message content.
### Message
```go
type Message struct {
Role Role
Content string
}
```
Message is one chat turn. Role and Content are sent unchanged.
### Options
```go
type Options struct {
Model string
Temperature *float64
MaxTokens *int
}
```
Options configures one Chat or Stream call. Model is the server-side model name. The Client does not check that it is set; the server rejects an empty model. A nil Temperature or MaxTokens is left out of the request. On [AdapterOllama](#adapterollama), MaxTokens is sent as num\_predict.
### Role
```go
type Role string
```
Role is the speaker of one Message.
#### RoleSystem
#### RoleUser
#### RoleAssistant
```go
const (
// RoleSystem is a system instruction.
RoleSystem Role = "system"
// RoleUser is the caller's turn.
RoleUser Role = "user"
// RoleAssistant is a previous model turn.
RoleAssistant Role = "assistant"
)
```
+145
View File
@@ -0,0 +1,145 @@
# go-llm guide
How to use the library. Symbol signatures and doc comments are in [API](api.md). Words for the domain are in [CONTEXT.md](../CONTEXT.md).
## Adapters
### Purpose
An adapter is the wire protocol a client speaks. There are two: Ollama native and OpenAI-compatible. One client is bound to one adapter, one base URL, and an optional API key.
### When to use it
Choose the adapter when constructing the client. Use the OpenAI adapter for any server that implements `/v1/chat/completions` and `/v1/models`. Use the Ollama adapter for Ollama's `/api/chat` and `/api/tags`.
### Call sequence
1. Pick `AdapterOpenAI` or `AdapterOllama`.
2. Call `NewClient` with the adapter, the base URL, and the API key.
3. Use that client for Chat, Stream, and List Models. The client adds the path for the adapter.
A trailing slash on the base URL is removed. An empty API key sends no `Authorization` header. A non-empty key is sent as `Bearer` on every request.
OpenAI paths are `POST /v1/chat/completions` and `GET /v1/models`. Ollama paths are `POST /api/chat` and `GET /api/tags`. On Ollama, max tokens are sent as `num_predict`.
### Errors
`NewClient` returns an error when the adapter is not one of the two values, or when the base URL is empty or only whitespace. It does not contact the server.
### Example
```go
client, err := gollm.NewClient(gollm.AdapterOpenAI, "http://localhost:8000", "")
if err != nil {
return err
}
_ = client
```
## Chat
### Purpose
Chat sends a list of messages and returns the assistant's text in one response.
### When to use it
Use Chat when the caller wants the full reply before continuing. Use Stream when the caller wants to show text as it arrives.
### Call sequence
1. Build a `[]Message`. Each message has a `Role` (`RoleSystem`, `RoleUser`, or `RoleAssistant`) and `Content`.
2. Set `Options.Model` to the server-side model name.
3. Set `Options.Temperature` or `Options.MaxTokens` only when the call should send them. A nil pointer is omitted. On the Ollama adapter, `MaxTokens` is sent as `num_predict`.
4. Call `Client.Chat` with a context, the messages, and the options.
5. Use the returned string as the assistant text.
The client does not check that `Model` is set. The server rejects an empty model.
### Errors
`Chat` returns an error when the context is canceled, the HTTP status is not 2xx, or the body cannot be decoded. An OpenAI response with no choices is also an error. An HTTP failure includes a short prefix of the response body.
### Example
```go
temp := 0.2
text, err := client.Chat(ctx, []gollm.Message{{
Role: gollm.RoleUser,
Content: "Hello",
}}, gollm.Options{Model: "llama", Temperature: &temp})
if err != nil {
return err
}
_ = text
```
## Stream
### Purpose
Stream sends the same request as Chat and delivers the assistant text as non-empty chunks, in order.
### When to use it
Use Stream when the caller wants incremental text. The request body matches Chat, with streaming turned on.
### Call sequence
1. Build messages and options the same way as Chat.
2. Call `Client.Stream` with a `yield` function. `yield` receives each non-empty chunk.
3. Return nil from `yield` to keep reading. Return an error from `yield` to stop the stream.
The OpenAI adapter ends the stream when a data line is the token DONE. The Ollama adapter reads one JSON object per line and yields `message.content`.
### Errors
`Stream` returns the error from `yield`. It also returns an error when the context is canceled, the HTTP status is not 2xx, or a stream line cannot be decoded.
### Example
```go
var reply strings.Builder
err := client.Stream(ctx, []gollm.Message{{
Role: gollm.RoleUser,
Content: "Hello",
}}, gollm.Options{Model: "llama"}, func(chunk string) error {
reply.WriteString(chunk)
return nil
})
if err != nil {
return err
}
```
## List Models
### Purpose
List Models returns the model names the server reports.
### When to use it
Use it to discover names to put in `Options.Model`. The client does not cache the list.
### Call sequence
1. Use the same client as Chat.
2. Call `Client.ListModels` with a context.
3. Use the returned names. Empty names from the server are omitted.
The OpenAI adapter reads `data[].id` from `GET /v1/models`. The Ollama adapter reads `models[].name` from `GET /api/tags`.
### Errors
`ListModels` returns an error when the context is canceled, the HTTP status is not 2xx, or the body cannot be decoded.
### Example
```go
names, err := client.ListModels(ctx)
if err != nil {
return err
}
_ = names
```
+1 -1
View File
@@ -1,3 +1,3 @@
module gitea.neitzel.de/konrad/go-llm module gitea.neitzel.de/konrad/go-llm
go 1.26.4 go 1.27.0