From 3fcd0dfb7b64b6f4d2a36683b77b98b30b291939 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 2 Sep 2026 20:36:19 -0500 Subject: [PATCH 1/8] fix(alias): dedupe merged oauth-model-alias entries on (name, alias), not alias alone A per-auth rename and a global fork sharing one client-visible alias must both survive: the fork registers the id, the rename decides the upstream body. Keyed on alias alone the per-auth entry evicted the fork and the id was never routable. Exercised live exposing Codex gpt-reserve on 2026-09-02. --- sdk/cliproxy/service_models.go | 17 ++++++-- .../service_models_alias_pair_test.go | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 sdk/cliproxy/service_models_alias_pair_test.go diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index 553123b5f13..bc0f983590c 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -918,18 +918,27 @@ func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map return perAuthAliases } out := make([]config.OAuthModelAlias, 0, len(perAuthAliases)+len(globalAliases)) - seenAlias := make(map[string]struct{}, len(perAuthAliases)+len(globalAliases)) + // Dedupe on (name, alias), not on alias alone. The two alias tables have different + // jobs and different constraints: the catalog fork (applyOAuthModelAliasEntries) can + // only clone an upstream id that is already in the model list, while the request-time + // rename (oauth_model_alias.go) accepts any upstream string and consults per-auth + // entries first. Exposing a hidden upstream model such as Codex "gpt-reserve" needs + // both: a global `gpt-5.6-luna -> X, fork` to make X routable, and a per-auth + // `gpt-reserve -> X` to rewrite the body. Keyed on alias alone, the per-auth entry + // evicted the global fork, X was never registered, and every request for it failed + // with "unknown provider". Same-pair duplicates are still collapsed, per-auth first. + seenPair := make(map[string]struct{}, len(perAuthAliases)+len(globalAliases)) add := func(aliases []config.OAuthModelAlias) { for _, entry := range aliases { alias := strings.TrimSpace(entry.Alias) if alias == "" { continue } - key := strings.ToLower(alias) - if _, exists := seenAlias[key]; exists { + key := strings.ToLower(strings.TrimSpace(entry.Name)) + "->" + strings.ToLower(alias) + if _, exists := seenPair[key]; exists { continue } - seenAlias[key] = struct{}{} + seenPair[key] = struct{}{} out = append(out, entry) } } diff --git a/sdk/cliproxy/service_models_alias_pair_test.go b/sdk/cliproxy/service_models_alias_pair_test.go new file mode 100644 index 00000000000..4bf717697bf --- /dev/null +++ b/sdk/cliproxy/service_models_alias_pair_test.go @@ -0,0 +1,39 @@ +package cliproxy + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +// A per-auth rename and a global fork that share one client-visible alias must BOTH +// survive the merge: the fork registers the id, the rename decides what goes upstream. +// Before the (name, alias) key, the per-auth entry evicted the fork and the id was never +// routable. Exercised live with Codex "gpt-reserve" on 2026-09-02. +func TestOAuthModelAliasesForAuthKeepsForkAlongsidePerAuthRename(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "codex": {{Name: "gpt-5.6-luna", Alias: "gpt-5.6-luna-reserve", Fork: true}}, + }} + attrs := map[string]string{ + "model_aliases": `[{"name":"gpt-reserve","alias":"gpt-5.6-luna-reserve"}]`, + } + got := oauthModelAliasesForAuth(cfg, "codex", attrs) + if len(got) != 2 { + t.Fatalf("expected both entries to survive, got %d: %+v", len(got), got) + } + if got[0].Name != "gpt-reserve" || got[1].Name != "gpt-5.6-luna" || !got[1].Fork { + t.Fatalf("expected per-auth rename first then global fork, got %+v", got) + } +} + +// Identical (name, alias) pairs still collapse, with the per-auth copy winning. +func TestOAuthModelAliasesForAuthStillDedupesSamePair(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "codex": {{Name: "gpt-5.6-luna", Alias: "luna-fast"}}, + }} + attrs := map[string]string{"model_aliases": `[{"name":"gpt-5.6-luna","alias":"luna-fast","force-mapping":true}]`} + got := oauthModelAliasesForAuth(cfg, "codex", attrs) + if len(got) != 1 || !got[0].ForceMapping { + t.Fatalf("expected one entry, the per-auth one, got %+v", got) + } +} From 9aa0aab0a51b1dc071fc86134ae0242a4e386359 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 3 Sep 2026 16:26:54 -0500 Subject: [PATCH 2/8] fix(alias): key the merge on a struct pair and drop shadowed global renames Review found two gaps in the (name, alias) merge: a joined-string key can collide when a name or alias contains the joiner, and a global rename that shares its alias with a per-auth entry, if retained, lets the catalog be renamed from the global source while the credential routes the id to its own upstream. The key is now a struct; a shadowed global entry survives only when it is a fork, which is the case the original change exists for. --- sdk/cliproxy/service_models.go | 69 ++++++++++++------- .../service_models_alias_pair_test.go | 27 ++++++++ 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index bc0f983590c..24e96683c51 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -917,33 +917,54 @@ func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map if len(globalAliases) == 0 { return perAuthAliases } + // Two tables, two jobs. The global table may fork a catalog entry (clone an upstream + // id under a new client-visible id); a per-auth entry decides what one credential sends + // upstream for that id, and request-time resolution consults per-auth first. A hidden + // upstream model (Codex "gpt-reserve") needs both: a global fork so the id is routable, + // and a per-auth rename so the body says gpt-reserve. Keyed on alias alone, the per-auth + // entry evicted the fork and the id was never registered. + // + // So the key is the (name, alias) pair, with one exception: a global entry that is not a + // fork and shares its alias with a per-auth entry is still dropped. Keeping it would let + // applyOAuthModelAliasEntries rename the catalog from the global source while the + // credential routes the id elsewhere, so the registered entry would carry the wrong + // upstream's metadata and the global source would lose its own id. + type aliasPair struct{ name, alias string } + pairOf := func(entry config.OAuthModelAlias) aliasPair { + return aliasPair{ + name: strings.ToLower(strings.TrimSpace(entry.Name)), + alias: strings.ToLower(strings.TrimSpace(entry.Alias)), + } + } out := make([]config.OAuthModelAlias, 0, len(perAuthAliases)+len(globalAliases)) - // Dedupe on (name, alias), not on alias alone. The two alias tables have different - // jobs and different constraints: the catalog fork (applyOAuthModelAliasEntries) can - // only clone an upstream id that is already in the model list, while the request-time - // rename (oauth_model_alias.go) accepts any upstream string and consults per-auth - // entries first. Exposing a hidden upstream model such as Codex "gpt-reserve" needs - // both: a global `gpt-5.6-luna -> X, fork` to make X routable, and a per-auth - // `gpt-reserve -> X` to rewrite the body. Keyed on alias alone, the per-auth entry - // evicted the global fork, X was never registered, and every request for it failed - // with "unknown provider". Same-pair duplicates are still collapsed, per-auth first. - seenPair := make(map[string]struct{}, len(perAuthAliases)+len(globalAliases)) - add := func(aliases []config.OAuthModelAlias) { - for _, entry := range aliases { - alias := strings.TrimSpace(entry.Alias) - if alias == "" { - continue - } - key := strings.ToLower(strings.TrimSpace(entry.Name)) + "->" + strings.ToLower(alias) - if _, exists := seenPair[key]; exists { - continue - } - seenPair[key] = struct{}{} - out = append(out, entry) + seenPair := make(map[aliasPair]struct{}, len(perAuthAliases)+len(globalAliases)) + perAuthAlias := make(map[string]struct{}, len(perAuthAliases)) + for _, entry := range perAuthAliases { + pair := pairOf(entry) + if pair.alias == "" { + continue + } + if _, exists := seenPair[pair]; exists { + continue + } + seenPair[pair] = struct{}{} + perAuthAlias[pair.alias] = struct{}{} + out = append(out, entry) + } + for _, entry := range globalAliases { + pair := pairOf(entry) + if pair.alias == "" { + continue + } + if _, exists := seenPair[pair]; exists { + continue + } + if _, shadowed := perAuthAlias[pair.alias]; shadowed && !entry.Fork { + continue } + seenPair[pair] = struct{}{} + out = append(out, entry) } - add(perAuthAliases) - add(globalAliases) return out } diff --git a/sdk/cliproxy/service_models_alias_pair_test.go b/sdk/cliproxy/service_models_alias_pair_test.go index 4bf717697bf..e11699560a5 100644 --- a/sdk/cliproxy/service_models_alias_pair_test.go +++ b/sdk/cliproxy/service_models_alias_pair_test.go @@ -26,6 +26,33 @@ func TestOAuthModelAliasesForAuthKeepsForkAlongsidePerAuthRename(t *testing.T) { } } +// A global rename (not a fork) that shares its alias with a per-auth entry is still dropped: +// keeping it would rename the catalog from the global source while the credential routes +// the id to its own upstream. +func TestOAuthModelAliasesForAuthDropsShadowedGlobalRename(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "codex": {{Name: "gpt-5.6-luna", Alias: "shared"}}, + }} + attrs := map[string]string{"model_aliases": `[{"name":"gpt-reserve","alias":"shared"}]`} + got := oauthModelAliasesForAuth(cfg, "codex", attrs) + if len(got) != 1 || got[0].Name != "gpt-reserve" { + t.Fatalf("expected only the per-auth entry, got %+v", got) + } +} + +// The pair key is a struct, not a joined string, so names and aliases that themselves +// contain the joiner cannot collide: {a->b, c} and {a, b->c} are different pairs. +func TestOAuthModelAliasesForAuthPairKeyIsCollisionFree(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "codex": {{Name: "a", Alias: "b->c", Fork: true}}, + }} + attrs := map[string]string{"model_aliases": `[{"name":"a->b","alias":"c"}]`} + got := oauthModelAliasesForAuth(cfg, "codex", attrs) + if len(got) != 2 { + t.Fatalf("expected both pairs to survive, got %d: %+v", len(got), got) + } +} + // Identical (name, alias) pairs still collapse, with the per-auth copy winning. func TestOAuthModelAliasesForAuthStillDedupesSamePair(t *testing.T) { cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ From 7efb11d7ec5219dc0c98aec2ea2c1f372e098aa1 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 3 Sep 2026 16:31:03 -0500 Subject: [PATCH 3/8] fix(alias): keep a shadowed global fork only when the per-auth source is not in the catalog applyOAuthModelAliasEntries walks the catalog in order, so a global fork A -> X that survives the merge can claim X before the per-auth rename B -> X is reached, leaving B exposed under its own id while requests for X route to B. The fork is a fallback for the hidden-model case only: it is kept when B is absent from the catalog and dropped when B can supply the entry itself. The merge now receives the catalog's id set for that check. --- sdk/cliproxy/service_models.go | 45 +++++++++++++---- .../service_models_alias_pair_test.go | 49 +++++++++++++++++-- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index 24e96683c51..c7067ddaa41 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -898,14 +898,32 @@ func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, if channel == "" { return models } - aliases := oauthModelAliasesForAuth(cfg, channel, attributes) + aliases := oauthModelAliasesForAuth(cfg, channel, attributes, catalogModelIDs(models)) if len(aliases) == 0 { return models } return applyOAuthModelAliasEntries(aliases, models) } -func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map[string]string) []config.OAuthModelAlias { +// catalogModelIDs is the set of upstream ids present in the catalog, lowercased, so the +// alias merge can tell whether a per-auth source model can supply a catalog entry itself. +func catalogModelIDs(models []*ModelInfo) map[string]struct{} { + ids := make(map[string]struct{}, len(models)) + for _, model := range models { + if model == nil { + continue + } + if id := strings.ToLower(strings.TrimSpace(model.ID)); id != "" { + ids[id] = struct{}{} + } + } + return ids +} + +// oauthModelAliasesForAuth merges a credential's aliases with the channel's global table. +// catalog is the set of upstream ids the catalog already has; nil means unknown, which keeps +// every global fork. +func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map[string]string, catalog map[string]struct{}) []config.OAuthModelAlias { perAuthAliases := coreauth.OAuthModelAliasesFromAttributes(attributes) if cfg == nil || len(cfg.OAuthModelAlias) == 0 { return perAuthAliases @@ -924,11 +942,13 @@ func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map // and a per-auth rename so the body says gpt-reserve. Keyed on alias alone, the per-auth // entry evicted the fork and the id was never registered. // - // So the key is the (name, alias) pair, with one exception: a global entry that is not a - // fork and shares its alias with a per-auth entry is still dropped. Keeping it would let - // applyOAuthModelAliasEntries rename the catalog from the global source while the - // credential routes the id elsewhere, so the registered entry would carry the wrong - // upstream's metadata and the global source would lose its own id. + // So the key is the (name, alias) pair, with one exception. A global entry that shares + // its alias with a per-auth entry is a fallback for the catalog, kept only when the + // per-auth source model is not in the catalog itself (the hidden-model case) and only + // when it is a fork. Keeping it otherwise would let applyOAuthModelAliasEntries build + // the catalog entry from the global source, in catalog order, while the credential + // routes the id to its own upstream: the registered entry would carry the wrong + // upstream's metadata and the per-auth source would stay exposed under its own id. type aliasPair struct{ name, alias string } pairOf := func(entry config.OAuthModelAlias) aliasPair { return aliasPair{ @@ -938,7 +958,8 @@ func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map } out := make([]config.OAuthModelAlias, 0, len(perAuthAliases)+len(globalAliases)) seenPair := make(map[aliasPair]struct{}, len(perAuthAliases)+len(globalAliases)) - perAuthAlias := make(map[string]struct{}, len(perAuthAliases)) + // alias -> whether some per-auth source for that alias can supply the catalog entry. + perAuthAlias := make(map[string]bool, len(perAuthAliases)) for _, entry := range perAuthAliases { pair := pairOf(entry) if pair.alias == "" { @@ -948,7 +969,11 @@ func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map continue } seenPair[pair] = struct{}{} - perAuthAlias[pair.alias] = struct{}{} + inCatalog := false + if catalog != nil { + _, inCatalog = catalog[pair.name] + } + perAuthAlias[pair.alias] = perAuthAlias[pair.alias] || inCatalog out = append(out, entry) } for _, entry := range globalAliases { @@ -959,7 +984,7 @@ func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map if _, exists := seenPair[pair]; exists { continue } - if _, shadowed := perAuthAlias[pair.alias]; shadowed && !entry.Fork { + if sourceInCatalog, shadowed := perAuthAlias[pair.alias]; shadowed && (!entry.Fork || sourceInCatalog) { continue } seenPair[pair] = struct{}{} diff --git a/sdk/cliproxy/service_models_alias_pair_test.go b/sdk/cliproxy/service_models_alias_pair_test.go index e11699560a5..d8cb6f306ae 100644 --- a/sdk/cliproxy/service_models_alias_pair_test.go +++ b/sdk/cliproxy/service_models_alias_pair_test.go @@ -6,6 +6,18 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) +// catalogOf is the set of upstream ids the catalog holds, as applyOAuthModelAliasForAuth +// derives it from the model list. +func catalogOf(ids ...string) map[string]struct{} { + return catalogModelIDs(func() []*ModelInfo { + out := make([]*ModelInfo, 0, len(ids)) + for _, id := range ids { + out = append(out, &ModelInfo{ID: id}) + } + return out + }()) +} + // A per-auth rename and a global fork that share one client-visible alias must BOTH // survive the merge: the fork registers the id, the rename decides what goes upstream. // Before the (name, alias) key, the per-auth entry evicted the fork and the id was never @@ -17,7 +29,8 @@ func TestOAuthModelAliasesForAuthKeepsForkAlongsidePerAuthRename(t *testing.T) { attrs := map[string]string{ "model_aliases": `[{"name":"gpt-reserve","alias":"gpt-5.6-luna-reserve"}]`, } - got := oauthModelAliasesForAuth(cfg, "codex", attrs) + catalog := catalogOf("gpt-5.6-luna") + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) if len(got) != 2 { t.Fatalf("expected both entries to survive, got %d: %+v", len(got), got) } @@ -34,12 +47,38 @@ func TestOAuthModelAliasesForAuthDropsShadowedGlobalRename(t *testing.T) { "codex": {{Name: "gpt-5.6-luna", Alias: "shared"}}, }} attrs := map[string]string{"model_aliases": `[{"name":"gpt-reserve","alias":"shared"}]`} - got := oauthModelAliasesForAuth(cfg, "codex", attrs) + catalog := catalogOf("gpt-5.6-luna") + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) if len(got) != 1 || got[0].Name != "gpt-reserve" { t.Fatalf("expected only the per-auth entry, got %+v", got) } } +// When the per-auth source model is itself in the catalog, the global fork is not needed +// and would win catalog order over the per-auth rename, leaving the per-auth source +// exposed under its own id while requests for the alias route to it. Drop the fork. +func TestOAuthModelAliasesForAuthDropsGlobalForkWhenPerAuthSourceIsInCatalog(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "codex": {{Name: "gpt-5.6-luna", Alias: "gpt-5.6-luna-reserve", Fork: true}}, + }} + attrs := map[string]string{"model_aliases": `[{"name":"gpt-reserve","alias":"gpt-5.6-luna-reserve"}]`} + catalog := catalogOf("gpt-5.6-luna", "gpt-reserve") + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) + if len(got) != 1 || got[0].Name != "gpt-reserve" { + t.Fatalf("expected only the per-auth rename, got %+v", got) + } + // End to end: the catalog ends up with luna, and reserve renamed to the alias, once. + models := []*ModelInfo{{ID: "gpt-5.6-luna"}, {ID: "gpt-reserve"}} + out := applyOAuthModelAliasForAuth(cfg, "codex", "oauth", attrs, models) + ids := make([]string, 0, len(out)) + for _, m := range out { + ids = append(ids, m.ID) + } + if len(ids) != 2 || ids[0] != "gpt-5.6-luna" || ids[1] != "gpt-5.6-luna-reserve" { + t.Fatalf("expected [gpt-5.6-luna gpt-5.6-luna-reserve], got %v", ids) + } +} + // The pair key is a struct, not a joined string, so names and aliases that themselves // contain the joiner cannot collide: {a->b, c} and {a, b->c} are different pairs. func TestOAuthModelAliasesForAuthPairKeyIsCollisionFree(t *testing.T) { @@ -47,7 +86,8 @@ func TestOAuthModelAliasesForAuthPairKeyIsCollisionFree(t *testing.T) { "codex": {{Name: "a", Alias: "b->c", Fork: true}}, }} attrs := map[string]string{"model_aliases": `[{"name":"a->b","alias":"c"}]`} - got := oauthModelAliasesForAuth(cfg, "codex", attrs) + catalog := catalogOf("gpt-5.6-luna") + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) if len(got) != 2 { t.Fatalf("expected both pairs to survive, got %d: %+v", len(got), got) } @@ -59,7 +99,8 @@ func TestOAuthModelAliasesForAuthStillDedupesSamePair(t *testing.T) { "codex": {{Name: "gpt-5.6-luna", Alias: "luna-fast"}}, }} attrs := map[string]string{"model_aliases": `[{"name":"gpt-5.6-luna","alias":"luna-fast","force-mapping":true}]`} - got := oauthModelAliasesForAuth(cfg, "codex", attrs) + catalog := catalogOf("gpt-5.6-luna") + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) if len(got) != 1 || !got[0].ForceMapping { t.Fatalf("expected one entry, the per-auth one, got %+v", got) } From ac2af5fdee5415e4c96fdee1c8a3ac9438928fae Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 3 Sep 2026 16:36:54 -0500 Subject: [PATCH 4/8] fix(alias): an excluded per-auth source does not count as a hidden model A per-auth source that oauth-excluded-models removed looks, after filtering, exactly like a hidden upstream model, so the global fork was kept and registered the alias from the global source while request-time resolution (per-auth first) sent it to the excluded model. The merge now receives the credential's exclusion list and treats an excluded source as able to supply the entry, so the fork is dropped and the alias stays unroutable through that credential. --- sdk/cliproxy/service_models.go | 45 +++++++++++++++---- .../service_models_alias_pair_test.go | 36 ++++++++++++--- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index c7067ddaa41..6b1a5cb55fb 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -898,7 +898,7 @@ func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, if channel == "" { return models } - aliases := oauthModelAliasesForAuth(cfg, channel, attributes, catalogModelIDs(models)) + aliases := oauthModelAliasesForAuth(cfg, channel, attributes, catalogModelIDs(models), excludedModelsFromAttributes(attributes)) if len(aliases) == 0 { return models } @@ -920,10 +920,34 @@ func catalogModelIDs(models []*ModelInfo) map[string]struct{} { return ids } +// excludedModelsFromAttributes reads the synthesizer's pre-merged exclusion list, the same +// attribute registerModelsForAuth applies before the alias pass. +func excludedModelsFromAttributes(attributes map[string]string) []string { + if attributes == nil { + return nil + } + raw := strings.TrimSpace(attributes["excluded_models"]) + if raw == "" { + return nil + } + return strings.Split(raw, ",") +} + +func modelExcluded(id string, excluded []string) bool { + for _, pattern := range excluded { + if trimmed := strings.ToLower(strings.TrimSpace(pattern)); trimmed != "" && matchWildcard(trimmed, id) { + return true + } + } + return false +} + // oauthModelAliasesForAuth merges a credential's aliases with the channel's global table. -// catalog is the set of upstream ids the catalog already has; nil means unknown, which keeps -// every global fork. -func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map[string]string, catalog map[string]struct{}) []config.OAuthModelAlias { +// catalog is the set of upstream ids the catalog has after exclusions; nil means unknown, +// which keeps every global fork. excluded is the credential's exclusion list: a per-auth +// source that exclusions removed must not be treated as a hidden model, or the global fork +// would register an id that request-time resolution routes to the excluded model. +func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map[string]string, catalog map[string]struct{}, excluded []string) []config.OAuthModelAlias { perAuthAliases := coreauth.OAuthModelAliasesFromAttributes(attributes) if cfg == nil || len(cfg.OAuthModelAlias) == 0 { return perAuthAliases @@ -969,11 +993,14 @@ func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map continue } seenPair[pair] = struct{}{} - inCatalog := false - if catalog != nil { - _, inCatalog = catalog[pair.name] - } - perAuthAlias[pair.alias] = perAuthAlias[pair.alias] || inCatalog + // "Can supply the entry" is the fork-fallback question; an excluded source + // answers yes here on purpose, so the fork is dropped and the alias stays + // unroutable through this credential. + canSupply := modelExcluded(pair.name, excluded) + if !canSupply && catalog != nil { + _, canSupply = catalog[pair.name] + } + perAuthAlias[pair.alias] = perAuthAlias[pair.alias] || canSupply out = append(out, entry) } for _, entry := range globalAliases { diff --git a/sdk/cliproxy/service_models_alias_pair_test.go b/sdk/cliproxy/service_models_alias_pair_test.go index d8cb6f306ae..29e702f56ff 100644 --- a/sdk/cliproxy/service_models_alias_pair_test.go +++ b/sdk/cliproxy/service_models_alias_pair_test.go @@ -30,7 +30,7 @@ func TestOAuthModelAliasesForAuthKeepsForkAlongsidePerAuthRename(t *testing.T) { "model_aliases": `[{"name":"gpt-reserve","alias":"gpt-5.6-luna-reserve"}]`, } catalog := catalogOf("gpt-5.6-luna") - got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog, nil) if len(got) != 2 { t.Fatalf("expected both entries to survive, got %d: %+v", len(got), got) } @@ -48,7 +48,7 @@ func TestOAuthModelAliasesForAuthDropsShadowedGlobalRename(t *testing.T) { }} attrs := map[string]string{"model_aliases": `[{"name":"gpt-reserve","alias":"shared"}]`} catalog := catalogOf("gpt-5.6-luna") - got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog, nil) if len(got) != 1 || got[0].Name != "gpt-reserve" { t.Fatalf("expected only the per-auth entry, got %+v", got) } @@ -63,7 +63,7 @@ func TestOAuthModelAliasesForAuthDropsGlobalForkWhenPerAuthSourceIsInCatalog(t * }} attrs := map[string]string{"model_aliases": `[{"name":"gpt-reserve","alias":"gpt-5.6-luna-reserve"}]`} catalog := catalogOf("gpt-5.6-luna", "gpt-reserve") - got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog, nil) if len(got) != 1 || got[0].Name != "gpt-reserve" { t.Fatalf("expected only the per-auth rename, got %+v", got) } @@ -79,6 +79,32 @@ func TestOAuthModelAliasesForAuthDropsGlobalForkWhenPerAuthSourceIsInCatalog(t * } } +// A per-auth source removed by oauth-excluded-models must not read as a hidden model. If the +// global fork survived, X would be registered from the fork while request-time resolution +// (per-auth first) sent X to the excluded model. Both the merge and the end-to-end catalog +// pin that the fork is dropped and X is not registered. +func TestOAuthModelAliasesForAuthDropsGlobalForkWhenPerAuthSourceIsExcluded(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "codex": {{Name: "gpt-5.6-luna", Alias: "gpt-5.6-luna-reserve", Fork: true}}, + }} + attrs := map[string]string{ + "model_aliases": `[{"name":"gpt-reserve","alias":"gpt-5.6-luna-reserve"}]`, + "excluded_models": "gpt-reserve", + } + // The catalog after exclusions: gpt-reserve is gone, exactly as a hidden model would be. + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalogOf("gpt-5.6-luna"), []string{"gpt-reserve"}) + if len(got) != 1 || got[0].Name != "gpt-reserve" { + t.Fatalf("expected the fork to be dropped, got %+v", got) + } + models := applyExcludedModels([]*ModelInfo{{ID: "gpt-5.6-luna"}, {ID: "gpt-reserve"}}, []string{"gpt-reserve"}) + out := applyOAuthModelAliasForAuth(cfg, "codex", "oauth", attrs, models) + for _, m := range out { + if m.ID == "gpt-5.6-luna-reserve" { + t.Fatalf("excluded source must not be reachable through the alias, got %+v", out) + } + } +} + // The pair key is a struct, not a joined string, so names and aliases that themselves // contain the joiner cannot collide: {a->b, c} and {a, b->c} are different pairs. func TestOAuthModelAliasesForAuthPairKeyIsCollisionFree(t *testing.T) { @@ -87,7 +113,7 @@ func TestOAuthModelAliasesForAuthPairKeyIsCollisionFree(t *testing.T) { }} attrs := map[string]string{"model_aliases": `[{"name":"a->b","alias":"c"}]`} catalog := catalogOf("gpt-5.6-luna") - got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog, nil) if len(got) != 2 { t.Fatalf("expected both pairs to survive, got %d: %+v", len(got), got) } @@ -100,7 +126,7 @@ func TestOAuthModelAliasesForAuthStillDedupesSamePair(t *testing.T) { }} attrs := map[string]string{"model_aliases": `[{"name":"gpt-5.6-luna","alias":"luna-fast","force-mapping":true}]`} catalog := catalogOf("gpt-5.6-luna") - got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog) + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog, nil) if len(got) != 1 || !got[0].ForceMapping { t.Fatalf("expected one entry, the per-auth one, got %+v", got) } From 9e27241d123601f21aa5fc1109c324b2a0456d4e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 3 Sep 2026 16:47:58 -0500 Subject: [PATCH 5/8] fix(alias): pass the effective exclusion list into the alias merge registerModelsForAuth filters the catalog with the global oauth-excluded-models list when a credential carries no synthesized excluded_models attribute (SDK-created or legacy credentials). Reading exclusions back from attributes missed that list, so an excluded source could still read as hidden. Both callers now pass the list they filtered with, and the attribute reader is gone. --- sdk/cliproxy/service_executors.go | 2 +- sdk/cliproxy/service_models.go | 31 +++++++------------ .../service_models_alias_pair_test.go | 10 ++++-- .../service_oauth_model_alias_test.go | 2 +- 4 files changed, 20 insertions(+), 25 deletions(-) diff --git a/sdk/cliproxy/service_executors.go b/sdk/cliproxy/service_executors.go index 0213ee4f41e..325d4f4117e 100644 --- a/sdk/cliproxy/service_executors.go +++ b/sdk/cliproxy/service_executors.go @@ -552,7 +552,7 @@ func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreaut return true } models := applyExcludedModels(result.Models, activeExcluded) - models = applyOAuthModelAliasForAuth(s.cfg, providerKey, activeAuthKind, activeAuth.Attributes, models) + models = applyOAuthModelAliasForAuth(s.cfg, providerKey, activeAuthKind, activeAuth.Attributes, activeExcluded, models) if len(models) > 0 { s.registerResolvedModelsForAuth(activeAuth, providerKey, applyModelPrefixes(models, activeAuth.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix)) return true diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index 6b1a5cb55fb..0f363b24a56 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -262,7 +262,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut if ctx.Err() != nil { return } - models = applyOAuthModelAliasForAuth(s.cfg, provider, authKind, a.Attributes, models) + models = applyOAuthModelAliasForAuth(s.cfg, provider, authKind, a.Attributes, excluded, models) if ctx.Err() != nil { return } @@ -887,10 +887,13 @@ func rewriteModelInfoName(name, oldID, newID string) string { } func applyOAuthModelAlias(cfg *config.Config, provider, authKind string, models []*ModelInfo) []*ModelInfo { - return applyOAuthModelAliasForAuth(cfg, provider, authKind, nil, models) + return applyOAuthModelAliasForAuth(cfg, provider, authKind, nil, nil, models) } -func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, attributes map[string]string, models []*ModelInfo) []*ModelInfo { +// applyOAuthModelAliasForAuth applies the merged alias table to a catalog that has already +// had `excluded` removed. excluded is the same effective list the caller filtered with, so a +// source that exclusions removed is not mistaken for a hidden upstream model. +func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, attributes map[string]string, excluded []string, models []*ModelInfo) []*ModelInfo { if len(models) == 0 { return models } @@ -898,7 +901,7 @@ func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, if channel == "" { return models } - aliases := oauthModelAliasesForAuth(cfg, channel, attributes, catalogModelIDs(models), excludedModelsFromAttributes(attributes)) + aliases := oauthModelAliasesForAuth(cfg, channel, attributes, catalogModelIDs(models), excluded) if len(aliases) == 0 { return models } @@ -920,19 +923,6 @@ func catalogModelIDs(models []*ModelInfo) map[string]struct{} { return ids } -// excludedModelsFromAttributes reads the synthesizer's pre-merged exclusion list, the same -// attribute registerModelsForAuth applies before the alias pass. -func excludedModelsFromAttributes(attributes map[string]string) []string { - if attributes == nil { - return nil - } - raw := strings.TrimSpace(attributes["excluded_models"]) - if raw == "" { - return nil - } - return strings.Split(raw, ",") -} - func modelExcluded(id string, excluded []string) bool { for _, pattern := range excluded { if trimmed := strings.ToLower(strings.TrimSpace(pattern)); trimmed != "" && matchWildcard(trimmed, id) { @@ -944,9 +934,10 @@ func modelExcluded(id string, excluded []string) bool { // oauthModelAliasesForAuth merges a credential's aliases with the channel's global table. // catalog is the set of upstream ids the catalog has after exclusions; nil means unknown, -// which keeps every global fork. excluded is the credential's exclusion list: a per-auth -// source that exclusions removed must not be treated as a hidden model, or the global fork -// would register an id that request-time resolution routes to the excluded model. +// which keeps every global fork. excluded is the effective exclusion list the catalog was +// filtered with (global oauth-excluded-models or the synthesizer's merged attribute): a +// per-auth source that exclusions removed must not be treated as a hidden model, or the +// global fork would register an id that request-time resolution routes to the excluded model. func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map[string]string, catalog map[string]struct{}, excluded []string) []config.OAuthModelAlias { perAuthAliases := coreauth.OAuthModelAliasesFromAttributes(attributes) if cfg == nil || len(cfg.OAuthModelAlias) == 0 { diff --git a/sdk/cliproxy/service_models_alias_pair_test.go b/sdk/cliproxy/service_models_alias_pair_test.go index 29e702f56ff..d47bd75bbcb 100644 --- a/sdk/cliproxy/service_models_alias_pair_test.go +++ b/sdk/cliproxy/service_models_alias_pair_test.go @@ -69,7 +69,7 @@ func TestOAuthModelAliasesForAuthDropsGlobalForkWhenPerAuthSourceIsInCatalog(t * } // End to end: the catalog ends up with luna, and reserve renamed to the alias, once. models := []*ModelInfo{{ID: "gpt-5.6-luna"}, {ID: "gpt-reserve"}} - out := applyOAuthModelAliasForAuth(cfg, "codex", "oauth", attrs, models) + out := applyOAuthModelAliasForAuth(cfg, "codex", "oauth", attrs, nil, models) ids := make([]string, 0, len(out)) for _, m := range out { ids = append(ids, m.ID) @@ -96,8 +96,12 @@ func TestOAuthModelAliasesForAuthDropsGlobalForkWhenPerAuthSourceIsExcluded(t *t if len(got) != 1 || got[0].Name != "gpt-reserve" { t.Fatalf("expected the fork to be dropped, got %+v", got) } - models := applyExcludedModels([]*ModelInfo{{ID: "gpt-5.6-luna"}, {ID: "gpt-reserve"}}, []string{"gpt-reserve"}) - out := applyOAuthModelAliasForAuth(cfg, "codex", "oauth", attrs, models) + // The exclusion may come from the global oauth-excluded-models table rather than the + // attribute, as for an SDK-created credential; the caller passes whatever it filtered with. + delete(attrs, "excluded_models") + excluded := []string{"gpt-reserve"} + models := applyExcludedModels([]*ModelInfo{{ID: "gpt-5.6-luna"}, {ID: "gpt-reserve"}}, excluded) + out := applyOAuthModelAliasForAuth(cfg, "codex", "oauth", attrs, excluded, models) for _, m := range out { if m.ID == "gpt-5.6-luna-reserve" { t.Fatalf("excluded source must not be reachable through the alias, got %+v", out) diff --git a/sdk/cliproxy/service_oauth_model_alias_test.go b/sdk/cliproxy/service_oauth_model_alias_test.go index 784f34c93ed..9f4b7ee8fdc 100644 --- a/sdk/cliproxy/service_oauth_model_alias_test.go +++ b/sdk/cliproxy/service_oauth_model_alias_test.go @@ -171,7 +171,7 @@ func TestApplyOAuthModelAlias_PerAuthAlias(t *testing.T) { "model_aliases": `[{"name":"gpt-5.3-codex-spark","alias":"gpt-5.5","display-name":"Configured GPT Five"}]`, } - out := applyOAuthModelAliasForAuth(nil, "codex", "oauth", attributes, models) + out := applyOAuthModelAliasForAuth(nil, "codex", "oauth", attributes, nil, models) if len(out) != 1 { t.Fatalf("expected 1 model, got %d", len(out)) } From 58f7cee9055eabbad5408488be70e69b35040fb5 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 3 Sep 2026 16:51:43 -0500 Subject: [PATCH 6/8] fix(alias): an excluded client-visible alias also drops the shadowed fork Exclusions run before aliases, so a fork that survived the merge would recreate an excluded alias from the global source while requests for it routed to the per-auth upstream. The exclusion list is now checked against the alias as well as the source, wildcards included. --- sdk/cliproxy/service_models.go | 8 +++---- .../service_models_alias_pair_test.go | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index 0f363b24a56..0dc559c9f87 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -984,10 +984,10 @@ func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map continue } seenPair[pair] = struct{}{} - // "Can supply the entry" is the fork-fallback question; an excluded source - // answers yes here on purpose, so the fork is dropped and the alias stays - // unroutable through this credential. - canSupply := modelExcluded(pair.name, excluded) + // "Can supply the entry" is the fork-fallback question. An excluded source, or + // an excluded alias, answers yes on purpose: the fork is dropped and the id stays + // unroutable through this credential, which is what the exclusion asked for. + canSupply := modelExcluded(pair.name, excluded) || modelExcluded(pair.alias, excluded) if !canSupply && catalog != nil { _, canSupply = catalog[pair.name] } diff --git a/sdk/cliproxy/service_models_alias_pair_test.go b/sdk/cliproxy/service_models_alias_pair_test.go index d47bd75bbcb..7a3eaefae1f 100644 --- a/sdk/cliproxy/service_models_alias_pair_test.go +++ b/sdk/cliproxy/service_models_alias_pair_test.go @@ -109,6 +109,28 @@ func TestOAuthModelAliasesForAuthDropsGlobalForkWhenPerAuthSourceIsExcluded(t *t } } +// The exclusion may name the client-visible alias rather than the source. Exclusions run +// before aliases, so a surviving fork would recreate the excluded id from the global source +// while requests for it route to the per-auth upstream. The fork is dropped for that too. +func TestOAuthModelAliasesForAuthDropsGlobalForkWhenAliasIsExcluded(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "codex": {{Name: "gpt-5.6-luna", Alias: "gpt-5.6-luna-reserve", Fork: true}}, + }} + attrs := map[string]string{"model_aliases": `[{"name":"gpt-reserve","alias":"gpt-5.6-luna-reserve"}]`} + excluded := []string{"gpt-5.6-luna-*"} + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalogOf("gpt-5.6-luna"), excluded) + if len(got) != 1 || got[0].Name != "gpt-reserve" { + t.Fatalf("expected the fork to be dropped, got %+v", got) + } + models := applyExcludedModels([]*ModelInfo{{ID: "gpt-5.6-luna"}}, excluded) + out := applyOAuthModelAliasForAuth(cfg, "codex", "oauth", attrs, excluded, models) + for _, m := range out { + if m.ID == "gpt-5.6-luna-reserve" { + t.Fatalf("excluded alias must not be recreated, got %+v", out) + } + } +} + // The pair key is a struct, not a joined string, so names and aliases that themselves // contain the joiner cannot collide: {a->b, c} and {a, b->c} are different pairs. func TestOAuthModelAliasesForAuthPairKeyIsCollisionFree(t *testing.T) { From c564190b66835e38eda3e17632db1ef099380ba5 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 3 Sep 2026 16:55:50 -0500 Subject: [PATCH 7/8] fix(alias): count plugin-supplied sources as present when deciding a fork fallback registerModelsForAuth appends static plugin models after the alias pass, so a per-auth source a plugin supplies was absent from the catalog the merge saw and read as hidden, keeping a fork that then registered the alias from the global source. The caller now passes the ids it will end up serving, plugin models included. --- sdk/cliproxy/service_models.go | 19 +++++++++++++----- .../service_models_alias_pair_test.go | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index 0dc559c9f87..e37fa1488a1 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -262,14 +262,16 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut if ctx.Err() != nil { return } - models = applyOAuthModelAliasForAuth(s.cfg, provider, authKind, a.Attributes, excluded, models) - if ctx.Err() != nil { - return - } key := provider if key == "" { key = strings.ToLower(strings.TrimSpace(a.Provider)) } + // Plugin models are appended after the alias pass, so a per-auth source that a static + // plugin supplies is not in `models` yet; the alias merge still needs to know it exists. + models = applyOAuthModelAliasForAuthWithCatalog(s.cfg, provider, authKind, a.Attributes, excluded, models, catalogModelIDs(s.appendPluginModels(key, models))) + if ctx.Err() != nil { + return + } models = s.appendPluginModels(key, models) if len(models) > 0 { s.registerResolvedModelsForAuth(a, key, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix)) @@ -894,6 +896,13 @@ func applyOAuthModelAlias(cfg *config.Config, provider, authKind string, models // had `excluded` removed. excluded is the same effective list the caller filtered with, so a // source that exclusions removed is not mistaken for a hidden upstream model. func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, attributes map[string]string, excluded []string, models []*ModelInfo) []*ModelInfo { + return applyOAuthModelAliasForAuthWithCatalog(cfg, provider, authKind, attributes, excluded, models, catalogModelIDs(models)) +} + +// applyOAuthModelAliasForAuthWithCatalog is applyOAuthModelAliasForAuth with an explicit set +// of upstream ids the credential will end up serving, for callers whose final catalog is +// larger than `models` (plugin models are appended after the alias pass). +func applyOAuthModelAliasForAuthWithCatalog(cfg *config.Config, provider, authKind string, attributes map[string]string, excluded []string, models []*ModelInfo, catalog map[string]struct{}) []*ModelInfo { if len(models) == 0 { return models } @@ -901,7 +910,7 @@ func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, if channel == "" { return models } - aliases := oauthModelAliasesForAuth(cfg, channel, attributes, catalogModelIDs(models), excluded) + aliases := oauthModelAliasesForAuth(cfg, channel, attributes, catalog, excluded) if len(aliases) == 0 { return models } diff --git a/sdk/cliproxy/service_models_alias_pair_test.go b/sdk/cliproxy/service_models_alias_pair_test.go index 7a3eaefae1f..d0ca00ff0c6 100644 --- a/sdk/cliproxy/service_models_alias_pair_test.go +++ b/sdk/cliproxy/service_models_alias_pair_test.go @@ -131,6 +131,26 @@ func TestOAuthModelAliasesForAuthDropsGlobalForkWhenAliasIsExcluded(t *testing.T } } +// A per-auth source supplied by a static plugin is appended after the alias pass, so it is +// absent from the catalog the alias pass sees. The caller passes the ids it will end up +// serving, plugin models included, so a plugin-backed source is not read as hidden. +func TestOAuthModelAliasesForAuthSeesPluginSourcesAsPresent(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "codex": {{Name: "gpt-5.6-luna", Alias: "gpt-5.6-luna-reserve", Fork: true}}, + }} + attrs := map[string]string{"model_aliases": `[{"name":"gpt-reserve","alias":"gpt-5.6-luna-reserve"}]`} + // Upstream catalog without the plugin model: the fork would be kept. + if got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalogOf("gpt-5.6-luna"), nil); len(got) != 2 { + t.Fatalf("without the plugin source the fork is a needed fallback, got %+v", got) + } + // Final catalog including the plugin-supplied source: the fork is dropped. + models := []*ModelInfo{{ID: "gpt-5.6-luna"}} + out := applyOAuthModelAliasForAuthWithCatalog(cfg, "codex", "oauth", attrs, nil, models, catalogOf("gpt-5.6-luna", "gpt-reserve")) + if len(out) != 1 || out[0].ID != "gpt-5.6-luna" { + t.Fatalf("expected the fork dropped and no alias registered from the global source, got %+v", out) + } +} + // The pair key is a struct, not a joined string, so names and aliases that themselves // contain the joiner cannot collide: {a->b, c} and {a, b->c} are different pairs. func TestOAuthModelAliasesForAuthPairKeyIsCollisionFree(t *testing.T) { From 3bc7edb79ddd7d7f4f14b4e1670606c154e7b242 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 23 Sep 2026 15:16:34 -0500 Subject: [PATCH 8/8] fix(antigravity): keep the per-auth upstream in the reverse alias map The (name, alias) merge can return a per-auth rename and a global fork under one client-visible alias, per-auth first. buildAntigravityReverseAliasMap overwrote the first entry with the later fork, so the capability probe looked up the fork's source instead of the upstream the credential is actually sent. The first entry for an alias now wins, matching request-time resolution. Before the pair merge the table held at most one entry per alias, so existing configs map the same way. Co-Authored-By: Claude Opus 5.5 (1M context) --- sdk/cliproxy/antigravity_models.go | 13 ++++- sdk/cliproxy/antigravity_models_alias_test.go | 53 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 sdk/cliproxy/antigravity_models_alias_test.go diff --git a/sdk/cliproxy/antigravity_models.go b/sdk/cliproxy/antigravity_models.go index ee3b46f676d..dc74a85494a 100644 --- a/sdk/cliproxy/antigravity_models.go +++ b/sdk/cliproxy/antigravity_models.go @@ -458,6 +458,9 @@ func (s *Service) buildAntigravityReverseAliasMap(auth *coreauth.Auth) map[strin s.cfgMu.RUnlock() } channel := coreauth.OAuthModelAliasChannel(auth.Provider, auth.AuthKind()) + // The probe maps ids that are already registered back to their upstream, so it has no + // catalog or exclusion list of its own. A nil catalog keeps every global fork, which is + // what an id registered only by a fork needs to resolve. aliases := oauthModelAliasesForAuth(cfg, channel, auth.Attributes, nil, nil) if len(aliases) == 0 { return nil @@ -466,9 +469,15 @@ func (s *Service) buildAntigravityReverseAliasMap(auth *coreauth.Auth) map[strin for _, entry := range aliases { aliasName := strings.ToLower(strings.TrimSpace(entry.Alias)) upstreamName := strings.ToLower(strings.TrimSpace(entry.Name)) - if aliasName != "" && upstreamName != "" { - aliasMap[aliasName] = upstreamName + if aliasName == "" || upstreamName == "" { + continue + } + // One alias can carry a per-auth rename and a global fork. Per-auth entries come + // first and decide what this credential sends upstream, so the first entry wins. + if _, exists := aliasMap[aliasName]; exists { + continue } + aliasMap[aliasName] = upstreamName } return aliasMap } diff --git a/sdk/cliproxy/antigravity_models_alias_test.go b/sdk/cliproxy/antigravity_models_alias_test.go new file mode 100644 index 00000000000..a4db064d179 --- /dev/null +++ b/sdk/cliproxy/antigravity_models_alias_test.go @@ -0,0 +1,53 @@ +package cliproxy + +import ( + "testing" + + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" +) + +func antigravityAuth(aliases string) *coreauth.Auth { + return &coreauth.Auth{ + ID: "antigravity-alias-test", + Provider: "antigravity", + Attributes: map[string]string{ + "auth_kind": coreauth.AuthKindOAuth, + "model_aliases": aliases, + }, + } +} + +// The capability probe maps a registered id back to the upstream the credential is actually +// sent. The (name, alias) merge can return a per-auth rename and a global fork under one +// alias; request-time resolution consults the per-auth entry, so the reverse map must too. +func TestBuildAntigravityReverseAliasMapPrefersPerAuthEntry(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "antigravity": {{Name: "gemini-3-pro", Alias: "gemini-3-pro-preview", Fork: true}}, + }} + auth := antigravityAuth(`[{"name":"gemini-3-pro-internal","alias":"gemini-3-pro-preview"}]`) + got := (&Service{cfg: cfg}).buildAntigravityReverseAliasMap(auth) + if got["gemini-3-pro-preview"] != "gemini-3-pro-internal" { + t.Fatalf("alias resolved to %q, want the per-auth upstream gemini-3-pro-internal", got["gemini-3-pro-preview"]) + } +} + +// A global fork with no per-auth entry for its alias must stay in the map: the id exists only +// because the fork registered it. +func TestBuildAntigravityReverseAliasMapKeepsUnshadowedGlobalFork(t *testing.T) { + cfg := &config.Config{OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "antigravity": {{Name: "gemini-3-pro", Alias: "gemini-3-pro-preview", Fork: true}}, + }} + auth := antigravityAuth(`[{"name":"gemini-3-flash-internal","alias":"gemini-3-flash"}]`) + got := (&Service{cfg: cfg}).buildAntigravityReverseAliasMap(auth) + if got["gemini-3-pro-preview"] != "gemini-3-pro" { + t.Fatalf("forked alias resolved to %q, want gemini-3-pro", got["gemini-3-pro-preview"]) + } + if got["gemini-3-flash"] != "gemini-3-flash-internal" { + t.Fatalf("per-auth alias resolved to %q, want gemini-3-flash-internal", got["gemini-3-flash"]) + } + // resolveAntigravityUpstreamModelID strips the credential prefix before the lookup. + if id := resolveAntigravityUpstreamModelID("ag/gemini-3-pro-preview", "ag", got); id != "gemini-3-pro" { + t.Fatalf("prefixed id resolved to %q, want gemini-3-pro", id) + } +}