From c6a1e166a2d1f68f9dd207b02f1971289ef72502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Fri, 13 Nov 2026 17:57:40 +0800 Subject: [PATCH] fix: hide models no granted account will serve /v1/models kept listing a provider's whole catalog after an account was limited to selected models, so clients showed names the key could not call. --- changelog/unreleased/hide-disabled-models.md | 7 ++ internal/api/auth_test.go | 46 +++++++ internal/app/model_catalog.go | 29 ++++- internal/control/catalog_policy.go | 124 +++++++++++++++++++ internal/control/catalog_source_test.go | 47 +++++++ 5 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 changelog/unreleased/hide-disabled-models.md diff --git a/changelog/unreleased/hide-disabled-models.md b/changelog/unreleased/hide-disabled-models.md new file mode 100644 index 00000000..5bb30861 --- /dev/null +++ b/changelog/unreleased/hide-disabled-models.md @@ -0,0 +1,7 @@ +### English + +- `/v1/models` now hides a model once every account the API key can reach has left it out of its selected models. A model stays listed while any reachable account still serves it. + +### 中文 + +- `/v1/models` 会在 API key 能用到的每个账号都没把它选进可用模型时,不再列出这个模型。只要还有一个可用账号能接,它就继续显示。 diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 89889260..521d09de 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -812,6 +812,52 @@ func TestNamedAPIKeyModelsListOnlyIncludesAllowedProviders(t *testing.T) { } } +func TestNamedAPIKeyModelsListHidesModelsNoAccountEnables(t *testing.T) { + srv := New(config.Config{ + Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret", + QoderHome: t.TempDir(), DataDir: t.TempDir(), + }) + defer srv.Close() + created, err := srv.Control.Keys.Create(context.Background(), accounts.CreateAPIKey{ + Name: "ci", Providers: []string{"qoder"}, Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + srv.Providers.Register(providers.Adapter{ID: "qoder", Models: &countingCatalog{models: []providers.ModelInfo{ + {NativeModel: "glm-5.2", PublicModel: "glm-5.2"}, + {NativeModel: "kimi-k2.6", PublicModel: "kimi-k2.6"}, + }}}) + srv.Pool.Upsert(executor.Item{ + ID: "q1", Provider: "qoder", Region: "global", Runtime: string(providers.RuntimeInProcess), + ModelsRestricted: true, EnabledModels: []string{"glm-5.2"}, + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Header.Set("Authorization", "Bearer "+created.Secret) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /v1/models = %d %s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"glm-5.2"`)) || bytes.Contains(rec.Body.Bytes(), []byte(`kimi-k2.6`)) { + t.Fatalf("restricted account leaked a disabled model: %s", rec.Body.String()) + } + + // A second unrestricted account of the same provider restores the name, + // because the key can still reach it. + srv.Pool.Upsert(executor.Item{ + ID: "q2", Provider: "qoder", Region: "global", Runtime: string(providers.RuntimeInProcess), + }) + req = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Header.Set("Authorization", "Bearer "+created.Secret) + rec = httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(`kimi-k2.6`)) { + t.Fatalf("unrestricted sibling should keep the model listed: %d %s", rec.Code, rec.Body.String()) + } +} + func TestAPIKeysCRUDAndConsoleKeyPrefix(t *testing.T) { srv := New(config.Config{ Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret", diff --git a/internal/app/model_catalog.go b/internal/app/model_catalog.go index 584b83c3..d55a16d3 100644 --- a/internal/app/model_catalog.go +++ b/internal/app/model_catalog.go @@ -8,5 +8,32 @@ import ( func (a *App) RequestedAccount(r *http.Request) string { return endpoint.RequestedAccount(r) } func (a *App) filterModelsForIdentity(r *http.Request, models []map[string]any) []map[string]any { - return control.FilterModelsForIdentity(a.requestIdentity(r), models) + identity := a.requestIdentity(r) + models = control.FilterModelsByAccountAllow(models, identity, a.accountModelAllows()) + return control.FilterModelsForIdentity(identity, models) +} + +// accountModelAllows is the routing allowlist the public model list consults. +// It is read from the live pool, not the catalog cache, so saving "only +// selected models" hides those names on the next /v1/models without waiting +// for a catalog refresh. +func (a *App) accountModelAllows() []control.AccountModelAllow { + if a == nil || a.Pool == nil { + return nil + } + items := a.Pool.Items() + if len(items) == 0 { + return nil + } + out := make([]control.AccountModelAllow, 0, len(items)) + for _, item := range items { + out = append(out, control.AccountModelAllow{ + Provider: item.Provider, + Region: item.Region, + ModelsRestricted: item.ModelsRestricted, + EnabledModels: item.EnabledModels, + Aliases: item.ModelAliases, + }) + } + return out } diff --git a/internal/control/catalog_policy.go b/internal/control/catalog_policy.go index 36456820..29ea0451 100644 --- a/internal/control/catalog_policy.go +++ b/internal/control/catalog_policy.go @@ -210,6 +210,130 @@ func ProviderModelEntry(model providers.ModelInfo, provider string) map[string]a return entry } +// AccountModelAllow is one account's contribution to the public model list. +// A restricted account contributes only EnabledModels; an unrestricted one +// contributes every model of its provider and region. Enabled IDs are the +// canonical form stored by NormalizeAccountRouting. +type AccountModelAllow struct { + Provider string + Region string + ModelsRestricted bool + EnabledModels []string + // Aliases maps a client-facing canonical name onto the canonical catalog + // model this account rewrites it to. The public list keeps an alias only + // when some granted account both defines it and may serve its target. + Aliases map[string]string +} + +// ModelServedByAccounts reports whether at least one account the caller can +// reach will accept this catalog entry. Provider and region grants are applied +// here too, so a key scoped to one family never keeps another family's name +// just because that other account left its list unrestricted. +// +// An empty account list keeps the catalog unchanged: tests and a pool that has +// not reported routing yet must not blank /v1/models. A model stays listed when +// any granted account can serve it — either because that account is +// unrestricted, or because the model (or an alias that rewrites to it) is in +// its enabled set. Region-qualified entries ("workbuddy-cn/…") only consult +// accounts of that region; a merged entry stays when any of its regions has a +// serving account. +func ModelServedByAccounts(entry map[string]any, identity auth.Identity, pool []AccountModelAllow) bool { + if len(pool) == 0 { + return true + } + provider, _ := entry["provider"].(string) + if provider == "" { + provider, _ = entry["owned_by"].(string) + } + provider = accounts.NormalizeProviderFamily(provider) + if strings.TrimSpace(provider) == "" { + return true + } + if !identity.AllowsProvider(provider) { + return false + } + regions := EntryModelRegions(entry) + id, _ := entry["id"].(string) + modelKey := modelAllowKey(id) + if modelKey == "" { + return true + } + for _, account := range pool { + if accounts.NormalizeProviderFamily(account.Provider) != provider { + continue + } + if !identity.AllowsProviderRegion(provider, account.Region) { + continue + } + if !accountServesRegion(account.Region, regions) { + continue + } + if accountServesModel(account, modelKey) { + return true + } + } + return false +} + +// FilterModelsByAccountAllow drops catalog entries no granted account will +// serve. See ModelServedByAccounts for the empty-pool and mixed-account rules. +func FilterModelsByAccountAllow(models []map[string]any, identity auth.Identity, pool []AccountModelAllow) []map[string]any { + if len(pool) == 0 || len(models) == 0 { + return models + } + filtered := make([]map[string]any, 0, len(models)) + for _, model := range models { + if !ModelServedByAccounts(model, identity, pool) { + continue + } + filtered = append(filtered, model) + } + return filtered +} + +func accountServesRegion(accountRegion string, entryRegions []string) bool { + if len(entryRegions) == 0 { + return true + } + accountRegion = accounts.NormalizeRegion(accountRegion) + for _, region := range entryRegions { + if accounts.NormalizeRegion(region) == accountRegion { + return true + } + } + return false +} + +func accountServesModel(account AccountModelAllow, modelKey string) bool { + if !account.ModelsRestricted { + return true + } + if target := account.Aliases[modelKey]; target != "" { + modelKey = target + } + for _, enabled := range account.EnabledModels { + if enabled == modelKey { + return true + } + } + return false +} + +// modelAllowKey folds a catalog id the same way routing folds a request, and +// strips a provider prefix ("qoder/glm-5.2", "workbuddy-cn/glm-5.2") so a +// prefixed public list matches the bare ids stored on the account. +func modelAllowKey(id string) string { + id = strings.TrimSpace(id) + if slash := strings.LastIndex(id, "/"); slash >= 0 { + id = id[slash+1:] + } + key := accounts.CanonicalModelID(id) + if key == "" || key == "auto" { + return "" + } + return key +} + // FilterModelsForIdentity applies grants after loading the shared catalog. func FilterModelsForIdentity(identity auth.Identity, models []map[string]any) []map[string]any { if len(identity.AllowedProviders) == 0 { diff --git a/internal/control/catalog_source_test.go b/internal/control/catalog_source_test.go index 2655a254..b9519588 100644 --- a/internal/control/catalog_source_test.go +++ b/internal/control/catalog_source_test.go @@ -218,6 +218,53 @@ func TestCatalogSourceQoderFallbackStampsRegion(t *testing.T) { } } +func TestFilterModelsByAccountAllowDropsModelsNoGrantedAccountServes(t *testing.T) { + models := []map[string]any{ + {"id": "glm-5.2", "provider": "qoder", "regions": []string{"global"}}, + {"id": "kimi-k2.6", "provider": "qoder", "regions": []string{"global"}}, + {"id": "qoder/deepseek-v4", "provider": "qoder", "regions": []string{"cn"}}, + {"id": "trae-only", "provider": "trae", "regions": []string{"cn"}}, + } + pool := []AccountModelAllow{ + {Provider: "qoder", Region: "global", ModelsRestricted: true, EnabledModels: []string{"glm-5.2"}}, + {Provider: "qoder", Region: "cn", ModelsRestricted: true, EnabledModels: []string{"glm-5.2"}}, + {Provider: "trae", Region: "cn"}, + } + key := auth.Identity{Kind: auth.KindKey, AllowedProviders: []string{"qoder"}} + filtered := FilterModelsByAccountAllow(models, key, pool) + if len(filtered) != 1 || filtered[0]["id"] != "glm-5.2" { + t.Fatalf("qoder key filtered=%v", filtered) + } + + // An alias stays visible only when its target is one this account may serve. + aliased := []AccountModelAllow{{ + Provider: "qoder", Region: "global", ModelsRestricted: true, + EnabledModels: []string{"glm-5.2"}, Aliases: map[string]string{"my-glm": "glm-5.2", "my-kimi": "kimi-k2.6"}, + }} + withAlias := append(models, map[string]any{"id": "my-glm", "provider": "qoder", "regions": []string{"global"}}) + withAlias = append(withAlias, map[string]any{"id": "my-kimi", "provider": "qoder", "regions": []string{"global"}}) + filtered = FilterModelsByAccountAllow(withAlias, key, aliased) + got := map[string]bool{} + for _, model := range filtered { + id, _ := model["id"].(string) + got[id] = true + } + if !got["glm-5.2"] || !got["my-glm"] || got["my-kimi"] || got["kimi-k2.6"] { + t.Fatalf("alias filtered=%v", got) + } + + // One unrestricted account of the same provider keeps the whole catalog, + // and an empty pool (tests, pool not yet reported) changes nothing. + open := []AccountModelAllow{{Provider: "qoder", Region: "global"}} + // The CN-only entry stays hidden: this account does not cover that region. + if n := len(FilterModelsByAccountAllow(models, key, open)); n != 2 { + t.Fatalf("unrestricted qoder account hid models: %d", n) + } + if n := len(FilterModelsByAccountAllow(models, key, nil)); n != len(models) { + t.Fatalf("empty pool filtered %d of %d", n, len(models)) + } +} + func TestFilterModelsForIdentityDropsDisallowedProviderAndRegion(t *testing.T) { models := []map[string]any{ {"id": "glm-5.2", "provider": "qoder", "regions": []string{"cn"}},