First AI generated version
This commit is contained in:
commit
c7d14ffee7
27
.cursor/rules/project-specification.mdc
Normal file
27
.cursor/rules/project-specification.mdc
Normal 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
44
.gitignore
vendored
Normal 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
47
README.md
Normal 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
104
call_options.go
Normal 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
103
chat.go
Normal 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
101
client.go
Normal 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
159
client_test.go
Normal 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
78
complete.go
Normal 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
|
||||
}
|
||||
59
docs/project-specification.md
Normal file
59
docs/project-specification.md
Normal 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
24
embed.go
Normal 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
90
errors.go
Normal 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
51
examples/simple/main.go
Normal 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
11
export_test.go
Normal 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
22
go.mod
Normal 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
59
go.sum
Normal 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
85
integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
130
internal/integrationtest/config.go
Normal file
130
internal/integrationtest/config.go
Normal 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
33
json.go
Normal 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
156
models.go
Normal 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
238
options.go
Normal 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
49
options_test.go
Normal 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
28
result.go
Normal 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
98
session.go
Normal 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
81
stream.go
Normal 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
3
testconfig/defaults.toml
Normal file
@ -0,0 +1,3 @@
|
||||
host = "http://debian:11434"
|
||||
model = "llama3.2"
|
||||
timeout = "30s"
|
||||
4
testconfig/local.toml.example
Normal file
4
testconfig/local.toml.example
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user