| 💾 Saved. d722711 k33g 4h ago | 1 | // 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. |
| 7 | package engine |
| 8 | |
| 9 | import ( |
| 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. |
| 27 | func 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. |
| 54 | func 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 | |
| 62 | func 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. |
| 66 | func 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 | |
| 73 | func 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. |
| 83 | func 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. |
| 130 | func 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. |
| 161 | func 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. |
| 217 | func 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. |
| 229 | func 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. |
| 242 | func 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. |
| 280 | func 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. |
| 317 | func 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. |
| 373 | func 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). |
| 381 | func 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. |
| 398 | func 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 | } |