diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 145d7ac4a..f1761cb70 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -743,23 +743,50 @@ func fetchOpenAICompatibleModels(ctx context.Context, fetchURL, apiKey string) ( return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode) } - var parsed struct { - Data []struct { - ID string `json:"id"` - OwnedBy string `json:"owned_by"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { - return nil, err + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) } - models := make([]upstreamModel, 0, len(parsed.Data)) - for _, m := range parsed.Data { - if m.ID != "" { - models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy}) - } + type modelItem struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by"` } - return models, nil + + // {"data": [...]} envelope. Distinguish "envelope shape with empty list" + // from "object without a data key" via Data being non-nil after unmarshal: + // json.Unmarshal sets Data to []modelItem{} for `{"data":[]}` but leaves + // it as nil when "data" is absent or null. + var envelope struct { + Data []modelItem `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil { + models := make([]upstreamModel, 0, len(envelope.Data)) + for _, m := range envelope.Data { + if m.ID != "" { + models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy}) + } + } + return models, nil + } + + // Bare-array shape, including `[]`. + var arr []modelItem + if err := json.Unmarshal(body, &arr); err == nil { + models := make([]upstreamModel, 0, len(arr)) + for _, m := range arr { + if m.ID != "" { + models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy}) + } + } + return models, nil + } + + preview := body + if len(preview) > 256 { + preview = preview[:256] + } + return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview))) } func fetchOllamaModels(ctx context.Context, fetchURL string) ([]upstreamModel, error) { diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 0b1f04848..71b6b279c 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -2219,3 +2220,174 @@ func TestMaskAPIKey(t *testing.T) { }) } } + +func TestFetchOpenAICompatibleModels_ResponseShapes(t *testing.T) { + tests := []struct { + name string + response string + apiKey string + wantLen int + wantFirst struct { + id, ownedBy string + } + wantSecond struct { + id, ownedBy string + } + }{ + { + name: "envelope shape", + response: `{"data":[{"id":"gpt-4o","owned_by":"openai"},{"id":"gpt-4o-mini","owned_by":"openai"}]}`, + apiKey: "test-key", + wantLen: 2, + wantFirst: struct { + id, ownedBy string + }{id: "gpt-4o", ownedBy: "openai"}, + wantSecond: struct { + id, ownedBy string + }{id: "gpt-4o-mini", ownedBy: "openai"}, + }, + { + name: "bare array shape", + response: `[{"id":"qwen-max","owned_by":"qwen"},{"id":"qwen-plus","owned_by":"qwen"}]`, + apiKey: "", + wantLen: 2, + wantFirst: struct { + id, ownedBy string + }{id: "qwen-max", ownedBy: "qwen"}, + wantSecond: struct { + id, ownedBy string + }{id: "qwen-plus", ownedBy: "qwen"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, tt.response) + })) + defer srv.Close() + + models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", tt.apiKey) + if err != nil { + t.Fatalf("error = %v", err) + } + if len(models) != tt.wantLen { + t.Fatalf("len(models) = %d, want %d", len(models), tt.wantLen) + } + if models[0].ID != tt.wantFirst.id || models[0].OwnedBy != tt.wantFirst.ownedBy { + t.Fatalf("models[0] = %+v, want {ID:%s OwnedBy:%s}", models[0], tt.wantFirst.id, tt.wantFirst.ownedBy) + } + if models[1].ID != tt.wantSecond.id || models[1].OwnedBy != tt.wantSecond.ownedBy { + t.Fatalf("models[1] = %+v, want {ID:%s OwnedBy:%s}", models[1], tt.wantSecond.id, tt.wantSecond.ownedBy) + } + }) + } +} + +func TestFetchOpenAICompatibleModels_EmptyEnvelopeReturnsEmptySlice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[]}`) + })) + defer srv.Close() + + models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k") + if err != nil { + t.Fatalf("error = %v", err) + } + if len(models) != 0 { + t.Fatalf("len(models) = %d, want 0", len(models)) + } +} + +func TestFetchOpenAICompatibleModels_EmptyBareArrayReturnsEmptySlice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[]`) + })) + defer srv.Close() + + models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k") + if err != nil { + t.Fatalf("error = %v", err) + } + if len(models) != 0 { + t.Fatalf("len(models) = %d, want 0", len(models)) + } +} + +func TestFetchOpenAICompatibleModels_UnrecognizedShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"models":[],"error":"unsupported"}`) + })) + defer srv.Close() + + _, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k") + if err == nil { + t.Fatal("error = nil, want unrecognized shape error") + } + if !strings.Contains(err.Error(), "unrecognized shape") { + t.Fatalf("error = %q, want it to contain 'unrecognized shape'", err.Error()) + } +} + +func TestFetchOpenAICompatibleModels_FiltersEmptyIDs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[`+ + `{"id":"gpt-4o","owned_by":"openai"},`+ + `{"id":"","owned_by":"openai"},`+ + `{"id":"gpt-4o-mini"}]}`) + })) + defer srv.Close() + + models, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "k") + if err != nil { + t.Fatalf("error = %v", err) + } + if len(models) != 2 { + t.Fatalf("len(models) = %d, want 2 (empty IDs should be filtered)", len(models)) + } + if models[0].ID != "gpt-4o" { + t.Fatalf("models[0].ID = %q, want %q", models[0].ID, "gpt-4o") + } + if models[1].ID != "gpt-4o-mini" { + t.Fatalf("models[1].ID = %q, want %q", models[1].ID, "gpt-4o-mini") + } +} + +func TestFetchOpenAICompatibleModels_SetsAuthorizationHeader(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[{"id":"m1"}]}`) + })) + defer srv.Close() + + if _, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", "my-secret-key"); err != nil { + t.Fatalf("error = %v", err) + } + if gotAuth != "Bearer my-secret-key" { + t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer my-secret-key") + } +} + +func TestFetchOpenAICompatibleModels_NoAuthHeaderWhenKeyEmpty(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[{"id":"m1"}]`) + })) + defer srv.Close() + + if _, err := fetchOpenAICompatibleModels(t.Context(), srv.URL+"/models", ""); err != nil { + t.Fatalf("error = %v", err) + } + if gotAuth != "" { + t.Fatalf("Authorization = %q, want empty", gotAuth) + } +}