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,18 +743,26 @@ func fetchOpenAICompatibleModels(ctx context.Context, fetchURL, apiKey string) (
|
||||||
return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed struct {
|
body, err := io.ReadAll(resp.Body)
|
||||||
Data []struct {
|
if err != nil {
|
||||||
ID string `json:"id"`
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
OwnedBy string `json:"owned_by"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
models := make([]upstreamModel, 0, len(parsed.Data))
|
type modelItem struct {
|
||||||
for _, m := range parsed.Data {
|
ID string `json:"id"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// {"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 != "" {
|
if m.ID != "" {
|
||||||
models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy})
|
models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy})
|
||||||
}
|
}
|
||||||
|
|
@ -762,6 +770,25 @@ func fetchOpenAICompatibleModels(ctx context.Context, fetchURL, apiKey string) (
|
||||||
return models, nil
|
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) {
|
func fetchOllamaModels(ctx context.Context, fetchURL string) ([]upstreamModel, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"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