From 07feedf7b760d33cc14af9cbe517e4ad6aa98aa0 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:30:30 +0800 Subject: [PATCH 1/2] fix(api): allow empty home model catalog Return an empty catalog when home returns no model records, e.g. when the client's access scopes have no routes, instead of erroring. Still reject null payloads, null sections, and non-empty sections with no usable model IDs as malformed. --- internal/api/home_models_empty_test.go | 124 +++++++++++++++++++++++++ internal/api/server_routes.go | 13 ++- 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 internal/api/home_models_empty_test.go diff --git a/internal/api/home_models_empty_test.go b/internal/api/home_models_empty_test.go new file mode 100644 index 000000000..83fd9d5ab --- /dev/null +++ b/internal/api/home_models_empty_test.go @@ -0,0 +1,124 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/home" + "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" +) + +func TestDecodeHomeModelsEmptyCatalog(t *testing.T) { + for _, raw := range []string{"{}", " { } ", `{"claude":[],"codex-pro":[]}`} { + entries, errDecode := decodeHomeModels([]byte(raw)) + if errDecode != nil || entries == nil || len(entries) != 0 { + t.Errorf("decodeHomeModels(%q) = %v, %v, want empty list", raw, entries, errDecode) + } + } + for _, raw := range []string{ + "", "null", "[]", "{bad}", `{"claude":[{}]}`, `{"claude":null}`, `{"error":null}`, + `{"error":{"type":"internal_error","message":"unavailable"}}`, + } { + if _, errDecode := decodeHomeModels([]byte(raw)); errDecode == nil { + t.Errorf("decodeHomeModels(%q) accepted malformed or error payload", raw) + } + } +} + +func TestHomeEmptyModelCatalogResponses(t *testing.T) { + server := newTestServer(t) + server.cfg.Home.Enabled = true + engine := gin.New() + engine.GET("/v1/models", server.unifiedModelsHandler(nil, nil)) + engine.GET("/v1beta/models", server.geminiModelsHandler(nil)) + engine.GET("/v1beta/models/*action", server.geminiGetHandler(nil)) + client := newHomeCatalogClient(t, "{}") + previousHome := home.Current() + home.SetCurrent(client) + t.Cleanup(func() { home.SetCurrent(previousHome) }) + registry.GetGlobalRegistry().RegisterClient("empty-catalog-local", "codex", []*registry.ModelInfo{{ID: "local-only-model"}}) + t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient("empty-catalog-local") }) + + tests := []struct { + name string + path string + headers map[string]string + field string + }{ + {name: "openai", path: "/v1/models", field: "data"}, + {name: "claude", path: "/v1/models", headers: map[string]string{"Anthropic-Version": "2023-06-01"}, field: "data"}, + {name: "codex", path: "/v1/models?client_version=0.153.4", field: "models"}, + {name: "cpa", path: "/v1/models?client_version=cpa", field: "models"}, + {name: "gemini", path: "/v1beta/models", field: "models"}, + {name: "grok", path: "/v1/models", headers: map[string]string{"User-Agent": "grok-shell"}, field: "data"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + request.Header.Set("Authorization", "Bearer scoped-key") + for key, value := range test.headers { + request.Header.Set(key, value) + } + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String()) + } + var payload map[string]json.RawMessage + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &payload); errUnmarshal != nil { + t.Fatal(errUnmarshal) + } + if string(payload[test.field]) != "[]" { + t.Fatalf("%s = %s, want []; body=%s", test.field, payload[test.field], recorder.Body.String()) + } + }) + } + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1beta/models/inaccessible-model", nil)) + if recorder.Code != http.StatusNotFound { + t.Fatalf("inaccessible model status = %d, want 404; body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestHomeModelCatalogErrorsRemainErrors(t *testing.T) { + server := newTestServer(t) + server.cfg.Home.Enabled = true + engine := gin.New() + engine.GET("/v1/models", server.unifiedModelsHandler(nil, nil)) + for _, test := range []struct { + name string + payload string + status int + }{ + {name: "missing credentials", payload: `{"error":{"type":"no_credentials","message":"Missing API key"}}`, status: http.StatusUnauthorized}, + {name: "invalid credentials", payload: `{"error":{"type":"invalid_credential","message":"Invalid API key"}}`, status: http.StatusUnauthorized}, + {name: "policy failure", payload: `{"error":{"type":"internal_error","message":"Model access service error"}}`, status: http.StatusBadGateway}, + {name: "empty response", payload: "", status: http.StatusBadGateway}, + {name: "malformed JSON", payload: "{bad}", status: http.StatusBadGateway}, + {name: "null catalog", payload: "null", status: http.StatusBadGateway}, + {name: "null section", payload: `{"claude":null}`, status: http.StatusBadGateway}, + {name: "missing model IDs", payload: `{"claude":[{}]}`, status: http.StatusBadGateway}, + } { + t.Run(test.name, func(t *testing.T) { + client := newHomeCatalogClient(t, test.payload) + previousHome := home.Current() + home.SetCurrent(client) + t.Cleanup(func() { home.SetCurrent(previousHome) }) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/models", nil)) + if recorder.Code != test.status { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.status, recorder.Body.String()) + } + var payload map[string]json.RawMessage + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &payload); errUnmarshal != nil { + t.Fatal(errUnmarshal) + } + if _, exists := payload["error"]; !exists { + t.Fatalf("expected error response; body=%s", recorder.Body.String()) + } + }) + } +} diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index 8113a815d..abe1108a3 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -1164,13 +1164,18 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { if err := json.Unmarshal(raw, &bySection); err != nil { return nil, fmt.Errorf("parse home models payload: %w", err) } - if len(bySection) == 0 { - return nil, fmt.Errorf("home models payload has no sections") + if bySection == nil { + return nil, fmt.Errorf("home models payload is not an object") } indexByID := make(map[string]int) out := make([]homeModelEntry, 0, 256) + hasModelRecords := false for section, models := range bySection { + if models == nil { + return nil, fmt.Errorf("home models section %q is null", section) + } + hasModelRecords = hasModelRecords || len(models) > 0 provider := strings.ToLower(strings.TrimSpace(section)) for _, model := range models { id, _ := model["id"].(string) @@ -1221,7 +1226,9 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { } sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id }) - if len(out) == 0 { + // An empty catalog is valid when the client's access scopes have no routes. + // Non-empty sections with no usable model IDs still indicate malformed data. + if len(out) == 0 && hasModelRecords { return nil, fmt.Errorf("home models payload contains no models") } return out, nil From 4d34502f07f607450857739aff71593835ad07a9 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:11:57 +0800 Subject: [PATCH 2/2] fix(api): reject home models error envelopes Reject any home models payload containing an error key before parsing provider sections. Array-shaped error values were previously treated as model sections, masking upstream errors as valid or empty catalogs instead of returning an error. --- internal/api/home_models_empty_test.go | 6 ++++++ internal/api/server_routes.go | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/internal/api/home_models_empty_test.go b/internal/api/home_models_empty_test.go index 83fd9d5ab..b4bef17d0 100644 --- a/internal/api/home_models_empty_test.go +++ b/internal/api/home_models_empty_test.go @@ -21,6 +21,8 @@ func TestDecodeHomeModelsEmptyCatalog(t *testing.T) { for _, raw := range []string{ "", "null", "[]", "{bad}", `{"claude":[{}]}`, `{"claude":null}`, `{"error":null}`, `{"error":{"type":"internal_error","message":"unavailable"}}`, + `{"error":[]}`, `{"error":[],"claude":[]}`, + `{"error":[{"id":"not-a-model"}]}`, `{"error":[],"claude":[{"id":"model-a"}]}`, } { if _, errDecode := decodeHomeModels([]byte(raw)); errDecode == nil { t.Errorf("decodeHomeModels(%q) accepted malformed or error payload", raw) @@ -96,6 +98,10 @@ func TestHomeModelCatalogErrorsRemainErrors(t *testing.T) { {name: "missing credentials", payload: `{"error":{"type":"no_credentials","message":"Missing API key"}}`, status: http.StatusUnauthorized}, {name: "invalid credentials", payload: `{"error":{"type":"invalid_credential","message":"Invalid API key"}}`, status: http.StatusUnauthorized}, {name: "policy failure", payload: `{"error":{"type":"internal_error","message":"Model access service error"}}`, status: http.StatusBadGateway}, + {name: "empty array error", payload: `{"error":[]}`, status: http.StatusBadGateway}, + {name: "error alongside empty section", payload: `{"error":[],"claude":[]}`, status: http.StatusBadGateway}, + {name: "error containing model-shaped records", payload: `{"error":[{"id":"not-a-model"}]}`, status: http.StatusBadGateway}, + {name: "error alongside models", payload: `{"error":[],"claude":[{"id":"model-a"}]}`, status: http.StatusBadGateway}, {name: "empty response", payload: "", status: http.StatusBadGateway}, {name: "malformed JSON", payload: "{bad}", status: http.StatusBadGateway}, {name: "null catalog", payload: "null", status: http.StatusBadGateway}, diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index abe1108a3..4a9b0a069 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -1167,6 +1167,10 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { if bySection == nil { return nil, fmt.Errorf("home models payload is not an object") } + // The error envelope must never be interpreted as a provider model section. + if _, hasError := bySection["error"]; hasError { + return nil, fmt.Errorf("home models payload contains an error envelope") + } indexByID := make(map[string]int) out := make([]homeModelEntry, 0, 256)