mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-10 02:16:49 +08:00
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:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user