From 1d4c0bd0e10b59c58af0155dcd2d4e21c43c8b16 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 08:37:22 -0400 Subject: [PATCH] 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())