mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 14:17:40 +00:00
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:
1 parent
bde95b70c5
commit
e8579f5e1c
2 files changed
+429
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user