Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
golang 1.25.5
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
- iFlow multi-account load balancing
- OpenAI Codex multi-account load balancing
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
- **Global model aliases** to map any model name to another (e.g., `claude-haiku` → `claude-3-5-haiku-20241022`)
- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)

## Getting Started
Expand Down
1 change: 1 addition & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元
- 支持 iFlow 多账户轮询
- 支持 OpenAI Codex 多账户轮询
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
- **全局模型别名**,可将任何模型名称映射到另一个名称(例如 `claude-haiku` → `claude-3-5-haiku-20241022`)
- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)

## 新手入门
Expand Down
47 changes: 34 additions & 13 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -199,16 +199,37 @@ ws-auth: false
# - "tstars2.0"

# Optional payload configuration
# payload:
# default: # Default rules only set parameters when they are missing in the payload.
# - models:
# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
# params: # JSON path (gjson/sjson syntax) -> value
# "generationConfig.thinkingConfig.thinkingBudget": 32768
# override: # Override rules always set parameters, overwriting any existing values.
# - models:
# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
# params: # JSON path (gjson/sjson syntax) -> value
# "reasoning.effort": "high"
payload:
default: # Default rules only set parameters when they are missing in the payload.
- models:
- name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
params: # JSON path (gjson/sjson syntax) -> value
"generationConfig.thinkingConfig.thinkingBudget": 32768
override: # Override rules always set parameters, overwriting any existing values.
- models:
- name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
params: # JSON path (gjson/sjson syntax) -> value
"reasoning.effort": "high"
Comment on lines +202 to +214
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The payload configuration section has been uncommented in this pull request. If this change is unrelated to the multi-target alias feature, it might be better to revert it or move it to a separate PR to keep this one focused on a single concern.


# Global model aliases
# Define custom aliases that can be used across all providers to map to specific model IDs.
# This eliminates the need for per-provider alias configuration.
# Supports both single-target strings and multi-target lists for provider aggregation.
aliases:
# Single target alias
"claude-haiku-4-5": "claude-haiku-4-5-20251001"

# Multi-target alias (aggregates providers from all listed models)
"claude-sonnet-4-5":
- "claude-sonnet-4-5-20250929"
- "gemini-claude-sonnet-4-5"

"claude-opus-4-5":
- "claude-opus-4-5-20251101"
- "gemini-claude-opus-4-5-thinking"

# Custom aliases - any name you prefer
"best-model": "claude-sonnet-4-5-20250929"
"fast-model": "claude-haiku-4-5-20251001"
84 changes: 84 additions & 0 deletions internal/api/handlers/management/config_basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ func (h *Handler) PutConfigYAML(c *gin.Context) {
return
}
h.cfg = newCfg

// Update global aliases from the new config
if newCfg.Aliases != nil {
util.SetAliases(newCfg.Aliases)
}

c.JSON(http.StatusOK, gin.H{"ok": true, "changed": []string{"config"}})
}

Expand Down Expand Up @@ -241,3 +247,81 @@ func (h *Handler) DeleteProxyURL(c *gin.Context) {
h.cfg.ProxyURL = ""
h.persist(c)
}

// Aliases
func (h *Handler) GetAliases(c *gin.Context) {
aliases := h.cfg.Aliases
if aliases == nil {
aliases = make(map[string][]string)
}
c.JSON(200, gin.H{"aliases": aliases})
}

func (h *Handler) PutAliases(c *gin.Context) {
var body struct {
Aliases *map[string][]string `json:"aliases"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Aliases == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
h.cfg.Aliases = *body.Aliases
h.persist(c)
// Update global aliases after persisting
if h.cfg.Aliases != nil {
util.SetAliases(h.cfg.Aliases)
}
Comment on lines +268 to +273
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The return value of h.persist(c) is not being checked here. Other handlers in this file, such as PutAlias and DeleteAlias, correctly check this return value and abort if persistence fails. This could lead to this endpoint returning a success status even when the configuration change was not saved.

h.cfg.Aliases = *body.Aliases
	if !h.persist(c) {
		return
	}
	// Update global aliases after persisting
	if h.cfg.Aliases != nil {
		util.SetAliases(h.cfg.Aliases)
	}

}

func (h *Handler) PutAlias(c *gin.Context) {
alias := c.Param("alias")
if alias == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing alias"})
return
}
var body struct {
Target *string `json:"target"`
Targets *[]string `json:"targets"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}

var targets []string
if body.Targets != nil {
targets = *body.Targets
} else if body.Target != nil {
targets = []string{*body.Target}
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing target or targets"})
return
}

if h.cfg.Aliases == nil {
h.cfg.Aliases = make(map[string][]string)
}
h.cfg.Aliases[alias] = targets
if !h.persist(c) {
return
}
// Update global aliases after persisting
util.SetAliases(h.cfg.Aliases)
c.JSON(200, gin.H{"alias": alias, "targets": targets})
}

