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
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-03 15:17:08 +07:00
1 parent bde95b70c5
commit e8579f5e1c
2 files changed
+429

No files matched your search

+84
View File
@@ -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 {
+345
View File
@@ -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)
}
}