First AI generated version

This commit is contained in:
Konrad Neitzel 2026-06-11 20:01:48 +02:00
commit c7d14ffee7
26 changed files with 1884 additions and 0 deletions

View File

@ -0,0 +1,27 @@
---
description: go-ollama wrapper — ergonomic client for github.com/ollama/ollama/api
alwaysApply: true
---
# go-ollama — Project Rules
Follow `docs/project-specification.md` for design and implementation.
## Stack
- **Language:** Go (see workspace `go.work` for version)
- **Module:** `gitea.neitzel.de/konrad/go-ollama`, package `gollama`
- **Wraps:** `github.com/ollama/ollama/api` — do not reimplement HTTP
## Design
- Default to non-streaming (`stream: false`); aggregate into `Result`
- `CallOption` for per-request overrides; `OptionSet` fluent builder for generation options
- `Session` for multi-turn chat with automatic history
- Project `.cursor` overrides workspace rules for this module
## Integration tests
- Default Ollama host: `http://debian:11434` in `testconfig/defaults.toml`
- Override via `testconfig/local.toml` (gitignored) or `OLLAMA_HOST`
- Run: `go test -tags=integration ./...`

44
.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
testconfig/local.toml
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binaries
*.test
# Test coverage
*.out
coverage.out
# Go workspace
go.work.sum
# Vendor (only if not committed)
vendor/
# Environment files
.env
.env.*
# Logs
*.log
# Temporary files
tmp/
temp/
# OS files
.DS_Store
Thumbs.db
# IntelliJ
.idea/
*.iml
# VSC
.vscode/

47
README.md Normal file
View File

