@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
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
|
+
}
|