bots-garden/mini-mepublic Fork 0
d72271127802973540c648bfb372176cdaaa8e4f
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

provider_test.go · 450 lines · 18.0 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 7h ago1// Tests for the provider layer, against httptest servers: the fake engine of
2// the backup docs speaks plain OpenAI and cannot say "I am llama-server", nor
3// fail on demand. What these tests guard: the URL precedence the `dmr` package
4// had (env, then file, then fallback when the first does not answer), the
5// llama.cpp probe, and the one-line error mapping — each line of the table in
6// PROVIDERS.md §5.7 that this step implements.
7package engine
8
9import (
10 "context"
11 "encoding/json"
12 "net/http"
13 "net/http/httptest"
14 "os"
15 "path/filepath"
16 "strings"
17 "testing"
18
19 "mm/internal/config"
20
21 "github.com/firebase/genkit/go/ai"
22)
23
24// openaiServer is a minimal OpenAI-compatible server: /models lists `models`,
25// every POST answers `status` with `body`. Enough to drive Resolve, Probe and
26// Explain; Generate's happy path is covered by engine_test.go's fake model.
27func openaiServer(t *testing.T, status int, body string, models ...string) *httptest.Server {
28 t.Helper()
29 mux := http.NewServeMux()
30 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
31 if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") {
32 var data []map[string]string
33 for _, m := range models {
34 data = append(data, map[string]string{"id": m, "object": "model"})
35 }
36 _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
37 return
38 }
39 if r.Method == http.MethodPost {
40 w.Header().Set("Content-Type", "application/json")
41 w.WriteHeader(status)
42 _, _ = w.Write([]byte(body))
43 return
44 }
45 http.NotFound(w, r)
46 })
47 srv := httptest.NewServer(mux)
48 t.Cleanup(srv.Close)
49 return srv
50}
51
52// closedURL returns a URL nothing listens on: a server started and stopped, so
53// the port was free a moment ago.
54func closedURL(t *testing.T) string {
55 t.Helper()
56 srv := httptest.NewServer(http.NotFoundHandler())
57 url := srv.URL
58 srv.Close()
59 return url
60}
61
62func ptr(s string) *string { return &s }
63
64// clearEnv: the precedence tests must not depend on the developer's shell,
65// where DMR_BASE_URL is often exported.
66func clearEnv(t *testing.T) {
67 t.Helper()
68 for _, k := range []string{"AGENT_BASE_URL", "DMR_BASE_URL", "LLAMA_API_KEY", "AGENT_PROVIDER", "AGENT_MODEL"} {
69 t.Setenv(k, "")
70 }
71}
72
73func TestLookupListsKnownProviders(t *testing.T) {
74 _, err := Lookup("ollama")
75 if err == nil || !strings.Contains(err.Error(), "dmr, llamacpp") {
76 t.Fatalf("Lookup(ollama) = %v, want an error naming the known providers", err)
77 }
78}
79
80// TestResolveURLPrecedence replays the rules of the former config.DMRBaseURL —
81// env wins without probing, the fallback is taken only when baseUrl does not
82// answer — and checks that llamacpp neither inherits DMR's URL nor its env var.
83func TestResolveURLPrecedence(t *testing.T) {
84 clearEnv(t)
85 up := openaiServer(t, 200, "{}", "fake-model")
86 down := closedURL(t)
87
88 cases := []struct {
89 name string
90 provider string
91 cfg config.Config
92 env map[string]string
93 wantURL string
94 wantKey string
95 }{
96 {"dmr keeps a reachable baseUrl", "dmr", config.Config{BaseURL: up.URL}, nil, up.URL, "not-needed"},
97 {"dmr falls back when baseUrl is down", "dmr", config.Config{BaseURL: down, Fallback: ptr(up.URL)}, nil, up.URL, "not-needed"},
98 {"an explicit empty fallback disables it", "dmr", config.Config{BaseURL: down, Fallback: ptr("")}, nil, down, "not-needed"},
99 {"AGENT_BASE_URL wins without probing", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"AGENT_BASE_URL": down}, down, "not-needed"},
100 {"DMR_BASE_URL still works for dmr", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"DMR_BASE_URL": down}, down, "not-needed"},
101 {"llamacpp default is llama-server's port", "llamacpp", config.Config{}, nil, "http://127.0.0.1:8080/v1", "not-needed"},
102 {"llamacpp ignores DMR_BASE_URL", "llamacpp", config.Config{}, map[string]string{"DMR_BASE_URL": down}, "http://127.0.0.1:8080/v1", "not-needed"},
103 {"llamacpp reads LLAMA_API_KEY when set", "llamacpp", config.Config{}, map[string]string{"LLAMA_API_KEY": "secret"}, "http://127.0.0.1:8080/v1", "secret"},
104 {"apiKeyEnv names another variable", "llamacpp", config.Config{APIKeyEnv: "MY_KEY"}, map[string]string{"MY_KEY": "k2"}, "http://127.0.0.1:8080/v1", "k2"},
105 }
106 for _, tc := range cases {
107 t.Run(tc.name, func(t *testing.T) {
108 clearEnv(t)
109 for k, v := range tc.env {
110 t.Setenv(k, v)
111 }
112 p, _ := Lookup(tc.provider)
113 tc.cfg.Model = "fake-model"
114 b, err := p.Resolve(tc.cfg)
115 if err != nil {
116 t.Fatalf("Resolve: %v", err)
117 }
118 if b.BaseURL != tc.wantURL {
119 t.Errorf("BaseURL = %q, want %q", b.BaseURL, tc.wantURL)
120 }
121 if b.APIKey != tc.wantKey {
122 t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey)
123 }
124 })
125 }
126}
127
128// TestProbeLlamaCpp: /props sits one level above /v1 and carries the SERVED
129// context size; the config's value, when given, wins over it.
130func TestProbeLlamaCpp(t *testing.T) {
131 mux := http.NewServeMux()
132 mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) {
133 _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`))
134 })
135 mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
136 _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"qwen2.5-coder","object":"model"}]}`))
137 })
138 srv := httptest.NewServer(mux)
139 defer srv.Close()
140
141 p, _ := Lookup("llamacpp")
142 info := p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder"})
143 if !info.Reachable || info.ContextWindow != 32768 || info.ContextSource != "/props" {
144 t.Errorf("Probe = %+v, want reachable, 32768 from /props", info)
145 }
146 if len(info.Warnings) != 0 {
147 t.Errorf("unexpected warnings: %q", info.Warnings)
148 }
149
150 info = p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder", ContextWindow: 4096})
151 if info.ContextWindow != 4096 || info.ContextSource != "config" {
152 t.Errorf("with a config hint: %+v, want 4096 from config", info)
153 }
154}
155
156// TestEnsureContextWindowReprobes: the start-up probe runs once, and a
157// llama-server started AFTER the agent left `ctx: unknown` for the whole
158// session. The lazy re-probe must turn 0 into n_ctx once /props answers — and
159// must stop asking as soon as the window is known, or when the config gave
160// it: the hit counter on /props is the measure.
161func TestEnsureContextWindowReprobes(t *testing.T) {
162 var propsHits int
163 up := false // false = llama-server not ready yet: /props answers 503
164 mux := http.NewServeMux()
165 mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) {
166 propsHits++
167 if !up {
168 w.WriteHeader(http.StatusServiceUnavailable)
169 return
170 }
171 _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`))
172 })
173 mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
174 _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"m","object":"model"}]}`))
175 })
176 srv := httptest.NewServer(mux)
177 defer srv.Close()
178
179 p, _ := Lookup("llamacpp")
180 e := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m"}}
181
182 // Server not ready: still unknown, nothing learned, but the probe was made.
183 if w, src := e.EnsureContextWindow(context.Background()); w != 0 || src != "" || e.ContextWindow != 0 {
184 t.Errorf("before the server is up: got (%d, %q), ContextWindow=%d, want (0, \"\"), 0", w, src, e.ContextWindow)
185 }
186 if propsHits != 1 {
187 t.Fatalf("/props hits = %d after the first re-probe, want 1", propsHits)
188 }
189
190 // Server up: the window is learned, remembered, and its origin is named.
191 up = true
192 if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "/props" || e.ContextWindow != 32768 {
193 t.Errorf("after the server is up: got (%d, %q), ContextWindow=%d, want (32768, \"/props\"), 32768", w, src, e.ContextWindow)
194 }
195 if propsHits != 2 {
196 t.Fatalf("/props hits = %d after the second re-probe, want 2", propsHits)
197 }
198
199 // Known window: answered from memory, no request, no origin to announce.
200 if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "" {
201 t.Errorf("known window: got (%d, %q), want (32768, \"\")", w, src)
202 }
203 if propsHits != 2 {
204 t.Errorf("/props hits = %d after asking with a known window, want still 2", propsHits)
205 }
206
207 // A window from the config is known from the start: never probed at all.
208 propsHits = 0
209 known := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m", ContextWindow: 4096}, ContextWindow: 4096}
210 if w, src := known.EnsureContextWindow(context.Background()); w != 4096 || src != "" || propsHits != 0 {
211 t.Errorf("config window: got (%d, %q) with %d /props hit(s), want (4096, \"\") and 0", w, src, propsHits)
212 }
213}
214
215// TestProbeUnreachableWarns: a server that is down is a warning with the start
216// command, not a failure — the demo often starts the server second.
217func TestProbeUnreachableWarns(t *testing.T) {
218 for _, name := range []string{"dmr", "llamacpp"} {
219 p, _ := Lookup(name)
220 info := p.Probe(context.Background(), Backend{Provider: name, BaseURL: closedURL(t) + "/v1", Model: "m"})
221 if info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "nothing answers") {
222 t.Errorf("%s: Probe on a closed port = %+v", name, info)
223 }
224 }
225}
226
227// TestProbeModelsWarnsOnMissingModel: DMR lists what is pulled; asking for
228// something else fails only once the request is sent, after a long spinner.
229func TestProbeModelsWarnsOnMissingModel(t *testing.T) {
230 srv := openaiServer(t, 200, "{}", "ai/other")
231 p, _ := Lookup("dmr")
232 info := p.Probe(context.Background(), Backend{Provider: "dmr", BaseURL: srv.URL, Model: "ai/qwen2.5-coder"})
233 if !info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "docker model pull ai/qwen2.5-coder") {
234 t.Errorf("Probe = %+v, want the pull hint", info)
235 }
236}
237
238// TestExplainThroughTheStack drives a real Open + Generate against servers that
239// fail on purpose, and checks the ONE line the REPL would print. Going through
240// Genkit matters: it is what wraps the OpenAI client's error, and the mapping
241// has to survive that wrapping.
242func TestExplainThroughTheStack(t *testing.T) {
243 cases := []struct {
244 name string
245 provider string
246 status int
247 body string
248 want string
249 }{
250 {"llama-server without --jinja", "llamacpp", 500,
251 `{"error":{"code":500,"message":"tools param requires --jinja flag","type":"server_error"}}`,
252 "tool calls need llama-server started with --jinja"},
253 {"401", "llamacpp", 401, `{"error":{"message":"Invalid API Key","type":"authentication_error"}}`,
254 "authentication failed"},
255 {"404 on dmr names the pull command", "dmr", 404, `{"error":{"message":"model not found","type":"not_found"}}`,
256 "docker model pull fake-model"},
257 }
258 for _, tc := range cases {
259 t.Run(tc.name, func(t *testing.T) {
260 srv := openaiServer(t, tc.status, tc.body, "fake-model")
261 got := explainAgainst(t, tc.provider, srv.URL)
262 if !strings.Contains(got, tc.want) {
263 t.Errorf("Explain = %q, want it to contain %q", got, tc.want)
264 }
265 })
266 }
267
268 t.Run("connection refused", func(t *testing.T) {
269 got := explainAgainst(t, "llamacpp", closedURL(t))
270 if !strings.Contains(got, "nothing answers") || !strings.Contains(got, "--jinja --port 8080") {
271 t.Errorf("Explain = %q, want the start hint", got)
272 }
273 })
274}
275
276// TestExplain401CitesTheResolvedVariable guards the phantom variable: the first
277// Explain named $AGENT_API_KEY on a 401, a variable nothing read. The message
278// must name the variable Resolve used — the yaml's `apiKeyEnv` when set — and
279// name none when the provider has none, rather than invent one.
280func TestExplain401CitesTheResolvedVariable(t *testing.T) {
281 body := `{"error":{"message":"Invalid API Key","type":"authentication_error"}}`
282
283 t.Run("yaml apiKeyEnv is the name cited", func(t *testing.T) {
284 clearEnv(t)
285 t.Setenv("MY_KEY", "wrong")
286 srv := openaiServer(t, 401, body, "fake-model")
287 p, _ := Lookup("llamacpp")
288 b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr(""), APIKeyEnv: "MY_KEY"})
289 if err != nil {
290 t.Fatal(err)
291 }
292 got := explainWith(t, p, b)
293 if !strings.Contains(got, "$MY_KEY") || strings.Contains(got, "LLAMA_API_KEY") {
294 t.Errorf("Explain = %q, want it to cite $MY_KEY and nothing else", got)
295 }
296 })
297
298 t.Run("dmr has no variable: none is invented", func(t *testing.T) {
299 clearEnv(t)
300 srv := openaiServer(t, 401, body, "fake-model")
301 p, _ := Lookup("dmr")
302 b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr("")})
303 if err != nil {
304 t.Fatal(err)
305 }
306 got := explainWith(t, p, b)
307 if strings.Contains(got, "$") || !strings.Contains(got, "should not need a key") {
308 t.Errorf("Explain = %q, want no $VARIABLE and the check-baseUrl hint", got)
309 }
310 })
311}
312
313// TestResolveKeyPolicy exercises the keyRequired branch with a throwaway
314// provider built directly — not registered, so the global registry stays as the
315// other tests expect it. Neither shipped provider requires a key, so without
316// this the branch a paid API will rely on has never run.
317func TestResolveKeyPolicy(t *testing.T) {
318 clearEnv(t)
319 paid := &openaiCompat{name: "paid", baseURL: "https://paid.example/v1", apiKeyEnv: "PAID_API_KEY", keyRequired: true, dummyKey: "must-not-appear"}
320 free := &openaiCompat{name: "free", baseURL: "http://127.0.0.1:1/v1", apiKeyEnv: "FREE_API_KEY", dummyKey: "not-needed"}
321
322 cases := []struct {
323 name string
324 p *openaiCompat
325 cfg config.Config
326 env map[string]string
327 wantErr string // "" = success
328 wantKey string
329 wantEnv string
330 }{
331 {"required and unset: the error names the variable", paid, config.Config{}, nil,
332 "paid: PAID_API_KEY is not set", "", "PAID_API_KEY"},
333 {"required and set: the value, not the dummy", paid, config.Config{}, map[string]string{"PAID_API_KEY": "sk-live"},
334 "", "sk-live", "PAID_API_KEY"},
335 {"required, yaml apiKeyEnv redirects to another variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, map[string]string{"OTHER_KEY": "sk-other"},
336 "", "sk-other", "OTHER_KEY"},
337 {"required, yaml apiKeyEnv unset: names THAT variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, nil,
338 "paid: OTHER_KEY is not set", "", "OTHER_KEY"},
339 {"not required and unset: the dummy key", free, config.Config{}, nil,
340 "", "not-needed", "FREE_API_KEY"},
341 {"not required and set: the value", free, config.Config{}, map[string]string{"FREE_API_KEY": "abc"},
342 "", "abc", "FREE_API_KEY"},
343 }
344 for _, tc := range cases {
345 t.Run(tc.name, func(t *testing.T) {
346 for _, k := range []string{"PAID_API_KEY", "FREE_API_KEY", "OTHER_KEY"} {
347 t.Setenv(k, "")
348 }
349 for k, v := range tc.env {
350 t.Setenv(k, v)
351 }
352 tc.cfg.Model = "m"
353 tc.cfg.Fallback = ptr("") // no probe: the URLs here do not exist
354 b, err := tc.p.Resolve(tc.cfg)
355 switch {
356 case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)):
357 t.Fatalf("Resolve error = %v, want it to contain %q", err, tc.wantErr)
358 case tc.wantErr == "" && err != nil:
359 t.Fatalf("Resolve: %v", err)
360 }
361 if b.APIKey != tc.wantKey {
362 t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey)
363 }
364 if b.APIKeyEnv != tc.wantEnv {
365 t.Errorf("APIKeyEnv = %q, want %q", b.APIKeyEnv, tc.wantEnv)
366 }
367 })
368 }
369}
370
371// explainAgainst opens the provider on baseURL, asks one question and returns
372// the explained error. The spinner and the retry print to stdout: harmless.
373func explainAgainst(t *testing.T, provider, baseURL string) string {
374 t.Helper()
375 p, _ := Lookup(provider)
376 return explainWith(t, p, Backend{Provider: provider, BaseURL: baseURL, Model: "fake-model", APIKey: "not-needed"})
377}
378
379// explainWith is explainAgainst for a Backend built by Resolve — needed when
380// the message under test depends on what Resolve recorded (APIKeyEnv).
381func explainWith(t *testing.T, p Provider, b Backend) string {
382 t.Helper()
383 g, model, err := p.Open(context.Background(), b)
384 if err != nil {
385 t.Fatalf("Open: %v", err)
386 }
387 e := &Engine{G: g, Model: model, Provider: p, Backend: b}
388 _, _, err = e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil)
389 if err == nil {
390 t.Fatal("Generate succeeded against a failing server")
391 }
392 return e.Explain(err)
393}
394
395// TestConfigCompat: an agent.yaml written for part 07 — no `provider`, an
396// explicit `fallback: ""` — must load with the same meaning; and a llamacpp
397// file with no baseUrl must land on llama-server's port, not DMR's.
398func TestConfigCompat(t *testing.T) {
399 clearEnv(t)
400 saved := config.Cfg
401 defer func() { config.Cfg = saved }()
402
403 dir := t.TempDir()
404 write := func(name, body string) string {
405 path := filepath.Join(dir, name)
406 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
407 t.Fatal(err)
408 }
409 return path
410 }
411
412 old := write("probe.yaml", "model: fake-model\nbaseUrl: http://127.0.0.1:18234/engines/v1\nfallback: \"\"\nmaxOutput: 16000\nmaxTurns: 25\nsystem: |\n Probe.\nsampling:\n temperature: 0.0\n")
413 if _, err := config.Load(old); err != nil {
414 t.Fatalf("Load(07 config): %v", err)
415 }
416 if config.Cfg.Provider != "dmr" {
417 t.Errorf("Provider = %q, want dmr by default", config.Cfg.Provider)
418 }
419 p, _ := Lookup(config.Cfg.Provider)
420 b, err := p.Resolve(config.Cfg)
421 if err != nil {
422 t.Fatal(err)
423 }
424 if b.BaseURL != "http://127.0.0.1:18234/engines/v1" {
425 t.Errorf("BaseURL = %q: an explicit empty fallback must keep the file's URL even when it is down", b.BaseURL)
426 }
427
428 config.Cfg = saved
429 llama := write("llama.yaml", "provider: llamacpp\nmodel: qwen2.5-coder\ncontextWindow: 16384\n")
430 if _, err := config.Load(llama); err != nil {
431 t.Fatalf("Load(llamacpp config): %v", err)
432 }
433 p, _ = Lookup(config.Cfg.Provider)
434 b, err = p.Resolve(config.Cfg)
435 if err != nil {
436 t.Fatal(err)
437 }
438 if b.BaseURL != "http://127.0.0.1:8080/v1" || b.ContextWindow != 16384 {
439 t.Errorf("Backend = %+v, want llama-server's default URL and the 16384 hint", b)
440 }
441
442 config.Cfg = saved
443 bad := write("bad.yaml", "provider: ollama\nmodel: m\n")
444 if _, err := config.Load(bad); err != nil {
445 t.Fatalf("Load must accept an unknown provider (engine.New refuses it): %v", err)
446 }
447 if _, err := Lookup(config.Cfg.Provider); err == nil {
448 t.Error("Lookup(ollama) must fail")
449 }
450}