@ -0,0 +1,47 @@
# go-ollama
Ergonomic Go wrapper around [`github.com/ollama/ollama/api`](https://github.com/ollama/ollama/tree/main/api).
## Quick start
```go
client, err := gollama.FromEnv()
if err != nil {
log.Fatal(err)
}
names, _ := client.ModelNames(ctx)
reply, _ := client.Complete(ctx, "Say hi.", gollama.WithModel("llama3.2"))
fmt.Println(reply.Text)
```
## Install
```bash
go get gitea.neitzel.de/konrad/go-ollama
```
## Configuration
- `OLLAMA_HOST` — Ollama server URL (default `http://127.0.0.1:11434`)
- Client defaults: `WithDefaultModel`, `WithDefaultOptions`
## API overview
- **Models:** `ModelNames`, `Pull`, `Show`, `Delete`, `RunningModels`
- **Simple:** `Complete`, `Chat`
- **History:** `ChatMessages`, `Session`
- **Structured:** `CompleteJSON`, `ChatJSON`
- **Streaming:** `CompleteStream`, `ChatStream`
- **Embeddings:** `Embed`
See [docs/project-specification.md](docs/project-specification.md) for details.
## Tests
```bash
go test ./...
go test -tags=integration ./...
```
Integration defaults live in `testconfig/defaults.toml` (`http://debian:11434`). Copy `testconfig/local.toml.example` to `testconfig/local.toml` to override.

104
call_options.go Normal file
View File

@ -0,0 +1,104 @@
package gollama
import (
"encoding/json"
"time"
"github.com/ollama/ollama/api"
)
// CallOption configures a single completion or chat request.
type CallOption func(*callConfig)
type callConfig struct {
model string
opts OptionSet
system string
raw bool
keepAlive *api.Duration
think *api.ThinkValue
format json.RawMessage
truncate *bool
shift *bool
stream *bool
}
func (c *Client) mergeCallConfig(callOpts ...CallOption) (*callConfig, error) {
cfg := &callConfig{opts: c.defaultOpts}
for _, opt := range callOpts {
opt(cfg)
}
model, err := c.resolveModel(cfg.model)
if err != nil {
return nil, err
}
cfg.model = model
return cfg, nil
}
func (cfg *callConfig) optionsMap() (map[string]any, error) {
return cfg.opts.toMap()
}
func boolPtr(v bool) *bool { return &v }
// WithModel sets the model for a single call.
func WithModel(name string) CallOption {
return func(c *callConfig) { c.model = name }
}
// WithOptions merges per-call generation options.
func WithOptions(opts OptionSet) CallOption {
return func(c *callConfig) { c.opts = c.opts.Merge(opts) }
}
// WithSystem sets a system prompt for the call.
func WithSystem(system string) CallOption {
return func(c *callConfig) { c.system = system }
}
// WithKeepAlive controls how long the model stays loaded after the request.
func WithKeepAlive(d time.Duration) CallOption {
return func(c *callConfig) {
c.keepAlive = &api.Duration{Duration: d}
}
}
// WithThink enables thinking/reasoning for supported models.
func WithThink(enabled bool) CallOption {
return func(c *callConfig) {
c.think = &api.ThinkValue{Value: enabled}
}
}
// WithThinkLevel sets thinking level ("high", "medium", "low", "max").
func WithThinkLevel(level string) CallOption {
return func(c *callConfig) {
c.think = &api.ThinkValue{Value: level}
}
}
// WithFormatJSON requests JSON output format.
func WithFormatJSON() CallOption {
return func(c *callConfig) { c.format = json.RawMessage(`"json"`) }
}
// WithFormatSchema requests structured output matching a JSON schema.
func WithFormatSchema(schema any) CallOption {
return func(c *callConfig) {
b, err := json.Marshal(schema)
if err == nil {
c.format = b
}
}
}
// WithTruncate truncates chat history when context is exceeded.
func WithTruncate(enabled bool) CallOption {
return func(c *callConfig) { c.truncate = &enabled }
}
// WithShift shifts chat history when context is exceeded.
func WithShift(enabled bool) CallOption {
return func(c *callConfig) { c.shift = &enabled }
}

103
chat.go Normal file
View File

@ -0,0 +1,103 @@
package gollama
import (
"context"
"strings"
"github.com/ollama/ollama/api"
)
// Message is a chat message with role and content.
type Message struct {
Role string
Content string
}
// User returns a user message.
func User(content string) Message { return Message{Role: "user", Content: content} }
// System returns a system message.
func System(content string) Message { return Message{Role: "system", Content: content} }
// Assistant returns an assistant message.
func Assistant(content string) Message { return Message{Role: "assistant", Content: content} }
func toAPIMessages(msgs []Message) []api.Message {
out := make([]api.Message, len(msgs))
for i, m := range msgs {
out[i] = api.Message{Role: m.Role, Content: m.Content}
}
return out
}
func fromAPIMessage(m api.Message) Message {
return Message{Role: m.Role, Content: m.Content}
}
// Chat sends a single user message.
func (c *Client) Chat(ctx context.Context, message string, opts ...CallOption) (*Result, error) {
return c.ChatMessages(ctx, []Message{User(message)}, opts...)
}
// ChatWithSystem sends a system and user message.
func (c *Client) ChatWithSystem(ctx context.Context, system, message string, opts ...CallOption) (*Result, error) {
return c.ChatMessages(ctx, []Message{System(system), User(message)}, opts...)
}
// ChatMessages sends an explicit message list (history + new turns).
func (c *Client) ChatMessages(ctx context.Context, messages []Message, opts ...CallOption) (*Result, error) {
cfg, err := c.mergeCallConfig(opts...)
if err != nil {
return nil, err
}
optMap, err := cfg.optionsMap()
if err != nil {
return nil, wrapError("chat", cfg.model, err)
}
msgs := toAPIMessages(messages)
if cfg.system != "" {
msgs = append([]api.Message{{Role: "system", Content: cfg.system}}, msgs...)
}
stream := false
req := &api.ChatRequest{
Model: cfg.model,
Messages: msgs,
Stream: &stream,
Options: optMap,
KeepAlive: cfg.keepAlive,
Think: cfg.think,
Format: cfg.format,
Truncate: cfg.truncate,
Shift: cfg.shift,
}
var text, thinking string
var final api.ChatResponse
err = c.api.Chat(ctx, req, func(resp api.ChatResponse) error {
text += resp.Message.Content
if resp.Message.Thinking != "" {
thinking += resp.Message.Thinking
}
if resp.Done {
final = resp
}
return nil
})
if err != nil {
return nil, wrapError("chat", cfg.model, err)
}
if text == "" {
text = final.Message.Content
}
return &Result{
Text: strings.TrimSpace(text),
Model: cfg.model,
Thinking: thinking,
Metrics: final.Metrics,
Raw: &final,
}, nil
}

101
client.go Normal file
View File

@ -0,0 +1,101 @@
package gollama
import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"github.com/ollama/ollama/api"
)
// Client wraps the official Ollama API client with ergonomic defaults.
type Client struct {
api *api.Client
host *url.URL
defaultModel string
defaultOpts OptionSet
}
// ClientOption configures a Client.
type ClientOption func(*Client)
// WithDefaultModel sets the model used when calls do not specify one.
func WithDefaultModel(name string) ClientOption {
return func(c *Client) { c.defaultModel = name }
}
// WithDefaultOptions sets client-wide generation options.
func WithDefaultOptions(opts OptionSet) ClientOption {
return func(c *Client) { c.defaultOpts = opts }
}
// WithHTTPClient sets the HTTP client used for API requests.
func WithHTTPClient(httpClient *http.Client) ClientOption {
return func(c *Client) {
if httpClient != nil && c.host != nil {
c.api = api.NewClient(c.host, httpClient)
}
}
}
// FromEnv creates a Client using OLLAMA_HOST (default http://127.0.0.1:11434).
func FromEnv(opts ...ClientOption) (*Client, error) {
apiClient, err := api.ClientFromEnvironment()
if err != nil {
return nil, wrapError("connect", "", err)
}
c := newClient(apiClient, nil, opts...)
// Re-read host from env for WithHTTPClient; api.ClientFromEnvironment uses envconfig.Host().
if u, err := url.Parse(hostFromEnv()); err == nil {
c.host = u
}
return c, nil
}
// New creates a Client for the given Ollama host URL.
func New(host string, opts ...ClientOption) (*Client, error) {
u, err := url.Parse(host)
if err != nil {
return nil, wrapError("connect", "", fmt.Errorf("invalid host %q: %w", host, err))
}
apiClient := api.NewClient(u, http.DefaultClient)
return newClient(apiClient, u, opts...), nil
}
func newClient(apiClient *api.Client, host *url.URL, opts ...ClientOption) *Client {
c := &Client{api: apiClient, host: host}
for _, opt := range opts {
opt(c)
}
return c
}
func hostFromEnv() string {
if v := os.Getenv("OLLAMA_HOST"); v != "" {
return v
}
return "http://127.0.0.1:11434"
}
// Ping checks whether the Ollama server is reachable.
func (c *Client) Ping(ctx context.Context) error {
return wrapError("ping", "", c.api.Heartbeat(ctx))
}
// DefaultModel returns the client's default model name.
func (c *Client) DefaultModel() string { return c.defaultModel }
// API returns the underlying official api.Client for advanced use.
func (c *Client) API() *api.Client { return c.api }
func (c *Client) resolveModel(model string) (string, error) {
if model != "" {
return model, nil
}
if c.defaultModel != "" {
return c.defaultModel, nil
}
return "", errNoModel()
}

159
client_test.go Normal file
View File

