From 44b6c872e200c19a01cfde2ec55867f7774b9992 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 4 Jan 2026 01:18:29 +0800 Subject: [PATCH] feat(config): add support for `Fork` in OAuth model mappings with alias handling Implemented `Fork` flag in `ModelNameMapping` to allow aliases as additional models while preserving the original model ID. Updated the `applyOAuthModelMappings` logic, added tests for `Fork` behavior, and updated documentation and examples accordingly. --- config.example.yaml | 1 + internal/config/config.go | 9 ++- internal/config/oauth_model_mappings_test.go | 27 +++++++++ internal/watcher/diff/oauth_model_mappings.go | 3 + sdk/cliproxy/service.go | 51 ++++++++++++++-- .../service_oauth_model_mappings_test.go | 58 +++++++++++++++++++ 6 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 internal/config/oauth_model_mappings_test.go create mode 100644 sdk/cliproxy/service_oauth_model_mappings_test.go diff --git a/config.example.yaml b/config.example.yaml index 2a35fe68..332fba70 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -206,6 +206,7 @@ ws-auth: false # gemini-cli: # - name: "gemini-2.5-pro" # original model name under this channel # alias: "g2.5p" # client-visible alias +# fork: true # when true, keep original and also add the alias as an extra model (default: false) # vertex: # - name: "gemini-2.5-pro" # alias: "g2.5p" diff --git a/internal/config/config.go b/internal/config/config.go index 668764d9..e8ae3554 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -145,11 +145,14 @@ type RoutingConfig struct { Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"` } -// ModelNameMapping defines a model ID rename mapping for a specific channel. -// It maps the original model name (Name) to the client-visible alias (Alias). +// ModelNameMapping defines a model ID mapping for a specific channel. +// It maps the upstream model name (Name) to the client-visible alias (Alias). +// When Fork is true, the alias is added as an additional model in listings while +// keeping the original model ID available. type ModelNameMapping struct { Name string `yaml:"name" json:"name"` Alias string `yaml:"alias" json:"alias"` + Fork bool `yaml:"fork,omitempty" json:"fork,omitempty"` } // AmpModelMapping defines a model name mapping for Amp CLI requests. @@ -551,7 +554,7 @@ func (cfg *Config) SanitizeOAuthModelMappings() { } seenName[nameKey] = struct{}{} seenAlias[aliasKey] = struct{}{} - clean = append(clean, ModelNameMapping{Name: name, Alias: alias}) + clean = append(clean, ModelNameMapping{Name: name, Alias: alias, Fork: mapping.Fork}) } if len(clean) > 0 { out[channel] = clean diff --git a/internal/config/oauth_model_mappings_test.go b/internal/config/oauth_model_mappings_test.go new file mode 100644 index 00000000..7b801a79 --- /dev/null +++ b/internal/config/oauth_model_mappings_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestSanitizeOAuthModelMappings_PreservesForkFlag(t *testing.T) { + cfg := &Config{ + OAuthModelMappings: map[string][]ModelNameMapping{ + " CoDeX ": { + {Name: " gpt-5 ", Alias: " g5 ", Fork: true}, + {Name: "gpt-6", Alias: "g6"}, + }, + }, + } + + cfg.SanitizeOAuthModelMappings() + + mappings := cfg.OAuthModelMappings["codex"] + if len(mappings) != 2 { + t.Fatalf("expected 2 sanitized mappings, got %d", len(mappings)) + } + if mappings[0].Name != "gpt-5" || mappings[0].Alias != "g5" || !mappings[0].Fork { + t.Fatalf("expected first mapping to be gpt-5->g5 fork=true, got name=%q alias=%q fork=%v", mappings[0].Name, mappings[0].Alias, mappings[0].Fork) + } + if mappings[1].Name != "gpt-6" || mappings[1].Alias != "g6" || mappings[1].Fork { + 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) + } +} diff --git a/internal/watcher/diff/oauth_model_mappings.go b/internal/watcher/diff/oauth_model_mappings.go index 9228dbab..c002855c 100644 --- a/internal/watcher/diff/oauth_model_mappings.go +++ b/internal/watcher/diff/oauth_model_mappings.go @@ -80,6 +80,9 @@ func summarizeOAuthModelMappingList(list []config.ModelNameMapping) OAuthModelMa continue } key := name + "->" + alias + if mapping.Fork { + key += "|fork" + } if _, exists := seen[key]; exists { continue } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 21690f8e..b91ce015 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -1231,7 +1231,13 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode if len(mappings) == 0 { return models } - forward := make(map[string]string, len(mappings)) + + type mappingEntry struct { + alias string + fork bool + } + + forward := make(map[string]mappingEntry, len(mappings)) for i := range mappings { name := strings.TrimSpace(mappings[i].Name) alias := strings.TrimSpace(mappings[i].Alias) @@ -1245,7 +1251,7 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode if _, exists := forward[key]; exists { continue } - forward[key] = alias + forward[key] = mappingEntry{alias: alias, fork: mappings[i].Fork} } if len(forward) == 0 { return models @@ -1260,10 +1266,45 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode if id == "" { continue } - mappedID := id - if to, ok := forward[strings.ToLower(id)]; ok && strings.TrimSpace(to) != "" { - mappedID = strings.TrimSpace(to) + 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 == "" { + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, model) + continue + } + + if entry.fork { + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} + out = append(out, model) + } + aliasKey := strings.ToLower(mappedID) + if _, exists := seen[aliasKey]; exists { + continue + } + seen[aliasKey] = struct{}{} + clone := *model + clone.ID = mappedID + if clone.Name != "" { + clone.Name = rewriteModelInfoName(clone.Name, id, mappedID) + } + out = append(out, &clone) + continue + } + uniqueKey := strings.ToLower(mappedID) if _, exists := seen[uniqueKey]; exists { continue diff --git a/sdk/cliproxy/service_oauth_model_mappings_test.go b/sdk/cliproxy/service_oauth_model_mappings_test.go new file mode 100644 index 00000000..7d8da08a --- /dev/null +++ b/sdk/cliproxy/service_oauth_model_mappings_test.go @@ -0,0 +1,58 @@ +package cliproxy + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" +) + +func TestApplyOAuthModelMappings_Rename(t *testing.T) { + cfg := &config.Config{ + OAuthModelMappings: map[string][]config.ModelNameMapping{ + "codex": { + {Name: "gpt-5", Alias: "g5"}, + }, + }, + } + models := []*ModelInfo{ + {ID: "gpt-5", Name: "models/gpt-5"}, + } + + out := applyOAuthModelMappings(cfg, "codex", "oauth", models) + if len(out) != 1 { + t.Fatalf("expected 1 model, got %d", len(out)) + } + if out[0].ID != "g5" { + t.Fatalf("expected model id %q, got %q", "g5", out[0].ID) + } + if out[0].Name != "models/g5" { + t.Fatalf("expected model name %q, got %q", "models/g5", out[0].Name) + } +} + +func TestApplyOAuthModelMappings_ForkAddsAlias(t *testing.T) { + cfg := &config.Config{ + OAuthModelMappings: map[string][]config.ModelNameMapping{ + "codex": { + {Name: "gpt-5", Alias: "g5", Fork: true}, + }, + }, + } + models := []*ModelInfo{ + {ID: "gpt-5", Name: "models/gpt-5"}, + } + + out := applyOAuthModelMappings(cfg, "codex", "oauth", models) + if len(out) != 2 { + t.Fatalf("expected 2 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) + } +}