From 7c1c4ee60b170794e9732a84f8a1686090da3fee Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:09:28 +0800 Subject: [PATCH 1/5] feat(gemini): add Gemini API key endpoints --- MANAGEMENT_API.md | 66 ++++++++- MANAGEMENT_API_CN.md | 66 ++++++++- README.md | 25 +++- README_CN.md | 25 +++- config.example.yaml | 13 +- .../api/handlers/management/config_lists.go | 133 +++++++++++++++++- internal/api/server.go | 13 +- internal/config/config.go | 81 ++++++++++- internal/runtime/executor/gemini_executor.go | 67 ++++++++- internal/util/provider.go | 2 +- internal/watcher/watcher.go | 105 ++++++++++---- sdk/cliproxy/providers.go | 4 +- 12 files changed, 538 insertions(+), 62 deletions(-) diff --git a/MANAGEMENT_API.md b/MANAGEMENT_API.md index d2cad492..676027b4 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","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":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"gemini-api-key":[{"api-key":"AI...01","base-url":"https://generativelanguage.googleapis.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""},{"api-key":"AI...02","proxy-url":"socks5://proxy.example.com:1080"}],"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 @@ -283,7 +283,69 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro { "status": "ok" } ``` -### Gemini API Key (Generative Language) +### Gemini API Key +- GET `/gemini-api-key` + - Request: + ```bash + curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/gemini-api-key + ``` + - Response: + ```json + { + "gemini-api-key": [ + {"api-key":"AIzaSy...01","base-url":"https://generativelanguage.googleapis.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""}, + {"api-key":"AIzaSy...02","proxy-url":"socks5://proxy.example.com:1080"} + ] + } + ``` +- PUT `/gemini-api-key` + - Request (array form): + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '[{"api-key":"AIzaSy-1","headers":{"X-Custom-Header":"vendor-value"}},{"api-key":"AIzaSy-2","base-url":"https://custom.example.com"}]' \ + http://localhost:8317/v0/management/gemini-api-key + ``` + - Response: + ```json + { "status": "ok" } + ``` +- PATCH `/gemini-api-key` + - Request (update by index): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"index":0,"value":{"api-key":"AIzaSy-1","base-url":"https://custom.example.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""}}' \ + http://localhost:8317/v0/management/gemini-api-key + ``` + - Request (update by api-key match): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"match":"AIzaSy-1","value":{"api-key":"AIzaSy-1","headers":{"X-Custom-Header":"custom-value"},"proxy-url":"socks5://proxy.example.com:1080"}}' \ + http://localhost:8317/v0/management/gemini-api-key + ``` + - Response: + ```json + { "status": "ok" } + ``` +- DELETE `/gemini-api-key` + - Request (by api-key): + ```bash + curl -H 'Authorization: Bearer ' -X DELETE \ + 'http://localhost:8317/v0/management/gemini-api-key?api-key=AIzaSy-1' + ``` + - Request (by index): + ```bash + curl -H 'Authorization: Bearer ' -X DELETE \ + 'http://localhost:8317/v0/management/gemini-api-key?index=0' + ``` + - Response: + ```json + { "status": "ok" } + ``` + +### Generative Language API Key (Legacy) - GET `/generative-language-api-key` - Request: ```bash diff --git a/MANAGEMENT_API_CN.md b/MANAGEMENT_API_CN.md index a59bde31..fe1d3f17 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","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":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"gemini-api-key":[{"api-key":"AI...01","base-url":"https://generativelanguage.googleapis.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""},{"api-key":"AI...02","proxy-url":"socks5://proxy.example.com:1080"}],"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 @@ -283,7 +283,69 @@ { "status": "ok" } ``` -### Gemini API Key(生成式语言) +### Gemini API Key +- GET `/gemini-api-key` + - 请求: + ```bash + curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/gemini-api-key + ``` + - 响应: + ```json + { + "gemini-api-key": [ + {"api-key":"AIzaSy...01","base-url":"https://generativelanguage.googleapis.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""}, + {"api-key":"AIzaSy...02","proxy-url":"socks5://proxy.example.com:1080"} + ] + } + ``` +- PUT `/gemini-api-key` + - 请求(数组形式): + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '[{"api-key":"AIzaSy-1","headers":{"X-Custom-Header":"vendor-value"}},{"api-key":"AIzaSy-2","base-url":"https://custom.example.com"}]' \ + http://localhost:8317/v0/management/gemini-api-key + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- PATCH `/gemini-api-key` + - 请求(按索引更新): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"index":0,"value":{"api-key":"AIzaSy-1","base-url":"https://custom.example.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""}}' \ + http://localhost:8317/v0/management/gemini-api-key + ``` + - 请求(按 api-key 匹配更新): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"match":"AIzaSy-1","value":{"api-key":"AIzaSy-1","headers":{"X-Custom-Header":"custom-value"},"proxy-url":"socks5://proxy.example.com:1080"}}' \ + http://localhost:8317/v0/management/gemini-api-key + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- DELETE `/gemini-api-key` + - 请求(按 api-key 删除): + ```bash + curl -H 'Authorization: Bearer ' -X DELETE \ + 'http://localhost:8317/v0/management/gemini-api-key?api-key=AIzaSy-1' + ``` + - 请求(按索引删除): + ```bash + curl -H 'Authorization: Bearer ' -X DELETE \ + 'http://localhost:8317/v0/management/gemini-api-key?index=0' + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### Generative Language API Key(兼容接口) - GET `/generative-language-api-key` - 请求: ```bash diff --git a/README.md b/README.md index b5e63809..6ed7928d 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,12 @@ The server uses a YAML configuration file (`config.yaml`) located in the project | `logging-to-file` | boolean | true | Write application logs to rotating files instead of stdout. Set to `false` to log to stdout/stderr. | | `usage-statistics-enabled` | boolean | true | Enable in-memory usage aggregation for management APIs. Disable to drop all collected usage metrics. | | `api-keys` | string[] | [] | Legacy shorthand for inline API keys. Values are mirrored into the `config-api-key` provider for backwards compatibility. | -| `generative-language-api-key` | string[] | [] | List of Generative Language API keys. | +| `gemini-api-key` | object[] | [] | Gemini API key entries with optional per-key `base-url` and `proxy-url` overrides. | +| `gemini-api-key.*.api-key` | string | "" | Gemini API key. | +| `gemini-api-key.*.base-url` | string | "" | Optional Gemini API endpoint override. | +| `gemini-api-key.*.headers` | object | {} | Optional extra HTTP headers sent to the overridden Gemini endpoint only. | +| `gemini-api-key.*.proxy-url` | string | "" | Optional per-key proxy override for the Gemini API key. | +| `generative-language-api-key` | string[] | [] | (Legacy) List of Generative Language API keys without per-key overrides. | | `codex-api-key` | object | {} | List of Codex API keys. | | `codex-api-key.api-key` | string | "" | Codex API key. | | `codex-api-key.base-url` | string | "" | Custom Codex API endpoint, if you use a third-party API endpoint. | @@ -394,12 +399,18 @@ quota-exceeded: switch-project: true # Whether to automatically switch to another project when a quota is exceeded switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded -# API keys for official Generative Language API +# Gemini API keys (preferred) +gemini-api-key: + - api-key: "AIzaSy...01" + base-url: "https://generativelanguage.googleapis.com" + headers: + X-Custom-Header: "custom-value" + proxy-url: "socks5://proxy.example.com:1080" + - api-key: "AIzaSy...02" + +# API keys for official Generative Language API (legacy compatibility) generative-language-api-key: - "AIzaSy...01" - - "AIzaSy...02" - - "AIzaSy...03" - - "AIzaSy...04" # Codex API keys codex-api-key: @@ -558,9 +569,9 @@ By default, WebSocket connections to CLIProxyAPI do not require authentication. The `auth-dir` parameter specifies where authentication tokens are stored. When you run the login command, the application will create JSON files in this directory containing the authentication tokens for your Google accounts. Multiple accounts can be used for load balancing. -### Official Generative Language API +### Gemini API Configuration -The `generative-language-api-key` parameter allows you to define a list of API keys that can be used to authenticate requests to the official Generative Language API. +Use the `gemini-api-key` parameter to configure Gemini API keys. Each entry accepts optional `base-url`, `headers`, and `proxy-url` values; headers are only attached to requests sent to the overridden Gemini endpoint and are never forwarded to proxy servers. When `base-url` is omitted the server behaves the same as the legacy `generative-language-api-key` list. The legacy array remains supported for backwards compatibility and is automatically mirrored into the new structure. ## Hot Reloading diff --git a/README_CN.md b/README_CN.md index 37ccd544..5e2d9628 100644 --- a/README_CN.md +++ b/README_CN.md @@ -334,7 +334,12 @@ console.log(await claudeResponse.json()); | `logging-to-file` | boolean | true | 是否将应用日志写入滚动文件;设为 false 时输出到 stdout/stderr。 | | `usage-statistics-enabled` | boolean | true | 是否启用内存中的使用统计;设为 false 时直接丢弃所有统计数据。 | | `api-keys` | string[] | [] | 兼容旧配置的简写,会自动同步到默认 `config-api-key` 提供方。 | -| `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 | +| `gemini-api-key` | object[] | [] | Gemini API 密钥配置,支持为每个密钥设置可选的 `base-url` 与 `proxy-url`。 | +| `gemini-api-key.*.api-key` | string | "" | Gemini API 密钥。 | +| `gemini-api-key.*.base-url` | string | "" | 可选的 Gemini API 端点覆盖地址。 | +| `gemini-api-key.*.headers` | object | {} | 可选的额外 HTTP 头部,仅在访问覆盖后的 Gemini 端点时发送。 | +| `gemini-api-key.*.proxy-url` | string | "" | 可选的单独代理设置,会覆盖全局 `proxy-url`。 | +| `generative-language-api-key` | string[] | [] | (兼容项)不带扩展配置的生成式语言 API 密钥列表。 | | `codex-api-key` | object | {} | Codex API密钥列表。 | | `codex-api-key.api-key` | string | "" | Codex API密钥。 | | `codex-api-key.base-url` | string | "" | 自定义的Codex API端点 | @@ -407,12 +412,18 @@ quota-exceeded: switch-project: true # 当配额超限时是否自动切换到另一个项目 switch-preview-model: true # 当配额超限时是否自动切换到预览模型 -# AIStduio Gemini API 的 API 密钥 +# Gemini API 密钥(推荐) +gemini-api-key: + - api-key: "AIzaSy...01" + base-url: "https://generativelanguage.googleapis.com" + headers: + X-Custom-Header: "custom-value" + proxy-url: "socks5://proxy.example.com:1080" + - api-key: "AIzaSy...02" + +# AIStudio Gemini API 的遗留密钥配置 generative-language-api-key: - "AIzaSy...01" - - "AIzaSy...02" - - "AIzaSy...03" - - "AIzaSy...04" # Codex API 密钥 codex-api-key: @@ -569,9 +580,9 @@ openai-compatibility: `auth-dir` 参数指定身份验证令牌的存储位置。当您运行登录命令时,应用程序将在此目录中创建包含 Google 账户身份验证令牌的 JSON 文件。多个账户可用于轮询。 -### 官方生成式语言 API +### Gemini API 配置 -`generative-language-api-key` 参数允许您定义可用于验证对官方 AIStudio Gemini API 请求的 API 密钥列表。 +使用 `gemini-api-key` 参数来配置 Gemini API 密钥;每个条目都可以选择性地提供 `base-url`、`headers` 与 `proxy-url`。`headers` 仅会附加到访问覆盖后 Gemini 端点的请求,不会转发给代理服务器。当 `base-url` 留空时,其行为与遗留的 `generative-language-api-key` 列表一致。旧字段仍受支持,会自动同步到新的结构中以保持兼容性。 ## 热更新 diff --git a/config.example.yaml b/config.example.yaml index 26c8335b..27c11c25 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -46,12 +46,19 @@ quota-exceeded: # When true, enable authentication for the WebSocket API (/v1/ws). ws-auth: false -# API keys for official Generative Language API +# Gemini API keys (preferred) +#gemini-api-key: +# - api-key: "AIzaSy...01" +# # base-url: "https://generativelanguage.googleapis.com" +# # headers: +# # X-Custom-Header: "custom-value" +# # proxy-url: "socks5://proxy.example.com:1080" +# - api-key: "AIzaSy...02" + +# API keys for official Generative Language API (legacy compatibility) #generative-language-api-key: # - "AIzaSy...01" # - "AIzaSy...02" -# - "AIzaSy...03" -# - "AIzaSy...04" # Codex API keys #codex-api-key: diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 8ab86435..530adacc 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -124,10 +124,137 @@ func (h *Handler) GetGlKeys(c *gin.Context) { c.JSON(200, gin.H{"generative-language-api-key": h.cfg.GlAPIKey}) } func (h *Handler) PutGlKeys(c *gin.Context) { - h.putStringList(c, func(v []string) { h.cfg.GlAPIKey = v }, nil) + h.putStringList(c, func(v []string) { + h.cfg.GlAPIKey = append([]string(nil), v...) + }, func() { + h.cfg.SyncGeminiKeys() + }) +} +func (h *Handler) PatchGlKeys(c *gin.Context) { + h.patchStringList(c, &h.cfg.GlAPIKey, func() { h.cfg.SyncGeminiKeys() }) +} +func (h *Handler) DeleteGlKeys(c *gin.Context) { + h.deleteFromStringList(c, &h.cfg.GlAPIKey, func() { h.cfg.SyncGeminiKeys() }) +} + +// gemini-api-key: []GeminiKey +func (h *Handler) GetGeminiKeys(c *gin.Context) { + c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey}) +} +func (h *Handler) PutGeminiKeys(c *gin.Context) { + data, err := c.GetRawData() + if err != nil { + c.JSON(400, gin.H{"error": "failed to read body"}) + return + } + var arr []config.GeminiKey + if err = json.Unmarshal(data, &arr); err != nil { + var obj struct { + Items []config.GeminiKey `json:"items"` + } + if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + arr = obj.Items + } + h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...) + h.cfg.SyncGeminiKeys() + h.persist(c) +} +func (h *Handler) PatchGeminiKey(c *gin.Context) { + var body struct { + Index *int `json:"index"` + Match *string `json:"match"` + Value *config.GeminiKey `json:"value"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + value := *body.Value + value.APIKey = strings.TrimSpace(value.APIKey) + value.BaseURL = strings.TrimSpace(value.BaseURL) + value.ProxyURL = strings.TrimSpace(value.ProxyURL) + if value.APIKey == "" { + // Treat empty API key as delete. + if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) { + h.cfg.GeminiKey = append(h.cfg.GeminiKey[:*body.Index], h.cfg.GeminiKey[*body.Index+1:]...) + h.cfg.SyncGeminiKeys() + h.persist(c) + return + } + if body.Match != nil { + match := strings.TrimSpace(*body.Match) + if match != "" { + out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey)) + removed := false + for i := range h.cfg.GeminiKey { + if !removed && h.cfg.GeminiKey[i].APIKey == match { + removed = true + continue + } + out = append(out, h.cfg.GeminiKey[i]) + } + if removed { + h.cfg.GeminiKey = out + h.cfg.SyncGeminiKeys() + h.persist(c) + return + } + } + } + c.JSON(404, gin.H{"error": "item not found"}) + return + } + + if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) { + h.cfg.GeminiKey[*body.Index] = value + h.cfg.SyncGeminiKeys() + h.persist(c) + return + } + if body.Match != nil { + match := strings.TrimSpace(*body.Match) + for i := range h.cfg.GeminiKey { + if h.cfg.GeminiKey[i].APIKey == match { + h.cfg.GeminiKey[i] = value + h.cfg.SyncGeminiKeys() + h.persist(c) + return + } + } + } + c.JSON(404, gin.H{"error": "item not found"}) +} +func (h *Handler) DeleteGeminiKey(c *gin.Context) { + if val := strings.TrimSpace(c.Query("api-key")); val != "" { + out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey)) + for _, v := range h.cfg.GeminiKey { + if v.APIKey != val { + out = append(out, v) + } + } + if len(out) != len(h.cfg.GeminiKey) { + h.cfg.GeminiKey = out + h.cfg.SyncGeminiKeys() + h.persist(c) + } else { + c.JSON(404, gin.H{"error": "item not found"}) + } + return + } + if idxStr := c.Query("index"); idxStr != "" { + var idx int + if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) { + h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...) + h.cfg.SyncGeminiKeys() + h.persist(c) + return + } + } + c.JSON(400, gin.H{"error": "missing api-key or index"}) } -func (h *Handler) PatchGlKeys(c *gin.Context) { h.patchStringList(c, &h.cfg.GlAPIKey, nil) } -func (h *Handler) DeleteGlKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.GlAPIKey, nil) } // claude-api-key: []ClaudeKey func (h *Handler) GetClaudeKeys(c *gin.Context) { diff --git a/internal/api/server.go b/internal/api/server.go index d834abc3..5eaea422 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -474,6 +474,11 @@ func (s *Server) registerManagementRoutes() { mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys) mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys) + mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys) + mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys) + mgmt.PATCH("/gemini-api-key", s.mgmt.PatchGeminiKey) + mgmt.DELETE("/gemini-api-key", s.mgmt.DeleteGeminiKey) + mgmt.GET("/logs", s.mgmt.GetLogs) mgmt.DELETE("/logs", s.mgmt.DeleteLogs) mgmt.GET("/request-log", s.mgmt.GetRequestLog) @@ -847,7 +852,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { // Count client sources from configuration and auth directory authFiles := util.CountAuthFiles(cfg.AuthDir) - glAPIKeyCount := len(cfg.GlAPIKey) + geminiAPIKeyCount := len(cfg.GeminiKey) claudeAPIKeyCount := len(cfg.ClaudeKey) codexAPIKeyCount := len(cfg.CodexKey) openAICompatCount := 0 @@ -860,11 +865,11 @@ func (s *Server) UpdateClients(cfg *config.Config) { openAICompatCount += len(entry.APIKeys) } - total := authFiles + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount - fmt.Printf("server clients and configuration updated: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)\n", + total := authFiles + geminiAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount + fmt.Printf("server clients and configuration updated: %d clients (%d auth files + %d Gemini API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)\n", total, authFiles, - glAPIKeyCount, + geminiAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount, diff --git a/internal/config/config.go b/internal/config/config.go index 9fd338d3..1424788f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,9 +43,12 @@ type Config struct { // WebsocketAuth enables or disables authentication for the WebSocket API. WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"` - // GlAPIKey is the API key for the generative language API. + // GlAPIKey exposes the legacy generative language API key list for backward compatibility. GlAPIKey []string `yaml:"generative-language-api-key" json:"generative-language-api-key"` + // GeminiKey defines Gemini API key configurations with optional routing overrides. + GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"` + // RequestRetry defines the retry times when the request failed. RequestRetry int `yaml:"request-retry" json:"request-retry"` @@ -122,6 +125,22 @@ type CodexKey struct { ProxyURL string `yaml:"proxy-url" json:"proxy-url"` } +// GeminiKey represents the configuration for a Gemini API key, +// including optional overrides for upstream base URL, proxy routing, and headers. +type GeminiKey struct { + // APIKey is the authentication key for accessing Gemini API services. + APIKey string `yaml:"api-key" json:"api-key"` + + // BaseURL optionally overrides the Gemini API endpoint. + BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"` + + // ProxyURL optionally overrides the global proxy for this API key. + ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"` + + // Headers optionally adds extra HTTP headers for requests sent with this key. + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` +} + // OpenAICompatibility represents the configuration for OpenAI API compatibility // with external providers, allowing model aliases to be routed through OpenAI API format. type OpenAICompatibility struct { @@ -227,6 +246,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Sync request authentication providers with inline API keys for backwards compatibility. syncInlineAccessProvider(&cfg) + // Normalize Gemini API key configuration and migrate legacy entries. + cfg.SyncGeminiKeys() + // Sanitize OpenAI compatibility providers: drop entries without base-url sanitizeOpenAICompatibility(&cfg) @@ -276,6 +298,63 @@ func sanitizeCodexKeys(cfg *Config) { cfg.CodexKey = out } +func (cfg *Config) SyncGeminiKeys() { + if cfg == nil { + return + } + + if len(cfg.GeminiKey) > 0 { + out := make([]GeminiKey, 0, len(cfg.GeminiKey)) + for i := range cfg.GeminiKey { + entry := cfg.GeminiKey[i] + entry.APIKey = strings.TrimSpace(entry.APIKey) + entry.BaseURL = strings.TrimSpace(entry.BaseURL) + entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) + if entry.APIKey == "" { + continue + } + if len(entry.Headers) > 0 { + clean := make(map[string]string, len(entry.Headers)) + for hk, hv := range entry.Headers { + key := strings.TrimSpace(hk) + val := strings.TrimSpace(hv) + if key == "" || val == "" { + continue + } + clean[key] = val + } + if len(clean) == 0 { + entry.Headers = nil + } else { + entry.Headers = clean + } + } + out = append(out, entry) + } + cfg.GeminiKey = out + } + + if len(cfg.GeminiKey) == 0 && len(cfg.GlAPIKey) > 0 { + out := make([]GeminiKey, 0, len(cfg.GlAPIKey)) + for i := range cfg.GlAPIKey { + key := strings.TrimSpace(cfg.GlAPIKey[i]) + if key == "" { + continue + } + out = append(out, GeminiKey{APIKey: key}) + } + cfg.GeminiKey = out + } + + cfg.GlAPIKey = cfg.GlAPIKey[:0] + if len(cfg.GeminiKey) > 0 { + cfg.GlAPIKey = make([]string, 0, len(cfg.GeminiKey)) + for i := range cfg.GeminiKey { + cfg.GlAPIKey = append(cfg.GlAPIKey, cfg.GeminiKey[i].APIKey) + } + } +} + func syncInlineAccessProvider(cfg *Config) { if cfg == nil { return diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index d712aaa1..91955a66 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -94,7 +95,8 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r action = "countTokens" } } - url := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, req.Model, action) + baseURL := resolveGeminiBaseURL(auth) + url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, req.Model, action) if opts.Alt != "" && action != "countTokens" { url = url + fmt.Sprintf("?$alt=%s", opts.Alt) } @@ -111,6 +113,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } else if bearer != "" { httpReq.Header.Set("Authorization", "Bearer "+bearer) } + applyGeminiHeaders(httpReq, auth) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -180,7 +183,8 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body = util.StripThinkingConfigIfUnsupported(req.Model, body) body = fixGeminiImageAspectRatio(req.Model, body) - url := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, req.Model, "streamGenerateContent") + baseURL := resolveGeminiBaseURL(auth) + url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, req.Model, "streamGenerateContent") if opts.Alt == "" { url = url + "?alt=sse" } else { @@ -199,6 +203,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } else { httpReq.Header.Set("Authorization", "Bearer "+bearer) } + applyGeminiHeaders(httpReq, auth) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -290,7 +295,8 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools") translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig") - url := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, req.Model, "countTokens") + baseURL := resolveGeminiBaseURL(auth) + url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, req.Model, "countTokens") requestBody := bytes.NewReader(translatedReq) @@ -304,6 +310,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut } else { httpReq.Header.Set("Authorization", "Bearer "+bearer) } + applyGeminiHeaders(httpReq, auth) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -473,6 +480,60 @@ func geminiCreds(a *cliproxyauth.Auth) (apiKey, bearer string) { return } +func resolveGeminiBaseURL(auth *cliproxyauth.Auth) string { + base := glEndpoint + if auth != nil && auth.Attributes != nil { + if custom := strings.TrimSpace(auth.Attributes["base_url"]); custom != "" { + base = strings.TrimRight(custom, "/") + } + } + if base == "" { + return glEndpoint + } + return base +} + +func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) { + if req == nil { + return + } + headers := geminiCustomHeaders(auth) + if len(headers) == 0 { + return + } + for k, v := range headers { + if k == "" || v == "" { + continue + } + req.Header.Set(k, v) + } +} + +func geminiCustomHeaders(auth *cliproxyauth.Auth) map[string]string { + if auth == nil || auth.Attributes == nil { + return nil + } + headers := make(map[string]string, len(auth.Attributes)) + for k, v := range auth.Attributes { + if !strings.HasPrefix(k, "header:") { + continue + } + name := strings.TrimSpace(strings.TrimPrefix(k, "header:")) + if name == "" { + continue + } + val := strings.TrimSpace(v) + if val == "" { + continue + } + headers[name] = val + } + if len(headers) == 0 { + return nil + } + return headers +} + func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte { if modelName == "gemini-2.5-flash-image-preview" { aspectRatioResult := gjson.GetBytes(rawJSON, "generationConfig.imageConfig.aspectRatio") diff --git a/internal/util/provider.go b/internal/util/provider.go index 8c6cefdb..8c36ae8c 100644 --- a/internal/util/provider.go +++ b/internal/util/provider.go @@ -178,7 +178,7 @@ func MaskAuthorizationHeader(value string) string { func MaskSensitiveHeaderValue(key, value string) string { lowerKey := strings.ToLower(strings.TrimSpace(key)) switch { - case lowerKey == "authorization": + case strings.Contains(lowerKey, "authorization"): return MaskAuthorizationHeader(value) case strings.Contains(lowerKey, "api-key"), strings.Contains(lowerKey, "apikey"), diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index c25c1095..e2cb4e5e 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -604,8 +604,8 @@ func (w *Watcher) reloadClients(rescanAuth bool) { // no legacy clients to unregister // Create new API key clients based on the new config - glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := BuildAPIKeyClients(cfg) - totalAPIKeyClients := glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount + geminiAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := BuildAPIKeyClients(cfg) + totalAPIKeyClients := geminiAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount log.Debugf("loaded %d API key clients", totalAPIKeyClients) var authFileCount int @@ -648,7 +648,7 @@ func (w *Watcher) reloadClients(rescanAuth bool) { w.clientsMutex.Unlock() } - totalNewClients := authFileCount + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount + totalNewClients := authFileCount + geminiAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount // Ensure consumers observe the new configuration before auth updates dispatch. if w.reloadCallback != nil { @@ -658,10 +658,10 @@ func (w *Watcher) reloadClients(rescanAuth bool) { w.refreshAuthState() - log.Infof("full client load complete - %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)", + log.Infof("full client load complete - %d clients (%d auth files + %d Gemini API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)", totalNewClients, authFileCount, - glAPIKeyCount, + geminiAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount, @@ -746,23 +746,41 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { w.clientsMutex.RUnlock() if cfg != nil { // Gemini official API keys -> synthesize auths - for i := range cfg.GlAPIKey { - k := strings.TrimSpace(cfg.GlAPIKey[i]) - if k == "" { + for i := range cfg.GeminiKey { + entry := cfg.GeminiKey[i] + key := strings.TrimSpace(entry.APIKey) + if key == "" { continue } - id, token := idGen.next("gemini:apikey", k) + base := strings.TrimSpace(entry.BaseURL) + proxyURL := strings.TrimSpace(entry.ProxyURL) + id, token := idGen.next("gemini:apikey", key, base) + attrs := map[string]string{ + "source": fmt.Sprintf("config:gemini[%s]", token), + "api_key": key, + } + if base != "" { + attrs["base_url"] = base + } + if len(entry.Headers) > 0 { + for hk, hv := range entry.Headers { + key := strings.TrimSpace(hk) + val := strings.TrimSpace(hv) + if key == "" || val == "" { + continue + } + attrs["header:"+key] = val + } + } a := &coreauth.Auth{ - ID: id, - Provider: "gemini", - Label: "gemini-apikey", - Status: coreauth.StatusActive, - Attributes: map[string]string{ - "source": fmt.Sprintf("config:gemini[%s]", token), - "api_key": k, - }, - CreatedAt: now, - UpdatedAt: now, + ID: id, + Provider: "gemini", + Label: "gemini-apikey", + Status: coreauth.StatusActive, + ProxyURL: proxyURL, + Attributes: attrs, + CreatedAt: now, + UpdatedAt: now, } out = append(out, a) } @@ -1030,14 +1048,14 @@ func (w *Watcher) loadFileClients(cfg *config.Config) int { } func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int) { - glAPIKeyCount := 0 + geminiAPIKeyCount := 0 claudeAPIKeyCount := 0 codexAPIKeyCount := 0 openAICompatCount := 0 - if len(cfg.GlAPIKey) > 0 { + if len(cfg.GeminiKey) > 0 { // Stateless executor handles Gemini API keys; avoid constructing legacy clients. - glAPIKeyCount += len(cfg.GlAPIKey) + geminiAPIKeyCount += len(cfg.GeminiKey) } if len(cfg.ClaudeKey) > 0 { claudeAPIKeyCount += len(cfg.ClaudeKey) @@ -1056,7 +1074,7 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int) { } } } - return glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount + return geminiAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount } func diffOpenAICompatibility(oldList, newList []config.OpenAICompatibility) []string { @@ -1239,10 +1257,31 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { } else if !reflect.DeepEqual(trimStrings(oldCfg.APIKeys), trimStrings(newCfg.APIKeys)) { changes = append(changes, "api-keys: values updated (count unchanged, redacted)") } - if len(oldCfg.GlAPIKey) != len(newCfg.GlAPIKey) { - changes = append(changes, fmt.Sprintf("generative-language-api-key count: %d -> %d", len(oldCfg.GlAPIKey), len(newCfg.GlAPIKey))) - } else if !reflect.DeepEqual(trimStrings(oldCfg.GlAPIKey), trimStrings(newCfg.GlAPIKey)) { - changes = append(changes, "generative-language-api-key: values updated (count unchanged, redacted)") + if len(oldCfg.GeminiKey) != len(newCfg.GeminiKey) { + changes = append(changes, fmt.Sprintf("gemini-api-key count: %d -> %d", len(oldCfg.GeminiKey), len(newCfg.GeminiKey))) + } else { + for i := range oldCfg.GeminiKey { + if i >= len(newCfg.GeminiKey) { + break + } + o := oldCfg.GeminiKey[i] + n := newCfg.GeminiKey[i] + if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) { + changes = append(changes, fmt.Sprintf("gemini[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL))) + } + if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) { + changes = append(changes, fmt.Sprintf("gemini[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL))) + } + if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { + changes = append(changes, fmt.Sprintf("gemini[%d].api-key: updated", i)) + } + if !equalStringMap(o.Headers, n.Headers) { + changes = append(changes, fmt.Sprintf("gemini[%d].headers: updated", i)) + } + } + if !reflect.DeepEqual(trimStrings(oldCfg.GlAPIKey), trimStrings(newCfg.GlAPIKey)) { + changes = append(changes, "generative-language-api-key: values updated (legacy view, redacted)") + } } // Claude keys (do not print key material) @@ -1325,3 +1364,15 @@ func trimStrings(in []string) []string { } return out } + +func equalStringMap(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} diff --git a/sdk/cliproxy/providers.go b/sdk/cliproxy/providers.go index 13e39ccb..a5810336 100644 --- a/sdk/cliproxy/providers.go +++ b/sdk/cliproxy/providers.go @@ -29,7 +29,7 @@ func NewAPIKeyClientProvider() APIKeyClientProvider { type apiKeyClientProvider struct{} func (p *apiKeyClientProvider) Load(ctx context.Context, cfg *config.Config) (*APIKeyClientResult, error) { - glCount, claudeCount, codexCount, openAICompat := watcher.BuildAPIKeyClients(cfg) + geminiCount, claudeCount, codexCount, openAICompat := watcher.BuildAPIKeyClients(cfg) if ctx != nil { select { case <-ctx.Done(): @@ -38,7 +38,7 @@ func (p *apiKeyClientProvider) Load(ctx context.Context, cfg *config.Config) (*A } } return &APIKeyClientResult{ - GeminiKeyCount: glCount, + GeminiKeyCount: geminiCount, ClaudeKeyCount: claudeCount, CodexKeyCount: codexCount, OpenAICompatCount: openAICompat, From 16be3f0a127aa73bbb80b2cc507054b533db5f2d Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:20:10 +0800 Subject: [PATCH 2/5] fix(config): dedupe and normalize Gemini keys and headers --- .../api/handlers/management/config_basic.go | 8 +- .../api/handlers/management/config_lists.go | 65 ++++++++++++-- internal/config/config.go | 85 ++++++++++--------- 3 files changed, 107 insertions(+), 51 deletions(-) diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go index 0f052fbd..9a8c2923 100644 --- a/internal/api/handlers/management/config_basic.go +++ b/internal/api/handlers/management/config_basic.go @@ -12,7 +12,13 @@ import ( ) func (h *Handler) GetConfig(c *gin.Context) { - c.JSON(200, h.cfg) + if h == nil || h.cfg == nil { + c.JSON(200, gin.H{}) + return + } + cfgCopy := *h.cfg + cfgCopy.GlAPIKey = geminiKeyStringsFromConfig(h.cfg) + c.JSON(200, &cfgCopy) } func (h *Handler) GetConfigYAML(c *gin.Context) { diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 530adacc..af48b14f 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -87,10 +87,10 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after f return } } - if val := c.Query("value"); val != "" { + if val := strings.TrimSpace(c.Query("value")); val != "" { out := make([]string, 0, len(*target)) for _, v := range *target { - if v != val { + if strings.TrimSpace(v) != val { out = append(out, v) } } @@ -104,6 +104,53 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after f c.JSON(400, gin.H{"error": "missing index or value"}) } +func sanitizeStringSlice(in []string) []string { + out := make([]string, 0, len(in)) + for i := range in { + if trimmed := strings.TrimSpace(in[i]); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +func geminiKeyStringsFromConfig(cfg *config.Config) []string { + if cfg == nil || len(cfg.GeminiKey) == 0 { + return nil + } + out := make([]string, 0, len(cfg.GeminiKey)) + for i := range cfg.GeminiKey { + if key := strings.TrimSpace(cfg.GeminiKey[i].APIKey); key != "" { + out = append(out, key) + } + } + return out +} + +func (h *Handler) applyLegacyKeys(keys []string) { + if h == nil || h.cfg == nil { + return + } + sanitized := sanitizeStringSlice(keys) + existing := make(map[string]config.GeminiKey, len(h.cfg.GeminiKey)) + for _, entry := range h.cfg.GeminiKey { + if key := strings.TrimSpace(entry.APIKey); key != "" { + existing[key] = entry + } + } + newList := make([]config.GeminiKey, 0, len(sanitized)) + for _, key := range sanitized { + if entry, ok := existing[key]; ok { + newList = append(newList, entry) + } else { + newList = append(newList, config.GeminiKey{APIKey: key}) + } + } + h.cfg.GeminiKey = newList + h.cfg.GlAPIKey = sanitized + h.cfg.SyncGeminiKeys() +} + // api-keys func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) } func (h *Handler) PutAPIKeys(c *gin.Context) { @@ -121,20 +168,20 @@ func (h *Handler) DeleteAPIKeys(c *gin.Context) { // generative-language-api-key func (h *Handler) GetGlKeys(c *gin.Context) { - c.JSON(200, gin.H{"generative-language-api-key": h.cfg.GlAPIKey}) + c.JSON(200, gin.H{"generative-language-api-key": geminiKeyStringsFromConfig(h.cfg)}) } func (h *Handler) PutGlKeys(c *gin.Context) { h.putStringList(c, func(v []string) { - h.cfg.GlAPIKey = append([]string(nil), v...) - }, func() { - h.cfg.SyncGeminiKeys() - }) + h.applyLegacyKeys(v) + }, nil) } func (h *Handler) PatchGlKeys(c *gin.Context) { - h.patchStringList(c, &h.cfg.GlAPIKey, func() { h.cfg.SyncGeminiKeys() }) + target := append([]string(nil), geminiKeyStringsFromConfig(h.cfg)...) + h.patchStringList(c, &target, func() { h.applyLegacyKeys(target) }) } func (h *Handler) DeleteGlKeys(c *gin.Context) { - h.deleteFromStringList(c, &h.cfg.GlAPIKey, func() { h.cfg.SyncGeminiKeys() }) + target := append([]string(nil), geminiKeyStringsFromConfig(h.cfg)...) + h.deleteFromStringList(c, &target, func() { h.applyLegacyKeys(target) }) } // gemini-api-key: []GeminiKey diff --git a/internal/config/config.go b/internal/config/config.go index 1424788f..ee01c117 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -303,56 +303,40 @@ func (cfg *Config) SyncGeminiKeys() { return } - if len(cfg.GeminiKey) > 0 { - out := make([]GeminiKey, 0, len(cfg.GeminiKey)) - for i := range cfg.GeminiKey { - entry := cfg.GeminiKey[i] - entry.APIKey = strings.TrimSpace(entry.APIKey) - entry.BaseURL = strings.TrimSpace(entry.BaseURL) - entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) - if entry.APIKey == "" { - continue - } - if len(entry.Headers) > 0 { - clean := make(map[string]string, len(entry.Headers)) - for hk, hv := range entry.Headers { - key := strings.TrimSpace(hk) - val := strings.TrimSpace(hv) - if key == "" || val == "" { - continue - } - clean[key] = val - } - if len(clean) == 0 { - entry.Headers = nil - } else { - entry.Headers = clean - } - } - out = append(out, entry) + seen := make(map[string]struct{}, len(cfg.GeminiKey)) + out := cfg.GeminiKey[:0] + for i := range cfg.GeminiKey { + entry := cfg.GeminiKey[i] + entry.APIKey = strings.TrimSpace(entry.APIKey) + if entry.APIKey == "" { + continue } - cfg.GeminiKey = out + entry.BaseURL = strings.TrimSpace(entry.BaseURL) + entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) + entry.Headers = normalizeGeminiHeaders(entry.Headers) + if _, exists := seen[entry.APIKey]; exists { + continue + } + seen[entry.APIKey] = struct{}{} + out = append(out, entry) } + cfg.GeminiKey = out - if len(cfg.GeminiKey) == 0 && len(cfg.GlAPIKey) > 0 { - out := make([]GeminiKey, 0, len(cfg.GlAPIKey)) - for i := range cfg.GlAPIKey { - key := strings.TrimSpace(cfg.GlAPIKey[i]) + if len(cfg.GlAPIKey) > 0 { + for _, raw := range cfg.GlAPIKey { + key := strings.TrimSpace(raw) if key == "" { continue } - out = append(out, GeminiKey{APIKey: key}) + if _, exists := seen[key]; exists { + continue + } + cfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key}) + seen[key] = struct{}{} } - cfg.GeminiKey = out } - cfg.GlAPIKey = cfg.GlAPIKey[:0] - if len(cfg.GeminiKey) > 0 { - cfg.GlAPIKey = make([]string, 0, len(cfg.GeminiKey)) - for i := range cfg.GeminiKey { - cfg.GlAPIKey = append(cfg.GlAPIKey, cfg.GeminiKey[i].APIKey) - } - } + cfg.GlAPIKey = nil } func syncInlineAccessProvider(cfg *Config) { @@ -372,6 +356,25 @@ func looksLikeBcrypt(s string) bool { return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$") } +func normalizeGeminiHeaders(headers map[string]string) map[string]string { + if len(headers) == 0 { + return nil + } + clean := make(map[string]string, len(headers)) + for k, v := range headers { + key := strings.TrimSpace(k) + val := strings.TrimSpace(v) + if key == "" || val == "" { + continue + } + clean[key] = val + } + if len(clean) == 0 { + return nil + } + return clean +} + // hashSecret hashes the given secret using bcrypt. func hashSecret(secret string) (string, error) { // Use default cost for simplicity. From 5c65938113181002f6ab6591a03f0a106b6e7f17 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:21:58 +0800 Subject: [PATCH 3/5] fix(config): stabilize YAML sequence merges by reordering items --- .../api/handlers/management/config_lists.go | 2 +- internal/config/config.go | 147 ++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index af48b14f..bc261cc8 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -116,7 +116,7 @@ func sanitizeStringSlice(in []string) []string { func geminiKeyStringsFromConfig(cfg *config.Config) []string { if cfg == nil || len(cfg.GeminiKey) == 0 { - return nil + return []string{} } out := make([]string, 0, len(cfg.GeminiKey)) for i := range cfg.GeminiKey { diff --git a/internal/config/config.go b/internal/config/config.go index ee01c117..68008a9b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -574,6 +574,7 @@ func mergeNodePreserve(dst, src *yaml.Node) { dst.Tag = "!!seq" dst.Content = nil } + reorderSequenceForMerge(dst, src) // Update elements in place minContent := len(dst.Content) if len(src.Content) < minContent { @@ -657,6 +658,152 @@ func copyNodeShallow(dst, src *yaml.Node) { } } +func reorderSequenceForMerge(dst, src *yaml.Node) { + if dst == nil || src == nil { + return + } + if len(dst.Content) == 0 { + return + } + if len(src.Content) == 0 { + return + } + original := append([]*yaml.Node(nil), dst.Content...) + used := make([]bool, len(original)) + ordered := make([]*yaml.Node, len(src.Content)) + for i := range src.Content { + if idx := matchSequenceElement(original, used, src.Content[i]); idx >= 0 { + ordered[i] = original[idx] + used[idx] = true + } + } + dst.Content = ordered +} + +func matchSequenceElement(original []*yaml.Node, used []bool, target *yaml.Node) int { + if target == nil { + return -1 + } + switch target.Kind { + case yaml.MappingNode: + id := sequenceElementIdentity(target) + if id != "" { + for i := range original { + if used[i] || original[i] == nil || original[i].Kind != yaml.MappingNode { + continue + } + if sequenceElementIdentity(original[i]) == id { + return i + } + } + } + case yaml.ScalarNode: + val := strings.TrimSpace(target.Value) + if val != "" { + for i := range original { + if used[i] || original[i] == nil || original[i].Kind != yaml.ScalarNode { + continue + } + if strings.TrimSpace(original[i].Value) == val { + return i + } + } + } + } + // Fallback to structural equality to preserve nodes lacking explicit identifiers. + for i := range original { + if used[i] || original[i] == nil { + continue + } + if nodesStructurallyEqual(original[i], target) { + return i + } + } + return -1 +} + +func sequenceElementIdentity(node *yaml.Node) string { + if node == nil || node.Kind != yaml.MappingNode { + return "" + } + identityKeys := []string{"id", "name", "alias", "api-key", "api_key", "apikey", "key", "provider", "model"} + for _, k := range identityKeys { + if v := mappingScalarValue(node, k); v != "" { + return k + "=" + v + } + } + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + valNode := node.Content[i+1] + if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode { + continue + } + val := strings.TrimSpace(valNode.Value) + if val != "" { + return strings.ToLower(strings.TrimSpace(keyNode.Value)) + "=" + val + } + } + return "" +} + +func mappingScalarValue(node *yaml.Node, key string) string { + if node == nil || node.Kind != yaml.MappingNode { + return "" + } + lowerKey := strings.ToLower(key) + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + valNode := node.Content[i+1] + if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode { + continue + } + if strings.ToLower(strings.TrimSpace(keyNode.Value)) == lowerKey { + return strings.TrimSpace(valNode.Value) + } + } + return "" +} + +func nodesStructurallyEqual(a, b *yaml.Node) bool { + if a == nil || b == nil { + return a == b + } + if a.Kind != b.Kind { + return false + } + switch a.Kind { + case yaml.MappingNode: + if len(a.Content) != len(b.Content) { + return false + } + for i := 0; i+1 < len(a.Content); i += 2 { + if !nodesStructurallyEqual(a.Content[i], b.Content[i]) { + return false + } + if !nodesStructurallyEqual(a.Content[i+1], b.Content[i+1]) { + return false + } + } + return true + case yaml.SequenceNode: + if len(a.Content) != len(b.Content) { + return false + } + for i := range a.Content { + if !nodesStructurallyEqual(a.Content[i], b.Content[i]) { + return false + } + } + return true + case yaml.ScalarNode: + return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value) + case yaml.AliasNode: + return nodesStructurallyEqual(a.Alias, b.Alias) + default: + return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value) + } +} + func removeMapKey(mapNode *yaml.Node, key string) { if mapNode == nil || mapNode.Kind != yaml.MappingNode || key == "" { return From 488334979518421ebbf982778d8377d7e46845be Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:22:09 +0800 Subject: [PATCH 4/5] Update doc --- MANAGEMENT_API.md | 6 ++++-- MANAGEMENT_API_CN.md | 4 +++- README.md | 10 +++------- README_CN.md | 10 +++------- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/MANAGEMENT_API.md b/MANAGEMENT_API.md index 676027b4..a2e3988b 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},"gemini-api-key":[{"api-key":"AI...01","base-url":"https://generativelanguage.googleapis.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""},{"api-key":"AI...02","proxy-url":"socks5://proxy.example.com:1080"}],"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":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"gemini-api-key":[{"api-key":"AI...01","base-url":"https://generativelanguage.googleapis.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""},{"api-key":"AI...02","proxy-url":"socks5://proxy.example.com:1080"}],"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 @@ -345,7 +345,7 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro { "status": "ok" } ``` -### Generative Language API Key (Legacy) +### Generative Language API Key (Legacy Alias) - GET `/generative-language-api-key` - Request: ```bash @@ -388,6 +388,8 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro ```json { "status": "ok" } ``` +- Notes: + - This endpoint mirrors the key-only view of `gemini-api-key`. ### Codex API KEY (object array) - GET `/codex-api-key` — List all diff --git a/MANAGEMENT_API_CN.md b/MANAGEMENT_API_CN.md index fe1d3f17..f352993e 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},"gemini-api-key":[{"api-key":"AI...01","base-url":"https://generativelanguage.googleapis.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""},{"api-key":"AI...02","proxy-url":"socks5://proxy.example.com:1080"}],"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":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"gemini-api-key":[{"api-key":"AI...01","base-url":"https://generativelanguage.googleapis.com","headers":{"X-Custom-Header":"custom-value"},"proxy-url":""},{"api-key":"AI...02","proxy-url":"socks5://proxy.example.com:1080"}],"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 @@ -388,6 +388,8 @@ ```json { "status": "ok" } ``` +- 说明: + - 该接口只读写纯字符串列表,实际上会映射到 `gemini-api-key`。 ### Codex API KEY(对象数组) - GET `/codex-api-key` — 列出全部 diff --git a/README.md b/README.md index 6ed7928d..b1270d72 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ The server uses a YAML configuration file (`config.yaml`) located in the project | `gemini-api-key.*.base-url` | string | "" | Optional Gemini API endpoint override. | | `gemini-api-key.*.headers` | object | {} | Optional extra HTTP headers sent to the overridden Gemini endpoint only. | | `gemini-api-key.*.proxy-url` | string | "" | Optional per-key proxy override for the Gemini API key. | -| `generative-language-api-key` | string[] | [] | (Legacy) List of Generative Language API keys without per-key overrides. | +| `generative-language-api-key` | string[] | [] | (Legacy alias) View-only list mirrored from `gemini-api-key`. Writes through the legacy management endpoint update the underlying Gemini entries. | | `codex-api-key` | object | {} | List of Codex API keys. | | `codex-api-key.api-key` | string | "" | Codex API key. | | `codex-api-key.base-url` | string | "" | Custom Codex API endpoint, if you use a third-party API endpoint. | @@ -399,7 +399,7 @@ quota-exceeded: switch-project: true # Whether to automatically switch to another project when a quota is exceeded switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded -# Gemini API keys (preferred) +# Gemini API keys gemini-api-key: - api-key: "AIzaSy...01" base-url: "https://generativelanguage.googleapis.com" @@ -408,10 +408,6 @@ gemini-api-key: proxy-url: "socks5://proxy.example.com:1080" - api-key: "AIzaSy...02" -# API keys for official Generative Language API (legacy compatibility) -generative-language-api-key: - - "AIzaSy...01" - # Codex API keys codex-api-key: - api-key: "sk-atSM..." @@ -571,7 +567,7 @@ The `auth-dir` parameter specifies where authentication tokens are stored. When ### Gemini API Configuration -Use the `gemini-api-key` parameter to configure Gemini API keys. Each entry accepts optional `base-url`, `headers`, and `proxy-url` values; headers are only attached to requests sent to the overridden Gemini endpoint and are never forwarded to proxy servers. When `base-url` is omitted the server behaves the same as the legacy `generative-language-api-key` list. The legacy array remains supported for backwards compatibility and is automatically mirrored into the new structure. +Use the `gemini-api-key` parameter to configure Gemini API keys. Each entry accepts optional `base-url`, `headers`, and `proxy-url` values; headers are only attached to requests sent to the overridden Gemini endpoint and are never forwarded to proxy servers. The legacy `generative-language-api-key` endpoint exposes a mirrored, key-only view for backwards compatibility—writes through that endpoint update the Gemini list but drop any per-key overrides, and the legacy field is no longer persisted in `config.yaml`. ## Hot Reloading diff --git a/README_CN.md b/README_CN.md index 5e2d9628..97000d53 100644 --- a/README_CN.md +++ b/README_CN.md @@ -339,7 +339,7 @@ console.log(await claudeResponse.json()); | `gemini-api-key.*.base-url` | string | "" | 可选的 Gemini API 端点覆盖地址。 | | `gemini-api-key.*.headers` | object | {} | 可选的额外 HTTP 头部,仅在访问覆盖后的 Gemini 端点时发送。 | | `gemini-api-key.*.proxy-url` | string | "" | 可选的单独代理设置,会覆盖全局 `proxy-url`。 | -| `generative-language-api-key` | string[] | [] | (兼容项)不带扩展配置的生成式语言 API 密钥列表。 | +| `generative-language-api-key` | string[] | [] | (兼容别名)旧管理接口返回的纯密钥列表。通过该接口写入会更新 `gemini-api-key`。 | | `codex-api-key` | object | {} | Codex API密钥列表。 | | `codex-api-key.api-key` | string | "" | Codex API密钥。 | | `codex-api-key.base-url` | string | "" | 自定义的Codex API端点 | @@ -412,7 +412,7 @@ quota-exceeded: switch-project: true # 当配额超限时是否自动切换到另一个项目 switch-preview-model: true # 当配额超限时是否自动切换到预览模型 -# Gemini API 密钥(推荐) +# Gemini API 密钥 gemini-api-key: - api-key: "AIzaSy...01" base-url: "https://generativelanguage.googleapis.com" @@ -421,10 +421,6 @@ gemini-api-key: proxy-url: "socks5://proxy.example.com:1080" - api-key: "AIzaSy...02" -# AIStudio Gemini API 的遗留密钥配置 -generative-language-api-key: - - "AIzaSy...01" - # Codex API 密钥 codex-api-key: - api-key: "sk-atSM..." @@ -582,7 +578,7 @@ openai-compatibility: ### Gemini API 配置 -使用 `gemini-api-key` 参数来配置 Gemini API 密钥;每个条目都可以选择性地提供 `base-url`、`headers` 与 `proxy-url`。`headers` 仅会附加到访问覆盖后 Gemini 端点的请求,不会转发给代理服务器。当 `base-url` 留空时,其行为与遗留的 `generative-language-api-key` 列表一致。旧字段仍受支持,会自动同步到新的结构中以保持兼容性。 +使用 `gemini-api-key` 参数来配置 Gemini API 密钥;每个条目都可以选择性地提供 `base-url`、`headers` 与 `proxy-url`。`headers` 仅会附加到访问覆盖后 Gemini 端点的请求,不会转发给代理服务器。旧的 `generative-language-api-key` 管理接口仍提供纯密钥视图以保持兼容——通过该接口写入会替换整个 Gemini 列表,并丢弃任何额外配置,同时该字段不再持久化到 `config.yaml`。 ## 热更新 From 2021ae38916c45240d72affe579fde754d9f9f46 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:56:47 +0800 Subject: [PATCH 5/5] fix(config): skip persisting empty API key and compat entries --- .../api/handlers/management/config_lists.go | 2 +- internal/config/config.go | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index bc261cc8..af48b14f 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -116,7 +116,7 @@ func sanitizeStringSlice(in []string) []string { func geminiKeyStringsFromConfig(cfg *config.Config) []string { if cfg == nil || len(cfg.GeminiKey) == 0 { - return []string{} + return nil } out := make([]string, 0, len(cfg.GeminiKey)) for i := range cfg.GeminiKey { diff --git a/internal/config/config.go b/internal/config/config.go index 68008a9b..baed1a54 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -544,6 +544,9 @@ func mergeMappingPreserve(dst, src *yaml.Node) { dv := dst.Content[idx+1] mergeNodePreserve(dv, sv) } else { + if shouldSkipEmptyCollectionOnPersist(sk.Value, sv) { + continue + } // Append new key/value pair by deep-copying from src dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv)) } @@ -623,6 +626,33 @@ func findMapKeyIndex(mapNode *yaml.Node, key string) int { return -1 } +func shouldSkipEmptyCollectionOnPersist(key string, node *yaml.Node) bool { + switch key { + case "generative-language-api-key", + "gemini-api-key", + "claude-api-key", + "codex-api-key", + "openai-compatibility": + return isEmptyCollectionNode(node) + default: + return false + } +} + +func isEmptyCollectionNode(node *yaml.Node) bool { + if node == nil { + return true + } + switch node.Kind { + case yaml.SequenceNode: + return len(node.Content) == 0 + case yaml.ScalarNode: + return node.Tag == "!!null" + default: + return false + } +} + // deepCopyNode creates a deep copy of a yaml.Node graph. func deepCopyNode(n *yaml.Node) *yaml.Node { if n == nil {