@ -0,0 +1,159 @@
package gollama_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
gollama "gitea.neitzel.de/konrad/go-ollama"
)
func TestPing(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/" {
w.WriteHeader(http.StatusOK)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
client, err := gollama.New(srv.URL)
if err != nil {
t.Fatal(err)
}
if err := client.Ping(context.Background()); err != nil {
t.Fatalf("ping: %v", err)
}
}
func TestModelNames(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
_ = json.NewEncoder(w).Encode(map[string]any{
"models": []map[string]any{
{"name": "llama3.2", "model": "llama3.2", "size": 100},
{"name": "mistral", "model": "mistral", "size": 200},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
client, err := gollama.New(srv.URL)
if err != nil {
t.Fatal(err)
}
names, err := client.ModelNames(context.Background())
if err != nil {
t.Fatal(err)
}
if len(names) != 2 || names[0] != "llama3.2" || names[1] != "mistral" {
t.Fatalf("names = %v", names)
}
}
func TestComplete(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/generate" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "test",
"response": "hello world",
"done": true,
})
}))
defer srv.Close()
client, err := gollama.New(srv.URL, gollama.WithDefaultModel("test"))
if err != nil {
t.Fatal(err)
}
result, err := client.Complete(context.Background(), "hi")
if err != nil {
t.Fatal(err)
}
if result.Text != "hello world" {
t.Fatalf("text = %q", result.Text)
}
}
func TestChatMessages(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "test",
"message": map[string]any{
"role": "assistant",
"content": "chat reply",
},
"done": true,
})
}))
defer srv.Close()
client, err := gollama.New(srv.URL, gollama.WithDefaultModel("test"))
if err != nil {
t.Fatal(err)
}
result, err := client.ChatMessages(context.Background(), []gollama.Message{
gollama.User("hello"),
})
if err != nil {
t.Fatal(err)
}
if result.Text != "chat reply" {
t.Fatalf("text = %q", result.Text)
}
}
func TestSession(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
http.NotFound(w, r)
return
}
calls++
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "test",
"message": map[string]any{
"role": "assistant",
"content": "reply",
},
"done": true,
})
}))
defer srv.Close()
client, err := gollama.New(srv.URL)
if err != nil {
t.Fatal(err)
}
session, err := client.NewSession("test", gollama.WithSessionSystem("be brief"))
if err != nil {
t.Fatal(err)
}
if _, err := session.Send(context.Background(), "one"); err != nil {
t.Fatal(err)
}
if _, err := session.Send(context.Background(), "two"); err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("calls = %d", calls)
}
msgs := session.Messages()
if len(msgs) < 3 {
t.Fatalf("messages = %d", len(msgs))
}
}

78
complete.go Normal file
View File

@ -0,0 +1,78 @@
package gollama
import (
"context"
"strings"
"github.com/ollama/ollama/api"
)
// Complete sends a single prompt and returns the full response.
func (c *Client) Complete(ctx context.Context, prompt string, opts ...CallOption) (*Result, error) {
return c.generate(ctx, prompt, false, opts...)
}
// CompleteWithSystem sends a system prompt and user prompt.
func (c *Client) CompleteWithSystem(ctx context.Context, system, prompt string, opts ...CallOption) (*Result, error) {
opts = append([]CallOption{WithSystem(system)}, opts...)
return c.generate(ctx, prompt, false, opts...)
}
// CompleteRaw sends a prompt without template formatting.
func (c *Client) CompleteRaw(ctx context.Context, prompt string, opts ...CallOption) (*Result, error) {
return c.generate(ctx, prompt, true, opts...)
}
func (c *Client) generate(ctx context.Context, prompt string, raw bool, callOpts ...CallOption) (*Result, error) {
cfg, err := c.mergeCallConfig(callOpts...)
if err != nil {
return nil, err
}
optMap, err := cfg.optionsMap()
if err != nil {
return nil, wrapError("complete", cfg.model, err)
}
stream := false
req := &api.GenerateRequest{
Model: cfg.model,
Prompt: prompt,
System: cfg.system,
Raw: raw,
Stream: &stream,
Options: optMap,
KeepAlive: cfg.keepAlive,
Think: cfg.think,
Format: cfg.format,
Truncate: cfg.truncate,
Shift: cfg.shift,
}
var text, thinking string
var final api.GenerateResponse
err = c.api.Generate(ctx, req, func(resp api.GenerateResponse) error {
text += resp.Response
thinking += resp.Thinking
if resp.Done {
final = resp
}
return nil
})
if err != nil {
return nil, wrapError("complete", cfg.model, err)
}
if text == "" {
text = final.Response
}
if thinking == "" {
thinking = final.Thinking
}
return &Result{
Text: strings.TrimSpace(text),
Model: cfg.model,
Thinking: thinking,
Metrics: final.Metrics,
Raw: &final,
}, nil
}

View File

@ -0,0 +1,59 @@
# go-ollama — Project Specification
## Module
- **Path:** `gitea.neitzel.de/konrad/go-ollama`
- **Package:** `gollama`
- **Dependency:** `github.com/ollama/ollama/api` (pinned in `go.mod`)
Thin wrapper around the official Ollama Go API. Adds ergonomic defaults, option builders, and tiered prompt helpers.
## Client
```go
client, _ := gollama.FromEnv() // OLLAMA_HOST
client, _ := gollama.New("http://localhost:11434")
client, _ := gollama.New(host, gollama.WithDefaultModel("llama3.2"))
```
## Model management
| Method | Purpose |
|--------|---------|
| `ModelNames` | Sorted local model names |
| `Models` | Local models with metadata |
| `Pull` / `PullWithProgress` | Download/load model |
| `Show` | Model details |
| `ModelDefaults` | Parse modelfile parameters to `OptionSet` |
| `Delete` | Remove model |
| `RunningModels` | Models loaded in memory |
## Prompt API tiers
| Tier | Methods | Use case |
|------|---------|----------|
| 1 | `Complete`, `CompleteWithSystem`, `CompleteRaw` | Single prompt |
| 2 | `Chat`, `ChatWithSystem` | One chat turn |
| 3 | `ChatMessages` + `User`/`System`/`Assistant` | History + new message |
| 4 | `Session.Send` | Stateful multi-turn |
| 5 | `CompleteJSON`, `ChatJSON` | Structured output |
| 6 | `CompleteStream`, `ChatStream` | Streaming |
| 7 | `Embed` | Embeddings |
## Options
- `NewOptions()` / fluent builder (`Temperature`, `NumCtx`, `Stop`, …)
- `KnownOptions()` — documented option keys
- Precedence: per-call `WithOptions` > session > client defaults > server
## Integration tests
Config load order: env vars → `testconfig/local.toml``testconfig/defaults.toml`.
Default host: `http://debian:11434`
```bash
go test -tags=integration ./...
OLLAMA_HOST=http://other:11434 go test -tags=integration ./...
GOLLAMA_TEST_PULL=1 go test -tags=integration -run Pull ./...
```

