From c7196ba7dc60df9024701db53131151e20289747 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 28 Oct 2025 00:03:41 +0800 Subject: [PATCH] feat(claude): add model alias mapping and improve key normalization - Introduced model alias mapping for Claude configurations, enabling upstream and client-facing model name associations. - Added `computeClaudeModelsHash` to generate a consistent hash for model aliases. - Implemented `normalizeClaudeKey` function to standardize input API key configuration, including models. - Enhanced executor to resolve model aliases to upstream names dynamically. - Updated documentation and configuration examples to reflect new model alias support. --- MANAGEMENT_API.md | 2 +- MANAGEMENT_API_CN.md | 2 +- README.md | 11 ++- README_CN.md | 11 ++- config.example.yaml | 3 + .../api/handlers/management/config_lists.go | 27 ++++++ internal/config/config.go | 12 +++ internal/runtime/executor/claude_executor.go | 84 ++++++++++++++++++- internal/watcher/watcher.go | 23 ++++- sdk/cliproxy/service.go | 80 ++++++++++++++++++ 10 files changed, 242 insertions(+), 13 deletions(-) diff --git a/MANAGEMENT_API.md b/MANAGEMENT_API.md index 31d2088a..d2cad492 100644 --- a/MANAGEMENT_API.md +++ b/MANAGEMENT_API.md @@ -95,7 +95,7 @@ If a plaintext key is detected in the config at startup, it will be bcrypt‑has ``` - Response: ```json - {"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01","AI...02","AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api","proxy-url":""},{"api-key":"sk-...q2","base-url":"https://example.com","proxy-url":""}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1","proxy-url":""}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk...01","proxy-url":""}],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-key-entries":[{"api-key":"sk...7e","proxy-url":"socks5://proxy.example.com:1080"}],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]} + {"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01","AI...02","AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api","proxy-url":"socks5://proxy.example.com:1080","models":[{"name":"claude-3-5-sonnet-20241022","alias":"claude-sonnet-latest"}]},{"api-key":"cr...e3","base-url":"http://example.com:3000/api","proxy-url":""},{"api-key":"sk-...q2","base-url":"https://example.com","proxy-url":""}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1","proxy-url":""}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk...01","proxy-url":""}],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-key-entries":[{"api-key":"sk...7e","proxy-url":"socks5://proxy.example.com:1080"}],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]} ``` ### Debug diff --git a/MANAGEMENT_API_CN.md b/MANAGEMENT_API_CN.md index 6d899594..a59bde31 100644 --- a/MANAGEMENT_API_CN.md +++ b/MANAGEMENT_API_CN.md @@ -95,7 +95,7 @@ ``` - 响应: ```json - {"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01","AI...02","AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api","proxy-url":""},{"api-key":"sk-...q2","base-url":"https://example.com","proxy-url":""}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1","proxy-url":""}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk...01","proxy-url":""}],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-key-entries":[{"api-key":"sk...7e","proxy-url":"socks5://proxy.example.com:1080"}],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]} + {"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01","AI...02","AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api","proxy-url":"socks5://proxy.example.com:1080","models":[{"name":"claude-3-5-sonnet-20241022","alias":"claude-sonnet-latest"}]},{"api-key":"cr...e3","base-url":"http://example.com:3000/api","proxy-url":""},{"api-key":"sk-...q2","base-url":"https://example.com","proxy-url":""}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1","proxy-url":""}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk...01","proxy-url":""}],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-key-entries":[{"api-key":"sk...7e","proxy-url":"socks5://proxy.example.com:1080"}],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]} ``` ### Debug diff --git a/README.md b/README.md index 2cb78360..f533deea 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,9 @@ The server uses a YAML configuration file (`config.yaml`) located in the project | `claude-api-key.api-key` | string | "" | Claude API key. | | `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. | | `claude-api-key.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. | +| `claude-api-key.models` | object[] | [] | Model alias entries for this key. | +| `claude-api-key.models.*.name` | string | "" | Upstream Claude model name invoked against the API. | +| `claude-api-key.models.*.alias` | string | "" | Client-facing alias that maps to the upstream model name. | | `openai-compatibility` | object[] | [] | Upstream OpenAI-compatible providers configuration (name, base-url, api-keys, models). | | `openai-compatibility.*.name` | string | "" | The name of the provider. It will be used in the user agent and other places. | | `openai-compatibility.*.base-url` | string | "" | The base URL of the provider. | @@ -325,9 +328,11 @@ The server uses a YAML configuration file (`config.yaml`) located in the project | `openai-compatibility.*.api-key-entries` | object[] | [] | API key entries with optional per-key proxy configuration. Preferred over api-keys. | | `openai-compatibility.*.api-key-entries.*.api-key` | string | "" | The API key for this entry. | | `openai-compatibility.*.api-key-entries.*.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. | -| `openai-compatibility.*.models` | object[] | [] | The actual model name. | -| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. | -| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. | +| `openai-compatibility.*.models` | object[] | [] | Model alias definitions routing client aliases to upstream names. | +| `openai-compatibility.*.models.*.name` | string | "" | Upstream model name invoked against the provider. | +| `openai-compatibility.*.models.*.alias` | string | "" | Client alias routed to the upstream model. | + +When `claude-api-key.models` is specified, only the provided aliases are registered in the model registry (mirroring OpenAI compatibility behaviour), and the default Claude catalog is suppressed for that credential. ### Example Configuration File diff --git a/README_CN.md b/README_CN.md index 8f9b465e..15bb900c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -331,6 +331,9 @@ console.log(await claudeResponse.json()); | `claude-api-key.api-key` | string | "" | Claude API密钥。 | | `claude-api-key.base-url` | string | "" | 自定义的Claude API端点,如果您使用第三方的API端点。 | | `claude-api-key.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 | +| `claude-api-key.models` | object[] | [] | Model alias entries for this key. | +| `claude-api-key.models.*.name` | string | "" | Upstream Claude model name invoked against the API. | +| `claude-api-key.models.*.alias` | string | "" | Client-facing alias that maps to the upstream model name. | | `openai-compatibility` | object[] | [] | 上游OpenAI兼容提供商的配置(名称、基础URL、API密钥、模型)。 | | `openai-compatibility.*.name` | string | "" | 提供商的名称。它将被用于用户代理(User Agent)和其他地方。 | | `openai-compatibility.*.base-url` | string | "" | 提供商的基础URL。 | @@ -338,9 +341,11 @@ console.log(await claudeResponse.json()); | `openai-compatibility.*.api-key-entries` | object[] | [] | API密钥条目,支持可选的每密钥代理配置。优先于api-keys。 | | `openai-compatibility.*.api-key-entries.*.api-key` | string | "" | 该条目的API密钥。 | | `openai-compatibility.*.api-key-entries.*.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 | -| `openai-compatibility.*.models` | object[] | [] | 实际的模型名称。 | -| `openai-compatibility.*.models.*.name` | string | "" | 提供商支持的模型。 | -| `openai-compatibility.*.models.*.alias` | string | "" | 在API中使用的别名。 | +| `openai-compatibility.*.models` | object[] | [] | Model alias definitions routing client aliases to upstream names. | +| `openai-compatibility.*.models.*.name` | string | "" | Upstream model name invoked against the provider. | +| `openai-compatibility.*.models.*.alias` | string | "" | Client alias routed to the upstream model. | + +When `claude-api-key.models` is provided, only the listed aliases are registered for that credential, and the default Claude model catalog is skipped. ### 配置文件示例 diff --git a/config.example.yaml b/config.example.yaml index d5795719..26c8335b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -65,6 +65,9 @@ ws-auth: false # - api-key: "sk-atSM..." # base-url: "https://www.example.com" # use the custom claude API endpoint # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override +# models: +# - name: "claude-3-5-sonnet-20241022" # upstream model name +# alias: "claude-sonnet-latest" # client alias mapped to the upstream model # OpenAI compatibility providers #openai-compatibility: diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index fe8d3141..8ab86435 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -150,6 +150,9 @@ func (h *Handler) PutClaudeKeys(c *gin.Context) { } arr = obj.Items } + for i := range arr { + normalizeClaudeKey(&arr[i]) + } h.cfg.ClaudeKey = arr h.persist(c) } @@ -163,6 +166,7 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } + normalizeClaudeKey(body.Value) if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) { h.cfg.ClaudeKey[*body.Index] = *body.Value h.persist(c) @@ -472,3 +476,26 @@ func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) } return out } + +func normalizeClaudeKey(entry *config.ClaudeKey) { + if entry == nil { + return + } + entry.APIKey = strings.TrimSpace(entry.APIKey) + entry.BaseURL = strings.TrimSpace(entry.BaseURL) + entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) + if len(entry.Models) == 0 { + return + } + normalized := make([]config.ClaudeModel, 0, len(entry.Models)) + for i := range entry.Models { + model := entry.Models[i] + model.Name = strings.TrimSpace(model.Name) + model.Alias = strings.TrimSpace(model.Alias) + if model.Name == "" && model.Alias == "" { + continue + } + normalized = append(normalized, model) + } + entry.Models = normalized +} diff --git a/internal/config/config.go b/internal/config/config.go index bc4d217a..9fd338d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -94,6 +94,18 @@ type ClaudeKey struct { // ProxyURL overrides the global proxy setting for this API key if provided. ProxyURL string `yaml:"proxy-url" json:"proxy-url"` + + // Models defines upstream model names and aliases for request routing. + Models []ClaudeModel `yaml:"models" json:"models"` +} + +// ClaudeModel describes a mapping between an alias and the actual upstream model name. +type ClaudeModel struct { + // Name is the upstream model identifier used when issuing requests. + Name string `yaml:"name" json:"name"` + + // Alias is the client-facing model name that maps to Name. + Alias string `yaml:"alias" json:"alias"` } // CodexKey represents the configuration for a Codex API key, diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 77cfc1ea..033e3bd3 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -49,8 +49,13 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Use streaming translation to preserve function calling, except for claude. stream := from != to body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream) + modelForUpstream := req.Model + if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { + body, _ = sjson.SetBytes(body, "model", modelOverride) + modelForUpstream = modelOverride + } - if !strings.HasPrefix(req.Model, "claude-3-5-haiku") { + if !strings.HasPrefix(modelForUpstream, "claude-3-5-haiku") { body, _ = sjson.SetRawBytes(body, "system", []byte(misc.ClaudeCodeInstructions)) } @@ -141,6 +146,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A from := opts.SourceFormat to := sdktranslator.FromString("claude") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) + if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { + body, _ = sjson.SetBytes(body, "model", modelOverride) + } body, _ = sjson.SetRawBytes(body, "system", []byte(misc.ClaudeCodeInstructions)) url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) @@ -256,8 +264,13 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut // Use streaming translation to preserve function calling, except for claude. stream := from != to body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream) + modelForUpstream := req.Model + if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { + body, _ = sjson.SetBytes(body, "model", modelOverride) + modelForUpstream = modelOverride + } - if !strings.HasPrefix(req.Model, "claude-3-5-haiku") { + if !strings.HasPrefix(modelForUpstream, "claude-3-5-haiku") { body, _ = sjson.SetRawBytes(body, "system", []byte(misc.ClaudeCodeInstructions)) } @@ -358,6 +371,73 @@ func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) ( return auth, nil } +func (e *ClaudeExecutor) resolveUpstreamModel(alias string, auth *cliproxyauth.Auth) string { + if alias == "" { + return "" + } + entry := e.resolveClaudeConfig(auth) + if entry == nil { + return "" + } + for i := range entry.Models { + model := entry.Models[i] + name := strings.TrimSpace(model.Name) + modelAlias := strings.TrimSpace(model.Alias) + if modelAlias != "" { + if strings.EqualFold(modelAlias, alias) { + if name != "" { + return name + } + return alias + } + continue + } + if name != "" && strings.EqualFold(name, alias) { + return name + } + } + return "" +} + +func (e *ClaudeExecutor) resolveClaudeConfig(auth *cliproxyauth.Auth) *config.ClaudeKey { + if auth == nil || e.cfg == nil { + return nil + } + var attrKey, attrBase string + if auth.Attributes != nil { + attrKey = strings.TrimSpace(auth.Attributes["api_key"]) + attrBase = strings.TrimSpace(auth.Attributes["base_url"]) + } + for i := range e.cfg.ClaudeKey { + entry := &e.cfg.ClaudeKey[i] + cfgKey := strings.TrimSpace(entry.APIKey) + cfgBase := strings.TrimSpace(entry.BaseURL) + if attrKey != "" && attrBase != "" { + if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) { + return entry + } + continue + } + if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { + if attrBase == "" || cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { + return entry + } + } + if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { + return entry + } + } + if attrKey != "" { + for i := range e.cfg.ClaudeKey { + entry := &e.cfg.ClaudeKey[i] + if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) { + return entry + } + } + } + return nil +} + func hasZSTDEcoding(contentEncoding string) bool { if contentEncoding == "" { return false diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 93694710..c25c1095 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -423,6 +423,19 @@ func computeOpenAICompatModelsHash(models []config.OpenAICompatibilityModel) str return hex.EncodeToString(sum[:]) } +// computeClaudeModelsHash returns a stable hash for Claude model aliases. +func computeClaudeModelsHash(models []config.ClaudeModel) string { + if len(models) == 0 { + return "" + } + data, err := json.Marshal(models) + if err != nil || len(data) == 0 { + return "" + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + // SetClients sets the file-based clients. // SetClients removed // SetAPIKeyClients removed @@ -760,13 +773,17 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if key == "" { continue } - id, token := idGen.next("claude:apikey", key, ck.BaseURL) + base := strings.TrimSpace(ck.BaseURL) + id, token := idGen.next("claude:apikey", key, base) attrs := map[string]string{ "source": fmt.Sprintf("config:claude[%s]", token), "api_key": key, } - if ck.BaseURL != "" { - attrs["base_url"] = ck.BaseURL + if base != "" { + attrs["base_url"] = base + } + if hash := computeClaudeModelsHash(ck.Models); hash != "" { + attrs["models_hash"] = hash } proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index ada70eb5..32c64837 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -627,6 +627,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { models = registry.GetGeminiCLIModels() case "claude": models = registry.GetClaudeModels() + if entry := s.resolveConfigClaudeKey(a); entry != nil && len(entry.Models) > 0 { + models = buildClaudeConfigModels(entry) + } case "codex": models = registry.GetOpenAIModels() case "qwen": @@ -743,3 +746,80 @@ func mergeGeminiModels() []*ModelInfo { appendModels(registry.GetGeminiCLIModels()) return models } + +func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey { + if auth == nil || s.cfg == nil { + return nil + } + var attrKey, attrBase string + if auth.Attributes != nil { + attrKey = strings.TrimSpace(auth.Attributes["api_key"]) + attrBase = strings.TrimSpace(auth.Attributes["base_url"]) + } + for i := range s.cfg.ClaudeKey { + entry := &s.cfg.ClaudeKey[i] + cfgKey := strings.TrimSpace(entry.APIKey) + cfgBase := strings.TrimSpace(entry.BaseURL) + if attrKey != "" && attrBase != "" { + if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) { + return entry + } + continue + } + if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { + if attrBase == "" || cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { + return entry + } + } + if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { + return entry + } + } + if attrKey != "" { + for i := range s.cfg.ClaudeKey { + entry := &s.cfg.ClaudeKey[i] + if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) { + return entry + } + } + } + return nil +} + +func buildClaudeConfigModels(entry *config.ClaudeKey) []*ModelInfo { + if entry == nil || len(entry.Models) == 0 { + return nil + } + now := time.Now().Unix() + out := make([]*ModelInfo, 0, len(entry.Models)) + seen := make(map[string]struct{}, len(entry.Models)) + for i := range entry.Models { + model := entry.Models[i] + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if alias == "" { + alias = name + } + if alias == "" { + continue + } + key := strings.ToLower(alias) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + display := name + if display == "" { + display = alias + } + out = append(out, &ModelInfo{ + ID: alias, + Object: "model", + Created: now, + OwnedBy: "claude", + Type: "claude", + DisplayName: display, + }) + } + return out +}