commit c7d14ffee738e80ebe29af163dee8d6f98211b7b Author: Konrad Neitzel Date: Thu Jun 11 20:01:48 2026 +0200 First AI generated version diff --git a/.cursor/rules/project-specification.mdc b/.cursor/rules/project-specification.mdc new file mode 100644 index 0000000..0f50fcd --- /dev/null +++ b/.cursor/rules/project-specification.mdc @@ -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 ./...` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffb8127 --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3b4082 --- /dev/null +++ b/README.md @@ -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. diff --git a/call_options.go b/call_options.go new file mode 100644 index 0000000..0bac702 --- /dev/null +++ b/call_options.go @@ -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 } +} diff --git a/chat.go b/chat.go new file mode 100644 index 0000000..bfdb6e7 --- /dev/null +++ b/chat.go @@ -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 +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..44ccc0e --- /dev/null +++ b/client.go @@ -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() +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..46b2ed2 --- /dev/null +++ b/client_test.go @@ -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)) + } +} diff --git a/complete.go b/complete.go new file mode 100644 index 0000000..ed2ca7c --- /dev/null +++ b/complete.go @@ -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 +} diff --git a/docs/project-specification.md b/docs/project-specification.md new file mode 100644 index 0000000..84c3ed8 --- /dev/null +++ b/docs/project-specification.md @@ -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 ./... +``` diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..b2979e2 --- /dev/null +++ b/embed.go @@ -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 +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..3059c8c --- /dev/null +++ b/errors.go @@ -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)") +} diff --git a/examples/simple/main.go b/examples/simple/main.go new file mode 100644 index 0000000..8437273 --- /dev/null +++ b/examples/simple/main.go @@ -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) +} diff --git a/export_test.go b/export_test.go new file mode 100644 index 0000000..1cfa6c8 --- /dev/null +++ b/export_test.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d6ed2b --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..41a6964 --- /dev/null +++ b/go.sum @@ -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= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..6db7e77 --- /dev/null +++ b/integration_test.go @@ -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) + } +} diff --git a/internal/integrationtest/config.go b/internal/integrationtest/config.go new file mode 100644 index 0000000..7359eb3 --- /dev/null +++ b/internal/integrationtest/config.go @@ -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 +} diff --git a/json.go b/json.go new file mode 100644 index 0000000..cc4ad1a --- /dev/null +++ b/json.go @@ -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 +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..7aac53e --- /dev/null +++ b/models.go @@ -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 +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..77f9eb8 --- /dev/null +++ b/options.go @@ -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 +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..2a79d21 --- /dev/null +++ b/options_test.go @@ -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") + } +} diff --git a/result.go b/result.go new file mode 100644 index 0000000..5913de8 --- /dev/null +++ b/result.go @@ -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 +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..6a72037 --- /dev/null +++ b/session.go @@ -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:]...) +} diff --git a/stream.go b/stream.go new file mode 100644 index 0000000..31c54a2 --- /dev/null +++ b/stream.go @@ -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, + }) + })) +} diff --git a/testconfig/defaults.toml b/testconfig/defaults.toml new file mode 100644 index 0000000..6e9108a --- /dev/null +++ b/testconfig/defaults.toml @@ -0,0 +1,3 @@ +host = "http://debian:11434" +model = "llama3.2" +timeout = "30s" diff --git a/testconfig/local.toml.example b/testconfig/local.toml.example new file mode 100644 index 0000000..f4a75cf --- /dev/null +++ b/testconfig/local.toml.example @@ -0,0 +1,4 @@ +# Copy to local.toml for machine-specific overrides (local.toml is gitignored). +host = "http://localhost:11434" +model = "mistral" +timeout = "30s"