fix(web,api): support bare-array responses in fetchOpenAICompatibleModels

This commit is contained in:
SiYue-ZO 2026-05-13 13:09:51 +08:00
parent 426159285e
commit 0dcb52708f
2 changed files with 213 additions and 14 deletions

View file

@ -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) {

View file

@ -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)
}
}