24
embed.go Normal file
View File

@ -0,0 +1,24 @@
package gollama
import (
"context"
"github.com/ollama/ollama/api"
)
// Embed generates vector embeddings for the given texts.
func (c *Client) Embed(ctx context.Context, model string, texts []string) ([][]float32, error) {
name, err := c.resolveModel(model)
if err != nil {
return nil, err
}
req := &api.EmbedRequest{
Model: name,
Input: texts,
}
resp, err := c.api.Embed(ctx, req)
if err != nil {
return nil, wrapError("embed", name, err)
}
return resp.Embeddings, nil
}

90
errors.go Normal file
View File

@ -0,0 +1,90 @@
package gollama
import (
"errors"
"fmt"
"net"
"strings"
"github.com/ollama/ollama/api"
)
// Error wraps Ollama API and transport failures with operation context.
type Error struct {
Op string
Model string
Status int
Message string
Err error
}
func (e *Error) Error() string {
var b strings.Builder
if e.Op != "" {
b.WriteString(e.Op)
b.WriteString(": ")
}
if e.Model != "" {
b.WriteString(e.Model)
b.WriteString(": ")
}
if e.Message != "" {
b.WriteString(e.Message)
} else if e.Err != nil {
b.WriteString(e.Err.Error())
}
return b.String()
}
func (e *Error) Unwrap() error { return e.Err }
func wrapError(op, model string, err error) error {
if err == nil {
return nil
}
var e *Error
if errors.As(err, &e) {
return err
}
out := &Error{Op: op, Model: model, Err: err}
var status api.StatusError
if errors.As(err, &status) {
out.Status = status.StatusCode
out.Message = status.Error()
}
return out
}
// IsNotFound reports whether err is a 404 from the Ollama API.
func IsNotFound(err error) bool {
var status api.StatusError
if errors.As(err, &status) {
return status.StatusCode == 404
}
var e *Error
if errors.As(err, &e) {
return e.Status == 404
}
return false
}
// IsConnection reports whether err is a network connectivity failure.
func IsConnection(err error) bool {
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
return true
}
msg := err.Error()
return strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no such host") ||
strings.Contains(msg, "i/o timeout")
}
func errNoModel() error {
return fmt.Errorf("model name is required (set default model on client or pass WithModel)")
}

51
examples/simple/main.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"context"
"fmt"
"log"
"os"
"time"
gollama "gitea.neitzel.de/konrad/go-ollama"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
client, err := gollama.FromEnv()
if err != nil {
log.Fatalf("connect: %v", err)
}
if err := client.Ping(ctx); err != nil {
log.Fatalf("ping: %v", err)
}
names, err := client.ModelNames(ctx)
if err != nil {
log.Fatalf("list models: %v", err)
}
fmt.Println("models:", names)
model := "llama3.2"
if len(os.Args) > 1 {
model = os.Args[1]
}
reply, err := client.Complete(ctx, "Say hi in one sentence.", gollama.WithModel(model))
if err != nil {
log.Fatalf("complete: %v", err)
}
fmt.Println("reply:", reply.Text)
session, err := client.NewSession(model, gollama.WithSessionSystem("Be brief."))
if err != nil {
log.Fatalf("session: %v", err)
}
r, err := session.Send(ctx, "What is Go?")
if err != nil {
log.Fatalf("chat: %v", err)
}
fmt.Println("session:", r.Text)
}

11
export_test.go Normal file
View File

@ -0,0 +1,11 @@
package gollama
// Test helpers exported to _test package in the same module.
func (o OptionSet) toMapForTest() (map[string]any, error) { return o.toMap() }
func (o OptionSet) ToMapForTest() (map[string]any, error) { return o.toMap() }
func ParseModelfileParametersForTest(parameters string) (OptionSet, error) {
return parseModelfileParameters(parameters)
}

22
go.mod Normal file
View File

@ -0,0 +1,22 @@
module gitea.neitzel.de/konrad/go-ollama
go 1.26.4
require (
github.com/ollama/ollama v0.30.7
github.com/pelletier/go-toml/v2 v2.2.2
)
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/sys v0.37.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

59
go.sum Normal file
View File

@ -0,0 +1,59 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/ollama/ollama v0.30.7 h1:8RT8xUg0DvVxqU9+naDzqvrqlZo4QQ9Dnvhj6GfEaK0=
github.com/ollama/ollama v0.30.7/go.mod h1:TjwyryJftKpcf7ByoIuZWso/Wx2Jr2AcGubxadv13dY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

85
integration_test.go Normal file
View File

