feat(auth): add per-auth OAuth model alias support

- Introduced `SetOAuthModelAliasesAttribute` and `OAuthModelAliasesFromAttributes` for managing per-auth model aliases.
- Enhanced OAuth model resolution logic to prioritize per-auth aliases over global aliases.
- Updated metadata handling to extract and sanitize per-account model aliases.
- Added tests to validate alias precedence, empty attributes, and conflict scenarios.

Closes: #3764
This commit is contained in:
Luis Pater
2026-06-20 11:31:57 +08:00
parent 379167c992
commit f66376f0b9
7 changed files with 301 additions and 6 deletions
+11
View File
@@ -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": "[email protected]",
# "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"
+35
View File
@@ -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 {
+41
View File
@@ -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": "[email protected]",
"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()
+102 -1
View File
@@ -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 {
@@ -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()
+45 -5
View File
@@ -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
@@ -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)
}
}