feat(gemini): add Gemini API key endpoints

This commit is contained in:
hkfires
2025-10-31 11:09:28 +08:00
parent 96c7271448
commit 7c1c4ee60b
12 changed files with 538 additions and 62 deletions

View File

@@ -95,7 +95,7 @@ If a plaintext key is detected in the config at startup, it will be bcrypthas
```
- 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 <MANAGEMENT_KEY>' 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 <MANAGEMENT_KEY>' \
-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 <MANAGEMENT_KEY>' \
-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 <MANAGEMENT_KEY>' \
-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 <MANAGEMENT_KEY>' -X DELETE \
'http://localhost:8317/v0/management/gemini-api-key?api-key=AIzaSy-1'
```
- Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -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

View File

@@ -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 <MANAGEMENT_KEY>' 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 <MANAGEMENT_KEY>' \
-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 <MANAGEMENT_KEY>' \
-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 <MANAGEMENT_KEY>' \
-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 <MANAGEMENT_KEY>' -X DELETE \
'http://localhost:8317/v0/management/gemini-api-key?api-key=AIzaSy-1'
```
- 请求(按索引删除):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -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

View File

@@ -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

View File

@@ -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` 列表一致。旧字段仍受支持,会自动同步到新的结构中以保持兼容性
## 热更新

View File

@@ -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:

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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

View File

@@ -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")

View File

@@ -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"),

View File

@@ -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,21 +746,39 @@ 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,
},
ProxyURL: proxyURL,
Attributes: attrs,
CreatedAt: now,
UpdatedAt: now,
}
@@ -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
}

View File

@@ -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,