@ -0,0 +1,85 @@
//go:build integration
package gollama_test
import (
"context"
"os"
"testing"
gollama "gitea.neitzel.de/konrad/go-ollama"
"gitea.neitzel.de/konrad/go-ollama/internal/integrationtest"
)
func TestIntegrationPing(t *testing.T) {
client := integrationtest.RequireOllama(t)
cfg := integrationtest.ConfigForTest(t)
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
if err := client.Ping(ctx); err != nil {
t.Fatal(err)
}
}
func TestIntegrationModelNames(t *testing.T) {
client := integrationtest.RequireOllama(t)
cfg := integrationtest.ConfigForTest(t)
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
names, err := client.ModelNames(ctx)
if err != nil {
t.Fatal(err)
}
t.Logf("models: %v", names)
}
func TestIntegrationComplete(t *testing.T) {
client := integrationtest.RequireOllama(t)
cfg := integrationtest.ConfigForTest(t)
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
model := cfg.Model
names, err := client.ModelNames(ctx)
if err != nil {
t.Fatal(err)
}
if len(names) == 0 {
t.Skip("no models available on server")
}
if !contains(names, model) {
model = names[0]
t.Logf("configured model %q not found, using %q", cfg.Model, model)
}
result, err := client.Complete(ctx, "Reply with exactly one word: ok", gollama.WithModel(model))
if err != nil {
t.Fatal(err)
}
if result.Text == "" {
t.Fatal("empty response")
}
t.Logf("response: %q", result.Text)
}
func contains(ss []string, s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}
func TestIntegrationPull(t *testing.T) {
if os.Getenv("GOLLAMA_TEST_PULL") != "1" {
t.Skip("set GOLLAMA_TEST_PULL=1 to run pull integration test")
}
client := integrationtest.RequireOllama(t)
cfg := integrationtest.ConfigForTest(t)
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout*4)
defer cancel()
if err := client.Pull(ctx, cfg.Model); err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,130 @@
package integrationtest
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/pelletier/go-toml/v2"
gollama "gitea.neitzel.de/konrad/go-ollama"
)
// Config holds settings for integration tests against a live Ollama server.
type Config struct {
Host string
Model string
Timeout time.Duration
}
type fileConfig struct {
Host string `toml:"host"`
Model string `toml:"model"`
Timeout string `toml:"timeout"`
}
// LoadConfig merges environment variables, local.toml, and defaults.toml.
func LoadConfig() (Config, error) {
cfg := Config{
Host: "http://debian:11434",
Model: "llama3.2",
Timeout: 30 * time.Second,
}
if err := mergeFileConfig(&cfg, filepath.Join(configDir(), "defaults.toml")); err != nil {
return Config{}, err
}
_ = mergeFileConfig(&cfg, filepath.Join(configDir(), "local.toml"))
if v := os.Getenv("OLLAMA_HOST"); v != "" {
cfg.Host = v
}
if v := os.Getenv("GOLLAMA_TEST_MODEL"); v != "" {
cfg.Model = v
}
if v := os.Getenv("GOLLAMA_TEST_TIMEOUT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return Config{}, err
}
cfg.Timeout = d
}
return cfg, nil
}
func mergeFileConfig(cfg *Config, path string) error {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var fc fileConfig
if err := toml.Unmarshal(b, &fc); err != nil {
return err
}
if fc.Host != "" {
cfg.Host = fc.Host
}
if fc.Model != "" {
cfg.Model = fc.Model
}
if fc.Timeout != "" {
d, err := time.ParseDuration(fc.Timeout)
if err != nil {
return err
}
cfg.Timeout = d
}
return nil
}
func configDir() string {
_, file, _, ok := runtime.Caller(0)
if !ok {
return "testconfig"
}
return filepath.Join(filepath.Dir(file), "..", "..", "testconfig")
}
// NewClient creates a gollama client from integration test configuration.
func NewClient(t *testing.T) *gollama.Client {
t.Helper()
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("load integration config: %v", err)
}
client, err := gollama.New(cfg.Host, gollama.WithDefaultModel(cfg.Model))
if err != nil {
t.Fatalf("create client: %v", err)
}
return client
}
// ConfigForTest returns the loaded integration configuration.
func ConfigForTest(t *testing.T) Config {
t.Helper()
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("load integration config: %v", err)
}
return cfg
}
// RequireOllama skips the test when the configured Ollama server is unreachable.
func RequireOllama(t *testing.T) *gollama.Client {
t.Helper()
client := NewClient(t)
cfg := ConfigForTest(t)
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
if err := client.Ping(ctx); err != nil {
t.Skipf("ollama not reachable at %s: %v", cfg.Host, err)
}
return client
}

33
json.go Normal file
View File

@ -0,0 +1,33 @@
package gollama
import (
"context"
"encoding/json"
"fmt"
)
// CompleteJSON requests JSON output and unmarshals it into v.
func (c *Client) CompleteJSON(ctx context.Context, prompt string, v any, opts ...CallOption) error {
opts = append([]CallOption{WithFormatJSON()}, opts...)
result, err := c.Complete(ctx, prompt, opts...)
if err != nil {
return err
}
if err := json.Unmarshal([]byte(result.Text), v); err != nil {
return fmt.Errorf("unmarshal JSON response: %w", err)
}
return nil
}
// ChatJSON sends messages with JSON format and unmarshals the response into v.
func (c *Client) ChatJSON(ctx context.Context, messages []Message, v any, opts ...CallOption) error {
opts = append([]CallOption{WithFormatJSON()}, opts...)
result, err := c.ChatMessages(ctx, messages, opts...)
if err != nil {
return err
}
if err := json.Unmarshal([]byte(result.Text), v); err != nil {
return fmt.Errorf("unmarshal JSON response: %w", err)
}
return nil
}

156
models.go Normal file
View File

