@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 ADDED
@@ -0,0 +1,24 @@
1
+ # Credits
2
+
3
+ The idea, the first working extension and the choice of Ollama's native `/api/chat`
4
+ endpoint (with the model list read from `/api/tags`) are by **Peder Munksgaard**
5
+ (<peder1981@gmail.com>, GitHub @peder1981), contributed as pigpen pull request 3,
6
+ "feat: add ollama-native extension with dynamic model listing". MIT.
7
+
8
+ This Package is a rewrite of that extension for Pigpen's rules, and keeps its
9
+ design and its `ollama-native/<model>` naming. What changed, and why:
10
+
11
+ - **No network at startup.** The first version asked Ollama for its models while
12
+ the extension was being constructed, so a pig start waited for (or failed on) a
13
+ server that might not be running. Here the catalog is read when PiG refreshes
14
+ provider catalogs, stored, and restored from the store at the next start.
15
+ - **More than the first tool call.** Ollama sends no tool-call ids and may send
16
+ several calls in one answer; every call is kept and gets an id.
17
+ - **No 120-second cap on a whole request.** A cold model or a long answer outlasts
18
+ it; only connecting is bounded. Cancelling a turn cancels the request.
19
+ - **The whole conversation goes to the model**: the system prompt, earlier tool
20
+ calls and their results, and images.
21
+ - **Context window, thinking and image support** come from `/api/show` instead of
22
+ fixed numbers.
23
+ - Tests with recorded-format fixtures, a README, and a `go.mod` pinned like the
24
+ other Pigpen modules.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Kinsy
4
+ Copyright (c) 2026 Peder Munksgaard
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # ollama-native Package
2
+
3
+ A PiG model provider for [Ollama](https://ollama.com), through Ollama's **native**
4
+ `/api/chat` endpoint (not the OpenAI-compatible `/v1` one), so tool calls, thinking
5
+ and images use Ollama's own message format. Models appear as `ollama-native/<model>`.
6
+ Go extension on PiG's public SDK; PiG builds it from source with a Go toolchain, and a
7
+ Piglet Binary can fuse it. It was started by Peder Munksgaard; see [CREDITS.md](CREDITS.md).
8
+
9
+ **It does nothing until you use it.** It is not part of any Piglet. It makes no network
10
+ call at start-up, so pig starts the same with Ollama stopped. It asks Ollama for
11
+ anything only when PiG refreshes provider catalogs, when you run `/ollama refresh`, or
12
+ when you send a message to an `ollama-native` model.
13
+
14
+ ## Set up
15
+
16
+ 1. Install and start Ollama, and pull a model that supports tools:
17
+ `ollama pull granite4.1:3b` (or `qwen3:8b`, `llama3.2`, and so on).
18
+ 2. Install the Package from the Pigpen checkout root:
19
+
20
+ ```sh
21
+ pig package validate ./components/ollama-native
22
+ pig install ./components/ollama-native
23
+ ```
24
+
25
+ The local install references this checkout; keep it where it is and remove it with
26
+ `pig remove` using the same path. To try it without installing, load the source
27
+ directly: `pig -e ./components/ollama-native/extensions/ollama-native`.
28
+ 3. Load the model list once. Start `pig` interactively (PiG refreshes provider
29
+ catalogs when a session starts) or run `/ollama refresh`. The list is stored, so
30
+ later runs, including one-shot `pig -p`, find the models with Ollama not asked:
31
+
32
+ ```sh
33
+ pig --list-models ollama
34
+ pig --model ollama-native/granite4.1:3b -p "Say hello"
35
+ ```
36
+
37
+ Run `/ollama refresh` again after `ollama pull` or `ollama rm`.
38
+
39
+ ## Configuration
40
+
41
+ | Variable | Meaning | Default |
42
+ | --- | --- | --- |
43
+ | `OLLAMA_HOST` | Where Ollama listens: `host`, `host:port`, `:port` or a full `http(s)://` URL, as Ollama's own tools read it. | `http://127.0.0.1:11434` |
44
+ | `OLLAMA_API_KEY` | Sent as `Authorization: Bearer` to a protected server (a proxy, Ollama's hosted service). A local Ollama needs none, and no key is ever sent unless this is set. | unset |
45
+
46
+ `PI_OFFLINE=1` (or `--offline`) keeps PiG from refreshing catalogs on its own.
47
+
48
+ ## What it does
49
+
50
+ - **Models** come from `/api/tags`; none is written down in the code. For each model
51
+ `/api/show` gives the context length and capabilities: `thinking` marks a reasoning
52
+ model, `vision` adds image input, and a model without `completion` (an embedding
53
+ model) is left out. When `/api/show` fails the model is still listed, with a 32,768
54
+ token context window.
55
+ - **Chat** streams `/api/chat`: text, thinking, and any number of tool calls per answer.
56
+ Ollama sends no tool-call ids, so each call gets one. Tool results go back as `tool`
57
+ messages. Token counts come from Ollama's final message. Cancelling a turn closes the
58
+ request.
59
+ - **Errors** say what to do: Ollama not reachable (start it with `ollama serve` or set
60
+ `OLLAMA_HOST`), a model that is not installed (`ollama pull <model>`), or the message
61
+ Ollama returned, including one that arrives in the middle of a stream.
62
+ - `think` is sent only to models marked as reasoning, and only when a thinking level
63
+ is chosen.
64
+
65
+ ## Limits
66
+
67
+ - Tool calls need a model that supports them. Ollama says so in the error when it does not.
68
+ - PiG 0.4.0 gives a provider the system prompt's named sections as an unordered map,
69
+ so they are sent in name order, not PiG's authored order. The text is all there.
70
+ - The catalog's `maxTokens` is 8,192 (or the context window if smaller); PiG sends a
71
+ limit only when you set one.
72
+ - Only chat is covered: no embeddings, no model management (pull, delete).
73
+
74
+ ## Tests
75
+
76
+ `extensions/ollama-native/testdata/` holds the Ollama responses the tests replay from a
77
+ local HTTP server: `/api/tags`, `/api/show` and the `/api/chat` streams (text,
78
+ thinking, tool calls, length stop, a mid-stream error, a truncated body, a 404). They
79
+ are written to the wire format in Ollama's API documentation, not recorded from a live
80
+ server. `go test ./...` in `extensions/ollama-native` runs them; nothing needs Ollama.
81
+ A run against a real Ollama is the owner's check; it has not been done here.
@@ -0,0 +1,241 @@
1
+ package ollamanative
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "net/http"
11
+ "strings"
12
+ "time"
13
+
14
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
15
+ )
16
+
17
+ // stream sends one chat turn to Ollama's /api/chat and streams the answer back
18
+ // as PiG model events. It returns at once; the work runs on its own goroutine,
19
+ // and every failure becomes an error event on the stream.
20
+ func (c *client) stream(model, transcript map[string]any, opts sdk.ProviderStreamOptions) (*sdk.ModelEventStream, error) {
21
+ out := sdk.CreateAssistantMessageEventStream()
22
+ ctx := opts.Signal
23
+ if ctx == nil {
24
+ ctx = context.Background()
25
+ }
26
+ id, _ := model["id"].(string)
27
+ go c.run(ctx, out, id, chatRequest(model, transcript, opts), apiKey(opts))
28
+ return out, nil
29
+ }
30
+
31
+ func apiKey(opts sdk.ProviderStreamOptions) string {
32
+ key, _ := opts.Values["apiKey"].(string)
33
+ if key == localKey {
34
+ return ""
35
+ }
36
+ return key
37
+ }
38
+
39
+ // reply is the assistant message under construction. Each event carries a
40
+ // snapshot of it, so later changes never reach an event already delivered.
41
+ type reply struct {
42
+ model string
43
+ blocks []map[string]any
44
+ usage map[string]any
45
+ stop string
46
+ now func() time.Time
47
+ }
48
+
49
+ func (r *reply) snapshot() map[string]any {
50
+ blocks := make([]any, len(r.blocks))
51
+ for i, b := range r.blocks {
52
+ cp := make(map[string]any, len(b))
53
+ for k, v := range b {
54
+ cp[k] = v
55
+ }
56
+ blocks[i] = cp
57
+ }
58
+ msg := map[string]any{
59
+ "role": "assistant", "api": ProviderID, "provider": ProviderID, "model": r.model,
60
+ "content": blocks, "stopReason": r.stop, "timestamp": r.now().UnixMilli(),
61
+ "usage": r.usage,
62
+ }
63
+ return msg
64
+ }
65
+
66
+ func zeroUsage() map[string]any {
67
+ return map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
68
+ "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0}}
69
+ }
70
+
71
+ // chunk is one line of Ollama's streamed /api/chat answer.
72
+ type chunk struct {
73
+ Error string `json:"error"`
74
+ Message struct {
75
+ Content string `json:"content"`
76
+ Thinking string `json:"thinking"`
77
+ ToolCalls []struct {
78
+ ID string `json:"id"`
79
+ Function struct {
80
+ Name string `json:"name"`
81
+ Arguments map[string]any `json:"arguments"`
82
+ } `json:"function"`
83
+ } `json:"tool_calls"`
84
+ } `json:"message"`
85
+ Done bool `json:"done"`
86
+ DoneReason string `json:"done_reason"`
87
+ PromptEvalCount int `json:"prompt_eval_count"`
88
+ EvalCount int `json:"eval_count"`
89
+ }
90
+
91
+ func (c *client) run(ctx context.Context, out *sdk.ModelEventStream, model string, request map[string]any, key string) {
92
+ r := &reply{model: model, usage: zeroUsage(), stop: "stop", now: time.Now}
93
+ out.Push(map[string]any{"type": "start", "partial": r.snapshot()})
94
+
95
+ fail := func(err error) {
96
+ reason, text := "error", err.Error()
97
+ if ctx.Err() != nil {
98
+ reason, text = "aborted", "Request was aborted"
99
+ }
100
+ r.stop = reason
101
+ msg := r.snapshot()
102
+ msg["errorMessage"] = text
103
+ out.Push(map[string]any{"type": "error", "reason": reason, "error": msg})
104
+ }
105
+
106
+ base := c.baseURL()
107
+ resp, cancel, err := c.doChat(ctx, request, key)
108
+ if err != nil {
109
+ if isDialFailure(err) {
110
+ err = unreachable(base, err)
111
+ } else if ctx.Err() == nil {
112
+ err = fmt.Errorf("request to Ollama at %s failed: %w", base, rootCause(err))
113
+ }
114
+ fail(err)
115
+ return
116
+ }
117
+ defer cancel()
118
+ defer resp.Body.Close()
119
+ if resp.StatusCode != http.StatusOK {
120
+ text := apiError(resp)
121
+ if resp.StatusCode == http.StatusNotFound && strings.Contains(strings.ToLower(text), "not found") {
122
+ fail(fmt.Errorf("Ollama has no model %q (%s). Install it with: ollama pull %s", model, text, model))
123
+ return
124
+ }
125
+ fail(fmt.Errorf("Ollama at %s answered %d: %s", base, resp.StatusCode, text))
126
+ return
127
+ }
128
+
129
+ var open string // "text" or "thinking": the block deltas are going into, if any
130
+ closeOpen := func() {
131
+ if open == "" {
132
+ return
133
+ }
134
+ last := r.blocks[len(r.blocks)-1]
135
+ key := "text"
136
+ if open == "thinking" {
137
+ key = "thinking"
138
+ }
139
+ out.Push(map[string]any{"type": open + "_end", "contentIndex": len(r.blocks) - 1, "content": last[key], "partial": r.snapshot()})
140
+ open = ""
141
+ }
142
+ delta := func(kind, text string) {
143
+ if text == "" {
144
+ return
145
+ }
146
+ if open != kind {
147
+ closeOpen()
148
+ r.blocks = append(r.blocks, map[string]any{"type": kind, kind: ""})
149
+ open = kind
150
+ out.Push(map[string]any{"type": kind + "_start", "contentIndex": len(r.blocks) - 1, "partial": r.snapshot()})
151
+ }
152
+ last := r.blocks[len(r.blocks)-1]
153
+ last[kind] = last[kind].(string) + text
154
+ out.Push(map[string]any{"type": kind + "_delta", "contentIndex": len(r.blocks) - 1, "delta": text, "partial": r.snapshot()})
155
+ }
156
+
157
+ toolCalls := 0
158
+ reader := bufio.NewReaderSize(resp.Body, 64<<10)
159
+ for {
160
+ line, readErr := reader.ReadBytes('\n')
161
+ if len(strings.TrimSpace(string(line))) > 0 {
162
+ var ch chunk
163
+ if err := json.Unmarshal(line, &ch); err != nil {
164
+ fail(fmt.Errorf("Ollama sent a line that is not JSON: %.80q", line))
165
+ return
166
+ }
167
+ if ch.Error != "" {
168
+ fail(errors.New(ch.Error))
169
+ return
170
+ }
171
+ delta("thinking", ch.Message.Thinking)
172
+ delta("text", ch.Message.Content)
173
+ for _, tc := range ch.Message.ToolCalls {
174
+ closeOpen()
175
+ id := tc.ID
176
+ if id == "" {
177
+ id = c.newID()
178
+ }
179
+ args := tc.Function.Arguments
180
+ if args == nil {
181
+ args = map[string]any{}
182
+ }
183
+ idx := len(r.blocks)
184
+ call := map[string]any{"type": "toolCall", "id": id, "name": tc.Function.Name, "arguments": map[string]any{}}
185
+ r.blocks = append(r.blocks, call)
186
+ out.Push(map[string]any{"type": "toolcall_start", "contentIndex": idx, "partial": r.snapshot()})
187
+ call["arguments"] = args
188
+ raw, _ := json.Marshal(args)
189
+ out.Push(map[string]any{"type": "toolcall_delta", "contentIndex": idx, "delta": string(raw), "partial": r.snapshot()})
190
+ out.Push(map[string]any{"type": "toolcall_end", "contentIndex": idx, "toolCall": call, "partial": r.snapshot()})
191
+ toolCalls++
192
+ }
193
+ if ch.Done {
194
+ closeOpen()
195
+ r.usage["input"], r.usage["output"] = ch.PromptEvalCount, ch.EvalCount
196
+ r.usage["totalTokens"] = ch.PromptEvalCount + ch.EvalCount
197
+ switch {
198
+ case toolCalls > 0:
199
+ r.stop = "toolUse"
200
+ case ch.DoneReason == "length":
201
+ r.stop = "length"
202
+ default:
203
+ r.stop = "stop"
204
+ }
205
+ out.Push(map[string]any{"type": "done", "reason": r.stop, "message": r.snapshot()})
206
+ return
207
+ }
208
+ }
209
+ if readErr != nil {
210
+ if errors.Is(readErr, io.EOF) {
211
+ fail(errors.New("The response stream from Ollama ended before it finished (no final message)."))
212
+ } else {
213
+ fail(fmt.Errorf("reading Ollama's response failed: %w", rootCause(readErr)))
214
+ }
215
+ return
216
+ }
217
+ }
218
+ }
219
+
220
+ func (c *client) doChat(ctx context.Context, request map[string]any, key string) (*http.Response, context.CancelFunc, error) {
221
+ body, err := json.Marshal(request)
222
+ if err != nil {
223
+ return nil, nil, err
224
+ }
225
+ ctx, cancel := context.WithCancel(ctx)
226
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/chat", strings.NewReader(string(body)))
227
+ if err != nil {
228
+ cancel()
229
+ return nil, nil, err
230
+ }
231
+ req.Header.Set("Content-Type", "application/json")
232
+ if key != "" {
233
+ req.Header.Set("Authorization", "Bearer "+key)
234
+ }
235
+ resp, err := c.http.Do(req)
236
+ if err != nil {
237
+ cancel()
238
+ return nil, nil, err
239
+ }
240
+ return resp, cancel, nil
241
+ }
@@ -0,0 +1,287 @@
1
+ package ollamanative
2
+
3
+ import (
4
+ "context"
5
+ "reflect"
6
+ "strings"
7
+ "testing"
8
+ "time"
9
+
10
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
11
+ )
12
+
13
+ func run(t *testing.T, c *client, model, transcript map[string]any, opts sdk.ProviderStreamOptions) streamed {
14
+ t.Helper()
15
+ stream, err := c.stream(model, transcript, opts)
16
+ if err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ return collect(t, stream)
20
+ }
21
+
22
+ func content(msg map[string]any) []any { return msg["content"].([]any) }
23
+
24
+ func TestStreamText(t *testing.T) {
25
+ f := newFake(t)
26
+ s := run(t, newClient(f.URL), graniteModel, userTranscript("hi"), sdk.ProviderStreamOptions{})
27
+ want := []string{"start", "text_start", "text_delta", "text_delta", "text_delta", "text_end", "done"}
28
+ if !reflect.DeepEqual(s.types(), want) {
29
+ t.Fatalf("events = %v", s.types())
30
+ }
31
+ done := s.terminal()
32
+ msg := done["message"].(map[string]any)
33
+ if done["reason"] != "stop" || msg["stopReason"] != "stop" {
34
+ t.Errorf("reason = %v / %v", done["reason"], msg["stopReason"])
35
+ }
36
+ if blocks := content(msg); len(blocks) != 1 || blocks[0].(map[string]any)["text"] != "Hello, world." {
37
+ t.Errorf("content = %v", msg["content"])
38
+ }
39
+ u := msg["usage"].(map[string]any)
40
+ if u["input"] != 26 || u["output"] != 5 || u["totalTokens"] != 31 {
41
+ t.Errorf("usage = %v", u)
42
+ }
43
+ if msg["api"] != "ollama-native" || msg["provider"] != "ollama-native" || msg["model"] != "granite4.1:3b" {
44
+ t.Errorf("identity = %v", msg)
45
+ }
46
+ // Deltas carry the right slice of text.
47
+ if s.events[2]["delta"] != "Hel" || s.events[4]["delta"] != ", world." {
48
+ t.Errorf("deltas = %v %v", s.events[2], s.events[4])
49
+ }
50
+ // The request is the native chat shape: streaming, one user message, no tools.
51
+ req := f.chats[0]
52
+ if req["model"] != "granite4.1:3b" || req["stream"] != true || req["tools"] != nil {
53
+ t.Errorf("request = %v", req)
54
+ }
55
+ if f.requests[0] != "POST /api/chat" {
56
+ t.Errorf("path = %v", f.requests)
57
+ }
58
+ }
59
+
60
+ func TestStreamThinkingThenText(t *testing.T) {
61
+ f := newFake(t)
62
+ f.chat = "chat_thinking.ndjson"
63
+ s := run(t, newClient(f.URL), map[string]any{"id": "qwen3:8b", "reasoning": true}, userTranscript("hi"), sdk.ProviderStreamOptions{})
64
+ want := []string{"start", "thinking_start", "thinking_delta", "thinking_delta", "thinking_end", "text_start", "text_delta", "text_end", "done"}
65
+ if !reflect.DeepEqual(s.types(), want) {
66
+ t.Fatalf("events = %v", s.types())
67
+ }
68
+ blocks := content(s.terminal()["message"].(map[string]any))
69
+ if len(blocks) != 2 || blocks[0].(map[string]any)["thinking"] != "The user wants a greeting." || blocks[1].(map[string]any)["text"] != "Hi!" {
70
+ t.Errorf("content = %v", blocks)
71
+ }
72
+ }
73
+
74
+ func TestStreamToolCalls(t *testing.T) {
75
+ f := newFake(t)
76
+ f.chat = "chat_toolcalls.ndjson"
77
+ s := run(t, newClient(f.URL), graniteModel, userTranscript("look around"), sdk.ProviderStreamOptions{})
78
+ want := []string{"start", "text_start", "text_delta", "text_end",
79
+ "toolcall_start", "toolcall_delta", "toolcall_end",
80
+ "toolcall_start", "toolcall_delta", "toolcall_end", "done"}
81
+ if !reflect.DeepEqual(s.types(), want) {
82
+ t.Fatalf("events = %v", s.types())
83
+ }
84
+ done := s.terminal()
85
+ msg := done["message"].(map[string]any)
86
+ if done["reason"] != "toolUse" || msg["stopReason"] != "toolUse" {
87
+ t.Errorf("stop = %v / %v", done["reason"], msg["stopReason"])
88
+ }
89
+ blocks := content(msg)
90
+ if len(blocks) != 3 {
91
+ t.Fatalf("content = %v", blocks)
92
+ }
93
+ first, second := blocks[1].(map[string]any), blocks[2].(map[string]any)
94
+ // Ollama sent no id for the first call: one is made up, and is unique.
95
+ if first["type"] != "toolCall" || first["name"] != "read" || first["id"] != "call_t1" {
96
+ t.Errorf("first = %v", first)
97
+ }
98
+ if !reflect.DeepEqual(first["arguments"], map[string]any{"path": "README.md"}) {
99
+ t.Errorf("first args = %v", first["arguments"])
100
+ }
101
+ // A server-sent id is kept.
102
+ if second["name"] != "bash" || second["id"] != "call_server7" || second["arguments"].(map[string]any)["timeout"] != float64(10) {
103
+ t.Errorf("second = %v", second)
104
+ }
105
+ end := s.events[6]
106
+ if end["contentIndex"] != 1 || end["toolCall"].(map[string]any)["name"] != "read" {
107
+ t.Errorf("toolcall_end = %v", end)
108
+ }
109
+ }
110
+
111
+ func TestStreamLengthStop(t *testing.T) {
112
+ f := newFake(t)
113
+ f.chat = "chat_length.ndjson"
114
+ s := run(t, newClient(f.URL), graniteModel, userTranscript("hi"), sdk.ProviderStreamOptions{})
115
+ if s.terminal()["reason"] != "length" {
116
+ t.Errorf("terminal = %v", s.terminal())
117
+ }
118
+ }
119
+
120
+ func errorMessage(s streamed) string {
121
+ e := s.terminal()
122
+ if e["type"] != "error" {
123
+ return "<not an error: " + e["type"].(string) + ">"
124
+ }
125
+ return e["error"].(map[string]any)["errorMessage"].(string)
126
+ }
127
+
128
+ func TestStreamMidStreamError(t *testing.T) {
129
+ f := newFake(t)
130
+ f.chat = "chat_midstream_error.ndjson"
131
+ s := run(t, newClient(f.URL), graniteModel, userTranscript("hi"), sdk.ProviderStreamOptions{})
132
+ if msg := errorMessage(s); !strings.Contains(msg, "model runner has unexpectedly stopped") {
133
+ t.Errorf("error = %q (events %v)", msg, s.types())
134
+ }
135
+ // What arrived before the failure is kept on the failed message.
136
+ partial := content(s.terminal()["error"].(map[string]any))
137
+ if len(partial) != 1 || partial[0].(map[string]any)["text"] != "Partial " {
138
+ t.Errorf("partial = %v", partial)
139
+ }
140
+ if s.terminal()["reason"] != "error" {
141
+ t.Errorf("reason = %v", s.terminal()["reason"])
142
+ }
143
+ }
144
+
145
+ func TestStreamTruncatedBodyIsAnError(t *testing.T) {
146
+ f := newFake(t)
147
+ f.chat = "chat_truncated.ndjson"
148
+ s := run(t, newClient(f.URL), graniteModel, userTranscript("hi"), sdk.ProviderStreamOptions{})
149
+ if msg := errorMessage(s); !strings.Contains(msg, "ended before") {
150
+ t.Errorf("error = %q", msg)
151
+ }
152
+ }
153
+
154
+ func TestStreamUnknownModelTellsYouToPull(t *testing.T) {
155
+ f := newFake(t)
156
+ f.chat, f.chatStatus = "chat_404.json", 404
157
+ s := run(t, newClient(f.URL), map[string]any{"id": "nope:1b"}, userTranscript("hi"), sdk.ProviderStreamOptions{})
158
+ msg := errorMessage(s)
159
+ if !strings.Contains(msg, "nope:1b") || !strings.Contains(msg, "ollama pull nope:1b") {
160
+ t.Errorf("error = %q", msg)
161
+ }
162
+ }
163
+
164
+ func TestStreamOllamaNotRunning(t *testing.T) {
165
+ f := newFake(t)
166
+ url := f.URL
167
+ f.Close()
168
+ s := run(t, newClient(url), graniteModel, userTranscript("hi"), sdk.ProviderStreamOptions{})
169
+ msg := errorMessage(s)
170
+ if !strings.Contains(msg, "Ollama is not reachable at "+url) || !strings.Contains(msg, "ollama serve") || !strings.Contains(msg, "OLLAMA_HOST") {
171
+ t.Errorf("error = %q", msg)
172
+ }
173
+ }
174
+
175
+ func TestStreamAbort(t *testing.T) {
176
+ f := newFake(t)
177
+ f.hold = make(chan struct{})
178
+ defer close(f.hold)
179
+ ctx, cancel := context.WithCancel(context.Background())
180
+ stream, err := newClient(f.URL).stream(graniteModel, userTranscript("hi"), sdk.ProviderStreamOptions{Signal: ctx})
181
+ if err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ go func() { time.Sleep(100 * time.Millisecond); cancel() }()
185
+ s := collect(t, stream)
186
+ if s.terminal()["type"] != "error" || s.terminal()["reason"] != "aborted" {
187
+ t.Errorf("terminal = %v", s.terminal())
188
+ }
189
+ }
190
+
191
+ func TestChatRequestMapsTheTranscript(t *testing.T) {
192
+ transcript := map[string]any{"messages": []any{
193
+ map[string]any{"role": "system", "content": "Be brief.", "timestamp": 0,
194
+ "sections": map[string]any{"cwd": "<cwd>/work</cwd>", "docs": "<docs>old</docs>", "gone": "x"},
195
+ "toolsAdded": []any{
196
+ map[string]any{"name": "read", "description": "Read a file", "parameters": map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}}},
197
+ map[string]any{"name": "bash", "description": "Run", "parameters": map[string]any{"type": "object"}},
198
+ }},
199
+ map[string]any{"role": "system", "content": []any{map[string]any{"type": "text", "text": "Later note."}}, "timestamp": 1,
200
+ "sections": map[string]any{"docs": "<docs>new</docs>", "gone": nil},
201
+ "toolsRemoved": []any{map[string]any{"name": "bash"}}},
202
+ map[string]any{"role": "user", "content": []any{
203
+ map[string]any{"type": "text", "text": "what is this?"},
204
+ map[string]any{"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"}}},
205
+ map[string]any{"role": "assistant", "stopReason": "toolUse", "content": []any{
206
+ map[string]any{"type": "thinking", "thinking": "hmm"},
207
+ map[string]any{"type": "text", "text": "Reading."},
208
+ map[string]any{"type": "toolCall", "id": "call_1", "name": "read", "arguments": map[string]any{"path": "a.txt"}}}},
209
+ map[string]any{"role": "toolResult", "toolCallId": "call_1", "toolName": "read", "isError": false,
210
+ "content": []any{map[string]any{"type": "text", "text": "line one"}, map[string]any{"type": "text", "text": "line two"}}},
211
+ // An aborted turn that produced nothing is not replayed.
212
+ map[string]any{"role": "assistant", "stopReason": "aborted", "content": []any{}},
213
+ map[string]any{"role": "user", "content": "thanks"},
214
+ }}
215
+ opts := sdk.ProviderStreamOptions{Values: map[string]any{"temperature": 0.2, "maxTokens": float64(512)}}
216
+ req := chatRequest(map[string]any{"id": "granite4.1:3b"}, transcript, opts)
217
+
218
+ if req["model"] != "granite4.1:3b" || req["stream"] != true {
219
+ t.Fatalf("req = %v", req)
220
+ }
221
+ msgs := req["messages"].([]map[string]any)
222
+ roles := []string{}
223
+ for _, m := range msgs {
224
+ roles = append(roles, m["role"].(string))
225
+ }
226
+ if want := []string{"system", "user", "assistant", "tool", "user"}; !reflect.DeepEqual(roles, want) {
227
+ t.Fatalf("roles = %v", roles)
228
+ }
229
+ // One system message, as PiG itself renders it: the instructions, then the
230
+ // sections with later values replacing earlier ones and null removing one.
231
+ // PiG 0.4.0 hands a provider the sections as a map, so their authored order
232
+ // is lost: they come in name order.
233
+ if want := "Be brief.\n\nLater note.\n\n<cwd>/work</cwd>\n\n<docs>new</docs>"; msgs[0]["content"] != want {
234
+ t.Errorf("system = %q, want %q", msgs[0]["content"], want)
235
+ }
236
+ if msgs[1]["content"] != "what is this?" || !reflect.DeepEqual(msgs[1]["images"], []string{"aGVsbG8="}) {
237
+ t.Errorf("user = %v", msgs[1])
238
+ }
239
+ a := msgs[2]
240
+ calls := a["tool_calls"].([]map[string]any)
241
+ if a["content"] != "Reading." || a["thinking"] != nil || len(calls) != 1 {
242
+ t.Fatalf("assistant = %v", a)
243
+ }
244
+ fn := calls[0]["function"].(map[string]any)
245
+ if fn["name"] != "read" || !reflect.DeepEqual(fn["arguments"], map[string]any{"path": "a.txt"}) {
246
+ t.Errorf("call = %v", calls[0])
247
+ }
248
+ if msgs[3]["tool_name"] != "read" || msgs[3]["content"] != "line one\nline two" {
249
+ t.Errorf("tool result = %v", msgs[3])
250
+ }
251
+
252
+ // bash was removed by the second system message; only read is offered.
253
+ tools := req["tools"].([]map[string]any)
254
+ if len(tools) != 1 || tools[0]["type"] != "function" || tools[0]["function"].(map[string]any)["name"] != "read" {
255
+ t.Errorf("tools = %v", tools)
256
+ }
257
+ o := req["options"].(map[string]any)
258
+ if o["temperature"] != 0.2 || o["num_predict"] != 512 {
259
+ t.Errorf("options = %v", o)
260
+ }
261
+ }
262
+
263
+ func TestChatRequestThinkingOnlyForModelsThatThink(t *testing.T) {
264
+ on := sdk.ProviderStreamOptions{Values: map[string]any{"thinkingEnabled": true}}
265
+ if got := chatRequest(map[string]any{"id": "qwen3:8b", "reasoning": true}, userTranscript("x"), on)["think"]; got != true {
266
+ t.Errorf("think = %v", got)
267
+ }
268
+ // Ollama rejects `think` for a model that cannot think.
269
+ if _, has := chatRequest(map[string]any{"id": "granite4.1:3b", "reasoning": false}, userTranscript("x"), on)["think"]; has {
270
+ t.Error("think sent to a model without the thinking capability")
271
+ }
272
+ if _, has := chatRequest(map[string]any{"id": "qwen3:8b", "reasoning": true}, userTranscript("x"), sdk.ProviderStreamOptions{})["think"]; has {
273
+ t.Error("think sent although no level was asked for")
274
+ }
275
+ }
276
+
277
+ func TestAPIKeyOnlyWhenOneIsConfigured(t *testing.T) {
278
+ f := newFake(t)
279
+ var auth []string
280
+ inner := f.Server.Config.Handler
281
+ f.Server.Config.Handler = httpHandler(func(h map[string][]string) { auth = append(auth, strings.Join(h["Authorization"], "")) }, inner)
282
+ run(t, newClient(f.URL), graniteModel, userTranscript("hi"), sdk.ProviderStreamOptions{Values: map[string]any{"apiKey": "ollama"}})
283
+ run(t, newClient(f.URL), graniteModel, userTranscript("hi"), sdk.ProviderStreamOptions{Values: map[string]any{"apiKey": "sk-cloud"}})
284
+ if !reflect.DeepEqual(auth, []string{"", "Bearer sk-cloud"}) {
285
+ t.Errorf("Authorization headers = %q", auth)
286
+ }
287
+ }