@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,223 @@
1
+ package ollamanative
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
7
+ "net/http"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestNormalizeHost(t *testing.T) {
13
+ for in, want := range map[string]string{
14
+ "": "http://127.0.0.1:11434",
15
+ " ": "http://127.0.0.1:11434",
16
+ "http://127.0.0.1:11434": "http://127.0.0.1:11434",
17
+ "http://box:8080/": "http://box:8080",
18
+ "box": "http://box:11434",
19
+ "box:8080": "http://box:8080",
20
+ ":9000": "http://127.0.0.1:9000",
21
+ "https://ollama.example.com": "https://ollama.example.com:443",
22
+ "https://o.example.com:8443": "https://o.example.com:8443",
23
+ "http://box": "http://box:80",
24
+ "[::1]:11434": "http://[::1]:11434",
25
+ } {
26
+ if got := normalizeHost(in); got != want {
27
+ t.Errorf("normalizeHost(%q) = %q, want %q", in, got, want)
28
+ }
29
+ }
30
+ }
31
+
32
+ // Registering the provider must not touch the network: pig starts without Ollama
33
+ // running, and a model list is only fetched when the host asks for a refresh.
34
+ func TestConstructionAndGetModelsMakeNoRequests(t *testing.T) {
35
+ f := newFake(t)
36
+ t.Setenv("OLLAMA_HOST", f.URL)
37
+ _ = Extension()
38
+ p := newProvider(newClient(f.URL))
39
+ models, err := p.GetModels()
40
+ if err != nil || len(models) != 0 {
41
+ t.Fatalf("GetModels before any refresh = %v, %v; want empty", models, err)
42
+ }
43
+ if f.count() != 0 {
44
+ t.Fatalf("requests at startup: %v", f.requests)
45
+ }
46
+ }
47
+
48
+ func TestDiscoverReadsModelsFromTagsAndShow(t *testing.T) {
49
+ f := newFake(t)
50
+ models, err := newClient(f.URL).discover(context.Background())
51
+ if err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ // nomic-embed-text only embeds: it cannot chat, so it is not offered.
55
+ if len(models) != 2 {
56
+ t.Fatalf("models = %v", models)
57
+ }
58
+ g, q := models[0], models[1]
59
+ if g["id"] != "granite4.1:3b" || g["api"] != "ollama-native" || g["contextWindow"] != 131072 || g["reasoning"] != false {
60
+ t.Errorf("granite = %v", g)
61
+ }
62
+ if in := g["input"].([]string); len(in) != 1 || in[0] != "text" {
63
+ t.Errorf("granite input = %v", in)
64
+ }
65
+ if q["id"] != "qwen3:8b" || q["contextWindow"] != 40960 || q["reasoning"] != true {
66
+ t.Errorf("qwen3 = %v", q)
67
+ }
68
+ if in := q["input"].([]string); len(in) != 2 || in[1] != "image" {
69
+ t.Errorf("qwen3 input = %v", in)
70
+ }
71
+ }
72
+
73
+ // /api/show is best effort: a model it cannot describe still gets listed, with
74
+ // plain defaults, so one bad model does not hide the rest.
75
+ func TestDiscoverFallsBackToDefaultsWhenShowFails(t *testing.T) {
76
+ f := newFake(t)
77
+ f.tags = "tags_empty.json"
78
+ f.Server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79
+ if r.URL.Path == "/api/tags" {
80
+ w.Write([]byte(`{"models":[{"name":"mystery:1b"}]}`))
81
+ return
82
+ }
83
+ http.Error(w, `{"error":"boom"}`, 500)
84
+ })
85
+ models, err := newClient(f.URL).discover(context.Background())
86
+ if err != nil || len(models) != 1 {
87
+ t.Fatalf("models = %v, err = %v", models, err)
88
+ }
89
+ m := models[0]
90
+ if m["id"] != "mystery:1b" || m["contextWindow"] != defaultContextWindow || m["reasoning"] != false {
91
+ t.Errorf("defaults = %v", m)
92
+ }
93
+ }
94
+
95
+ func TestDiscoverEmptyListIsNotAnError(t *testing.T) {
96
+ f := newFake(t)
97
+ f.tags = "tags_empty.json"
98
+ models, err := newClient(f.URL).discover(context.Background())
99
+ if err != nil || len(models) != 0 {
100
+ t.Fatalf("models = %v, err = %v", models, err)
101
+ }
102
+ }
103
+
104
+ func TestDiscoverReportsOllamaNotRunning(t *testing.T) {
105
+ f := newFake(t)
106
+ url := f.URL
107
+ f.Close()
108
+ _, err := newClient(url).discover(context.Background())
109
+ if err == nil || !strings.Contains(err.Error(), "not reachable at "+url) || !strings.Contains(err.Error(), "OLLAMA_HOST") {
110
+ t.Fatalf("err = %v", err)
111
+ }
112
+ }
113
+
114
+ func TestRefreshModelsPublishesAndSwapsTheCatalog(t *testing.T) {
115
+ f := newFake(t)
116
+ p := newProvider(newClient(f.URL))
117
+
118
+ // Offline: no request, catalog untouched.
119
+ if err := p.RefreshModels(sdk.RefreshModelsContext{AllowNetwork: false, Signal: context.Background(), Publish: func(sdk.ModelsPublication) (bool, error) { t.Fatal("published offline"); return false, nil }}); err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ if f.count() != 0 {
123
+ t.Fatalf("offline refresh made requests: %v", f.requests)
124
+ }
125
+
126
+ published := 0
127
+ err := p.RefreshModels(sdk.RefreshModelsContext{AllowNetwork: true, Signal: context.Background(), Publish: func(pub sdk.ModelsPublication) (bool, error) {
128
+ published++
129
+ // The catalog is stored for the next start, which must not need Ollama.
130
+ var entry struct {
131
+ Models []map[string]any `json:"models"`
132
+ CheckedAt float64 `json:"checkedAt"`
133
+ }
134
+ if err := json.Unmarshal(pub.Persist, &entry); err != nil || len(entry.Models) != 2 || entry.CheckedAt == 0 {
135
+ t.Errorf("persist = %s (%v)", pub.Persist, err)
136
+ }
137
+ if before, _ := p.GetModels(); len(before) != 0 {
138
+ t.Error("catalog changed before the host applied the update")
139
+ }
140
+ return true, pub.Update()
141
+ }})
142
+ if err != nil || published != 1 {
143
+ t.Fatalf("refresh err = %v, published = %d", err, published)
144
+ }
145
+ models, _ := p.GetModels()
146
+ if len(models) != 2 || models[0]["id"] != "granite4.1:3b" {
147
+ t.Fatalf("models after refresh = %v", models)
148
+ }
149
+ }
150
+
151
+ // A failed refresh keeps the last good catalog and reports the reason.
152
+ func TestRefreshModelsKeepsLastCatalogWhenOllamaStops(t *testing.T) {
153
+ f := newFake(t)
154
+ c := newClient(f.URL)
155
+ p := newProvider(c)
156
+ publish := func(pub sdk.ModelsPublication) (bool, error) { return true, pub.Update() }
157
+ if err := p.RefreshModels(sdk.RefreshModelsContext{AllowNetwork: true, Signal: context.Background(), Publish: publish}); err != nil {
158
+ t.Fatal(err)
159
+ }
160
+ f.Close()
161
+ err := p.RefreshModels(sdk.RefreshModelsContext{AllowNetwork: true, Signal: context.Background(), Publish: publish})
162
+ if err == nil || !strings.Contains(err.Error(), "not reachable") {
163
+ t.Fatalf("err = %v", err)
164
+ }
165
+ if models, _ := p.GetModels(); len(models) != 2 {
166
+ t.Fatalf("catalog dropped: %v", models)
167
+ }
168
+ }
169
+
170
+ // PiG calls RefreshModels once without the network at start, handing back what
171
+ // was stored. That is how `pig -p --model ollama-native/x` finds its model with
172
+ // Ollama not even asked.
173
+ func TestOfflinePassRestoresTheStoredCatalogWithoutRequests(t *testing.T) {
174
+ f := newFake(t)
175
+ // A first process refreshes and persists.
176
+ var stored json.RawMessage
177
+ first := newProvider(newClient(f.URL))
178
+ err := first.RefreshModels(sdk.RefreshModelsContext{AllowNetwork: true, Signal: context.Background(), Publish: func(pub sdk.ModelsPublication) (bool, error) {
179
+ stored = pub.Persist
180
+ return true, pub.Update()
181
+ }})
182
+ if err != nil {
183
+ t.Fatal(err)
184
+ }
185
+ before := f.count()
186
+
187
+ // A second process starts: PiG hands the stored entry back, as decoded JSON.
188
+ var entry map[string]any
189
+ if err := json.Unmarshal(stored, &entry); err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ second := newProvider(newClient(f.URL))
193
+ applied := 0
194
+ err = second.RefreshModels(sdk.RefreshModelsContext{AllowNetwork: false, Stored: entry, Signal: context.Background(), Publish: func(pub sdk.ModelsPublication) (bool, error) {
195
+ applied++
196
+ if pub.Persist != nil {
197
+ t.Error("the offline pass must not rewrite the store")
198
+ }
199
+ return true, pub.Update()
200
+ }})
201
+ if err != nil || applied != 1 {
202
+ t.Fatalf("err = %v, applied = %d", err, applied)
203
+ }
204
+ if f.count() != before {
205
+ t.Fatalf("the offline pass made requests: %v", f.requests[before:])
206
+ }
207
+ models, _ := second.GetModels()
208
+ if len(models) != 2 || models[1]["id"] != "qwen3:8b" || models[1]["api"] != "ollama-native" {
209
+ t.Fatalf("restored = %v", models)
210
+ }
211
+ }
212
+
213
+ func TestOfflinePassIgnoresAMalformedStore(t *testing.T) {
214
+ p := newProvider(newClient("http://127.0.0.1:1"))
215
+ err := p.RefreshModels(sdk.RefreshModelsContext{AllowNetwork: false, Stored: map[string]any{"models": []any{"junk", map[string]any{"name": "no id"}, map[string]any{"id": "ok:1b", "api": "ollama-native"}}},
216
+ Publish: func(pub sdk.ModelsPublication) (bool, error) { return true, pub.Update() }})
217
+ if err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ if models, _ := p.GetModels(); len(models) != 1 || models[0]["id"] != "ok:1b" {
221
+ t.Fatalf("models = %v", models)
222
+ }
223
+ }
@@ -0,0 +1,184 @@
1
+ package ollamanative
2
+
3
+ import (
4
+ "slices"
5
+ "strings"
6
+
7
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
8
+ )
9
+
10
+ // chatRequest turns PiG's transcript into an /api/chat body.
11
+ //
12
+ // The transcript is {"messages": [...]}. System messages also carry the tool
13
+ // set: "toolsAdded" and "toolsRemoved" are changes, applied in order, so the
14
+ // tools on offer are what is left after the last message.
15
+ func chatRequest(model, transcript map[string]any, opts sdk.ProviderStreamOptions) map[string]any {
16
+ req := map[string]any{"model": model["id"], "stream": true}
17
+ var messages []map[string]any
18
+ var instructions []string
19
+ sections := map[string]string{}
20
+ var toolNames []string
21
+ tools := map[string]map[string]any{}
22
+
23
+ raw, _ := transcript["messages"].([]any)
24
+ for _, item := range raw {
25
+ m, _ := item.(map[string]any)
26
+ switch m["role"] {
27
+ case "system":
28
+ for _, t := range list(m["toolsRemoved"]) {
29
+ name, _ := t["name"].(string)
30
+ delete(tools, name)
31
+ toolNames = remove(toolNames, name)
32
+ }
33
+ for _, t := range list(m["toolsAdded"]) {
34
+ name, _ := t["name"].(string)
35
+ if _, seen := tools[name]; !seen {
36
+ toolNames = append(toolNames, name)
37
+ }
38
+ tools[name] = t
39
+ }
40
+ if text, _ := textAndImages(m["content"]); text != "" {
41
+ instructions = append(instructions, text)
42
+ }
43
+ if named, ok := m["sections"].(map[string]any); ok {
44
+ for name, value := range named {
45
+ if text, ok := value.(string); ok {
46
+ sections[name] = text
47
+ } else {
48
+ delete(sections, name) // null removes the section
49
+ }
50
+ }
51
+ }
52
+ case "user":
53
+ text, images := textAndImages(m["content"])
54
+ msg := map[string]any{"role": "user", "content": text}
55
+ if len(images) > 0 {
56
+ msg["images"] = images
57
+ }
58
+ messages = append(messages, msg)
59
+ case "assistant":
60
+ // Thinking is not sent back: Ollama's models are not trained on it as input.
61
+ var text []string
62
+ var calls []map[string]any
63
+ for _, b := range list(m["content"]) {
64
+ switch b["type"] {
65
+ case "text":
66
+ if s, _ := b["text"].(string); s != "" {
67
+ text = append(text, s)
68
+ }
69
+ case "toolCall":
70
+ args, _ := b["arguments"].(map[string]any)
71
+ if args == nil {
72
+ args = map[string]any{}
73
+ }
74
+ calls = append(calls, map[string]any{"function": map[string]any{"name": b["name"], "arguments": args}})
75
+ }
76
+ }
77
+ if len(text) == 0 && len(calls) == 0 {
78
+ continue // an aborted or failed turn that said nothing
79
+ }
80
+ msg := map[string]any{"role": "assistant", "content": strings.Join(text, "\n")}
81
+ if len(calls) > 0 {
82
+ msg["tool_calls"] = calls
83
+ }
84
+ messages = append(messages, msg)
85
+ case "toolResult":
86
+ text, images := textAndImages(m["content"])
87
+ msg := map[string]any{"role": "tool", "tool_name": m["toolName"], "content": text}
88
+ if len(images) > 0 {
89
+ msg["images"] = images
90
+ }
91
+ messages = append(messages, msg)
92
+ }
93
+ }
94
+ names := make([]string, 0, len(sections))
95
+ for name, text := range sections {
96
+ if text != "" {
97
+ names = append(names, name)
98
+ }
99
+ }
100
+ slices.Sort(names)
101
+ for _, name := range names {
102
+ instructions = append(instructions, sections[name])
103
+ }
104
+ if len(instructions) > 0 {
105
+ messages = append([]map[string]any{{"role": "system", "content": strings.Join(instructions, "\n\n")}}, messages...)
106
+ }
107
+ req["messages"] = messages
108
+
109
+ if len(toolNames) > 0 {
110
+ offered := make([]map[string]any, 0, len(toolNames))
111
+ for _, name := range toolNames {
112
+ t := tools[name]
113
+ params, _ := t["parameters"].(map[string]any)
114
+ if params == nil {
115
+ params = map[string]any{"type": "object", "properties": map[string]any{}}
116
+ }
117
+ offered = append(offered, map[string]any{"type": "function", "function": map[string]any{
118
+ "name": name, "description": t["description"], "parameters": params}})
119
+ }
120
+ req["tools"] = offered
121
+ }
122
+
123
+ options := map[string]any{}
124
+ if v, ok := opts.Values["temperature"].(float64); ok {
125
+ options["temperature"] = v
126
+ }
127
+ if v, ok := opts.Values["maxTokens"].(float64); ok && v > 0 {
128
+ options["num_predict"] = int(v)
129
+ }
130
+ if len(options) > 0 {
131
+ req["options"] = options
132
+ }
133
+ // Ollama refuses `think` for a model without the capability, so it is sent
134
+ // only to models the catalog marked as reasoning, and only when asked for.
135
+ if reasoning, _ := model["reasoning"].(bool); reasoning {
136
+ if on, ok := opts.Values["thinkingEnabled"].(bool); ok {
137
+ req["think"] = on
138
+ }
139
+ }
140
+ return req
141
+ }
142
+
143
+ func list(v any) []map[string]any {
144
+ items, _ := v.([]any)
145
+ out := make([]map[string]any, 0, len(items))
146
+ for _, item := range items {
147
+ if m, ok := item.(map[string]any); ok {
148
+ out = append(out, m)
149
+ }
150
+ }
151
+ return out
152
+ }
153
+
154
+ func remove(names []string, name string) []string {
155
+ out := names[:0]
156
+ for _, n := range names {
157
+ if n != name {
158
+ out = append(out, n)
159
+ }
160
+ }
161
+ return out
162
+ }
163
+
164
+ // textAndImages reads message content, which is a string or a list of blocks.
165
+ // Text blocks are joined with newlines; image blocks give their base64 data.
166
+ func textAndImages(content any) (string, []string) {
167
+ if s, ok := content.(string); ok {
168
+ return s, nil
169
+ }
170
+ var text, images []string
171
+ for _, b := range list(content) {
172
+ switch b["type"] {
173
+ case "text":
174
+ if s, _ := b["text"].(string); s != "" {
175
+ text = append(text, s)
176
+ }
177
+ case "image":
178
+ if s, _ := b["data"].(string); s != "" {
179
+ images = append(images, s)
180
+ }
181
+ }
182
+ }
183
+ return strings.Join(text, "\n"), images
184
+ }
@@ -0,0 +1 @@
1
+ {"error":"model 'nope:1b' not found"}
@@ -0,0 +1,2 @@
1
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:46:01.001Z","message":{"role":"assistant","content":"Long answer"},"done":false}
2
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:46:01.031Z","message":{"role":"assistant","content":""},"done_reason":"length","done":true,"prompt_eval_count":8,"eval_count":2}
@@ -0,0 +1,2 @@
1
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:44:01.001Z","message":{"role":"assistant","content":"Partial "},"done":false}
2
+ {"error":"model runner has unexpectedly stopped, this may be due to resource limitations or an internal error, check ollama server logs for details"}
@@ -0,0 +1,4 @@
1
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:41:07.101Z","message":{"role":"assistant","content":"Hel"},"done":false}
2
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:41:07.131Z","message":{"role":"assistant","content":"lo"},"done":false}
3
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:41:07.161Z","message":{"role":"assistant","content":", world."},"done":false}
4
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:41:07.190Z","message":{"role":"assistant","content":""},"done_reason":"stop","done":true,"total_duration":812345678,"load_duration":4567890,"prompt_eval_count":26,"prompt_eval_duration":123456789,"eval_count":5,"eval_duration":456789012}
@@ -0,0 +1,4 @@
1
+ {"model":"qwen3:8b","created_at":"2026-10-05T20:42:01.001Z","message":{"role":"assistant","content":"","thinking":"The user "},"done":false}
2
+ {"model":"qwen3:8b","created_at":"2026-10-05T20:42:01.031Z","message":{"role":"assistant","content":"","thinking":"wants a greeting."},"done":false}
3
+ {"model":"qwen3:8b","created_at":"2026-10-05T20:42:01.061Z","message":{"role":"assistant","content":"Hi!"},"done":false}
4
+ {"model":"qwen3:8b","created_at":"2026-10-05T20:42:01.091Z","message":{"role":"assistant","content":""},"done_reason":"stop","done":true,"prompt_eval_count":12,"eval_count":9}
@@ -0,0 +1,4 @@
1
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:43:11.001Z","message":{"role":"assistant","content":"Checking."},"done":false}
2
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:43:11.031Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"read","arguments":{"path":"README.md"}}}]},"done":false}
3
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:43:11.061Z","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_server7","function":{"index":1,"name":"bash","arguments":{"command":"ls -la","timeout":10}}}]},"done":false}
4
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:43:11.091Z","message":{"role":"assistant","content":""},"done_reason":"stop","done":true,"prompt_eval_count":210,"eval_count":44}
@@ -0,0 +1 @@
1
+ {"model":"granite4.1:3b","created_at":"2026-10-05T20:45:01.001Z","message":{"role":"assistant","content":"Cut o"},"done":false}
@@ -0,0 +1 @@
1
+ {"license":"Apache 2.0","modelfile":"# Modelfile","parameters":"stop \"<|end_of_text|>\"","template":"{{ .Prompt }}","details":{"parent_model":"","format":"gguf","family":"granite","families":["granite"],"parameter_size":"3.2B","quantization_level":"Q4_K_M"},"model_info":{"general.architecture":"granite","general.parameter_count":3188310016,"granite.context_length":131072},"capabilities":["completion","tools"]}
@@ -0,0 +1 @@
1
+ {"details":{"family":"nomic-bert"},"model_info":{"general.architecture":"nomic-bert","nomic-bert.context_length":2048},"capabilities":["embedding"]}
@@ -0,0 +1 @@
1
+ {"details":{"family":"qwen3","parameter_size":"8.2B"},"model_info":{"general.architecture":"qwen3","qwen3.context_length":40960},"capabilities":["completion","tools","thinking","vision"]}
@@ -0,0 +1 @@
1
+ {"models":[{"name":"granite4.1:3b","model":"granite4.1:3b","modified_at":"2026-09-30T10:12:44.1234567-06:00","size":2013458432,"digest":"b1c2d3e4f5a60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90","details":{"parent_model":"","format":"gguf","family":"granite","families":["granite"],"parameter_size":"3.2B","quantization_level":"Q4_K_M"}},{"name":"qwen3:8b","model":"qwen3:8b","modified_at":"2026-09-28T08:01:02.5-06:00","size":5225388032,"digest":"0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90","details":{"parent_model":"","format":"gguf","family":"qwen3","families":["qwen3"],"parameter_size":"8.2B","quantization_level":"Q4_K_M"}},{"name":"nomic-embed-text:latest","model":"nomic-embed-text:latest","modified_at":"2026-09-01T08:01:02.5-06:00","size":274302450,"digest":"c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00","details":{"parent_model":"","format":"gguf","family":"nomic-bert","families":["nomic-bert"],"parameter_size":"137M","quantization_level":"F16"}}]}
@@ -0,0 +1 @@
1
+ {"models":[]}
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@pi-in-go/pigpen-ollama-native",
3
+ "version": "0.1.0",
4
+ "description": "PiG model provider (Go extension) for Ollama's native /api/chat, with the model list read from the server",
5
+ "keywords": [
6
+ "pig-package",
7
+ "pigpen",
8
+ "extension"
9
+ ],
10
+ "license": "MIT",
11
+ "author": "Michael Kinsy",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/MichaelKinsy/pigpen.git",
15
+ "directory": "components/ollama-native"
16
+ },
17
+ "homepage": "https://github.com/MichaelKinsy/pigpen/tree/main/components/ollama-native#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/MichaelKinsy/pigpen/issues"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "files": [
25
+ "extensions",
26
+ "README.md",
27
+ "CREDITS.md",
28
+ "LICENSE",
29
+ "provenance.json"
30
+ ],
31
+ "contributors": [
32
+ "Peder Munksgaard"
33
+ ],
34
+ "pi": {
35
+ "extensions": [
36
+ "extensions/ollama-native"
37
+ ]
38
+ }
39
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "origin": "original",
3
+ "authors": ["Peder Munksgaard", "Michael Kinsy"],
4
+ "license": "MIT",
5
+ "licenseFile": "LICENSE",
6
+ "upstreams": []
7
+ }