@ -0,0 +1,156 @@
package gollama
import (
"context"
"sort"
"time"
"github.com/ollama/ollama/api"
)
// Model describes a locally available Ollama model.
type Model struct {
Name string
Size int64
ModifiedAt time.Time
Digest string
Family string
ParameterSize string
Quantization string
}
// ModelInfo contains detailed information from Ollama's show endpoint.
type ModelInfo struct {
Name string
License string
Modelfile string
Parameters string
Template string
System string
ModifiedAt time.Time
}
// ModelNames returns sorted names of locally available models.
func (c *Client) ModelNames(ctx context.Context) ([]string, error) {
models, err := c.Models(ctx)
if err != nil {
return nil, err
}
names := make([]string, len(models))
for i, m := range models {
names[i] = m.Name
}
sort.Strings(names)
return names, nil
}
// Models returns locally available models with metadata.
func (c *Client) Models(ctx context.Context) ([]Model, error) {
resp, err := c.api.List(ctx)
if err != nil {
return nil, wrapError("list", "", err)
}
out := make([]Model, 0, len(resp.Models))
for _, m := range resp.Models {
out = append(out, listModelToModel(m))
}
return out, nil
}
func listModelToModel(m api.ListModelResponse) Model {
name := m.Name
if name == "" {
name = m.Model
}
return Model{
Name: name,
Size: m.Size,
ModifiedAt: m.ModifiedAt,
Digest: m.Digest,
Family: m.Details.Family,
ParameterSize: m.Details.ParameterSize,
Quantization: m.Details.QuantizationLevel,
}
}
// Pull downloads a model and blocks until complete.
func (c *Client) Pull(ctx context.Context, name string) error {
return c.PullWithProgress(ctx, name, nil)
}
// PullWithProgress downloads a model, optionally reporting progress.
func (c *Client) PullWithProgress(ctx context.Context, name string, fn func(Progress) error) error {
stream := false
req := &api.PullRequest{Model: name, Stream: &stream}
err := c.api.Pull(ctx, req, func(p api.ProgressResponse) error {
if fn == nil {
return nil
}
return fn(Progress{
Status: p.Status,
Digest: p.Digest,
Total: p.Total,
Completed: p.Completed,
})
})
return wrapError("pull", name, err)
}
// Show returns detailed information about a model.
func (c *Client) Show(ctx context.Context, name string) (*ModelInfo, error) {
resp, err := c.api.Show(ctx, &api.ShowRequest{Model: name})
if err != nil {
return nil, wrapError("show", name, err)
}
return &ModelInfo{
Name: name,
License: resp.License,
Modelfile: resp.Modelfile,
Parameters: resp.Parameters,
Template: resp.Template,
System: resp.System,
ModifiedAt: resp.ModifiedAt,
}, nil
}
// ModelDefaults parses a model's modelfile parameters into an OptionSet.
func (c *Client) ModelDefaults(ctx context.Context, name string) (OptionSet, error) {
info, err := c.Show(ctx, name)
if err != nil {
return OptionSet{}, err
}
return parseModelfileParameters(info.Parameters)
}
// Delete removes a model from the local Ollama instance.
func (c *Client) Delete(ctx context.Context, name string) error {
return wrapError("delete", name, c.api.Delete(ctx, &api.DeleteRequest{Model: name}))
}
// RunningModel describes a model currently loaded in memory.
type RunningModel struct {
Name string
Size int64
Expires time.Time
}
// RunningModels returns models currently loaded in Ollama memory.
func (c *Client) RunningModels(ctx context.Context) ([]RunningModel, error) {
resp, err := c.api.ListRunning(ctx)
if err != nil {
return nil, wrapError("list_running", "", err)
}
out := make([]RunningModel, 0, len(resp.Models))
for _, m := range resp.Models {
name := m.Name
if name == "" {
name = m.Model
}
out = append(out, RunningModel{
Name: name,
Size: m.Size,
Expires: m.ExpiresAt,
})
}
return out, nil
}

238
options.go Normal file
View File