func (h *Handler) DeleteAlias(c *gin.Context) {
alias := c.Param("alias")
if alias == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing alias"})
return
}
if h.cfg.Aliases != nil {
delete(h.cfg.Aliases, alias)
}
util.SetAliases(h.cfg.Aliases)
if !h.persist(c) {
return
}
Comment on lines +322 to +325
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The order of operations in DeleteAlias is inconsistent with other handlers like PutAlias. Here, the global in-memory alias state is updated via util.SetAliases before the configuration is persisted to disk. If h.persist(c) fails, the in-memory state will be inconsistent with the persisted configuration. To ensure atomicity, you should persist the changes first and only update the in-memory state upon successful persistence.

	if !h.persist(c) {
		return
	}
	util.SetAliases(h.cfg.Aliases)

c.JSON(200, gin.H{"deleted": alias})
}
10 changes: 10 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,11 @@ func (s *Server) registerManagementRoutes() {
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL)

mgmt.GET("/aliases", s.mgmt.GetAliases)
mgmt.PUT("/aliases", s.mgmt.PutAliases)
mgmt.PUT("/aliases/:alias", s.mgmt.PutAlias)
mgmt.DELETE("/aliases/:alias", s.mgmt.DeleteAlias)

mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject)
mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
Expand Down Expand Up @@ -821,6 +826,11 @@ func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {
// - clients: The new slice of AI service clients
// - cfg: The new application configuration
func (s *Server) UpdateClients(cfg *config.Config) {
// Set global aliases from config
if cfg.Aliases != nil {
util.SetAliases(cfg.Aliases)
}

// Reconstruct old config from YAML snapshot to avoid reference sharing issues
var oldCfg *config.Config
if len(s.oldConfigYaml) > 0 {
Expand Down
65 changes: 65 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,74 @@ type Config struct {
// Payload defines default and override rules for provider payload parameters.
Payload PayloadConfig `yaml:"payload" json:"payload"`

// Aliases defines global model aliases that can be used across all providers.
// Map key is the alias name, value is the target model ID.
Aliases Aliases `yaml:"aliases" json:"aliases"`

legacyMigrationPending bool `yaml:"-" json:"-"`
}

// Aliases defines global model aliases that can be used across all providers.
// Map key is the alias name, value is a list of target model IDs.
type Aliases map[string][]string

// UnmarshalYAML implements custom YAML unmarshalling to accept either a mapping
// or a sequence of single-key mapping entries. It also supports both single string
// and sequence of strings for alias targets.
func (a *Aliases) UnmarshalYAML(node *yaml.Node) error {
if node == nil {
return nil
}

m := make(map[string][]string)

processMapping := func(n *yaml.Node) error {
if n.Kind != yaml.MappingNode {
return fmt.Errorf("expected mapping node, got kind=%d", n.Kind)
}
for i := 0; i+1 < len(n.Content); i += 2 {
keyNode := n.Content[i]
valNode := n.Content[i+1]
key := keyNode.Value

switch valNode.Kind {
case yaml.ScalarNode:
m[key] = append(m[key], valNode.Value)
case yaml.SequenceNode:
var targets []string
if err := valNode.Decode(&targets); err != nil {
return err
}
m[key] = append(m[key], targets...)
default:
return fmt.Errorf("alias target for %s must be a string or sequence, got kind=%d", key, valNode.Kind)
}
}
return nil
}

switch node.Kind {
case yaml.MappingNode:
if err := processMapping(node); err != nil {
return err
}
case yaml.SequenceNode:
for _, elm := range node.Content {
if elm == nil {
continue
}
if err := processMapping(elm); err != nil {
return err
}
}
default:
return fmt.Errorf("aliases must be a mapping or sequence, got kind=%d", node.Kind)
}

*a = m
return nil
}

// TLSConfig holds HTTPS server settings.
type TLSConfig struct {
// Enable toggles HTTPS server mode.
Expand Down
Loading