@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.
- package/CREDITS.md +24 -0
- package/LICENSE +22 -0
- package/README.md +81 -0
- package/extensions/ollama-native/chat.go +241 -0
- package/extensions/ollama-native/chat_test.go +287 -0
- package/extensions/ollama-native/extension.go +170 -0
- package/extensions/ollama-native/go.mod +6 -0
- package/extensions/ollama-native/go.sum +2 -0
- package/extensions/ollama-native/helpers_test.go +144 -0
- package/extensions/ollama-native/models.go +235 -0
- package/extensions/ollama-native/models_test.go +223 -0
- package/extensions/ollama-native/request.go +184 -0
- package/extensions/ollama-native/testdata/chat_404.json +1 -0
- package/extensions/ollama-native/testdata/chat_length.ndjson +2 -0
- package/extensions/ollama-native/testdata/chat_midstream_error.ndjson +2 -0
- package/extensions/ollama-native/testdata/chat_text.ndjson +4 -0
- package/extensions/ollama-native/testdata/chat_thinking.ndjson +4 -0
- package/extensions/ollama-native/testdata/chat_toolcalls.ndjson +4 -0
- package/extensions/ollama-native/testdata/chat_truncated.ndjson +1 -0
- package/extensions/ollama-native/testdata/show_granite4.1_3b.json +1 -0
- package/extensions/ollama-native/testdata/show_nomic-embed-text_latest.json +1 -0
- package/extensions/ollama-native/testdata/show_qwen3_8b.json +1 -0
- package/extensions/ollama-native/testdata/tags.json +1 -0
- package/extensions/ollama-native/testdata/tags_empty.json +1 -0
- package/package.json +39 -0
- package/provenance.json +7 -0
|
@@ -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,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
|
+
}
|