@ -0,0 +1,238 @@
package gollama
import (
"encoding/json"
"fmt"
"strings"
"github.com/ollama/ollama/api"
)
// OptionDoc describes a supported Ollama model option.
type OptionDoc struct {
Key string
Type string
Description string
}
// OptionSet holds typed generation options for Ollama requests.
type OptionSet struct {
opts api.Options
set map[string]struct{}
}
// NewOptions returns an empty OptionSet.
func NewOptions() OptionSet { return OptionSet{set: make(map[string]struct{})} }
// DefaultOptions returns Ollama's default option values.
func DefaultOptions() OptionSet {
return OptionSet{opts: api.DefaultOptions(), set: make(map[string]struct{})}
}
func (o OptionSet) mark(key string) OptionSet {
if o.set == nil {
o.set = make(map[string]struct{})
}
o.set[key] = struct{}{}
return o
}
func (o OptionSet) Temperature(v float32) OptionSet {
o.opts.Temperature = v
return o.mark("temperature")
}
func (o OptionSet) TopP(v float32) OptionSet {
o.opts.TopP = v
return o.mark("top_p")
}
func (o OptionSet) TopK(v int) OptionSet {
o.opts.TopK = v
return o.mark("top_k")
}
func (o OptionSet) MinP(v float32) OptionSet {
o.opts.MinP = v
return o.mark("min_p")
}
func (o OptionSet) TypicalP(v float32) OptionSet {
o.opts.TypicalP = v
return o.mark("typical_p")
}
func (o OptionSet) NumPredict(v int) OptionSet {
o.opts.NumPredict = v
return o.mark("num_predict")
}
func (o OptionSet) NumCtx(v int) OptionSet {
o.opts.NumCtx = v
return o.mark("num_ctx")
}
func (o OptionSet) NumKeep(v int) OptionSet {
o.opts.NumKeep = v
return o.mark("num_keep")
}
func (o OptionSet) NumBatch(v int) OptionSet {
o.opts.NumBatch = v
return o.mark("num_batch")
}
func (o OptionSet) NumGPU(v int) OptionSet {
o.opts.NumGPU = v
return o.mark("num_gpu")
}
func (o OptionSet) NumThread(v int) OptionSet {
o.opts.NumThread = v
return o.mark("num_thread")
}
func (o OptionSet) RepeatPenalty(v float32) OptionSet {
o.opts.RepeatPenalty = v
return o.mark("repeat_penalty")
}
func (o OptionSet) RepeatLastN(v int) OptionSet {
o.opts.RepeatLastN = v
return o.mark("repeat_last_n")
}
func (o OptionSet) PresencePenalty(v float32) OptionSet {
o.opts.PresencePenalty = v
return o.mark("presence_penalty")
}
func (o OptionSet) FrequencyPenalty(v float32) OptionSet {
o.opts.FrequencyPenalty = v
return o.mark("frequency_penalty")
}
func (o OptionSet) Seed(v int) OptionSet {
o.opts.Seed = v
return o.mark("seed")
}
func (o OptionSet) Stop(seq ...string) OptionSet {
o.opts.Stop = seq
return o.mark("stop")
}
// Merge combines base and override; fields set on override win.
func (base OptionSet) Merge(override OptionSet) OptionSet {
out := base
if override.set == nil {
return out
}
if out.set == nil {
out.set = make(map[string]struct{})
}
data, err := optionsToMap(override.opts)
if err != nil {
return out
}
for key := range override.set {
var partial api.Options
if err := partial.FromMap(map[string]any{key: data[key]}); err == nil {
merged, _ := optionsToMap(out.opts)
if v, ok := data[key]; ok {
merged[key] = v
}
_ = out.opts.FromMap(merged)
out.set[key] = struct{}{}
}
}
return out
}
func (o OptionSet) toMap() (map[string]any, error) {
return optionsToMapFiltered(o.opts, o.set)
}
func optionsToMap(opts api.Options) (map[string]any, error) {
b, err := json.Marshal(opts)
if err != nil {
return nil, err
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
return m, nil
}
func optionsToMapFiltered(opts api.Options, set map[string]struct{}) (map[string]any, error) {
all, err := optionsToMap(opts)
if err != nil {
return nil, err
}
if len(set) == 0 {
return map[string]any{}, nil
}
out := make(map[string]any, len(set))
for key := range set {
if v, ok := all[key]; ok {
out[key] = v
}
}
return out, nil
}
// KnownOptions returns documentation for all supported option keys.
func KnownOptions() []OptionDoc {
return []OptionDoc{
{Key: "temperature", Type: "float32", Description: "Sampling temperature"},
{Key: "top_p", Type: "float32", Description: "Nucleus sampling"},
{Key: "top_k", Type: "int", Description: "Top-k sampling"},
{Key: "min_p", Type: "float32", Description: "Minimum probability threshold"},
{Key: "typical_p", Type: "float32", Description: "Typical sampling"},
{Key: "num_predict", Type: "int", Description: "Maximum tokens to generate (-1 = unlimited)"},
{Key: "num_ctx", Type: "int", Description: "Context window size"},
{Key: "num_keep", Type: "int", Description: "Tokens to keep from prompt start"},
{Key: "num_batch", Type: "int", Description: "Batch size for prompt processing"},
{Key: "num_gpu", Type: "int", Description: "Number of GPU layers (-1 = auto)"},
{Key: "num_thread", Type: "int", Description: "CPU threads (0 = auto)"},
{Key: "repeat_penalty", Type: "float32", Description: "Penalty for repeated tokens"},
{Key: "repeat_last_n", Type: "int", Description: "Lookback for repeat penalty"},
{Key: "presence_penalty", Type: "float32", Description: "Presence penalty"},
{Key: "frequency_penalty", Type: "float32", Description: "Frequency penalty"},
{Key: "seed", Type: "int", Description: "Random seed (-1 = random)"},
{Key: "stop", Type: "[]string", Description: "Stop sequences"},
}
}
// parseModelfileParameters parses ShowResponse.Parameters into an OptionSet.
func parseModelfileParameters(parameters string) (OptionSet, error) {
out := NewOptions()
lines := strings.Split(parameters, "\n")
m := make(map[string]any)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
key, val, ok := strings.Cut(line, " ")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
if key == "" || val == "" {
continue
}
var parsed any
if err := json.Unmarshal([]byte(val), &parsed); err != nil {
parsed = val
}
m[key] = parsed
out.set[key] = struct{}{}
}
if err := out.opts.FromMap(m); err != nil {
return OptionSet{}, fmt.Errorf("parse model parameters: %w", err)
}
return out, nil
}

49
options_test.go Normal file
View File

@ -0,0 +1,49 @@
package gollama_test
import (
"testing"
gollama "gitea.neitzel.de/konrad/go-ollama"
)
func TestOptionSetMerge(t *testing.T) {
base := gollama.NewOptions().Temperature(0.8).TopP(0.9)
override := gollama.NewOptions().Temperature(0.2)
merged := base.Merge(override)
m, err := merged.ToMapForTest()
if err != nil {
t.Fatal(err)
}
if m["temperature"] != float64(0.2) {
t.Fatalf("temperature = %v", m["temperature"])
}
if m["top_p"] != float64(0.9) {
t.Fatalf("top_p = %v", m["top_p"])
}
}
func TestParseModelfileParameters(t *testing.T) {
params := "temperature 0.5\nnum_ctx 4096\n"
opts, err := gollama.ParseModelfileParametersForTest(params)
if err != nil {
t.Fatal(err)
}
m, err := opts.ToMapForTest()
if err != nil {
t.Fatal(err)
}
if m["temperature"] != float64(0.5) {
t.Fatalf("temperature = %v", m["temperature"])
}
if m["num_ctx"] != float64(4096) {
t.Fatalf("num_ctx = %v", m["num_ctx"])
}
}
func TestKnownOptions(t *testing.T) {
docs := gollama.KnownOptions()
if len(docs) == 0 {
t.Fatal("expected known options")
}
}

28
result.go Normal file
View File

