From e8579f5e1cb4c798b124a9d69fde6c21656bb759 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Fri, 3 Apr 2026 04:17:08 -0400 Subject: [PATCH] feat(providers): add rich Ollama model listing via /api/tags (#659) - Ollama/OllamaCloud providers now use native /api/tags for richer metadata - Display name built from family + parameter_size + quantization_level - Strips /v1 suffix, DockerLocalhost support, graceful fallback on error - 7 comprehensive tests covering all edge cases Closes #656 --- internal/http/provider_models.go | 84 +++++++ internal/http/provider_models_test.go | 345 ++++++++++++++++++++++++++ 2 files changed, 429 insertions(+) diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index f5cf9b05..3fd9373c 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -70,6 +71,25 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht return } + // Ollama: use native /api/tags for richer metadata (parameter size, quantization, family). + // ProviderOllama has no API key; ProviderOllamaCloud requires one but both use the same endpoint. + if p.ProviderType == store.ProviderOllama || p.ProviderType == store.ProviderOllamaCloud { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + apiBase := h.resolveAPIBase(p) + if apiBase == "" { + apiBase = "http://localhost:11434" + } + models, err := h.fetchOllamaModels(ctx, apiBase, p.APIKey) + if err != nil { + slog.Warn("providers.models.ollama", "provider", p.Name, "error", err) + respond([]ModelInfo{}) + return + } + respond(models) + return + } + if p.APIKey == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "API key")}) return @@ -343,6 +363,70 @@ func chatGPTOAuthModels() []ModelInfo { }) } +// fetchOllamaModels calls Ollama's native /api/tags endpoint to get model metadata +// including parameter size, quantization level, and model family. +// The api_base may include a /v1 suffix (from issue #654 normalization) — strip it +// before appending /api/tags since /api/tags lives at the root, not under /v1. +func (h *ProvidersHandler) fetchOllamaModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { + // Strip /v1 suffix if present, then trim trailing slash. + base := strings.TrimRight(strings.TrimSuffix(strings.TrimRight(apiBase, "/"), "/v1"), "/") + url := config.DockerLocalhost(base + "/api/tags") + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("ollama /api/tags returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Models []struct { + Name string `json:"name"` + Details struct { + Family string `json:"family"` + ParameterSize string `json:"parameter_size"` + QuantizationLevel string `json:"quantization_level"` + } `json:"details"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode ollama response: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Models)) + for _, m := range result.Models { + // Build a human-readable display name: "family paramSize quantLevel" e.g. "gemma4 8.0B Q4_K_M" + var parts []string + if m.Details.Family != "" { + parts = append(parts, m.Details.Family) + } + if m.Details.ParameterSize != "" { + parts = append(parts, m.Details.ParameterSize) + } + if m.Details.QuantizationLevel != "" { + parts = append(parts, m.Details.QuantizationLevel) + } + name := m.Name + if len(parts) > 0 { + name = strings.Join(parts, " ") + } + models = append(models, ModelInfo{ID: m.Name, Name: name}) + } + return models, nil +} + func withReasoningCapabilities(models []ModelInfo) []ModelInfo { result := make([]ModelInfo, 0, len(models)) for _, model := range models { diff --git a/internal/http/provider_models_test.go b/internal/http/provider_models_test.go index fd2d8a9e..265ae30f 100644 --- a/internal/http/provider_models_test.go +++ b/internal/http/provider_models_test.go @@ -10,6 +10,48 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// ollamaTagsHandler returns an httptest.Handler that serves /api/tags with the +// given response body. Any other path returns 404. +func ollamaTagsHandler(body string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tags" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + } +} + +// newOllamaProvider creates a ProviderOllama entry pointing at the given apiBase. +func newOllamaProvider(apiBase string) *store.LLMProviderData { + return &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: "local-ollama", + ProviderType: store.ProviderOllama, + APIBase: apiBase, + Enabled: true, + } +} + +// ollamaModelsRequest fires GET /v1/providers/{id}/models through mux and returns +// the decoded ProviderModelsResponse and the HTTP status code. +func ollamaModelsRequest(t *testing.T, mux *http.ServeMux, providerID uuid.UUID, token string) (ProviderModelsResponse, int) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+providerID.String()+"/models", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + return ProviderModelsResponse{}, w.Code + } + var result ProviderModelsResponse + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Decode() error = %v", err) + } + return result, w.Code +} + func TestProvidersHandlerListProviderModelsChatGPTOAuthIncludesReasoningMetadata(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() @@ -136,3 +178,306 @@ func TestProvidersHandlerListProviderModelsOpenAICompatAnnotatesKnownModels(t *t t.Fatalf("reasoning_defaults = %#v, want nil when provider has no saved defaults", result.ReasoningDefaults) } } + +// TestProvidersHandlerListProviderModelsOllamaRichMetadata verifies that the +// handler fetches /api/tags from Ollama and maps rich details (family, +// parameter_size, quantization_level) into the display name. +func TestProvidersHandlerListProviderModelsOllamaRichMetadata(t *testing.T) { + token := setupProvidersAdminToken(t) + + upstream := httptest.NewServer(ollamaTagsHandler(`{ + "models": [ + { + "name": "gemma4:8b-it-q4_K_M", + "details": { + "family": "gemma4", + "parameter_size": "8.0B", + "quantization_level": "Q4_K_M" + } + }, + { + "name": "llama3.2:3b", + "details": { + "family": "llama", + "parameter_size": "3.2B", + "quantization_level": "F16" + } + } + ] + }`)) + t.Cleanup(upstream.Close) + + providerStore := newMockProviderStore() + provider := newOllamaProvider(upstream.URL) + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result, code := ollamaModelsRequest(t, mux, provider.ID, token) + if code != http.StatusOK { + t.Fatalf("status code = %d, want %d", code, http.StatusOK) + } + if len(result.Models) != 2 { + t.Fatalf("models len = %d, want 2", len(result.Models)) + } + + // First model: ID is the raw Ollama name, Name is built from rich details. + m0 := result.Models[0] + if m0.ID != "gemma4:8b-it-q4_K_M" { + t.Errorf("models[0].id = %q, want gemma4:8b-it-q4_K_M", m0.ID) + } + if m0.Name != "gemma4 8.0B Q4_K_M" { + t.Errorf("models[0].name = %q, want \"gemma4 8.0B Q4_K_M\"", m0.Name) + } + + // Second model + m1 := result.Models[1] + if m1.ID != "llama3.2:3b" { + t.Errorf("models[1].id = %q, want llama3.2:3b", m1.ID) + } + if m1.Name != "llama 3.2B F16" { + t.Errorf("models[1].name = %q, want \"llama 3.2B F16\"", m1.Name) + } + + // Ollama models carry no reasoning metadata. + if result.ReasoningDefaults != nil { + t.Errorf("reasoning_defaults = %#v, want nil for Ollama provider", result.ReasoningDefaults) + } +} + +// TestProvidersHandlerListProviderModelsOllamaStripsV1Suffix verifies that a +// /v1-suffixed api_base (from issue #654 normalization) is correctly stripped +// so the request hits /api/tags at the root, not /v1/api/tags. +func TestProvidersHandlerListProviderModelsOllamaStripsV1Suffix(t *testing.T) { + token := setupProvidersAdminToken(t) + + var capturedPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + if r.URL.Path != "/api/tags" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models": [{"name": "phi4:latest", "details": {"family": "phi4", "parameter_size": "14B", "quantization_level": "Q4_K_M"}}]}`)) + })) + t.Cleanup(upstream.Close) + + providerStore := newMockProviderStore() + // APIBase has /v1 suffix — as would be saved after issue #654 normalization. + provider := newOllamaProvider(upstream.URL + "/v1") + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result, code := ollamaModelsRequest(t, mux, provider.ID, token) + if code != http.StatusOK { + t.Fatalf("status code = %d, want %d (body may be empty if path not stripped)", code, http.StatusOK) + } + if capturedPath != "/api/tags" { + t.Errorf("upstream received path %q, want /api/tags — /v1 suffix was not stripped", capturedPath) + } + if len(result.Models) != 1 { + t.Fatalf("models len = %d, want 1", len(result.Models)) + } + if result.Models[0].ID != "phi4:latest" { + t.Errorf("models[0].id = %q, want phi4:latest", result.Models[0].ID) + } +} + +// TestProvidersHandlerListProviderModelsOllamaNoV1Suffix verifies that a plain +// api_base without a /v1 suffix also hits /api/tags correctly. +func TestProvidersHandlerListProviderModelsOllamaNoV1Suffix(t *testing.T) { + token := setupProvidersAdminToken(t) + + upstream := httptest.NewServer(ollamaTagsHandler(`{"models": [{"name": "mistral:latest", "details": {"family": "mistral", "parameter_size": "7B", "quantization_level": "Q4_0"}}]}`)) + t.Cleanup(upstream.Close) + + providerStore := newMockProviderStore() + provider := newOllamaProvider(upstream.URL) // no /v1 suffix + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result, code := ollamaModelsRequest(t, mux, provider.ID, token) + if code != http.StatusOK { + t.Fatalf("status code = %d, want %d", code, http.StatusOK) + } + if len(result.Models) != 1 { + t.Fatalf("models len = %d, want 1", len(result.Models)) + } + if result.Models[0].Name != "mistral 7B Q4_0" { + t.Errorf("models[0].name = %q, want \"mistral 7B Q4_0\"", result.Models[0].Name) + } +} + +// TestProvidersHandlerListProviderModelsOllamaFallbackOn404 verifies that a +// non-200 response from /api/tags causes the handler to return an empty model +// list with HTTP 200 (graceful fallback, not a 5xx to the UI). +func TestProvidersHandlerListProviderModelsOllamaFallbackOn404(t *testing.T) { + token := setupProvidersAdminToken(t) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) // Ollama /api/tags returns 404 + })) + t.Cleanup(upstream.Close) + + providerStore := newMockProviderStore() + provider := newOllamaProvider(upstream.URL) + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result, code := ollamaModelsRequest(t, mux, provider.ID, token) + if code != http.StatusOK { + t.Fatalf("status code = %d, want %d — error must not bubble to UI", code, http.StatusOK) + } + if len(result.Models) != 0 { + t.Errorf("models len = %d, want 0 on fallback", len(result.Models)) + } +} + +// TestProvidersHandlerListProviderModelsOllamaEmptyList verifies that Ollama +// returning {"models": []} results in an empty model list without panic. +func TestProvidersHandlerListProviderModelsOllamaEmptyList(t *testing.T) { + token := setupProvidersAdminToken(t) + + upstream := httptest.NewServer(ollamaTagsHandler(`{"models": []}`)) + t.Cleanup(upstream.Close) + + providerStore := newMockProviderStore() + provider := newOllamaProvider(upstream.URL) + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result, code := ollamaModelsRequest(t, mux, provider.ID, token) + if code != http.StatusOK { + t.Fatalf("status code = %d, want %d", code, http.StatusOK) + } + if len(result.Models) != 0 { + t.Errorf("models len = %d, want 0 for empty Ollama list", len(result.Models)) + } +} + +// TestProvidersHandlerListProviderModelsOllamaPartialDetails verifies that +// models with only some detail fields set still produce a reasonable display +// name (omit missing parts rather than emitting empty tokens). +func TestProvidersHandlerListProviderModelsOllamaPartialDetails(t *testing.T) { + token := setupProvidersAdminToken(t) + + upstream := httptest.NewServer(ollamaTagsHandler(`{ + "models": [ + { + "name": "nodetails:latest", + "details": {} + }, + { + "name": "familyonly:latest", + "details": {"family": "llama"} + }, + { + "name": "sizequant:latest", + "details": {"parameter_size": "7B", "quantization_level": "Q8_0"} + } + ] + }`)) + t.Cleanup(upstream.Close) + + providerStore := newMockProviderStore() + provider := newOllamaProvider(upstream.URL) + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result, code := ollamaModelsRequest(t, mux, provider.ID, token) + if code != http.StatusOK { + t.Fatalf("status code = %d, want %d", code, http.StatusOK) + } + if len(result.Models) != 3 { + t.Fatalf("models len = %d, want 3", len(result.Models)) + } + + // No details: name falls back to the raw model name. + if result.Models[0].Name != "nodetails:latest" { + t.Errorf("models[0].name = %q, want nodetails:latest (fallback to ID)", result.Models[0].Name) + } + // Family only: display name is just the family. + if result.Models[1].Name != "llama" { + t.Errorf("models[1].name = %q, want llama", result.Models[1].Name) + } + // Size + quantization (no family): display name omits the empty family. + if result.Models[2].Name != "7B Q8_0" { + t.Errorf("models[2].name = %q, want \"7B Q8_0\"", result.Models[2].Name) + } +} + +// TestProvidersHandlerListProviderModelsOllamaCloudSendsAuthHeader verifies +// that ProviderOllamaCloud providers forward their API key as a Bearer token. +func TestProvidersHandlerListProviderModelsOllamaCloudSendsAuthHeader(t *testing.T) { + token := setupProvidersAdminToken(t) + + var capturedAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + if r.URL.Path != "/api/tags" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models": [{"name": "cloud-model:latest", "details": {"family": "cloud"}}]}`)) + })) + t.Cleanup(upstream.Close) + + providerStore := newMockProviderStore() + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: "ollama-cloud", + ProviderType: store.ProviderOllamaCloud, + APIBase: upstream.URL, + APIKey: "cloud-secret-key", + Enabled: true, + } + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result, code := ollamaModelsRequest(t, mux, provider.ID, token) + if code != http.StatusOK { + t.Fatalf("status code = %d, want %d", code, http.StatusOK) + } + if capturedAuth != "Bearer cloud-secret-key" { + t.Errorf("Authorization header = %q, want \"Bearer cloud-secret-key\"", capturedAuth) + } + if len(result.Models) != 1 || result.Models[0].ID != "cloud-model:latest" { + t.Errorf("models = %#v, want [{ID: cloud-model:latest}]", result.Models) + } +}