diff --git a/internal/api/home_models_empty_test.go b/internal/api/home_models_empty_test.go new file mode 100644 index 000000000..b4bef17d0 --- /dev/null +++ b/internal/api/home_models_empty_test.go @@ -0,0 +1,130 @@ +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"}}`, + `{"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) + } + } +} + +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 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}, + {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..4a9b0a069 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -1164,13 +1164,22 @@ 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") + } + // 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) + 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 +1230,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