From 6c324f2c8b1d1a90e4c31ae8c1fa01daeafea60b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 12 Jan 2026 10:40:34 +0800 Subject: [PATCH] Fixed: #936 feat(cliproxy): support multiple aliases for OAuth model mappings - Updated mapping logic to allow multiple aliases per upstream model name. - Adjusted `SanitizeOAuthModelMappings` to ensure aliases remain unique within channels. - Added test cases to validate multi-alias scenarios. - Updated example config to clarify multi-alias support. --- config.example.yaml | 1 + internal/config/config.go | 8 +-- internal/config/oauth_model_mappings_test.go | 29 +++++++++ sdk/cliproxy/service.go | 61 +++++++++---------- .../service_oauth_model_mappings_test.go | 34 +++++++++++ 5 files changed, 95 insertions(+), 38 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 332fba70..bf813433 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -202,6 +202,7 @@ ws-auth: false # These mappings rename model IDs for both model listing and request routing. # Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow. # NOTE: Mappings do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode. +# You can repeat the same name with different aliases to expose multiple client model names. # oauth-model-mappings: # gemini-cli: # - name: "gemini-2.5-pro" # original model name under this channel diff --git a/internal/config/config.go b/internal/config/config.go index e8ae3554..99beb481 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -521,7 +521,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // SanitizeOAuthModelMappings normalizes and deduplicates global OAuth model name mappings. // It trims whitespace, normalizes channel keys to lower-case, drops empty entries, -// and ensures (From, To) pairs are unique within each channel. +// allows multiple aliases per upstream name, and ensures aliases are unique within each channel. func (cfg *Config) SanitizeOAuthModelMappings() { if cfg == nil || len(cfg.OAuthModelMappings) == 0 { return @@ -532,7 +532,6 @@ func (cfg *Config) SanitizeOAuthModelMappings() { if channel == "" || len(mappings) == 0 { continue } - seenName := make(map[string]struct{}, len(mappings)) seenAlias := make(map[string]struct{}, len(mappings)) clean := make([]ModelNameMapping, 0, len(mappings)) for _, mapping := range mappings { @@ -544,15 +543,10 @@ func (cfg *Config) SanitizeOAuthModelMappings() { if strings.EqualFold(name, alias) { continue } - nameKey := strings.ToLower(name) aliasKey := strings.ToLower(alias) - if _, ok := seenName[nameKey]; ok { - continue - } if _, ok := seenAlias[aliasKey]; ok { continue } - seenName[nameKey] = struct{}{} seenAlias[aliasKey] = struct{}{} clean = append(clean, ModelNameMapping{Name: name, Alias: alias, Fork: mapping.Fork}) } diff --git a/internal/config/oauth_model_mappings_test.go b/internal/config/oauth_model_mappings_test.go index 7b801a79..10bfe165 100644 --- a/internal/config/oauth_model_mappings_test.go +++ b/internal/config/oauth_model_mappings_test.go @@ -25,3 +25,32 @@ func TestSanitizeOAuthModelMappings_PreservesForkFlag(t *testing.T) { t.Fatalf("expected second mapping to be gpt-6->g6 fork=false, got name=%q alias=%q fork=%v", mappings[1].Name, mappings[1].Alias, mappings[1].Fork) } } + +func TestSanitizeOAuthModelMappings_AllowsMultipleAliasesForSameName(t *testing.T) { + cfg := &Config{ + OAuthModelMappings: map[string][]ModelNameMapping{ + "antigravity": { + {Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101", Fork: true}, + {Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101-thinking", Fork: true}, + {Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5", Fork: true}, + }, + }, + } + + cfg.SanitizeOAuthModelMappings() + + mappings := cfg.OAuthModelMappings["antigravity"] + expected := []ModelNameMapping{ + {Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101", Fork: true}, + {Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5-20251101-thinking", Fork: true}, + {Name: "gemini-claude-opus-4-5-thinking", Alias: "claude-opus-4-5", Fork: true}, + } + if len(mappings) != len(expected) { + t.Fatalf("expected %d sanitized mappings, got %d", len(expected), len(mappings)) + } + for i, exp := range expected { + if mappings[i].Name != exp.Name || mappings[i].Alias != exp.Alias || mappings[i].Fork != exp.Fork { + t.Fatalf("expected mapping %d to be name=%q alias=%q fork=%v, got name=%q alias=%q fork=%v", i, exp.Name, exp.Alias, exp.Fork, mappings[i].Name, mappings[i].Alias, mappings[i].Fork) + } + } +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index b91ce015..695a77c8 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -1237,7 +1237,7 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode fork bool } - forward := make(map[string]mappingEntry, len(mappings)) + forward := make(map[string][]mappingEntry, len(mappings)) for i := range mappings { name := strings.TrimSpace(mappings[i].Name) alias := strings.TrimSpace(mappings[i].Alias) @@ -1248,14 +1248,12 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode continue } key := strings.ToLower(name) - if _, exists := forward[key]; exists { - continue - } - forward[key] = mappingEntry{alias: alias, fork: mappings[i].Fork} + forward[key] = append(forward[key], mappingEntry{alias: alias, fork: mappings[i].Fork}) } if len(forward) == 0 { return models } + out := make([]*ModelInfo, 0, len(models)) seen := make(map[string]struct{}, len(models)) for _, model := range models { @@ -1267,17 +1265,8 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode continue } key := strings.ToLower(id) - entry, ok := forward[key] - if !ok { - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - out = append(out, model) - continue - } - mappedID := strings.TrimSpace(entry.alias) - if mappedID == "" { + entries := forward[key] + if len(entries) == 0 { if _, exists := seen[key]; exists { continue } @@ -1286,11 +1275,29 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode continue } - if entry.fork { + keepOriginal := false + for _, entry := range entries { + if entry.fork { + keepOriginal = true + break + } + } + if keepOriginal { if _, exists := seen[key]; !exists { seen[key] = struct{}{} out = append(out, model) } + } + + addedAlias := false + for _, entry := range entries { + mappedID := strings.TrimSpace(entry.alias) + if mappedID == "" { + continue + } + if strings.EqualFold(mappedID, id) { + continue + } aliasKey := strings.ToLower(mappedID) if _, exists := seen[aliasKey]; exists { continue @@ -1302,24 +1309,16 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode clone.Name = rewriteModelInfoName(clone.Name, id, mappedID) } out = append(out, &clone) - continue + addedAlias = true } - uniqueKey := strings.ToLower(mappedID) - if _, exists := seen[uniqueKey]; exists { - continue - } - seen[uniqueKey] = struct{}{} - if mappedID == id { + if !keepOriginal && !addedAlias { + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} out = append(out, model) - continue } - clone := *model - clone.ID = mappedID - if clone.Name != "" { - clone.Name = rewriteModelInfoName(clone.Name, id, mappedID) - } - out = append(out, &clone) } return out } diff --git a/sdk/cliproxy/service_oauth_model_mappings_test.go b/sdk/cliproxy/service_oauth_model_mappings_test.go index 7d8da08a..ca9ff35a 100644 --- a/sdk/cliproxy/service_oauth_model_mappings_test.go +++ b/sdk/cliproxy/service_oauth_model_mappings_test.go @@ -56,3 +56,37 @@ func TestApplyOAuthModelMappings_ForkAddsAlias(t *testing.T) { t.Fatalf("expected forked model name %q, got %q", "models/g5", out[1].Name) } } + +func TestApplyOAuthModelMappings_ForkAddsMultipleAliases(t *testing.T) { + cfg := &config.Config{ + OAuthModelMappings: map[string][]config.ModelNameMapping{ + "codex": { + {Name: "gpt-5", Alias: "g5", Fork: true}, + {Name: "gpt-5", Alias: "g5-2", Fork: true}, + }, + }, + } + models := []*ModelInfo{ + {ID: "gpt-5", Name: "models/gpt-5"}, + } + + out := applyOAuthModelMappings(cfg, "codex", "oauth", models) + if len(out) != 3 { + t.Fatalf("expected 3 models, got %d", len(out)) + } + if out[0].ID != "gpt-5" { + t.Fatalf("expected first model id %q, got %q", "gpt-5", out[0].ID) + } + if out[1].ID != "g5" { + t.Fatalf("expected second model id %q, got %q", "g5", out[1].ID) + } + if out[1].Name != "models/g5" { + t.Fatalf("expected forked model name %q, got %q", "models/g5", out[1].Name) + } + if out[2].ID != "g5-2" { + t.Fatalf("expected third model id %q, got %q", "g5-2", out[2].ID) + } + if out[2].Name != "models/g5-2" { + t.Fatalf("expected forked model name %q, got %q", "models/g5-2", out[2].Name) + } +}