Merge pull request #6765 from router-for-me/models

fix(api): allow empty home model catalog
This commit is contained in:
Luis Pater
2026-10-19 12:26:16 +08:00
committed by GitHub
2 changed files with 144 additions and 3 deletions

View File

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

View File

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