From d5fe7548c5c2f3cc799e4fde23da363f0bdbf6b9 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 08:30:17 -0400 Subject: [PATCH 1/2] Ollama native adapter internal/ai/llm/ollama implements llm.Provider over plain net/http: POST {base}/api/chat with "stream": false, the request schema in "format", and options.temperature / options.num_predict only when the request sets them; GET {base}/api/tags for ListModels and Verify. No Authorization header is sent and redirects are not followed. Usage comes from prompt_eval_count/eval_count. A schema reply that is not a JSON object is ErrBadOutput. HTTP 404 on /api/chat is an ErrProvider naming the configured model, without quoting the body. Refused connections and timeouts match ErrTimeout and say "Ollama not reachable at {base}"; a cancelled context stays context.Canceled. The base URL is required, absolute http or https (LAN hosts allowed), and may not carry userinfo, a query or a fragment. Contract tests replay sanitized fixtures adapted from Ollama's public API docs against httptest servers: success, schema output, model missing and unreachable host. Maestro task: 51289131-5abf-48c3-b553-7e3809c09076 Co-Authored-By: Claude Opus 5.5 --- internal/ai/llm/ollama/ollama.go | 310 ++++++++++ internal/ai/llm/ollama/ollama_test.go | 572 ++++++++++++++++++ internal/ai/llm/ollama/testdata/README.md | 36 ++ .../ai/llm/ollama/testdata/chat_schema.json | 16 + .../ai/llm/ollama/testdata/chat_text.json | 16 + .../testdata/error_model_not_found.json | 1 + internal/ai/llm/ollama/testdata/tags.json | 34 ++ 7 files changed, 985 insertions(+) create mode 100644 internal/ai/llm/ollama/ollama.go create mode 100644 internal/ai/llm/ollama/ollama_test.go create mode 100644 internal/ai/llm/ollama/testdata/README.md create mode 100644 internal/ai/llm/ollama/testdata/chat_schema.json create mode 100644 internal/ai/llm/ollama/testdata/chat_text.json create mode 100644 internal/ai/llm/ollama/testdata/error_model_not_found.json create mode 100644 internal/ai/llm/ollama/testdata/tags.json diff --git a/internal/ai/llm/ollama/ollama.go b/internal/ai/llm/ollama/ollama.go new file mode 100644 index 0000000..f4816b6 --- /dev/null +++ b/internal/ai/llm/ollama/ollama.go @@ -0,0 +1,310 @@ +// Package ollama is the llm.Provider adapter for Ollama's native API. It +// calls POST {base}/api/chat and GET {base}/api/tags over plain net/http. +// +// Ollama takes no credentials: the adapter sends no Authorization header +// and never logs. The base URL may be a LAN address, and errors for an +// unreachable server name it, so New refuses a base URL that could carry a +// credential. +package ollama + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// Config configures a Provider. +type Config struct { + // BaseURL is the Ollama server root the /api endpoint paths are + // appended to, e.g. "http://192.168.1.20:11434" or + // "http://ollama:11434". It is required. + BaseURL string + // Model is the model Generate requests, e.g. "llama3.2". ListModels + // and Verify do not need it. + Model string +} + +// Provider is an Ollama native API llm.Provider. +type Provider struct { + base string + model string + client *http.Client +} + +var _ llm.Provider = (*Provider)(nil) + +// New validates cfg and returns a Provider. +func New(cfg Config) (*Provider, error) { + // Base URL errors never echo the URL: a rejected one may hold a + // credential. + u, err := url.Parse(cfg.BaseURL) + if err != nil { + return nil, errors.New("ollama: base URL does not parse") + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, errors.New("ollama: base URL must be an absolute http or https URL") + } + // A trailing "?" or "#" parses to an empty query or fragment, yet the + // endpoint path appended to it would land in the query or fragment. + if u.User != nil || strings.ContainsAny(cfg.BaseURL, "?#") { + return nil, errors.New("ollama: base URL must not carry credentials, a query or a fragment") + } + return &Provider{ + base: strings.TrimRight(cfg.BaseURL, "/"), + model: cfg.Model, + client: noRedirectClient(), + }, nil +} + +// maxResponseBody caps a successful response body; a larger one is an error. +const maxResponseBody = 16 << 20 + +// maxErrorBody caps how much of an error response body is read; only an +// llm.MaxBodyExcerpt of it is kept. +const maxErrorBody = 64 << 10 + +type message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type options struct { + Temperature *float64 `json:"temperature,omitempty"` + NumPredict int `json:"num_predict,omitempty"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []message `json:"messages"` + Stream bool `json:"stream"` + Format json.RawMessage `json:"format,omitempty"` + Options *options `json:"options,omitempty"` +} + +type chatResponse struct { + Model string `json:"model"` + Message *struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + Done bool `json:"done"` + DoneReason string `json:"done_reason"` + PromptEvalCount int `json:"prompt_eval_count"` + EvalCount int `json:"eval_count"` +} + +// Generate sends req to {base}/api/chat without streaming. A Request with a +// Schema sends it as "format", so Ollama constrains the reply to it; +// Response.JSON is then the reply text, which must be a JSON object. +func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, error) { + if err := req.Validate(); err != nil { + return llm.Response{}, fmt.Errorf("ollama: generate: %w", err) + } + if p.model == "" { + return llm.Response{}, errors.New("ollama: generate: no model configured") + } + body := chatRequest{Model: p.model, Stream: false} + if req.System != "" { + body.Messages = append(body.Messages, message{Role: "system", Content: req.System}) + } + for _, m := range req.Messages { + body.Messages = append(body.Messages, message{Role: string(m.Role), Content: m.Content}) + } + if req.Schema != nil { + body.Format = req.Schema.Schema + } + if req.Temperature != nil || req.MaxTokens > 0 { + body.Options = &options{Temperature: req.Temperature, NumPredict: req.MaxTokens} + } + payload, err := json.Marshal(body) + if err != nil { + return llm.Response{}, fmt.Errorf("ollama: generate: encode request: %w", err) + } + + if req.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, req.Timeout) + defer cancel() + } + raw, err := p.do(ctx, http.MethodPost, "/api/chat", payload) + if err != nil { + var pe *llm.ProviderError + if errors.As(err, &pe) && pe.Status() == http.StatusNotFound { + // Ollama answers 404 when the model is not pulled. The body is + // not quoted: the model name is all the caller needs. + return llm.Response{}, fmt.Errorf("ollama: generate: model %q not found: %w", p.model, llm.NewProviderError(pe.Status(), nil, "")) + } + return llm.Response{}, fmt.Errorf("ollama: generate: %w", err) + } + + var cr chatResponse + if err := json.Unmarshal(raw, &cr); err != nil { + return llm.Response{}, fmt.Errorf("ollama: generate: decode response: %w", err) + } + if !cr.Done || cr.Message == nil { + return llm.Response{}, errors.New("ollama: generate: response is not a finished message") + } + resp := llm.Response{ + Text: cr.Message.Content, + Usage: llm.Usage{InputTokens: cr.PromptEvalCount, OutputTokens: cr.EvalCount}, + Model: cr.Model, + FinishReason: cr.DoneReason, + } + if req.Schema == nil { + return resp, nil + } + if text := strings.TrimSpace(resp.Text); isJSONObject(text) { + resp.JSON = json.RawMessage(text) + return resp, nil + } + return resp, fmt.Errorf("ollama: generate: reply is not a JSON object: %w", llm.ErrBadOutput) +} + +// isJSONObject reports whether s is exactly one JSON object. +func isJSONObject(s string) bool { + return strings.HasPrefix(s, "{") && json.Valid([]byte(s)) +} + +type tagsResponse struct { + Models []struct { + Name string `json:"name"` + } `json:"models"` +} + +// ListModels returns every model {base}/api/tags lists. +func (p *Provider) ListModels(ctx context.Context) ([]llm.Model, error) { + models, err := p.tags(ctx) + if err != nil { + return nil, fmt.Errorf("ollama: list models: %w", err) + } + return models, nil +} + +// Verify checks that the server answers GET {base}/api/tags with a model +// list. +func (p *Provider) Verify(ctx context.Context) error { + if _, err := p.tags(ctx); err != nil { + return fmt.Errorf("ollama: verify: %w", err) + } + return nil +} + +func (p *Provider) tags(ctx context.Context) ([]llm.Model, error) { + raw, err := p.do(ctx, http.MethodGet, "/api/tags", nil) + if err != nil { + return nil, err + } + var tr tagsResponse + if err := json.Unmarshal(raw, &tr); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if tr.Models == nil { + return nil, errors.New("response has no models list") + } + models := make([]llm.Model, 0, len(tr.Models)) + for i, m := range tr.Models { + if m.Name == "" { + return nil, fmt.Errorf("model %d has no name", i) + } + models = append(models, llm.Model{ID: m.Name, DisplayName: m.Name}) + } + return models, nil +} + +// do sends one request and returns the body of a 2xx response. Any other +// status, a 3xx included, is an *llm.ProviderError. +func (p *Provider) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) { + var reqBody io.Reader + if payload != nil { + reqBody = bytes.NewReader(payload) + } + httpReq, err := http.NewRequestWithContext(ctx, method, p.base+path, reqBody) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + if payload != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + + httpResp, err := p.client.Do(httpReq) + if err != nil { + return nil, p.transportError(err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 { + body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxErrorBody)) + if err != nil { + return nil, p.transportError(err) + } + return nil, llm.NewProviderError(httpResp.StatusCode, body, "") + } + body, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBody+1)) + if err != nil { + return nil, p.transportError(err) + } + if len(body) > maxResponseBody { + return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBody) + } + return body, nil +} + +// noRedirectClient returns an HTTP client that never follows a redirect: +// the 3xx response is returned and becomes a provider error, so a request +// is only ever sent to the configured server. +func noRedirectClient() *http.Client { + return &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func isTimeout(err error) bool { + var ne net.Error + return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout()) +} + +// transportFailure is a failed HTTP exchange. Its text names the base URL +// and the cause; it unwraps only to the cause's category, llm.ErrTimeout or +// context.Canceled. +type transportFailure struct { + text string + kind error +} + +func (e *transportFailure) Error() string { return e.text } + +func (e *transportFailure) Unwrap() error { return e.kind } + +// transportError reports a failure to complete the HTTP exchange. A +// cancelled context is the caller's choice and stays context.Canceled. +// Every other failure, a refused connection or a timeout alike, means the +// server could not be reached in time: it matches llm.ErrTimeout and says +// "Ollama not reachable at {base}". +func (p *Provider) transportError(err error) error { + timedOut := isTimeout(err) + // The *url.Error repeats the request URL; the base is named once below. + var ue *url.Error + if errors.As(err, &ue) { + err = ue.Err + } + if !timedOut && errors.Is(err, context.Canceled) { + return &transportFailure{text: "request canceled: " + err.Error(), kind: context.Canceled} + } + text := fmt.Sprintf("Ollama not reachable at %s: %s", p.base, err) + if timedOut { + text = fmt.Sprintf("Ollama not reachable at %s: %s: %s", p.base, llm.ErrTimeout, err) + } + return &transportFailure{text: text, kind: llm.ErrTimeout} +} diff --git a/internal/ai/llm/ollama/ollama_test.go b/internal/ai/llm/ollama/ollama_test.go new file mode 100644 index 0000000..baedb10 --- /dev/null +++ b/internal/ai/llm/ollama/ollama_test.go @@ -0,0 +1,572 @@ +package ollama + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// recorded is one request the test server received. +type recorded struct { + method string + path string + query url.Values + header http.Header + body []byte +} + +// reply is a response the test server replays. +type reply struct { + status int + fixture string // testdata file + body string // used when fixture is empty + delay time.Duration +} + +// ollamaServer replays its replies in order, repeating the last, and +// records every request. +type ollamaServer struct { + *httptest.Server + mu sync.Mutex + got []recorded +} + +func newOllamaServer(t *testing.T, replies ...reply) *ollamaServer { + t.Helper() + payloads := make([][]byte, len(replies)) + for i, r := range replies { + payloads[i] = []byte(r.body) + if r.fixture != "" { + raw, err := os.ReadFile(filepath.Join("testdata", r.fixture)) + if err != nil { + t.Fatal(err) + } + payloads[i] = raw + } + } + s := &ollamaServer{} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + s.mu.Lock() + n := len(s.got) + s.got = append(s.got, recorded{req.Method, req.URL.Path, req.URL.Query(), req.Header.Clone(), body}) + s.mu.Unlock() + i := min(n, len(replies)-1) + r := replies[i] + if r.delay > 0 { + select { + case <-time.After(r.delay): + case <-req.Context().Done(): + return + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(r.status) + w.Write(payloads[i]) + })) + t.Cleanup(s.Close) + return s +} + +func (s *ollamaServer) requests() []recorded { + s.mu.Lock() + defer s.mu.Unlock() + return append([]recorded(nil), s.got...) +} + +func (s *ollamaServer) only(t *testing.T) recorded { + t.Helper() + got := s.requests() + if len(got) != 1 { + t.Fatalf("server got %d requests, want 1", len(got)) + } + return got[0] +} + +func newProvider(t *testing.T, base string) *Provider { + t.Helper() + p, err := New(Config{BaseURL: base, Model: "llama3.2"}) + if err != nil { + t.Fatal(err) + } + return p +} + +var dinnersSchema = json.RawMessage(`{"type":"object","properties":{"dinners":{"type":"array"}},"required":["dinners"]}`) + +func schemaRequest() llm.Request { + temp := 0.0 + return llm.Request{ + System: "You plan dinners for Our kitchen.", + Messages: []llm.Message{ + {Role: llm.RoleUser, Content: "Suggest a Side quest."}, + {Role: llm.RoleAssistant, Content: "Something quick?"}, + {Role: llm.RoleUser, Content: "Yes, a Quick dinner."}, + }, + Temperature: &temp, + MaxTokens: 400, + Schema: &llm.JSONSchema{Name: "side_quests", Schema: dinnersSchema}, + } +} + +func textRequest() llm.Request { + return llm.Request{Messages: []llm.Message{{Role: llm.RoleUser, Content: "What should we cook tonight?"}}} +} + +// sentBody decodes a request body into a generic map. +func sentBody(t *testing.T, r recorded) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(r.body, &m); err != nil { + t.Fatalf("request body %q: %v", r.body, err) + } + return m +} + +// canonical re-encodes JSON so two encodings of one value compare equal. +func canonical(t *testing.T, raw []byte) string { + t.Helper() + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("JSON %q: %v", raw, err) + } + out, _ := json.Marshal(v) + return string(out) +} + +func assertNoAuth(t *testing.T, r recorded) { + t.Helper() + if got := r.header.Values("Authorization"); len(got) != 0 { + t.Errorf("Authorization = %q, want none", got) + } +} + +func TestGenerateText(t *testing.T) { + s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"}) + p := newProvider(t, s.URL+"/") + + resp, err := p.Generate(context.Background(), textRequest()) + if err != nil { + t.Fatal(err) + } + if resp.Text != "Try a crock pot chili: it is a Quick dinner." || resp.JSON != nil { + t.Errorf("Text, JSON = %q, %s", resp.Text, resp.JSON) + } + if resp.Usage != (llm.Usage{InputTokens: 26, OutputTokens: 298}) || resp.Model != "llama3.2" || resp.FinishReason != "stop" { + t.Errorf("Usage, Model, FinishReason = %+v, %q, %q", resp.Usage, resp.Model, resp.FinishReason) + } + + got := s.only(t) + if got.method != http.MethodPost || got.path != "/api/chat" || len(got.query) != 0 { + t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode()) + } + assertNoAuth(t, got) + body := sentBody(t, got) + if stream, ok := body["stream"]; !ok || stream != false { + t.Errorf("stream = %v (present %v), want false", stream, ok) + } + if body["model"] != "llama3.2" { + t.Errorf("model = %v", body["model"]) + } + for _, field := range []string{"format", "options"} { + if v, ok := body[field]; ok { + t.Errorf("%s = %v, want it omitted", field, v) + } + } + if got, want := canonical(t, mustMarshal(t, body["messages"])), `[{"content":"What should we cook tonight?","role":"user"}]`; got != want { + t.Errorf("messages = %s, want %s", got, want) + } +} + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestGenerateSchemaOutput(t *testing.T) { + s := newOllamaServer(t, reply{status: 200, fixture: "chat_schema.json"}) + p := newProvider(t, s.URL) + + resp, err := p.Generate(context.Background(), schemaRequest()) + if err != nil { + t.Fatal(err) + } + want := `{"dinners":[{"name":"Sheet-pan gnocchi","why":"Like your Regular tray bake, but quicker."}]}` + if got := canonical(t, resp.JSON); got != want { + t.Errorf("Response.JSON = %s, want %s", got, want) + } + if canonical(t, []byte(resp.Text)) != want { + t.Errorf("Text = %q, want the reply content", resp.Text) + } + if resp.Usage != (llm.Usage{InputTokens: 147, OutputTokens: 41}) || resp.FinishReason != "stop" { + t.Errorf("Usage, FinishReason = %+v, %q", resp.Usage, resp.FinishReason) + } + + got := s.only(t) + assertNoAuth(t, got) + body := sentBody(t, got) + if body["stream"] != false { + t.Errorf("stream = %v, want false", body["stream"]) + } + if got, want := canonical(t, mustMarshal(t, body["format"])), canonical(t, dinnersSchema); got != want { + t.Errorf("format = %s, want the schema %s", got, want) + } + opts, _ := body["options"].(map[string]any) + if len(opts) != 2 || opts["temperature"] != 0.0 || opts["num_predict"] != 400.0 { + t.Errorf("options = %v, want temperature 0 and num_predict 400", body["options"]) + } + wantMessages := `[{"content":"You plan dinners for Our kitchen.","role":"system"},` + + `{"content":"Suggest a Side quest.","role":"user"},` + + `{"content":"Something quick?","role":"assistant"},` + + `{"content":"Yes, a Quick dinner.","role":"user"}]` + if got := canonical(t, mustMarshal(t, body["messages"])); got != wantMessages { + t.Errorf("messages = %s, want %s", got, wantMessages) + } +} + +func TestGenerateTemperatureOnlyWhenSet(t *testing.T) { + s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"}) + req := textRequest() + temp := 0.7 + req.Temperature = &temp + if _, err := newProvider(t, s.URL).Generate(context.Background(), req); err != nil { + t.Fatal(err) + } + opts, _ := sentBody(t, s.only(t))["options"].(map[string]any) + if len(opts) != 1 || opts["temperature"] != 0.7 { + t.Errorf("options = %v, want only temperature 0.7", opts) + } +} + +func TestGenerateBadOutput(t *testing.T) { + cases := map[string]string{ + "prose": `{"model":"m","message":{"role":"assistant","content":"Sure! Try chili."},"done":true,"done_reason":"stop"}`, + "array": `{"model":"m","message":{"role":"assistant","content":"[1,2]"},"done":true,"done_reason":"stop"}`, + "cut-off JSON": `{"model":"m","message":{"role":"assistant","content":"{\"dinners\": ["},"done":true,"done_reason":"length"}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + s := newOllamaServer(t, reply{status: 200, body: body}) + resp, err := newProvider(t, s.URL).Generate(context.Background(), schemaRequest()) + if !errors.Is(err, llm.ErrBadOutput) { + t.Fatalf("err = %v, want ErrBadOutput", err) + } + if resp.JSON != nil || resp.Model != "m" || resp.Text == "" { + t.Fatalf("Response = %+v, want no JSON and the reply kept", resp) + } + }) + } +} + +func TestGenerateRejectsMalformedResponses(t *testing.T) { + for name, body := range map[string]string{ + "not JSON": `not json`, + "not finished": `{"model":"m","message":{"role":"assistant","content":"hi"},"done":false}`, + "no message": `{"model":"m","done":true}`, + } { + s := newOllamaServer(t, reply{status: 200, body: body}) + _, err := newProvider(t, s.URL).Generate(context.Background(), textRequest()) + if err == nil || errors.Is(err, llm.ErrBadOutput) || errors.Is(err, llm.ErrProvider) { + t.Errorf("%s: err = %v, want an untyped malformed-response error", name, err) + } + } +} + +func TestGenerateRejectsInvalidRequests(t *testing.T) { + s := newOllamaServer(t, reply{status: 200, fixture: "chat_text.json"}) + if _, err := newProvider(t, s.URL).Generate(context.Background(), llm.Request{}); err == nil { + t.Error("Generate accepted a request with no messages") + } + noModel, err := New(Config{BaseURL: s.URL}) + if err != nil { + t.Fatal(err) + } + if _, err := noModel.Generate(context.Background(), textRequest()); err == nil { + t.Error("Generate ran with no model configured") + } + if n := len(s.requests()); n != 0 { + t.Errorf("server got %d requests, want 0", n) + } +} + +func TestGenerateModelMissing(t *testing.T) { + s := newOllamaServer(t, reply{status: 404, fixture: "error_model_not_found.json"}) + p, err := New(Config{BaseURL: s.URL, Model: "llama9"}) + if err != nil { + t.Fatal(err) + } + _, err = p.Generate(context.Background(), textRequest()) + var pe *llm.ProviderError + if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusNotFound { + t.Fatalf("err = %v, want a ProviderError for HTTP 404", err) + } + if errors.Is(err, llm.ErrTimeout) || errors.Is(err, llm.ErrBadOutput) { + t.Fatalf("err = %v matches an unrelated sentinel", err) + } + if want := `ollama: generate: model "llama9" not found: llm: provider error: HTTP 404`; err.Error() != want { + t.Fatalf("Error() = %q, want %q", err, want) + } + if pe.Body() != "" { + t.Fatalf("Body() = %q, want the response body left out", pe.Body()) + } +} + +func TestListModels(t *testing.T) { + s := newOllamaServer(t, reply{status: 200, fixture: "tags.json"}) + models, err := newProvider(t, s.URL).ListModels(context.Background()) + if err != nil { + t.Fatal(err) + } + want := []llm.Model{{ID: "llama3.2:latest", DisplayName: "llama3.2:latest"}, {ID: "qwen3:8b", DisplayName: "qwen3:8b"}} + if len(models) != len(want) || models[0] != want[0] || models[1] != want[1] { + t.Fatalf("ListModels() = %+v, want %+v", models, want) + } + got := s.only(t) + if got.method != http.MethodGet || got.path != "/api/tags" || len(got.query) != 0 { + t.Errorf("request = %s %s?%s", got.method, got.path, got.query.Encode()) + } + assertNoAuth(t, got) + + empty := newOllamaServer(t, reply{status: 200, body: `{"models":[]}`}) + if models, err := newProvider(t, empty.URL).ListModels(context.Background()); err != nil || models == nil || len(models) != 0 { + t.Fatalf("empty list: %v, %v", models, err) + } + for name, body := range map[string]string{ + "no models list": `{}`, + "unnamed model": `{"models":[{"model":"x"}]}`, + "not JSON": ``, + } { + bad := newOllamaServer(t, reply{status: 200, body: body}) + if _, err := newProvider(t, bad.URL).ListModels(context.Background()); err == nil { + t.Errorf("%s: ListModels accepted it", name) + } + } +} + +func TestVerify(t *testing.T) { + ok := newOllamaServer(t, reply{status: 200, fixture: "tags.json"}) + if err := newProvider(t, ok.URL).Verify(context.Background()); err != nil { + t.Fatalf("Verify() = %v", err) + } + got := ok.only(t) + if got.method != http.MethodGet || got.path != "/api/tags" { + t.Errorf("Verify called %s %s", got.method, got.path) + } + assertNoAuth(t, got) + + bad := newOllamaServer(t, reply{status: 500, body: `{"error":"something went wrong"}`}) + err := newProvider(t, bad.URL).Verify(context.Background()) + var pe *llm.ProviderError + if !errors.As(err, &pe) || pe.Status() != 500 || !strings.Contains(err.Error(), "something went wrong") { + t.Fatalf("Verify() = %v, want a ProviderError for HTTP 500", err) + } + malformed := newOllamaServer(t, reply{status: 200, body: `{}`}) + if err := newProvider(t, malformed.URL).Verify(context.Background()); err == nil { + t.Error("Verify accepted a body with no models list") + } +} + +func TestProviderErrors(t *testing.T) { + cases := map[string]reply{ + "400": {status: 400, body: `{"error":"invalid format"}`}, + "500": {status: 500, body: `{"error":"llama runner process has terminated"}`}, + "503": {status: 503, body: `{"error":"server busy, please try again"}`}, + "huge": {status: 500, body: strings.Repeat("x", 1<<20)}, + } + for name, r := range cases { + t.Run(name, func(t *testing.T) { + s := newOllamaServer(t, r) + p := newProvider(t, s.URL) + _, genErr := p.Generate(context.Background(), schemaRequest()) + _, listErr := p.ListModels(context.Background()) + for _, err := range []error{genErr, listErr} { + var pe *llm.ProviderError + if !errors.As(err, &pe) || pe.Status() != r.status { + t.Fatalf("err = %v, want a ProviderError for HTTP %d", err, r.status) + } + if errors.Is(err, llm.ErrTimeout) || strings.Contains(err.Error(), "not found") { + t.Fatalf("err = %v, want only a provider error", err) + } + if len(err.Error()) > llm.MaxBodyExcerpt+200 { + t.Fatalf("error text has %d bytes, want a bounded excerpt", len(err.Error())) + } + } + }) + } +} + +// assertUnreachable checks the error a request to an unreachable base +// returns: it matches llm.ErrTimeout and names the base. +func assertUnreachable(t *testing.T, err error, base string) { + t.Helper() + if !errors.Is(err, llm.ErrTimeout) { + t.Fatalf("err = %v, want ErrTimeout", err) + } + if errors.Is(err, llm.ErrProvider) || errors.Is(err, context.Canceled) { + t.Fatalf("err = %v matches an unrelated sentinel", err) + } + if want := "Ollama not reachable at " + base; !strings.Contains(err.Error(), want) { + t.Fatalf("Error() = %q, want it to contain %q", err, want) + } + var ue *url.Error + if errors.As(err, &ue) { + t.Fatalf("error chain exposes the url.Error: %v", ue) + } +} + +func TestUnreachableHost(t *testing.T) { + closed := httptest.NewServer(http.NotFoundHandler()) + base := closed.URL + closed.Close() + + p := newProvider(t, base+"/") + _, genErr := p.Generate(context.Background(), schemaRequest()) + _, listErr := p.ListModels(context.Background()) + verifyErr := p.Verify(context.Background()) + for _, err := range []error{genErr, listErr, verifyErr} { + assertUnreachable(t, err, base) + if !strings.Contains(err.Error(), "connection refused") { + t.Errorf("Error() = %q, want the cause", err) + } + } + + dropped := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + conn.Close() + })) + t.Cleanup(dropped.Close) + _, err := newProvider(t, dropped.URL).Generate(context.Background(), textRequest()) + assertUnreachable(t, err, dropped.URL) +} + +func TestTimeouts(t *testing.T) { + s := newOllamaServer(t, reply{status: 200, fixture: "chat_schema.json", delay: 5 * time.Second}) + p := newProvider(t, s.URL) + + req := schemaRequest() + req.Timeout = 50 * time.Millisecond + start := time.Now() + _, err := p.Generate(context.Background(), req) + assertUnreachable(t, err, s.URL) + if !strings.Contains(err.Error(), llm.ErrTimeout.Error()) { + t.Errorf("Error() = %q, want it to say the request timed out", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("Request.Timeout not honoured: took %v", elapsed) + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, err = p.ListModels(ctx) + assertUnreachable(t, err, s.URL) + ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + assertUnreachable(t, p.Verify(ctx), s.URL) + + ctx, cancel = context.WithCancel(context.Background()) + cancel() + _, err = p.Generate(ctx, schemaRequest()) + if !errors.Is(err, context.Canceled) || errors.Is(err, llm.ErrTimeout) || strings.Contains(err.Error(), "not reachable") { + t.Fatalf("cancelled context: err = %v, want context.Canceled only", err) + } +} + +func TestRedirectIsNotFollowed(t *testing.T) { + var mu sync.Mutex + var secondCalls int + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + secondCalls++ + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(second.Close) + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, second.URL+req.URL.Path, http.StatusFound) + })) + t.Cleanup(first.Close) + p := newProvider(t, first.URL) + + _, genErr := p.Generate(context.Background(), textRequest()) + verifyErr := p.Verify(context.Background()) + for _, err := range []error{genErr, verifyErr} { + var pe *llm.ProviderError + if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusFound { + t.Fatalf("err = %v, want a ProviderError for HTTP 302", err) + } + } + mu.Lock() + defer mu.Unlock() + if secondCalls != 0 { + t.Fatalf("redirect target got %d requests, want 0", secondCalls) + } +} + +func TestNewRejectsBadConfig(t *testing.T) { + const secret = "hunter2-secret" + cases := map[string]string{ + "empty base": "", + "relative base": "ollama:11434", + "host only": "192.168.1.20", + "ftp base": "ftp://192.168.1.20:11434", + "no host": "http://", + "credentials in URL": "http://user:" + secret + "@192.168.1.20:11434", + "user in URL": "http://" + secret + "@ollama:11434", + "secret in query": "http://ollama:11434/?token=" + secret, + "secret in fragment": "http://ollama:11434/#" + secret, + "unparseable": "http://ollama:11434/%zz?token=" + secret, + "empty query": "http://ollama:11434?", + "empty fragment": "http://ollama:11434#", + "empty both": "http://ollama:11434/?#", + } + for name, base := range cases { + _, err := New(Config{BaseURL: base, Model: "llama3.2"}) + if err == nil { + t.Errorf("%s: New() = nil error", name) + continue + } + if strings.Contains(err.Error(), secret) { + t.Errorf("%s: error echoes the URL: %v", name, err) + } + } + for _, base := range []string{ + "http://192.168.1.20:11434", + "http://ollama:11434", + "http://localhost:11434/", + "https://ollama.lan/proxy/", + } { + if _, err := New(Config{BaseURL: base, Model: "llama3.2"}); err != nil { + t.Errorf("New(%q) = %v", base, err) + } + } +} + +func TestBaseWithPath(t *testing.T) { + s := newOllamaServer(t, reply{status: 200, fixture: "tags.json"}) + if err := newProvider(t, s.URL+"/proxy/").Verify(context.Background()); err != nil { + t.Fatal(err) + } + if got := s.only(t).path; got != "/proxy/api/tags" { + t.Fatalf("path = %s, want /proxy/api/tags", got) + } +} diff --git a/internal/ai/llm/ollama/testdata/README.md b/internal/ai/llm/ollama/testdata/README.md new file mode 100644 index 0000000..6b7e194 --- /dev/null +++ b/internal/ai/llm/ollama/testdata/README.md @@ -0,0 +1,36 @@ +# Ollama adapter fixtures + +These are the native Ollama API response bodies the contract tests in +`ollama_test.go` replay. They are **not live captures**. Product AI is tested +with fakes and live vendor calls are opt-in, so no request was sent to an +Ollama server to make them. Each fixture is a sanitized adaptation of the +response shapes in Ollama's public API documentation +(): + +- Chat, non-streaming: `POST /api/chat` with `"stream": false`. +- Structured outputs: `POST /api/chat` with a JSON schema in `"format"`. +- List local models: `GET /api/tags`. +- Errors: a JSON `{"error": "..."}` body with a non-2xx status; a model + that is not pulled is HTTP 404. + +Sanitizing: + +- Timestamps, durations, sizes, digests and token counts are illustrative + values in the documented format, not values from a real server. +- Message content is replaced with MealQuest dinner text. +- Ollama takes no credentials, so none appear. + +Below, *not in the public example* marks values that were added for a test +case and do not come from the documented example. + +| Fixture | Case | Shape | Not in the public example | +| --- | --- | --- | --- | +| `chat_text.json` | Success | Finished chat message, `done_reason: stop`, `prompt_eval_count` and `eval_count`. | — | +| `chat_schema.json` | Schema output | Finished chat message whose content is a JSON object string. | The dinner object. | +| `error_model_not_found.json` | Model missing | `{"error": "model \"…\" not found, try pulling it first"}`, served with HTTP 404. | — | +| `tags.json` | Model list | `{"models": [...]}` with `name`, `model`, `digest` and `details`. | — | + +The unreachable-host case has no fixture: there is no response to record. +The tests close an `httptest` server and dial its address, so the +connection is refused, and they hold a reply past a short deadline for the +timeout. diff --git a/internal/ai/llm/ollama/testdata/chat_schema.json b/internal/ai/llm/ollama/testdata/chat_schema.json new file mode 100644 index 0000000..6fd8173 --- /dev/null +++ b/internal/ai/llm/ollama/testdata/chat_schema.json @@ -0,0 +1,16 @@ +{ + "model": "llama3.2", + "created_at": "2026-10-04T12:20:11.009132Z", + "message": { + "role": "assistant", + "content": "{\"dinners\": [{\"name\": \"Sheet-pan gnocchi\", \"why\": \"Like your Regular tray bake, but quicker.\"}]}" + }, + "done_reason": "stop", + "done": true, + "total_duration": 1416823291, + "load_duration": 12309083, + "prompt_eval_count": 147, + "prompt_eval_duration": 271802000, + "eval_count": 41, + "eval_duration": 1130948000 +} diff --git a/internal/ai/llm/ollama/testdata/chat_text.json b/internal/ai/llm/ollama/testdata/chat_text.json new file mode 100644 index 0000000..cbb988c --- /dev/null +++ b/internal/ai/llm/ollama/testdata/chat_text.json @@ -0,0 +1,16 @@ +{ + "model": "llama3.2", + "created_at": "2026-10-04T12:13:43.416799Z", + "message": { + "role": "assistant", + "content": "Try a crock pot chili: it is a Quick dinner." + }, + "done": true, + "done_reason": "stop", + "total_duration": 5191566416, + "load_duration": 2154458, + "prompt_eval_count": 26, + "prompt_eval_duration": 383809000, + "eval_count": 298, + "eval_duration": 4799921000 +} diff --git a/internal/ai/llm/ollama/testdata/error_model_not_found.json b/internal/ai/llm/ollama/testdata/error_model_not_found.json new file mode 100644 index 0000000..9b7a757 --- /dev/null +++ b/internal/ai/llm/ollama/testdata/error_model_not_found.json @@ -0,0 +1 @@ +{"error":"model \"llama9\" not found, try pulling it first"} diff --git a/internal/ai/llm/ollama/testdata/tags.json b/internal/ai/llm/ollama/testdata/tags.json new file mode 100644 index 0000000..d271139 --- /dev/null +++ b/internal/ai/llm/ollama/testdata/tags.json @@ -0,0 +1,34 @@ +{ + "models": [ + { + "name": "llama3.2:latest", + "model": "llama3.2:latest", + "modified_at": "2026-09-30T08:06:48.639712648-07:00", + "size": 2019393189, + "digest": "a80c4f17acd55265feec403c7aef86be0c25983ab279d83f3bcd3abbcb5b8b72", + "details": { + "parent_model": "", + "format": "gguf", + "family": "llama", + "families": ["llama"], + "parameter_size": "3.2B", + "quantization_level": "Q4_K_M" + } + }, + { + "name": "qwen3:8b", + "model": "qwen3:8b", + "modified_at": "2026-09-28T19:41:02.112408113-07:00", + "size": 5225388164, + "digest": "500a1f067a9f782620b40bee6f7b0c89e17ae61f686b92c24933e4ca4b2b8b41", + "details": { + "parent_model": "", + "format": "gguf", + "family": "qwen3", + "families": ["qwen3"], + "parameter_size": "8.2B", + "quantization_level": "Q4_K_M" + } + } + ] +} -- 2.55.0 From 1d4c0bd0e10b59c58af0155dcd2d4e21c43c8b16 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 08:37:22 -0400 Subject: [PATCH 2/2] Ollama: only treat a 404 as model-not-found when the body says so A 404 from a proxy without /api/chat, or any other 404 error, was reported as the configured model being missing. Generate now checks the {"error": ...} payload for Ollama's model-not-found message naming the configured model (double- or single-quoted); any other 404 stays a ProviderError with its redacted body excerpt. Co-Authored-By: Claude Opus 5.5 --- internal/ai/llm/ollama/ollama.go | 21 +++++++++++-- internal/ai/llm/ollama/ollama_test.go | 43 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/internal/ai/llm/ollama/ollama.go b/internal/ai/llm/ollama/ollama.go index f4816b6..e284a13 100644 --- a/internal/ai/llm/ollama/ollama.go +++ b/internal/ai/llm/ollama/ollama.go @@ -138,9 +138,9 @@ func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, raw, err := p.do(ctx, http.MethodPost, "/api/chat", payload) if err != nil { var pe *llm.ProviderError - if errors.As(err, &pe) && pe.Status() == http.StatusNotFound { - // Ollama answers 404 when the model is not pulled. The body is - // not quoted: the model name is all the caller needs. + if errors.As(err, &pe) && pe.Status() == http.StatusNotFound && p.modelNotFound(pe.Body()) { + // The body is not quoted: the model name is all the caller + // needs. Any other 404 keeps its body excerpt below. return llm.Response{}, fmt.Errorf("ollama: generate: model %q not found: %w", p.model, llm.NewProviderError(pe.Status(), nil, "")) } return llm.Response{}, fmt.Errorf("ollama: generate: %w", err) @@ -169,6 +169,21 @@ func (p *Provider) Generate(ctx context.Context, req llm.Request) (llm.Response, return resp, fmt.Errorf("ollama: generate: reply is not a JSON object: %w", llm.ErrBadOutput) } +// modelNotFound reports whether body is Ollama's error for the configured +// model not being pulled: {"error": "model \"name\" not found, ..."}, or +// with the name in single quotes as newer servers send it. A 404 from +// anything else, such as a proxy without /api/chat, is not. +func (p *Provider) modelNotFound(body string) bool { + var e struct { + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(body), &e); err != nil { + return false + } + return strings.HasPrefix(e.Error, fmt.Sprintf("model %q not found", p.model)) || + strings.HasPrefix(e.Error, fmt.Sprintf("model '%s' not found", p.model)) +} + // isJSONObject reports whether s is exactly one JSON object. func isJSONObject(s string) bool { return strings.HasPrefix(s, "{") && json.Valid([]byte(s)) diff --git a/internal/ai/llm/ollama/ollama_test.go b/internal/ai/llm/ollama/ollama_test.go index baedb10..ec0b434 100644 --- a/internal/ai/llm/ollama/ollama_test.go +++ b/internal/ai/llm/ollama/ollama_test.go @@ -327,6 +327,49 @@ func TestGenerateModelMissing(t *testing.T) { } } +func TestGenerateNotFoundUnrelatedToModel(t *testing.T) { + for name, body := range map[string]string{ + "proxy page": "404 page not found", + "other error": `{"error":"no route for /api/chat"}`, + "other model": `{"error":"model \"llama3\" not found, try pulling it first"}`, + "empty": "", + } { + t.Run(name, func(t *testing.T) { + s := newOllamaServer(t, reply{status: 404, body: body}) + p, err := New(Config{BaseURL: s.URL, Model: "llama9"}) + if err != nil { + t.Fatal(err) + } + _, err = p.Generate(context.Background(), textRequest()) + var pe *llm.ProviderError + if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != http.StatusNotFound { + t.Fatalf("err = %v, want a ProviderError for HTTP 404", err) + } + if strings.Contains(err.Error(), "not found") && !strings.Contains(body, "not found") { + t.Fatalf("Error() = %q claims something was not found", err) + } + if strings.Contains(err.Error(), "llama9") { + t.Fatalf("Error() = %q claims the model was not found", err) + } + if pe.Body() != strings.TrimSpace(body) { + t.Fatalf("Body() = %q, want the response body %q", pe.Body(), body) + } + }) + } +} + +func TestGenerateModelMissingSingleQuoted(t *testing.T) { + s := newOllamaServer(t, reply{status: 404, body: `{"error":"model 'llama9' not found"}`}) + p, err := New(Config{BaseURL: s.URL, Model: "llama9"}) + if err != nil { + t.Fatal(err) + } + _, err = p.Generate(context.Background(), textRequest()) + if want := `ollama: generate: model "llama9" not found: llm: provider error: HTTP 404`; err == nil || err.Error() != want { + t.Fatalf("err = %v, want %q", err, want) + } +} + func TestListModels(t *testing.T) { s := newOllamaServer(t, reply{status: 200, fixture: "tags.json"}) models, err := newProvider(t, s.URL).ListModels(context.Background()) -- 2.55.0