fix(web,api): support bare-array responses in fetchOpenAICompatibleModels
This commit is contained in:
parent
426159285e
commit
0dcb52708f
2 changed files with 213 additions and 14 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue