diff --git a/config.example.yaml b/config.example.yaml index 83896efc..c2b42295 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -354,6 +354,17 @@ nonstream-keepalive-interval: 0 # client-visible names can become ambiguous across providers. For strict backend pinning, use # unique aliases/prefixes or avoid overlapping names. # You can repeat the same name with different aliases to expose multiple client model names. +# Per-auth OAuth aliases can also be stored in an OAuth auth JSON file as "model-aliases". +# They apply only to that selected auth and take precedence over global aliases for the same client-visible alias. +# Example auth JSON: +# { +# "type": "codex", +# "email": "user@example.com", +# "model-aliases": [ +# {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.5"}, +# {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.4"} +# ] +# } # oauth-model-alias: # vertex: # - name: "gemini-2.5-pro" diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 1f7e34dc..f4cd1dea 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" ) @@ -95,6 +96,8 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] auth.Attributes["path"] = fullPath auth.Attributes["source"] = fullPath perAccountExcluded := extractExcludedModelsFromMetadata(metadata) + perAccountModelAliases := extractOAuthModelAliasesFromMetadata(metadata) + coreauth.SetOAuthModelAliasesAttribute(auth, perAccountModelAliases) ApplyAuthExcludedModelsMeta(auth, cfg, perAccountExcluded, "oauth") coreauth.ApplyCustomHeadersFromMetadata(auth) return []*coreauth.Auth{auth} @@ -140,6 +143,7 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] // Read per-account excluded models from the OAuth JSON file. perAccountExcluded := extractExcludedModelsFromMetadata(metadata) + perAccountModelAliases := extractOAuthModelAliasesFromMetadata(metadata) a := &coreauth.Auth{ ID: id, @@ -178,6 +182,7 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] } } coreauth.ApplyCustomHeadersFromMetadata(a) + coreauth.SetOAuthModelAliasesAttribute(a, perAccountModelAliases) ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth") // For codex auth files, extract plan_type from the JWT id_token. if provider == "codex" { @@ -192,6 +197,36 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] return []*coreauth.Auth{a} } +// extractOAuthModelAliasesFromMetadata reads per-account model aliases from OAuth JSON metadata. +// Supports both "model_aliases" and "model-aliases" keys. +func extractOAuthModelAliasesFromMetadata(metadata map[string]any) []config.OAuthModelAlias { + if metadata == nil { + return nil + } + raw, ok := metadata["model_aliases"] + if !ok { + raw, ok = metadata["model-aliases"] + } + if !ok || raw == nil { + return nil + } + data, errMarshal := json.Marshal(raw) + if errMarshal != nil { + return nil + } + var aliases []config.OAuthModelAlias + if errUnmarshal := json.Unmarshal(data, &aliases); errUnmarshal != nil { + return nil + } + cfg := config.Config{ + OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "auth": aliases, + }, + } + cfg.SanitizeOAuthModelAlias() + return cfg.OAuthModelAlias["auth"] +} + // extractExcludedModelsFromMetadata reads per-account excluded models from the OAuth JSON metadata. // Supports both "excluded_models" and "excluded-models" keys, and accepts both []string and []interface{}. func extractExcludedModelsFromMetadata(metadata map[string]any) []string { diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index b1962e9f..00276736 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -412,6 +412,47 @@ func TestFileSynthesizer_Synthesize_OAuthExcludedModelsMerged(t *testing.T) { } } +func TestFileSynthesizer_Synthesize_OAuthModelAliases(t *testing.T) { + tempDir := t.TempDir() + authData := map[string]any{ + "type": "codex", + "email": "codex@example.com", + "model-aliases": []map[string]any{ + {"name": " gpt-5.3-codex-spark ", "alias": " gpt-5.5 "}, + {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.4", "fork": true}, + {"name": "gpt-5.3-codex-spark", "alias": "gpt-5.5"}, + {"name": "", "alias": "ignored"}, + }, + } + data, _ := json.Marshal(authData) + errWriteFile := os.WriteFile(filepath.Join(tempDir, "codex-auth.json"), data, 0644) + if errWriteFile != nil { + t.Fatalf("failed to write auth file: %v", errWriteFile) + } + + synth := NewFileSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, errSynthesize := synth.Synthesize(ctx) + if errSynthesize != nil { + t.Fatalf("unexpected error: %v", errSynthesize) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + + got := auths[0].Attributes["model_aliases"] + want := `[{"name":"gpt-5.3-codex-spark","alias":"gpt-5.5"},{"name":"gpt-5.3-codex-spark","alias":"gpt-5.4","fork":true}]` + if got != want { + t.Fatalf("expected model_aliases %q, got %q", want, got) + } +} + func TestFileSynthesizer_Synthesize_IgnoresGeminiOAuthFile(t *testing.T) { tempDir := t.TempDir() diff --git a/sdk/cliproxy/auth/oauth_model_alias.go b/sdk/cliproxy/auth/oauth_model_alias.go index 2aa0073f..f936fa5a 100644 --- a/sdk/cliproxy/auth/oauth_model_alias.go +++ b/sdk/cliproxy/auth/oauth_model_alias.go @@ -1,12 +1,15 @@ package auth import ( + "encoding/json" "strings" internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" ) +const oauthModelAliasesAttributeKey = "model_aliases" + type modelAliasEntry interface { GetName() string GetAlias() string @@ -183,7 +186,105 @@ func resolveModelAliasFromConfigModels(requestedModel string, models []modelAlia // the suffix is preserved in the returned model name. However, if the alias's // original name already contains a suffix, the config suffix takes priority. func (m *Manager) resolveOAuthUpstreamModel(auth *Auth, requestedModel string) string { - return resolveUpstreamModelFromAliasTable(m, auth, requestedModel, modelAliasChannel(auth)) + channel := modelAliasChannel(auth) + if channel == "" { + return "" + } + if resolved := resolveUpstreamModelFromAliases(OAuthModelAliasesFromAttributes(authAttributes(auth)), requestedModel); resolved != "" { + return resolved + } + return resolveUpstreamModelFromAliasTable(m, auth, requestedModel, channel) +} + +func authAttributes(auth *Auth) map[string]string { + if auth == nil { + return nil + } + return auth.Attributes +} + +// SetOAuthModelAliasesAttribute stores sanitized per-auth OAuth model aliases on an auth entry. +func SetOAuthModelAliasesAttribute(auth *Auth, aliases []internalconfig.OAuthModelAlias) { + if auth == nil { + return + } + aliases = sanitizeOAuthModelAliases(aliases) + if len(aliases) == 0 { + return + } + data, errMarshal := json.Marshal(aliases) + if errMarshal != nil { + return + } + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + auth.Attributes[oauthModelAliasesAttributeKey] = string(data) +} + +// OAuthModelAliasesFromAttributes returns sanitized per-auth OAuth model aliases from auth attributes. +func OAuthModelAliasesFromAttributes(attributes map[string]string) []internalconfig.OAuthModelAlias { + if len(attributes) == 0 { + return nil + } + raw := strings.TrimSpace(attributes[oauthModelAliasesAttributeKey]) + if raw == "" { + return nil + } + var aliases []internalconfig.OAuthModelAlias + if errUnmarshal := json.Unmarshal([]byte(raw), &aliases); errUnmarshal != nil { + return nil + } + return sanitizeOAuthModelAliases(aliases) +} + +func sanitizeOAuthModelAliases(aliases []internalconfig.OAuthModelAlias) []internalconfig.OAuthModelAlias { + if len(aliases) == 0 { + return nil + } + cfg := internalconfig.Config{ + OAuthModelAlias: map[string][]internalconfig.OAuthModelAlias{ + "auth": aliases, + }, + } + cfg.SanitizeOAuthModelAlias() + clean := cfg.OAuthModelAlias["auth"] + if len(clean) == 0 { + return nil + } + return append([]internalconfig.OAuthModelAlias(nil), clean...) +} + +func resolveUpstreamModelFromAliases(aliases []internalconfig.OAuthModelAlias, requestedModel string) string { + if len(aliases) == 0 { + return "" + } + requestResult, candidates := modelAliasLookupCandidates(requestedModel) + if len(candidates) == 0 { + return "" + } + baseModel := requestResult.ModelName + if baseModel == "" { + baseModel = strings.TrimSpace(requestedModel) + } + for _, entry := range aliases { + original := strings.TrimSpace(entry.Name) + alias := strings.TrimSpace(entry.Alias) + if original == "" || alias == "" { + continue + } + for _, candidate := range candidates { + key := strings.TrimSpace(candidate) + if key == "" || !strings.EqualFold(alias, key) { + continue + } + if strings.EqualFold(original, baseModel) { + return "" + } + return preserveResolvedModelSuffix(original, requestResult) + } + } + return "" } func resolveUpstreamModelFromAliasTable(m *Manager, auth *Auth, requestedModel, channel string) string { diff --git a/sdk/cliproxy/auth/oauth_model_alias_test.go b/sdk/cliproxy/auth/oauth_model_alias_test.go index 77f0a489..3504a622 100644 --- a/sdk/cliproxy/auth/oauth_model_alias_test.go +++ b/sdk/cliproxy/auth/oauth_model_alias_test.go @@ -208,6 +208,53 @@ func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) { } } +func TestApplyOAuthModelAlias_PerAuthOverridesGlobalAlias(t *testing.T) { + t.Parallel() + + globalAliases := map[string][]internalconfig.OAuthModelAlias{ + "codex": {{Name: "gpt-5-global", Alias: "gpt-5.5"}}, + } + + mgr := NewManager(nil, nil, nil) + mgr.SetConfig(&internalconfig.Config{}) + mgr.SetOAuthModelAlias(globalAliases) + + auth := &Auth{ + ID: "codex-auth-id", + Provider: "codex", + Attributes: map[string]string{ + "auth_kind": "oauth", + "model_aliases": `[{"name":"gpt-5.3-codex-spark","alias":"gpt-5.5"}]`, + }, + } + + resolvedModel := mgr.applyOAuthModelAlias(auth, "gpt-5.5(high)") + if resolvedModel != "gpt-5.3-codex-spark(high)" { + t.Errorf("applyOAuthModelAlias() model = %q, want %q", resolvedModel, "gpt-5.3-codex-spark(high)") + } +} + +func TestApplyOAuthModelAlias_PerAuthAliasSkipsAPIKey(t *testing.T) { + t.Parallel() + + mgr := NewManager(nil, nil, nil) + mgr.SetConfig(&internalconfig.Config{}) + + auth := &Auth{ + ID: "codex-api-key-auth", + Provider: "codex", + Attributes: map[string]string{ + "auth_kind": "api_key", + "model_aliases": `[{"name":"gpt-5.3-codex-spark","alias":"gpt-5.5"}]`, + }, + } + + resolvedModel := mgr.applyOAuthModelAlias(auth, "gpt-5.5") + if resolvedModel != "gpt-5.5" { + t.Errorf("applyOAuthModelAlias() model = %q, want %q", resolvedModel, "gpt-5.5") + } +} + func TestApplyOAuthModelAlias_PluginProvider(t *testing.T) { t.Parallel() diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 3645e622..720ec13b 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -1121,7 +1121,7 @@ func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreaut } } models := applyExcludedModels(result.Models, activeExcluded) - models = applyOAuthModelAlias(s.cfg, providerKey, activeAuthKind, models) + models = applyOAuthModelAliasForAuth(s.cfg, providerKey, activeAuthKind, activeAuth.Attributes, models) if len(models) > 0 { s.registerResolvedModelsForAuth(activeAuth, providerKey, applyModelPrefixes(models, activeAuth.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix)) return true @@ -1953,7 +1953,7 @@ func (s *Service) registerModelsForAuth(ctx context.Context, a *coreauth.Auth) { } } } - models = applyOAuthModelAlias(s.cfg, provider, authKind, models) + models = applyOAuthModelAliasForAuth(s.cfg, provider, authKind, a.Attributes, models) key := provider if key == "" { key = strings.ToLower(strings.TrimSpace(a.Provider)) @@ -2418,18 +2418,58 @@ func rewriteModelInfoName(name, oldID, newID string) string { } func applyOAuthModelAlias(cfg *config.Config, provider, authKind string, models []*ModelInfo) []*ModelInfo { - if cfg == nil || len(models) == 0 { + return applyOAuthModelAliasForAuth(cfg, provider, authKind, nil, models) +} + +func applyOAuthModelAliasForAuth(cfg *config.Config, provider, authKind string, attributes map[string]string, models []*ModelInfo) []*ModelInfo { + if len(models) == 0 { return models } channel := coreauth.OAuthModelAliasChannel(provider, authKind) - if channel == "" || len(cfg.OAuthModelAlias) == 0 { + if channel == "" { return models } - aliases := cfg.OAuthModelAlias[channel] + aliases := oauthModelAliasesForAuth(cfg, channel, attributes) if len(aliases) == 0 { return models } + return applyOAuthModelAliasEntries(aliases, models) +} + +func oauthModelAliasesForAuth(cfg *config.Config, channel string, attributes map[string]string) []config.OAuthModelAlias { + perAuthAliases := coreauth.OAuthModelAliasesFromAttributes(attributes) + if cfg == nil || len(cfg.OAuthModelAlias) == 0 { + return perAuthAliases + } + globalAliases := cfg.OAuthModelAlias[channel] + if len(perAuthAliases) == 0 { + return globalAliases + } + if len(globalAliases) == 0 { + return perAuthAliases + } + 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) + } + } + add(perAuthAliases) + add(globalAliases) + return out +} +func applyOAuthModelAliasEntries(aliases []config.OAuthModelAlias, models []*ModelInfo) []*ModelInfo { type aliasEntry struct { alias string fork bool diff --git a/sdk/cliproxy/service_oauth_model_alias_test.go b/sdk/cliproxy/service_oauth_model_alias_test.go index c39fbb7b..df77cfa4 100644 --- a/sdk/cliproxy/service_oauth_model_alias_test.go +++ b/sdk/cliproxy/service_oauth_model_alias_test.go @@ -132,3 +132,23 @@ func TestApplyOAuthModelAlias_PluginProviderSkipsAPIKey(t *testing.T) { t.Fatalf("expected API key plugin model to remain unchanged, got %#v", out) } } + +func TestApplyOAuthModelAlias_PerAuthAlias(t *testing.T) { + models := []*ModelInfo{ + {ID: "gpt-5.3-codex-spark", Name: "models/gpt-5.3-codex-spark"}, + } + attributes := map[string]string{ + "model_aliases": `[{"name":"gpt-5.3-codex-spark","alias":"gpt-5.5"}]`, + } + + out := applyOAuthModelAliasForAuth(nil, "codex", "oauth", attributes, models) + if len(out) != 1 { + t.Fatalf("expected 1 model, got %d", len(out)) + } + if out[0].ID != "gpt-5.5" { + t.Fatalf("expected per-auth alias id %q, got %q", "gpt-5.5", out[0].ID) + } + if out[0].Name != "models/gpt-5.5" { + t.Fatalf("expected per-auth alias name %q, got %q", "models/gpt-5.5", out[0].Name) + } +}