@pi-in-go/pigpen-ollama-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ // Package ollamanative adds Ollama as a model provider for PiG, through Ollama's
2
+ // native /api/chat endpoint.
3
+ //
4
+ // It uses the native API instead of the OpenAI-compatible /v1 one so that tool
5
+ // calls, thinking and images go through Ollama's own message format. The model
6
+ // list is never written down here: it comes from Ollama's /api/tags (with
7
+ // /api/show for each model's context length and capabilities) whenever PiG
8
+ // refreshes provider catalogs. Nothing touches the network at startup, so pig
9
+ // starts the same whether or not Ollama is running; the extension stays silent
10
+ // until PiG asks it for models or for a response.
11
+ //
12
+ // The server address is OLLAMA_HOST, the variable Ollama's own tools read, and
13
+ // defaults to http://127.0.0.1:11434.
14
+ //
15
+ // The first version of this extension was contributed by Peder Munksgaard
16
+ // (pigpen pull request 3); see CREDITS.md.
17
+ package ollamanative
18
+
19
+ import (
20
+ "crypto/rand"
21
+ "encoding/hex"
22
+ "encoding/json"
23
+ "fmt"
24
+ "net/http"
25
+ "os"
26
+ "strings"
27
+ "sync"
28
+ "time"
29
+
30
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
31
+ )
32
+
33
+ // ProviderID is the provider name: models are selected as ollama-native/<model>.
34
+ const ProviderID = "ollama-native"
35
+
36
+ // Extension returns the extension that registers the ollama-native provider.
37
+ // It does no I/O.
38
+ func Extension() *sdk.Extension {
39
+ ext := sdk.New(ProviderID)
40
+ c := &client{
41
+ host: func() string { return os.Getenv("OLLAMA_HOST") },
42
+ http: &http.Client{Transport: transport()},
43
+ newID: randomID,
44
+ }
45
+ if err := ext.RegisterNativeProvider(newProvider(c)); err != nil {
46
+ panic(err) // a static declaration: this is a programming error, caught by the tests
47
+ }
48
+ ext.Command("ollama", "Ollama: /ollama refresh reads the installed models from the server", refreshCommand)
49
+ return ext
50
+ }
51
+
52
+ // refreshCommand is /ollama [refresh]. PiG refreshes provider catalogs when an
53
+ // interactive session starts; this does it on request, which is also how a
54
+ // one-shot `pig -p` run gets its first catalog.
55
+ func refreshCommand(ctx sdk.Context, args string) error {
56
+ if a := strings.TrimSpace(args); a != "" && a != "refresh" {
57
+ ctx.Notify("Usage: /ollama refresh", "warning")
58
+ return nil
59
+ }
60
+ allow := true
61
+ registry := ctx.ModelRegistry()
62
+ result, err := registry.Refresh(sdk.ModelsRefreshOptions{AllowNetwork: &allow, Providers: []string{ProviderID}})
63
+ if err != nil {
64
+ return err
65
+ }
66
+ if msg := result.Errors[ProviderID]; msg != "" {
67
+ ctx.Notify(msg, "error")
68
+ return nil
69
+ }
70
+ all, err := registry.GetAll()
71
+ if err != nil {
72
+ return err
73
+ }
74
+ n := 0
75
+ for _, m := range all {
76
+ if m["provider"] == ProviderID {
77
+ n++
78
+ }
79
+ }
80
+ ctx.Notify(fmt.Sprintf("Ollama: %d model(s) available as %s/<model>", n, ProviderID), "info")
81
+ return nil
82
+ }
83
+
84
+ // newProvider describes the provider. Its catalog starts empty and only changes
85
+ // when the host calls RefreshModels with the network allowed.
86
+ func newProvider(c *client) *sdk.Provider {
87
+ var mu sync.Mutex
88
+ models := []map[string]any{}
89
+ base := c.baseURL()
90
+ p := &sdk.Provider{
91
+ ID: ProviderID,
92
+ Name: "Ollama (native API)",
93
+ BaseURL: &base,
94
+ Auth: sdk.ProviderAuth{APIKey: &sdk.APIKeyAuth{
95
+ Name: "Ollama (no key needed; OLLAMA_API_KEY for a protected server)",
96
+ Resolve: func(sdk.APIKeyAuthInput) (*sdk.AuthResult, error) {
97
+ key := os.Getenv("OLLAMA_API_KEY")
98
+ if key == "" {
99
+ key = localKey
100
+ }
101
+ return &sdk.AuthResult{Auth: map[string]any{"apiKey": key}}, nil
102
+ },
103
+ }},
104
+ GetModels: func() ([]map[string]any, error) {
105
+ mu.Lock()
106
+ defer mu.Unlock()
107
+ return append([]map[string]any{}, models...), nil
108
+ },
109
+ RefreshModels: func(in sdk.RefreshModelsContext) error {
110
+ apply := func(next []map[string]any) func() error {
111
+ return func() error {
112
+ mu.Lock()
113
+ models = next
114
+ mu.Unlock()
115
+ return nil
116
+ }
117
+ }
118
+ if !in.AllowNetwork {
119
+ // PiG's start-up pass: no network, and the catalog stored by the last
120
+ // refresh comes back in Stored. Restoring it is how a one-shot
121
+ // `pig -p --model ollama-native/<model>` finds its model.
122
+ restored := restore(in.Stored)
123
+ if len(restored) == 0 {
124
+ return nil
125
+ }
126
+ _, err := in.Publish(sdk.ModelsPublication{Update: apply(restored)})
127
+ return err
128
+ }
129
+ found, err := c.discover(in.Signal)
130
+ if err != nil {
131
+ return err
132
+ }
133
+ // The host stores Persist for the next start and applies the new catalog
134
+ // by calling Update, then reads GetModels.
135
+ stored, err := json.Marshal(map[string]any{"models": found, "checkedAt": time.Now().Unix()})
136
+ if err != nil {
137
+ return err
138
+ }
139
+ _, err = in.Publish(sdk.ModelsPublication{Persist: stored, Update: apply(found)})
140
+ return err
141
+ },
142
+ Stream: c.stream,
143
+ StreamSimple: c.stream,
144
+ }
145
+ return p
146
+ }
147
+
148
+ // localKey is what Ollama gets as an API key when none is configured: PiG wants
149
+ // a key for every provider, a local Ollama ignores it, and it is never sent.
150
+ const localKey = "ollama"
151
+
152
+ func randomID() string {
153
+ b := make([]byte, 6)
154
+ _, _ = rand.Read(b)
155
+ return "call_" + hex.EncodeToString(b)
156
+ }
157
+
158
+ // restore reads back the catalog a refresh stored. Entries without an id are
159
+ // dropped, so a damaged store cannot put a nameless model in the list.
160
+ func restore(stored map[string]any) []map[string]any {
161
+ items, _ := stored["models"].([]any)
162
+ var out []map[string]any
163
+ for _, item := range items {
164
+ m, _ := item.(map[string]any)
165
+ if id, _ := m["id"].(string); id != "" {
166
+ out = append(out, m)
167
+ }
168
+ }
169
+ return out
170
+ }
@@ -0,0 +1,6 @@
1
+ module github.com/MichaelKinsy/pigpen/components/ollama-native/extensions/ollama-native
2
+
3
+ go 1.26
4
+
5
+ // Pig resolves this requirement to the version-matched staged SDK at build time.
6
+ require github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1
@@ -0,0 +1,2 @@
1
+ github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1/go.mod h1:4j0XhCW0EPOBWvqYE7XKjTvIAahI7K1VyyW3KpjTKj8=
2
+ github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1 h1:Ql9sTkc6dvAXaYQLq5nxqmSZgqu8S0L53ZTs8MkOuxw=
@@ -0,0 +1,144 @@
1
+ package ollamanative
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "io"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "sync"
13
+ "testing"
14
+ "time"
15
+
16
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
17
+ )
18
+
19
+ // fixture reads a recorded Ollama response from testdata. The files follow the
20
+ // wire format in Ollama's API documentation (docs/api.md): /api/tags and
21
+ // /api/show answer one JSON document, /api/chat streams newline-delimited JSON.
22
+ func fixture(t *testing.T, name string) []byte {
23
+ t.Helper()
24
+ b, err := os.ReadFile(filepath.Join("testdata", name))
25
+ if err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ return b
29
+ }
30
+
31
+ // fakeOllama serves the recorded responses and counts every request it gets.
32
+ type fakeOllama struct {
33
+ *httptest.Server
34
+ mu sync.Mutex
35
+ requests []string
36
+ chats []map[string]any
37
+ // chat is the fixture /api/chat streams, chatStatus its HTTP status.
38
+ chat string
39
+ chatStatus int
40
+ tags string
41
+ hold chan struct{} // when set, /api/chat sends the first line then waits for it
42
+ }
43
+
44
+ func newFake(t *testing.T) *fakeOllama {
45
+ t.Helper()
46
+ f := &fakeOllama{chat: "chat_text.ndjson", tags: "tags.json", chatStatus: 200}
47
+ f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48
+ f.mu.Lock()
49
+ f.requests = append(f.requests, r.Method+" "+r.URL.Path)
50
+ f.mu.Unlock()
51
+ switch r.URL.Path {
52
+ case "/api/tags":
53
+ w.Write(fixture(t, f.tags))
54
+ case "/api/show":
55
+ var in struct {
56
+ Model string `json:"model"`
57
+ }
58
+ json.NewDecoder(r.Body).Decode(&in)
59
+ name := strings.NewReplacer(":", "_", "/", "_").Replace(in.Model)
60
+ b, err := os.ReadFile(filepath.Join("testdata", "show_"+name+".json"))
61
+ if err != nil {
62
+ http.Error(w, `{"error":"model not found"}`, 404)
63
+ return
64
+ }
65
+ w.Write(b)
66
+ case "/api/chat":
67
+ var in map[string]any
68
+ json.NewDecoder(r.Body).Decode(&in)
69
+ f.mu.Lock()
70
+ f.chats = append(f.chats, in)
71
+ f.mu.Unlock()
72
+ w.WriteHeader(f.chatStatus)
73
+ body := fixture(t, f.chat)
74
+ if f.hold != nil {
75
+ line, rest, _ := strings.Cut(string(body), "\n")
76
+ io.WriteString(w, line+"\n")
77
+ w.(http.Flusher).Flush()
78
+ select {
79
+ case <-f.hold:
80
+ case <-r.Context().Done():
81
+ return
82
+ }
83
+ body = []byte(rest)
84
+ }
85
+ w.Write(body)
86
+ default:
87
+ http.NotFound(w, r)
88
+ }
89
+ }))
90
+ t.Cleanup(f.Close)
91
+ return f
92
+ }
93
+
94
+ func (f *fakeOllama) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.requests) }
95
+
96
+ // newClient builds a client aimed at base with deterministic tool-call ids.
97
+ func newClient(base string) *client {
98
+ n := 0
99
+ return &client{host: func() string { return base }, http: &http.Client{}, newID: func() string { n++; return "call_t" + string(rune('0'+n)) }}
100
+ }
101
+
102
+ type streamed struct {
103
+ events []map[string]any
104
+ result map[string]any
105
+ }
106
+
107
+ func (s streamed) types() []string {
108
+ var out []string
109
+ for _, e := range s.events {
110
+ out = append(out, e["type"].(string))
111
+ }
112
+ return out
113
+ }
114
+
115
+ func (s streamed) terminal() map[string]any { return s.events[len(s.events)-1] }
116
+
117
+ // collect drains a provider stream.
118
+ func collect(t *testing.T, stream *sdk.ModelEventStream) streamed {
119
+ t.Helper()
120
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
121
+ defer cancel()
122
+ var s streamed
123
+ for e := range stream.Events(ctx) {
124
+ s.events = append(s.events, e)
125
+ }
126
+ if ctx.Err() != nil {
127
+ t.Fatalf("stream did not finish: %v (events %v)", ctx.Err(), s.types())
128
+ }
129
+ s.result = stream.Result()
130
+ return s
131
+ }
132
+
133
+ func userTranscript(text string) map[string]any {
134
+ return map[string]any{"messages": []any{map[string]any{"role": "user", "content": text, "timestamp": 1}}}
135
+ }
136
+
137
+ var graniteModel = map[string]any{"id": "granite4.1:3b", "name": "granite4.1:3b", "api": "ollama-native", "provider": "ollama-native", "reasoning": false}
138
+
139
+ func httpHandler(see func(map[string][]string), next http.Handler) http.Handler {
140
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141
+ see(r.Header)
142
+ next.ServeHTTP(w, r)
143
+ })
144
+ }
@@ -0,0 +1,235 @@
1
+ package ollamanative
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "net"
11
+ "net/http"
12
+ "net/url"
13
+ "slices"
14
+ "strings"
15
+ "time"
16
+ )
17
+
18
+ const (
19
+ defaultHost = "http://127.0.0.1:11434"
20
+ defaultPort = "11434"
21
+ defaultContextWindow = 32768
22
+ defaultMaxTokens = 8192
23
+ )
24
+
25
+ // client talks to one Ollama server. host is read on every use, so a changed
26
+ // OLLAMA_HOST applies without restarting pig.
27
+ type client struct {
28
+ host func() string
29
+ http *http.Client
30
+ newID func() string
31
+ }
32
+
33
+ // transport bounds connecting, not the response: a large model can take a long
34
+ // time to load before the first token, and a long answer is a long stream.
35
+ func transport() *http.Transport {
36
+ t := http.DefaultTransport.(*http.Transport).Clone()
37
+ t.DialContext = (&net.Dialer{Timeout: 5 * time.Second}).DialContext
38
+ return t
39
+ }
40
+
41
+ func (c *client) baseURL() string { return normalizeHost(c.host()) }
42
+
43
+ // normalizeHost turns an OLLAMA_HOST value into a base URL the way Ollama's own
44
+ // client reads it: a bare host or host:port means http on port 11434, ":port"
45
+ // means this machine, and an explicit scheme without a port means that scheme's
46
+ // default port.
47
+ func normalizeHost(value string) string {
48
+ value = strings.TrimSpace(value)
49
+ if value == "" {
50
+ return defaultHost
51
+ }
52
+ scheme, rest := "http", value
53
+ explicit := false
54
+ if s, r, ok := strings.Cut(value, "://"); ok {
55
+ scheme, rest, explicit = strings.ToLower(s), r, true
56
+ }
57
+ u, err := url.Parse(scheme + "://" + rest)
58
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
59
+ return defaultHost
60
+ }
61
+ host, port := u.Hostname(), u.Port()
62
+ if host == "" {
63
+ host = "127.0.0.1"
64
+ }
65
+ if port == "" {
66
+ switch {
67
+ case !explicit:
68
+ port = defaultPort
69
+ case scheme == "https":
70
+ port = "443"
71
+ default:
72
+ port = "80"
73
+ }
74
+ }
75
+ return scheme + "://" + net.JoinHostPort(host, port) + strings.TrimRight(u.Path, "/")
76
+ }
77
+
78
+ // unreachable is the error for an Ollama that is not running (or not there).
79
+ func unreachable(base string, err error) error {
80
+ return fmt.Errorf("Ollama is not reachable at %s (%v). Start it with \"ollama serve\", or set OLLAMA_HOST to where it listens.", base, rootCause(err))
81
+ }
82
+
83
+ func rootCause(err error) error {
84
+ var op *net.OpError
85
+ if errors.As(err, &op) && op.Err != nil {
86
+ return fmt.Errorf("%s: %w", op.Op, op.Err)
87
+ }
88
+ var ue *url.Error
89
+ if errors.As(err, &ue) {
90
+ return ue.Err
91
+ }
92
+ return err
93
+ }
94
+
95
+ // isDialFailure reports a request that never reached a server.
96
+ func isDialFailure(err error) bool {
97
+ var op *net.OpError
98
+ return errors.As(err, &op) && op.Op == "dial"
99
+ }
100
+
101
+ func (c *client) do(ctx context.Context, method, path string, body any, timeout time.Duration) (*http.Response, context.CancelFunc, error) {
102
+ if ctx == nil {
103
+ ctx = context.Background()
104
+ }
105
+ cancel := func() {}
106
+ if timeout > 0 {
107
+ ctx, cancel = context.WithTimeout(ctx, timeout)
108
+ }
109
+ var reader io.Reader
110
+ if body != nil {
111
+ raw, err := json.Marshal(body)
112
+ if err != nil {
113
+ cancel()
114
+ return nil, nil, err
115
+ }
116
+ reader = bytes.NewReader(raw)
117
+ }
118
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL()+path, reader)
119
+ if err != nil {
120
+ cancel()
121
+ return nil, nil, err
122
+ }
123
+ if body != nil {
124
+ req.Header.Set("Content-Type", "application/json")
125
+ }
126
+ resp, err := c.http.Do(req)
127
+ if err != nil {
128
+ cancel()
129
+ return nil, nil, err
130
+ }
131
+ return resp, cancel, nil
132
+ }
133
+
134
+ // apiError reads Ollama's {"error": "..."} body, or falls back to the raw text.
135
+ func apiError(resp *http.Response) string {
136
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
137
+ var e struct {
138
+ Error string `json:"error"`
139
+ }
140
+ if json.Unmarshal(raw, &e) == nil && e.Error != "" {
141
+ return e.Error
142
+ }
143
+ if text := strings.TrimSpace(string(raw)); text != "" {
144
+ return text
145
+ }
146
+ return http.StatusText(resp.StatusCode)
147
+ }
148
+
149
+ // discover lists the models Ollama has installed. It reads /api/tags, then asks
150
+ // /api/show about each model for its context length and capabilities. A model
151
+ // /api/show cannot describe is still listed, with defaults.
152
+ func (c *client) discover(ctx context.Context) ([]map[string]any, error) {
153
+ resp, cancel, err := c.do(ctx, http.MethodGet, "/api/tags", nil, 10*time.Second)
154
+ if err != nil {
155
+ if ctx != nil && ctx.Err() != nil {
156
+ return nil, ctx.Err()
157
+ }
158
+ return nil, unreachable(c.baseURL(), err)
159
+ }
160
+ defer cancel()
161
+ defer resp.Body.Close()
162
+ if resp.StatusCode != http.StatusOK {
163
+ return nil, fmt.Errorf("Ollama at %s answered %d to /api/tags: %s", c.baseURL(), resp.StatusCode, apiError(resp))
164
+ }
165
+ var tags struct {
166
+ Models []struct {
167
+ Name string `json:"name"`
168
+ } `json:"models"`
169
+ }
170
+ if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
171
+ return nil, fmt.Errorf("Ollama at %s sent a model list that is not JSON: %w", c.baseURL(), err)
172
+ }
173
+ models := make([]map[string]any, 0, len(tags.Models))
174
+ for _, m := range tags.Models {
175
+ if m.Name == "" {
176
+ continue
177
+ }
178
+ info := c.show(ctx, m.Name)
179
+ if info.capabilities != nil && !slices.Contains(info.capabilities, "completion") {
180
+ continue // embedding-only models cannot chat
181
+ }
182
+ input := []string{"text"}
183
+ if slices.Contains(info.capabilities, "vision") {
184
+ input = append(input, "image")
185
+ }
186
+ window := info.contextLength
187
+ if window <= 0 {
188
+ window = defaultContextWindow
189
+ }
190
+ models = append(models, map[string]any{
191
+ "id": m.Name,
192
+ "name": m.Name,
193
+ "api": ProviderID,
194
+ "reasoning": slices.Contains(info.capabilities, "thinking"),
195
+ "input": input,
196
+ "contextWindow": window,
197
+ "maxTokens": min(window, defaultMaxTokens),
198
+ "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
199
+ })
200
+ }
201
+ return models, nil
202
+ }
203
+
204
+ type modelInfo struct {
205
+ contextLength int
206
+ capabilities []string
207
+ }
208
+
209
+ // show reads a model's details. Failure is not an error: the zero value means
210
+ // "unknown", and discover then uses defaults.
211
+ func (c *client) show(ctx context.Context, name string) modelInfo {
212
+ resp, cancel, err := c.do(ctx, http.MethodPost, "/api/show", map[string]any{"model": name}, 5*time.Second)
213
+ if err != nil {
214
+ return modelInfo{}
215
+ }
216
+ defer cancel()
217
+ defer resp.Body.Close()
218
+ if resp.StatusCode != http.StatusOK {
219
+ return modelInfo{}
220
+ }
221
+ var out struct {
222
+ ModelInfo map[string]any `json:"model_info"`
223
+ Capabilities []string `json:"capabilities"`
224
+ }
225
+ if json.NewDecoder(resp.Body).Decode(&out) != nil {
226
+ return modelInfo{}
227
+ }
228
+ info := modelInfo{capabilities: out.Capabilities}
229
+ if arch, _ := out.ModelInfo["general.architecture"].(string); arch != "" {
230
+ if n, ok := out.ModelInfo[arch+".context_length"].(float64); ok {
231
+ info.contextLength = int(n)
232
+ }
233
+ }
234
+ return info
235
+ }