diff --git a/sdk/cliproxy/antigravity_models.go b/sdk/cliproxy/antigravity_models.go index 4b9dd153faf..dc74a85494a 100644 --- a/sdk/cliproxy/antigravity_models.go +++ b/sdk/cliproxy/antigravity_models.go @@ -458,7 +458,10 @@ func (s *Service) buildAntigravityReverseAliasMap(auth *coreauth.Auth) map[strin s.cfgMu.RUnlock() } channel := coreauth.OAuthModelAliasChannel(auth.Provider, auth.AuthKind()) - aliases := oauthModelAliasesForAuth(cfg, channel, auth.Attributes) + // 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) + } +} diff --git a/sdk/cliproxy/service_executors.go b/sdk/cliproxy/service_executors.go index fc31f0beea1..7ee7fcf240d 100644 --- a/sdk/cliproxy/service_executors.go +++ b/sdk/cliproxy/service_executors.go @@ -560,7 +560,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 76693ee8348..ebaf71a7792 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -285,14 +285,16 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut if ctx.Err() != nil { return } - models = applyOAuthModelAliasForAuth(s.cfg, provider, authKind, a.Attributes, 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)) @@ -962,10 +964,20 @@ 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) +} + +// 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 { + return applyOAuthModelAliasForAuthWithCatalog(cfg, provider, authKind, attributes, excluded, models, catalogModelIDs(models)) } -func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, attributes map[string]string, models []*ModelInfo) []*ModelInfo { +// 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 } @@ -973,14 +985,44 @@ func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, if channel == "" { return models } - aliases := oauthModelAliasesForAuth(cfg, channel, attributes) + aliases := oauthModelAliasesForAuth(cfg, channel, attributes, catalog, excluded) 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 +} + +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 has after exclusions; nil means unknown, +// 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 { return perAuthAliases @@ -992,24 +1034,64 @@ 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 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{ + name: strings.ToLower(strings.TrimSpace(entry.Name)), + alias: strings.ToLower(strings.TrimSpace(entry.Alias)), + } + } out := make([]config.OAuthModelAlias, 0, len(perAuthAliases)+len(globalAliases)) - seenAlias := 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 { - continue - } - seenAlias[key] = struct{}{} - out = append(out, entry) + seenPair := make(map[aliasPair]struct{}, len(perAuthAliases)+len(globalAliases)) + // 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 == "" { + continue + } + if _, exists := seenPair[pair]; exists { + continue + } + seenPair[pair] = struct{}{} + // "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] + } + perAuthAlias[pair.alias] = perAuthAlias[pair.alias] || canSupply + out = append(out, entry) + } + for _, entry := range globalAliases { + pair := pairOf(entry) + if pair.alias == "" { + continue + } + if _, exists := seenPair[pair]; exists { + continue + } + if sourceInCatalog, shadowed := perAuthAlias[pair.alias]; shadowed && (!entry.Fork || sourceInCatalog) { + 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 new file mode 100644 index 00000000000..d0ca00ff0c6 --- /dev/null +++ b/sdk/cliproxy/service_models_alias_pair_test.go @@ -0,0 +1,179 @@ +package cliproxy + +import ( + "testing" + + "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 +// 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"}]`, + } + catalog := catalogOf("gpt-5.6-luna") + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog, nil) + 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) + } +} + +// 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"}]`} + catalog := catalogOf("gpt-5.6-luna") + 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) + } +} + +// 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, nil) + 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, nil, 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) + } +} + +// 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) + } + // 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) + } + } +} + +// 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) + } + } +} + +// 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) { + 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"}]`} + catalog := catalogOf("gpt-5.6-luna") + got := oauthModelAliasesForAuth(cfg, "codex", attrs, catalog, nil) + 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{ + "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}]`} + catalog := catalogOf("gpt-5.6-luna") + 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) + } +} diff --git a/sdk/cliproxy/service_oauth_model_alias_test.go b/sdk/cliproxy/service_oauth_model_alias_test.go index 463d7fb5ae4..33d7096cfa1 100644 --- a/sdk/cliproxy/service_oauth_model_alias_test.go +++ b/sdk/cliproxy/service_oauth_model_alias_test.go @@ -204,7 +204,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)) }