Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/hide-disabled-models.md
Original file line number Diff line number Diff line change
@@ -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 能用到的每个账号都没把它选进可用模型时,不再列出这个模型。只要还有一个可用账号能接,它就继续显示。
46 changes: 46 additions & 0 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 28 additions & 1 deletion internal/app/model_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
124 changes: 124 additions & 0 deletions internal/control/catalog_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions internal/control/catalog_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down
Loading