@ -0,0 +1,28 @@
package gollama
import "github.com/ollama/ollama/api"
// Result is the unified non-streaming response from completion and chat calls.
type Result struct {
Text string
Model string
Thinking string
Metrics api.Metrics
Raw any // *api.GenerateResponse or *api.ChatResponse
}
// Chunk is a single streaming response fragment.
type Chunk struct {
Text string
Thinking string
Done bool
Metrics api.Metrics
}
// Progress reports model pull/push progress.
type Progress struct {
Status string
Digest string
Total int64
Completed int64
}

98
session.go Normal file
View File

@ -0,0 +1,98 @@
package gollama
import "context"
// SessionOption configures a conversation session.
type SessionOption func(*Session)
// Session maintains multi-turn chat history for a model.
type Session struct {
client *Client
model string
system string
opts OptionSet
messages []Message
maxHistory int // max user+assistant pairs; 0 = unlimited
}
// WithSessionSystem sets the session system prompt.
func WithSessionSystem(system string) SessionOption {
return func(s *Session) { s.system = system }
}
// WithSessionOptions sets default options for session sends.
func WithSessionOptions(opts OptionSet) SessionOption {
return func(s *Session) { s.opts = opts }
}
// WithMaxHistory limits stored turns (user+assistant pairs); oldest trimmed first.
func WithMaxHistory(pairs int) SessionOption {
return func(s *Session) { s.maxHistory = pairs }
}
// NewSession creates a stateful chat session for the given model.
func (c *Client) NewSession(model string, opts ...SessionOption) (*Session, error) {
if model == "" {
model = c.defaultModel
}
if model == "" {
return nil, errNoModel()
}
s := &Session{client: c, model: model}
for _, opt := range opts {
opt(s)
}
if s.system != "" {
s.messages = append(s.messages, System(s.system))
}
return s, nil
}
// Send appends a user message, calls the model, and stores the assistant reply.
func (s *Session) Send(ctx context.Context, message string, opts ...CallOption) (*Result, error) {
s.messages = append(s.messages, User(message))
callOpts := append([]CallOption{WithModel(s.model), WithOptions(s.opts)}, opts...)
result, err := s.client.ChatMessages(ctx, s.messages, callOpts...)
if err != nil {
// roll back user message on failure
s.messages = s.messages[:len(s.messages)-1]
return nil, err
}
s.messages = append(s.messages, Assistant(result.Text))
s.trimHistory()
return result, nil
}
// Messages returns a copy of the session history.
func (s *Session) Messages() []Message {
out := make([]Message, len(s.messages))
copy(out, s.messages)
return out
}
// Reset clears conversation history but keeps the system prompt.
func (s *Session) Reset() {
if s.system != "" {
s.messages = []Message{System(s.system)}
return
}
s.messages = nil
}
func (s *Session) trimHistory() {
if s.maxHistory <= 0 {
return
}
// count non-system messages as pairs
start := 0
if len(s.messages) > 0 && s.messages[0].Role == "system" {
start = 1
}
conv := s.messages[start:]
maxMsgs := s.maxHistory * 2
if len(conv) <= maxMsgs {
return
}
trim := len(conv) - maxMsgs
s.messages = append(s.messages[:start], conv[trim:]...)
}

81
stream.go Normal file
View File

@ -0,0 +1,81 @@
package gollama
import (
"context"
"github.com/ollama/ollama/api"
)
// CompleteStream streams completion tokens through fn.
func (c *Client) CompleteStream(ctx context.Context, prompt string, fn func(Chunk) error, opts ...CallOption) error {
cfg, err := c.mergeCallConfig(opts...)
if err != nil {
return err
}
optMap, err := cfg.optionsMap()
if err != nil {
return wrapError("complete_stream", cfg.model, err)
}
stream := true
req := &api.GenerateRequest{
Model: cfg.model,
Prompt: prompt,
System: cfg.system,
Stream: &stream,
Options: optMap,
KeepAlive: cfg.keepAlive,
Think: cfg.think,
Format: cfg.format,
Truncate: cfg.truncate,
Shift: cfg.shift,
}
return wrapError("complete_stream", cfg.model, c.api.Generate(ctx, req, func(resp api.GenerateResponse) error {
return fn(Chunk{
Text: resp.Response,
Thinking: resp.Thinking,
Done: resp.Done,
Metrics: resp.Metrics,
})
}))
}
// ChatStream streams chat response tokens through fn.
func (c *Client) ChatStream(ctx context.Context, messages []Message, fn func(Chunk) error, opts ...CallOption) error {
cfg, err := c.mergeCallConfig(opts...)
if err != nil {
return err
}
optMap, err := cfg.optionsMap()
if err != nil {
return wrapError("chat_stream", cfg.model, err)
}
msgs := toAPIMessages(messages)
if cfg.system != "" {
msgs = append([]api.Message{{Role: "system", Content: cfg.system}}, msgs...)
}
stream := true
req := &api.ChatRequest{
Model: cfg.model,
Messages: msgs,
Stream: &stream,
Options: optMap,
KeepAlive: cfg.keepAlive,
Think: cfg.think,
Format: cfg.format,
Truncate: cfg.truncate,
Shift: cfg.shift,
}
return wrapError("chat_stream", cfg.model, c.api.Chat(ctx, req, func(resp api.ChatResponse) error {
return fn(Chunk{
Text: resp.Message.Content,
Thinking: resp.Message.Thinking,
Done: resp.Done,
Metrics: resp.Metrics,
})
}))
}

3
testconfig/defaults.toml Normal file
View File

@ -0,0 +1,3 @@
host = "http://debian:11434"
model = "llama3.2"
timeout = "30s"

View File

@ -0,0 +1,4 @@
# Copy to local.toml for machine-specific overrides (local.toml is gitignored).
host = "http://localhost:11434"
model = "mistral"
timeout = "30s"