Files
goclaw/internal/http/provider_models_test.go
Goon 591d809779 Merge remote-tracking branch 'upstream/dev' into dev
# Conflicts:
#	internal/cron/service.go
2026-06-15 14:14:16 +07:00

599 lines
20 KiB
Go

package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"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()
provider := &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
Name: "openai-codex",
ProviderType: store.ProviderChatGPTOAuth,
Enabled: true,
Settings: json.RawMessage(`{
"reasoning_defaults": {"effort": "high"}
}`),
}
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)
req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+provider.ID.String()+"/models", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
var result ProviderModelsResponse
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("Decode() error = %v", err)
}
if len(result.Models) == 0 {
t.Fatal("models = empty, want hardcoded ChatGPT OAuth list")
}
if result.ReasoningDefaults == nil {
t.Fatal("reasoning_defaults = nil, want provider defaults")
}
if result.ReasoningDefaults.Effort != "high" {
t.Fatalf("reasoning_defaults.effort = %q, want high", result.ReasoningDefaults.Effort)
}
var found bool
for _, model := range result.Models {
if model.ID != "gpt-5.5" {
continue
}
found = true
if model.Reasoning == nil {
t.Fatal("gpt-5.5 reasoning = nil, want capability metadata")
}
if model.Reasoning.DefaultEffort != "medium" {
t.Fatalf("gpt-5.5 default_effort = %q, want medium", model.Reasoning.DefaultEffort)
}
if got := model.Reasoning.Levels; len(got) != 5 || got[4] != "xhigh" {
t.Fatalf("gpt-5.5 levels = %#v, want none..xhigh", got)
}
}
if !found {
t.Fatal("gpt-5.5 not found in ChatGPT OAuth model list")
}
}
func TestProvidersHandlerListProviderModelsBailianIncludesQwen37Plus(t *testing.T) {
token := setupProvidersAdminToken(t)
providerStore := newMockProviderStore()
provider := &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
Name: "bailian-coding",
ProviderType: store.ProviderBailian,
APIKey: "token",
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)
req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+provider.ID.String()+"/models", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
var result ProviderModelsResponse
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("Decode() error = %v", err)
}
for _, model := range result.Models {
if model.ID != "qwen3.7-plus" {
continue
}
if model.Name != "Qwen 3.7 Plus" {
t.Fatalf("model name = %q, want Qwen 3.7 Plus", model.Name)
}
if model.Reasoning != nil {
t.Fatalf("model reasoning = %#v, want nil for Bailian OpenAI-compatible catalog entry", model.Reasoning)
}
return
}
t.Fatalf("qwen3.7-plus not found in Bailian model list: %#v", result.Models)
}
func TestProvidersHandlerListProviderModelsOpenAICompatAnnotatesKnownModels(t *testing.T) {
token := setupProvidersAdminToken(t)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/models" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]string{
{"id": "gpt-5.1-codex-max"},
{"id": "gpt-5.4-experimental"},
},
})
}))
t.Cleanup(upstream.Close)
providerStore := newMockProviderStore()
provider := &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
Name: "openai",
ProviderType: store.ProviderOpenAICompat,
APIBase: upstream.URL,
APIKey: "token",
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)
req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+provider.ID.String()+"/models", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
var result ProviderModelsResponse
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("Decode() error = %v", err)
}
if len(result.Models) != 2 {
t.Fatalf("models len = %d, want 2", len(result.Models))
}
if result.Models[0].Reasoning == nil {
t.Fatal("known GPT-5.1 Codex Max reasoning = nil, want capability metadata")
}
if result.Models[0].Reasoning.DefaultEffort != "none" {
t.Fatalf("known model default_effort = %q, want none", result.Models[0].Reasoning.DefaultEffort)
}
if result.Models[1].Reasoning != nil {
t.Fatalf("unknown GPT-5 variant reasoning = %#v, want nil", result.Models[1].Reasoning)
}
if result.ReasoningDefaults != nil {
t.Fatalf("reasoning_defaults = %#v, want nil when provider has no saved defaults", result.ReasoningDefaults)
}
}
func TestProvidersHandlerListProviderModelsKimiCodingSendsRequiredUserAgent(t *testing.T) {
token := setupProvidersAdminToken(t)
var capturedAuth, capturedUserAgent string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
capturedUserAgent = r.Header.Get("User-Agent")
if r.URL.Path != "/models" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]string{
{"id": store.KimiCodingDefaultModel},
},
})
}))
t.Cleanup(upstream.Close)
providerStore := newMockProviderStore()
provider := &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
Name: "kimi-coding",
ProviderType: store.ProviderKimiCoding,
APIBase: upstream.URL,
APIKey: "kimi-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)
req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+provider.ID.String()+"/models", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
if capturedAuth != "Bearer kimi-key" {
t.Fatalf("Authorization = %q, want Bearer kimi-key", capturedAuth)
}
if capturedUserAgent != store.KimiCodingRequiredUserAgent {
t.Fatalf("User-Agent = %q, want %q", capturedUserAgent, store.KimiCodingRequiredUserAgent)
}
var result ProviderModelsResponse
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("Decode() error = %v", err)
}
if len(result.Models) != 1 || result.Models[0].ID != store.KimiCodingDefaultModel {
t.Fatalf("models = %#v, want %q", result.Models, store.KimiCodingDefaultModel)
}
}
func TestOpenAIModelsAPIBaseDefaultsKimiCoding(t *testing.T) {
if got := openAIModelsAPIBase(store.ProviderKimiCoding, ""); got != store.KimiCodingDefaultAPIBase {
t.Fatalf("Kimi default api base = %q, want %q", got, store.KimiCodingDefaultAPIBase)
}
if got := openAIModelsAPIBase(store.ProviderOpenAICompat, ""); got != "https://api.openai.com/v1" {
t.Fatalf("OpenAI compat default api base = %q", got)
}
}
// 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)
}
}