mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-04 05:20:52 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b413a299b | ||
|
|
4657c98821 | ||
|
|
dd1e0da155 | ||
|
|
cf5476eb23 | ||
|
|
cf9a748159 | ||
|
|
2e328dd462 | ||
|
|
edd4b4d97f | ||
|
|
608d745159 | ||
|
|
fd795caf76 | ||
|
|
9e2d76f3ce | ||
|
|
ae646fba4b | ||
|
|
2eef6875e9 | ||
|
|
12c09f1a46 | ||
|
|
4a31f763af | ||
|
|
6629cadb87 | ||
|
|
41975c9e2b | ||
|
|
c589c0d998 | ||
|
|
7c157d6ab1 | ||
|
|
7c642bee09 | ||
|
|
beba2a7aa0 | ||
|
|
f2201dabfa | ||
|
|
108dcb7f70 | ||
|
|
8858e07d8b | ||
|
|
d33a89b89f | ||
|
|
1d70336a91 | ||
|
|
6080527e9e | ||
|
|
82187bffba | ||
|
|
f4977e5ef6 | ||
|
|
832268cae7 | ||
|
|
f6de2a709f | ||
|
|
de796ac1c2 | ||
|
|
6b5aefc27a | ||
|
|
5010b09329 | ||
|
|
368fd27393 | ||
|
|
b2ca49376c | ||
|
|
6d98a71796 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,5 +10,8 @@ auths/*
|
||||
.serena/*
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
*.exe
|
||||
temp/*
|
||||
temp/*
|
||||
cli-proxy-api
|
||||
static/*
|
||||
@@ -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"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api"},{"api-key":"sk-...q2","base-url":"https://example.com"}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1"}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk...01"],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-keys":["sk...7e"],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]}
|
||||
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01","AI...02","AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api","proxy-url":"socks5://proxy.example.com:1080"},{"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
|
||||
@@ -335,14 +335,14 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "codex-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
|
||||
{ "codex-api-key": [ { "api-key": "sk-a", "base-url": "", "proxy-url": "" } ] }
|
||||
```
|
||||
- PUT `/codex-api-key` — Replace the list
|
||||
- Request:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
|
||||
-d '[{"api-key":"sk-a","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"sk-b","base-url":"https://c.example.com","proxy-url":""}]' \
|
||||
http://localhost:8317/v0/management/codex-api-key
|
||||
```
|
||||
- Response:
|
||||
@@ -354,14 +354,14 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
|
||||
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com","proxy-url":""}}' \
|
||||
http://localhost:8317/v0/management/codex-api-key
|
||||
```
|
||||
- Request (by match):
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
|
||||
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":"","proxy-url":"socks5://proxy.example.com:1080"}}' \
|
||||
http://localhost:8317/v0/management/codex-api-key
|
||||
```
|
||||
- Response:
|
||||
@@ -430,22 +430,22 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro
|
||||
|
||||
### Claude API KEY (object array)
|
||||
- GET `/claude-api-key` — List all
|
||||
- Request:
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "claude-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
|
||||
```
|
||||
- Request:
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "claude-api-key": [ { "api-key": "sk-a", "base-url": "", "proxy-url": "" } ] }
|
||||
```
|
||||
- PUT `/claude-api-key` — Replace the list
|
||||
- Request:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- Request:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '[{"api-key":"sk-a","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"sk-b","base-url":"https://c.example.com","proxy-url":""}]' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
@@ -455,16 +455,16 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com","proxy-url":""}}' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- Request (by match):
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":"","proxy-url":"socks5://proxy.example.com:1080"}}' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
@@ -491,14 +491,14 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-keys": [], "models": [] } ] }
|
||||
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-key-entries": [ { "api-key": "sk", "proxy-url": "" } ], "models": [] } ] }
|
||||
```
|
||||
- PUT `/openai-compatibility` — Replace the list
|
||||
- Request:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk"],"models":[{"name":"m","alias":"a"}]}]' \
|
||||
-d '[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[{"name":"m","alias":"a"}]}]' \
|
||||
http://localhost:8317/v0/management/openai-compatibility
|
||||
```
|
||||
- Response:
|
||||
@@ -510,20 +510,23 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \
|
||||
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[]}}' \
|
||||
http://localhost:8317/v0/management/openai-compatibility
|
||||
```
|
||||
- Request (by index):
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"index":0,"value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \
|
||||
-d '{"index":0,"value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[]}}' \
|
||||
http://localhost:8317/v0/management/openai-compatibility
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
- Notes:
|
||||
- Legacy `api-keys` input remains accepted; keys are migrated into `api-key-entries` automatically so the legacy field will eventually remain empty in responses.
|
||||
- DELETE `/openai-compatibility` — Delete (`?name=` or `?index=`)
|
||||
- Request (by name):
|
||||
```bash
|
||||
|
||||
@@ -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"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api"},{"api-key":"sk-...q2","base-url":"https://example.com"}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1"}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk...01"],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-keys":["sk...7e"],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]}
|
||||
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01","AI...02","AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api","proxy-url":"socks5://proxy.example.com:1080"},{"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
|
||||
@@ -335,14 +335,14 @@
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "codex-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
|
||||
{ "codex-api-key": [ { "api-key": "sk-a", "base-url": "", "proxy-url": "" } ] }
|
||||
```
|
||||
- PUT `/codex-api-key` — 完整改写列表
|
||||
- 请求:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
|
||||
-d '[{"api-key":"sk-a","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"sk-b","base-url":"https://c.example.com","proxy-url":""}]' \
|
||||
http://localhost:8317/v0/management/codex-api-key
|
||||
```
|
||||
- 响应:
|
||||
@@ -354,14 +354,14 @@
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
|
||||
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com","proxy-url":""}}' \
|
||||
http://localhost:8317/v0/management/codex-api-key
|
||||
```
|
||||
- 请求(按匹配):
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
|
||||
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":"","proxy-url":"socks5://proxy.example.com:1080"}}' \
|
||||
http://localhost:8317/v0/management/codex-api-key
|
||||
```
|
||||
- 响应:
|
||||
@@ -430,22 +430,22 @@
|
||||
|
||||
### Claude API KEY(对象数组)
|
||||
- GET `/claude-api-key` — 列出全部
|
||||
- 请求:
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "claude-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
|
||||
```
|
||||
- 请求:
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "claude-api-key": [ { "api-key": "sk-a", "base-url": "", "proxy-url": "" } ] }
|
||||
```
|
||||
- PUT `/claude-api-key` — 完整改写列表
|
||||
- 请求:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- 请求:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '[{"api-key":"sk-a","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"sk-b","base-url":"https://c.example.com","proxy-url":""}]' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
@@ -455,16 +455,16 @@
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com","proxy-url":""}}' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- 请求(按匹配):
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":"","proxy-url":"socks5://proxy.example.com:1080"}}' \
|
||||
http://localhost:8317/v0/management/claude-api-key
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
@@ -491,14 +491,14 @@
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-keys": [], "models": [] } ] }
|
||||
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-key-entries": [ { "api-key": "sk", "proxy-url": "" } ], "models": [] } ] }
|
||||
```
|
||||
- PUT `/openai-compatibility` — 完整改写列表
|
||||
- 请求:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk"],"models":[{"name":"m","alias":"a"}]}]' \
|
||||
-d '[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[{"name":"m","alias":"a"}]}]' \
|
||||
http://localhost:8317/v0/management/openai-compatibility
|
||||
```
|
||||
- 响应:
|
||||
@@ -510,20 +510,23 @@
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \
|
||||
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[]}}' \
|
||||
http://localhost:8317/v0/management/openai-compatibility
|
||||
```
|
||||
- 请求(按索引):
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"index":0,"value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \
|
||||
-d '{"index":0,"value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[]}}' \
|
||||
http://localhost:8317/v0/management/openai-compatibility
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
- 说明:
|
||||
- 仍可提交遗留的 `api-keys` 字段,但所有密钥会自动迁移到 `api-key-entries` 中,返回结果中的 `api-keys` 会逐步留空。
|
||||
- DELETE `/openai-compatibility` — 删除(`?name=` 或 `?index=`)
|
||||
- 请求(按名称):
|
||||
```bash
|
||||
|
||||
74
README.md
74
README.md
@@ -72,6 +72,8 @@ A cross-platform desktop GUI client for CLIProxyAPI.
|
||||
|
||||
A web-based management center for CLIProxyAPI.
|
||||
|
||||
Set `remote-management.disable-control-panel` to `true` if you prefer to host the management UI elsewhere; the server will skip downloading `management.html` and `/management.html` will return 404.
|
||||
|
||||
### Authentication
|
||||
|
||||
You can authenticate for Gemini, OpenAI, and/or Claude. All can coexist in the same `auth-dir` and will be load balanced.
|
||||
@@ -252,6 +254,7 @@ console.log(await claudeResponse.json());
|
||||
- claude-opus-4-1-20250805
|
||||
- claude-opus-4-20250514
|
||||
- claude-sonnet-4-20250514
|
||||
- claude-sonnet-4-5-20250929
|
||||
- claude-3-7-sonnet-20250219
|
||||
- claude-3-5-haiku-20241022
|
||||
- qwen3-coder-plus
|
||||
@@ -276,6 +279,7 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
|
||||
| `request-retry` | integer | 0 | Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504. |
|
||||
| `remote-management.allow-remote` | boolean | false | Whether to allow remote (non-localhost) access to the management API. If false, only localhost can access. A management key is still required for localhost. |
|
||||
| `remote-management.secret-key` | string | "" | Management key. If a plaintext value is provided, it will be hashed on startup using bcrypt and persisted back to the config file. If empty, the entire management API is disabled (404). |
|
||||
| `remote-management.disable-control-panel` | boolean | false | When true, skip downloading `management.html` and return 404 for `/management.html`, effectively disabling the bundled management UI. |
|
||||
| `quota-exceeded` | object | {} | Configuration for handling quota exceeded. |
|
||||
| `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded. |
|
||||
| `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded. |
|
||||
@@ -284,19 +288,24 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `claude-api-key` | object | {} | List of Claude API keys. |
|
||||
| `claude-api-key.api-key` | string | "" | Claude API key. |
|
||||
| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. |
|
||||
| `openai-compatibility` | object[] | [] | Upstream OpenAI-compatible providers configuration (name, base-url, api-keys, models). |
|
||||
| `openai-compatibility.*.name` | string | "" | The name of the provider. It will be used in the user agent and other places. |
|
||||
| `openai-compatibility.*.base-url` | string | "" | The base URL of the provider. |
|
||||
| `openai-compatibility.*.api-keys` | string[] | [] | The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed. |
|
||||
| `openai-compatibility.*.models` | object[] | [] | The actual model name. |
|
||||
| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. |
|
||||
| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. |
|
||||
| `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. |
|
||||
| `codex-api-key.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. |
|
||||
| `claude-api-key` | object | {} | List of Claude API keys. |
|
||||
| `claude-api-key.api-key` | string | "" | Claude API key. |
|
||||
| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. |
|
||||
| `claude-api-key.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. |
|
||||
| `openai-compatibility` | object[] | [] | Upstream OpenAI-compatible providers configuration (name, base-url, api-keys, models). |
|
||||
| `openai-compatibility.*.name` | string | "" | The name of the provider. It will be used in the user agent and other places. |
|
||||
| `openai-compatibility.*.base-url` | string | "" | The base URL of the provider. |
|
||||
| `openai-compatibility.*.api-keys` | string[] | [] | (Deprecated) The API keys for the provider. Use api-key-entries instead for per-key proxy support. |
|
||||
| `openai-compatibility.*.api-key-entries` | object[] | [] | API key entries with optional per-key proxy configuration. Preferred over api-keys. |
|
||||
| `openai-compatibility.*.api-key-entries.*.api-key` | string | "" | The API key for this entry. |
|
||||
| `openai-compatibility.*.api-key-entries.*.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. |
|
||||
| `openai-compatibility.*.models` | object[] | [] | The actual model name. |
|
||||
| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. |
|
||||
| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. |
|
||||
| `gemini-web` | object | {} | Configuration specific to the Gemini Web client. |
|
||||
| `gemini-web.context` | boolean | true | Enables conversation context reuse for continuous dialogue. |
|
||||
| `gemini-web.code-mode` | boolean | false | Enables code mode for optimized responses in coding-related tasks. |
|
||||
@@ -320,6 +329,9 @@ remote-management:
|
||||
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
|
||||
secret-key: ""
|
||||
|
||||
# Disable the bundled management control panel asset download and HTTP route when true.
|
||||
disable-control-panel: false
|
||||
|
||||
# Authentication directory (supports ~ for home directory). If you use Windows, please set the directory like this: `C:/cli-proxy-api/`
|
||||
auth-dir: "~/.cli-proxy-api"
|
||||
|
||||
@@ -360,20 +372,28 @@ generative-language-api-key:
|
||||
codex-api-key:
|
||||
- api-key: "sk-atSM..."
|
||||
base-url: "https://www.example.com" # use the custom codex API endpoint
|
||||
|
||||
proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
|
||||
# Claude API keys
|
||||
claude-api-key:
|
||||
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||
- api-key: "sk-atSM..."
|
||||
base-url: "https://www.example.com" # use the custom claude API endpoint
|
||||
proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
|
||||
# OpenAI compatibility providers
|
||||
openai-compatibility:
|
||||
- name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
||||
base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
||||
api-keys: # The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed.
|
||||
- "sk-or-v1-...b780"
|
||||
- "sk-or-v1-...b781"
|
||||
# New format with per-key proxy support (recommended):
|
||||
api-key-entries:
|
||||
- api-key: "sk-or-v1-...b780"
|
||||
proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
- api-key: "sk-or-v1-...b781" # without proxy-url
|
||||
# Legacy format (still supported, but cannot specify proxy per key):
|
||||
# api-keys:
|
||||
# - "sk-or-v1-...b780"
|
||||
# - "sk-or-v1-...b781"
|
||||
models: # The models supported by the provider.
|
||||
- name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||
alias: "kimi-k2" # The alias used in the API.
|
||||
@@ -385,10 +405,26 @@ Configure upstream OpenAI-compatible providers (e.g., OpenRouter) via `openai-co
|
||||
|
||||
- name: provider identifier used internally
|
||||
- base-url: provider base URL
|
||||
- api-keys: optional list of API keys (omit if provider allows unauthenticated requests)
|
||||
- api-key-entries: list of API key entries with optional per-key proxy configuration (recommended)
|
||||
- api-keys: (deprecated) simple list of API keys without proxy support
|
||||
- models: list of mappings from upstream model `name` to local `alias`
|
||||
|
||||
Example:
|
||||
Example with per-key proxy support:
|
||||
|
||||
```yaml
|
||||
openai-compatibility:
|
||||
- name: "openrouter"
|
||||
base-url: "https://openrouter.ai/api/v1"
|
||||
api-key-entries:
|
||||
- api-key: "sk-or-v1-...b780"
|
||||
proxy-url: "socks5://proxy.example.com:1080"
|
||||
- api-key: "sk-or-v1-...b781"
|
||||
models:
|
||||
- name: "moonshotai/kimi-k2:free"
|
||||
alias: "kimi-k2"
|
||||
```
|
||||
|
||||
Legacy format (still supported):
|
||||
|
||||
```yaml
|
||||
openai-compatibility:
|
||||
|
||||
74
README_CN.md
74
README_CN.md
@@ -85,6 +85,8 @@ CLIProxyAPI 的跨平台桌面图形客户端。
|
||||
|
||||
CLIProxyAPI 的基于 Web 的管理中心。
|
||||
|
||||
如果希望自行托管管理页面,可在配置中将 `remote-management.disable-control-panel` 设为 `true`,服务器将停止下载 `management.html`,并让 `/management.html` 返回 404。
|
||||
|
||||
### 身份验证
|
||||
|
||||
您可以分别为 Gemini、OpenAI 和 Claude 进行身份验证,三者可同时存在于同一个 `auth-dir` 中并参与负载均衡。
|
||||
@@ -264,6 +266,7 @@ console.log(await claudeResponse.json());
|
||||
- claude-opus-4-1-20250805
|
||||
- claude-opus-4-20250514
|
||||
- claude-sonnet-4-20250514
|
||||
- claude-sonnet-4-5-20250929
|
||||
- claude-3-7-sonnet-20250219
|
||||
- claude-3-5-haiku-20241022
|
||||
- qwen3-coder-plus
|
||||
@@ -288,6 +291,7 @@ console.log(await claudeResponse.json());
|
||||
| `request-retry` | integer | 0 | 请求重试次数。如果HTTP响应码为403、408、500、502、503或504,将会触发重试。 |
|
||||
| `remote-management.allow-remote` | boolean | false | 是否允许远程(非localhost)访问管理接口。为false时仅允许本地访问;本地访问同样需要管理密钥。 |
|
||||
| `remote-management.secret-key` | string | "" | 管理密钥。若配置为明文,启动时会自动进行bcrypt加密并写回配置文件。若为空,管理接口整体不可用(404)。 |
|
||||
| `remote-management.disable-control-panel` | boolean | false | 当为 true 时,不再下载 `management.html`,且 `/management.html` 会返回 404,从而禁用内置管理界面。 |
|
||||
| `quota-exceeded` | object | {} | 用于处理配额超限的配置。 |
|
||||
| `quota-exceeded.switch-project` | boolean | true | 当配额超限时,是否自动切换到另一个项目。 |
|
||||
| `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时,是否自动切换到预览模型。 |
|
||||
@@ -296,19 +300,24 @@ console.log(await claudeResponse.json());
|
||||
| `usage-statistics-enabled` | boolean | true | 是否启用内存中的使用统计;设为 false 时直接丢弃所有统计数据。 |
|
||||
| `api-keys` | string[] | [] | 兼容旧配置的简写,会自动同步到默认 `config-api-key` 提供方。 |
|
||||
| `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端点 |
|
||||
| `claude-api-key` | object | {} | Claude API密钥列表。 |
|
||||
| `claude-api-key.api-key` | string | "" | Claude API密钥。 |
|
||||
| `claude-api-key.base-url` | string | "" | 自定义的Claude API端点,如果您使用第三方的API端点。 |
|
||||
| `openai-compatibility` | object[] | [] | 上游OpenAI兼容提供商的配置(名称、基础URL、API密钥、模型)。 |
|
||||
| `openai-compatibility.*.name` | string | "" | 提供商的名称。它将被用于用户代理(User Agent)和其他地方。 |
|
||||
| `openai-compatibility.*.base-url` | string | "" | 提供商的基础URL。 |
|
||||
| `openai-compatibility.*.api-keys` | string[] | [] | 提供商的API密钥。如果需要,可以添加多个密钥。如果允许未经身份验证的访问,则可以省略。 |
|
||||
| `openai-compatibility.*.models` | object[] | [] | 实际的模型名称。 |
|
||||
| `openai-compatibility.*.models.*.name` | string | "" | 提供商支持的模型。 |
|
||||
| `openai-compatibility.*.models.*.alias` | string | "" | 在API中使用的别名。 |
|
||||
| `codex-api-key` | object | {} | Codex API密钥列表。 |
|
||||
| `codex-api-key.api-key` | string | "" | Codex API密钥。 |
|
||||
| `codex-api-key.base-url` | string | "" | 自定义的Codex API端点 |
|
||||
| `codex-api-key.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 |
|
||||
| `claude-api-key` | object | {} | Claude API密钥列表。 |
|
||||
| `claude-api-key.api-key` | string | "" | Claude API密钥。 |
|
||||
| `claude-api-key.base-url` | string | "" | 自定义的Claude API端点,如果您使用第三方的API端点。 |
|
||||
| `claude-api-key.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 |
|
||||
| `openai-compatibility` | object[] | [] | 上游OpenAI兼容提供商的配置(名称、基础URL、API密钥、模型)。 |
|
||||
| `openai-compatibility.*.name` | string | "" | 提供商的名称。它将被用于用户代理(User Agent)和其他地方。 |
|
||||
| `openai-compatibility.*.base-url` | string | "" | 提供商的基础URL。 |
|
||||
| `openai-compatibility.*.api-keys` | string[] | [] | (已弃用) 提供商的API密钥。建议改用api-key-entries以获得每密钥代理支持。 |
|
||||
| `openai-compatibility.*.api-key-entries` | object[] | [] | API密钥条目,支持可选的每密钥代理配置。优先于api-keys。 |
|
||||
| `openai-compatibility.*.api-key-entries.*.api-key` | string | "" | 该条目的API密钥。 |
|
||||
| `openai-compatibility.*.api-key-entries.*.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 |
|
||||
| `openai-compatibility.*.models` | object[] | [] | 实际的模型名称。 |
|
||||
| `openai-compatibility.*.models.*.name` | string | "" | 提供商支持的模型。 |
|
||||
| `openai-compatibility.*.models.*.alias` | string | "" | 在API中使用的别名。 |
|
||||
| `gemini-web` | object | {} | Gemini Web 客户端的特定配置。 |
|
||||
| `gemini-web.context` | boolean | true | 是否启用会话上下文重用,以实现连续对话。 |
|
||||
| `gemini-web.code-mode` | boolean | false | 是否启用代码模式,优化代码相关任务的响应。 |
|
||||
@@ -331,6 +340,9 @@ remote-management:
|
||||
# 若为空,/v0/management 整体处于 404(禁用)。
|
||||
secret-key: ""
|
||||
|
||||
# 当设为 true 时,不下载管理面板文件,/management.html 将直接返回 404。
|
||||
disable-control-panel: false
|
||||
|
||||
# 身份验证目录(支持 ~ 表示主目录)。如果你使用Windows,建议设置成`C:/cli-proxy-api/`。
|
||||
auth-dir: "~/.cli-proxy-api"
|
||||
|
||||
@@ -372,20 +384,28 @@ generative-language-api-key:
|
||||
codex-api-key:
|
||||
- api-key: "sk-atSM..."
|
||||
base-url: "https://www.example.com" # 第三方 Codex API 中转服务端点
|
||||
proxy-url: "socks5://proxy.example.com:1080" # 可选:针对该密钥的代理设置
|
||||
|
||||
# Claude API 密钥
|
||||
claude-api-key:
|
||||
- api-key: "sk-atSM..." # 如果使用官方 Claude API,无需设置 base-url
|
||||
- api-key: "sk-atSM..." # 如果使用官方 Claude API,无需设置 base-url
|
||||
- api-key: "sk-atSM..."
|
||||
base-url: "https://www.example.com" # 第三方 Claude API 中转服务端点
|
||||
proxy-url: "socks5://proxy.example.com:1080" # 可选:针对该密钥的代理设置
|
||||
|
||||
# OpenAI 兼容提供商
|
||||
openai-compatibility:
|
||||
- name: "openrouter" # 提供商的名称;它将被用于用户代理和其它地方。
|
||||
base-url: "https://openrouter.ai/api/v1" # 提供商的基础URL。
|
||||
api-keys: # 提供商的API密钥。如果需要,可以添加多个密钥。如果允许未经身份验证的访问,则可以省略。
|
||||
- "sk-or-v1-...b780"
|
||||
- "sk-or-v1-...b781"
|
||||
# 新格式:支持每密钥代理配置(推荐):
|
||||
api-key-entries:
|
||||
- api-key: "sk-or-v1-...b780"
|
||||
proxy-url: "socks5://proxy.example.com:1080" # 可选:针对该密钥的代理设置
|
||||
- api-key: "sk-or-v1-...b781" # 不进行额外代理设置
|
||||
# 旧格式(仍支持,但无法为每个密钥指定代理):
|
||||
# api-keys:
|
||||
# - "sk-or-v1-...b780"
|
||||
# - "sk-or-v1-...b781"
|
||||
models: # 提供商支持的模型。
|
||||
- name: "moonshotai/kimi-k2:free" # 实际的模型名称。
|
||||
alias: "kimi-k2" # 在API中使用的别名。
|
||||
@@ -397,10 +417,26 @@ openai-compatibility:
|
||||
|
||||
- name:内部识别名
|
||||
- base-url:提供商基础地址
|
||||
- api-keys:可选,多密钥轮询(若提供商支持无鉴权可省略)
|
||||
- api-key-entries:API密钥条目列表,支持可选的每密钥代理配置(推荐)
|
||||
- api-keys:(已弃用) 简单的API密钥列表,不支持代理配置
|
||||
- models:将上游模型 `name` 映射为本地可用 `alias`
|
||||
|
||||
示例:
|
||||
支持每密钥代理配置的示例:
|
||||
|
||||
```yaml
|
||||
openai-compatibility:
|
||||
- name: "openrouter"
|
||||
base-url: "https://openrouter.ai/api/v1"
|
||||
api-key-entries:
|
||||
- api-key: "sk-or-v1-...b780"
|
||||
proxy-url: "socks5://proxy.example.com:1080"
|
||||
- api-key: "sk-or-v1-...b781"
|
||||
models:
|
||||
- name: "moonshotai/kimi-k2:free"
|
||||
alias: "kimi-k2"
|
||||
```
|
||||
|
||||
旧格式(仍支持):
|
||||
|
||||
```yaml
|
||||
openai-compatibility:
|
||||
|
||||
@@ -12,6 +12,9 @@ remote-management:
|
||||
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
|
||||
secret-key: ""
|
||||
|
||||
# Disable the bundled management control panel asset download and HTTP route when true.
|
||||
disable-control-panel: false
|
||||
|
||||
# Authentication directory (supports ~ for home directory)
|
||||
auth-dir: "~/.cli-proxy-api"
|
||||
|
||||
@@ -51,20 +54,28 @@ quota-exceeded:
|
||||
#codex-api-key:
|
||||
# - api-key: "sk-atSM..."
|
||||
# base-url: "https://www.example.com" # use the custom codex API endpoint
|
||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
|
||||
# Claude API keys
|
||||
#claude-api-key:
|
||||
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||
# - api-key: "sk-atSM..."
|
||||
# base-url: "https://www.example.com" # use the custom claude API endpoint
|
||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
|
||||
# OpenAI compatibility providers
|
||||
#openai-compatibility:
|
||||
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
||||
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
||||
# api-keys: # The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed.
|
||||
# - "sk-or-v1-...b780"
|
||||
# - "sk-or-v1-...b781"
|
||||
# # New format with per-key proxy support (recommended):
|
||||
# api-key-entries:
|
||||
# - api-key: "sk-or-v1-...b780"
|
||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
||||
# # Legacy format (still supported, but cannot specify proxy per key):
|
||||
# # api-keys:
|
||||
# # - "sk-or-v1-...b780"
|
||||
# # - "sk-or-v1-...b781"
|
||||
# models: # The models supported by the provider.
|
||||
# - name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||
# alias: "kimi-k2" # The alias used in the API.
|
||||
|
||||
@@ -79,51 +79,35 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Prov
|
||||
finalIDs[key] = struct{}{}
|
||||
}
|
||||
|
||||
if len(result) == 0 && len(newCfg.APIKeys) > 0 {
|
||||
sdkConfig.SyncInlineAPIKeys(&newCfg.SDKConfig, newCfg.APIKeys)
|
||||
if providerCfg := newCfg.ConfigAPIKeyProvider(); providerCfg != nil {
|
||||
key := providerIdentifier(providerCfg)
|
||||
if len(result) == 0 {
|
||||
if inline := sdkConfig.MakeInlineAPIKeyProvider(newCfg.APIKeys); inline != nil {
|
||||
key := providerIdentifier(inline)
|
||||
if key != "" {
|
||||
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
||||
isAliased := oldCfgProvider == providerCfg
|
||||
if !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
|
||||
if providerConfigEqual(oldCfgProvider, inline) {
|
||||
if existingProvider, okExisting := existingMap[key]; okExisting {
|
||||
result = append(result, existingProvider)
|
||||
} else {
|
||||
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
if _, existed := existingMap[key]; existed {
|
||||
appendChange(&updated, key)
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
result = append(result, provider)
|
||||
finalIDs[key] = struct{}{}
|
||||
goto inlineDone
|
||||
}
|
||||
} else {
|
||||
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
if _, existed := existingMap[key]; existed {
|
||||
appendChange(&updated, key)
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
result = append(result, provider)
|
||||
}
|
||||
} else {
|
||||
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
appendChange(&added, key)
|
||||
result = append(result, provider)
|
||||
}
|
||||
provider, buildErr := sdkaccess.BuildProvider(inline, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
if _, existed := existingMap[key]; existed {
|
||||
appendChange(&updated, key)
|
||||
} else if _, hadOld := oldCfgMap[key]; hadOld {
|
||||
appendChange(&updated, key)
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
result = append(result, provider)
|
||||
finalIDs[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
inlineDone:
|
||||
}
|
||||
|
||||
removedSet := make(map[string]struct{})
|
||||
@@ -192,7 +176,7 @@ func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider
|
||||
result[key] = providerCfg
|
||||
}
|
||||
if len(result) == 0 && len(cfg.APIKeys) > 0 {
|
||||
if provider := cfg.ConfigAPIKeyProvider(); provider != nil {
|
||||
if provider := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); provider != nil {
|
||||
if key := providerIdentifier(provider); key != "" {
|
||||
result[key] = provider
|
||||
}
|
||||
@@ -212,6 +196,11 @@ func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider {
|
||||
entries = append(entries, providerCfg)
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 && len(cfg.APIKeys) > 0 {
|
||||
if inline := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); inline != nil {
|
||||
entries = append(entries, inline)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ package management
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
)
|
||||
|
||||
// Generic helpers for list[string]
|
||||
@@ -107,13 +107,16 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after f
|
||||
// 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) {
|
||||
h.putStringList(c, func(v []string) { sdkConfig.SyncInlineAPIKeys(&h.cfg.SDKConfig, v) }, nil)
|
||||
h.putStringList(c, func(v []string) {
|
||||
h.cfg.APIKeys = append([]string(nil), v...)
|
||||
h.cfg.Access.Providers = nil
|
||||
}, nil)
|
||||
}
|
||||
func (h *Handler) PatchAPIKeys(c *gin.Context) {
|
||||
h.patchStringList(c, &h.cfg.APIKeys, func() { sdkConfig.SyncInlineAPIKeys(&h.cfg.SDKConfig, h.cfg.APIKeys) })
|
||||
h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
||||
}
|
||||
func (h *Handler) DeleteAPIKeys(c *gin.Context) {
|
||||
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { sdkConfig.SyncInlineAPIKeys(&h.cfg.SDKConfig, h.cfg.APIKeys) })
|
||||
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
||||
}
|
||||
|
||||
// generative-language-api-key
|
||||
@@ -202,7 +205,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
|
||||
|
||||
// openai-compatibility: []OpenAICompatibility
|
||||
func (h *Handler) GetOpenAICompat(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"openai-compatibility": h.cfg.OpenAICompatibility})
|
||||
c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
|
||||
}
|
||||
func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
@@ -221,6 +224,9 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
||||
}
|
||||
arr = obj.Items
|
||||
}
|
||||
for i := range arr {
|
||||
normalizeOpenAICompatibilityEntry(&arr[i])
|
||||
}
|
||||
h.cfg.OpenAICompatibility = arr
|
||||
h.persist(c)
|
||||
}
|
||||
@@ -234,6 +240,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
normalizeOpenAICompatibilityEntry(body.Value)
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
|
||||
h.cfg.OpenAICompatibility[*body.Index] = *body.Value
|
||||
h.persist(c)
|
||||
@@ -347,3 +354,51 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
|
||||
}
|
||||
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
||||
}
|
||||
|
||||
func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
existing := make(map[string]struct{}, len(entry.APIKeyEntries))
|
||||
for i := range entry.APIKeyEntries {
|
||||
trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
|
||||
entry.APIKeyEntries[i].APIKey = trimmed
|
||||
if trimmed != "" {
|
||||
existing[trimmed] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(entry.APIKeys) == 0 {
|
||||
return
|
||||
}
|
||||
for _, legacyKey := range entry.APIKeys {
|
||||
trimmed := strings.TrimSpace(legacyKey)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := existing[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
entry.APIKeyEntries = append(entry.APIKeyEntries, config.OpenAICompatibilityAPIKey{APIKey: trimmed})
|
||||
existing[trimmed] = struct{}{}
|
||||
}
|
||||
entry.APIKeys = nil
|
||||
}
|
||||
|
||||
func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]config.OpenAICompatibility, len(entries))
|
||||
for i := range entries {
|
||||
copyEntry := entries[i]
|
||||
if len(copyEntry.APIKeyEntries) > 0 {
|
||||
copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)
|
||||
}
|
||||
if len(copyEntry.APIKeys) > 0 {
|
||||
copyEntry.APIKeys = append([]string(nil), copyEntry.APIKeys...)
|
||||
}
|
||||
normalizeOpenAICompatibilityEntry(©Entry)
|
||||
out[i] = copyEntry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
@@ -125,6 +127,11 @@ type Server struct {
|
||||
// management handler
|
||||
mgmt *managementHandlers.Handler
|
||||
|
||||
// managementRoutesRegistered tracks whether the management routes have been attached to the engine.
|
||||
managementRoutesRegistered atomic.Bool
|
||||
// managementRoutesEnabled controls whether management endpoints serve real handlers.
|
||||
managementRoutesEnabled atomic.Bool
|
||||
|
||||
localPassword string
|
||||
|
||||
keepAliveEnabled bool
|
||||
@@ -209,6 +216,12 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
optionState.routerConfigurator(engine, s.handlers, cfg)
|
||||
}
|
||||
|
||||
// Register management routes only when a secret is present at startup.
|
||||
s.managementRoutesEnabled.Store(cfg.RemoteManagement.SecretKey != "")
|
||||
if cfg.RemoteManagement.SecretKey != "" {
|
||||
s.registerManagementRoutes()
|
||||
}
|
||||
|
||||
if optionState.keepAliveEnabled {
|
||||
s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)
|
||||
}
|
||||
@@ -225,6 +238,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
// setupRoutes configures the API routes for the server.
|
||||
// It defines the endpoints and associates them with their respective handlers.
|
||||
func (s *Server) setupRoutes() {
|
||||
s.engine.GET("/management.html", s.serveManagementControlPanel)
|
||||
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
|
||||
geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)
|
||||
geminiCLIHandlers := gemini.NewGeminiCLIAPIHandler(s.handlers)
|
||||
@@ -306,86 +320,133 @@ func (s *Server) setupRoutes() {
|
||||
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||
})
|
||||
|
||||
// Management API routes (delegated to management handlers)
|
||||
// New logic: if remote-management-key is empty, do not expose any management endpoint (404).
|
||||
if s.cfg.RemoteManagement.SecretKey != "" {
|
||||
mgmt := s.engine.Group("/v0/management")
|
||||
mgmt.Use(s.mgmt.Middleware())
|
||||
{
|
||||
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
||||
mgmt.GET("/config", s.mgmt.GetConfig)
|
||||
// Management routes are registered lazily by registerManagementRoutes when a secret is configured.
|
||||
}
|
||||
|
||||
mgmt.GET("/debug", s.mgmt.GetDebug)
|
||||
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
||||
mgmt.PATCH("/debug", s.mgmt.PutDebug)
|
||||
|
||||
mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile)
|
||||
mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||
mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||
|
||||
mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled)
|
||||
mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||
mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||
|
||||
mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
|
||||
mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
|
||||
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
|
||||
mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL)
|
||||
|
||||
mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject)
|
||||
mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
|
||||
mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
|
||||
|
||||
mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel)
|
||||
mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
|
||||
mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
|
||||
|
||||
mgmt.GET("/api-keys", s.mgmt.GetAPIKeys)
|
||||
mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
|
||||
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
|
||||
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
|
||||
|
||||
mgmt.GET("/generative-language-api-key", s.mgmt.GetGlKeys)
|
||||
mgmt.PUT("/generative-language-api-key", s.mgmt.PutGlKeys)
|
||||
mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys)
|
||||
mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys)
|
||||
|
||||
mgmt.GET("/request-log", s.mgmt.GetRequestLog)
|
||||
mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
|
||||
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)
|
||||
|
||||
mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
|
||||
mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
|
||||
mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry)
|
||||
|
||||
mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys)
|
||||
mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys)
|
||||
mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey)
|
||||
mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey)
|
||||
|
||||
mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys)
|
||||
mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys)
|
||||
mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)
|
||||
mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey)
|
||||
|
||||
mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
|
||||
mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
|
||||
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
|
||||
mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat)
|
||||
|
||||
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
|
||||
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
||||
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
||||
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
||||
|
||||
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
||||
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||
mgmt.POST("/gemini-web-token", s.mgmt.CreateGeminiWebToken)
|
||||
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||
}
|
||||
func (s *Server) registerManagementRoutes() {
|
||||
if s == nil || s.engine == nil || s.mgmt == nil {
|
||||
return
|
||||
}
|
||||
if !s.managementRoutesRegistered.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("management routes registered after secret key configuration")
|
||||
|
||||
mgmt := s.engine.Group("/v0/management")
|
||||
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
|
||||
{
|
||||
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
||||
mgmt.GET("/config", s.mgmt.GetConfig)
|
||||
|
||||
mgmt.GET("/debug", s.mgmt.GetDebug)
|
||||
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
||||
mgmt.PATCH("/debug", s.mgmt.PutDebug)
|
||||
|
||||
mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile)
|
||||
mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||
mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||
|
||||
mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled)
|
||||
mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||
mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||
|
||||
mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
|
||||
mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
|
||||
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
|
||||
mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL)
|
||||
|
||||
mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject)
|
||||
mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
|
||||
mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
|
||||
|
||||
mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel)
|
||||
mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
|
||||
mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
|
||||
|
||||
mgmt.GET("/api-keys", s.mgmt.GetAPIKeys)
|
||||
mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
|
||||
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
|
||||
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
|
||||
|
||||
mgmt.GET("/generative-language-api-key", s.mgmt.GetGlKeys)
|
||||
mgmt.PUT("/generative-language-api-key", s.mgmt.PutGlKeys)
|
||||
mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys)
|
||||
mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys)
|
||||
|
||||
mgmt.GET("/request-log", s.mgmt.GetRequestLog)
|
||||
mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
|
||||
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)
|
||||
|
||||
mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
|
||||
mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
|
||||
mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry)
|
||||
|
||||
mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys)
|
||||
mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys)
|
||||
mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey)
|
||||
mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey)
|
||||
|
||||
mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys)
|
||||
mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys)
|
||||
mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)
|
||||
mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey)
|
||||
|
||||
mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
|
||||
mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
|
||||
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
|
||||
mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat)
|
||||
|
||||
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
|
||||
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
||||
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
||||
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
||||
|
||||
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
||||
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||
mgmt.POST("/gemini-web-token", s.mgmt.CreateGeminiWebToken)
|
||||
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !s.managementRoutesEnabled.Load() {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
||||
cfg := s.cfg
|
||||
if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := managementasset.FilePath(s.configFilePath)
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL)
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.WithError(err).Error("failed to stat management control panel asset")
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
c.File(filePath)
|
||||
}
|
||||
|
||||
func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) {
|
||||
@@ -611,9 +672,37 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
prevSecretEmpty := true
|
||||
if oldCfg != nil {
|
||||
prevSecretEmpty = oldCfg.RemoteManagement.SecretKey == ""
|
||||
}
|
||||
newSecretEmpty := cfg.RemoteManagement.SecretKey == ""
|
||||
switch {
|
||||
case prevSecretEmpty && !newSecretEmpty:
|
||||
s.registerManagementRoutes()
|
||||
if s.managementRoutesEnabled.CompareAndSwap(false, true) {
|
||||
log.Info("management routes enabled after secret key update")
|
||||
} else {
|
||||
s.managementRoutesEnabled.Store(true)
|
||||
}
|
||||
case !prevSecretEmpty && newSecretEmpty:
|
||||
if s.managementRoutesEnabled.CompareAndSwap(true, false) {
|
||||
log.Info("management routes disabled after secret key removal")
|
||||
} else {
|
||||
s.managementRoutesEnabled.Store(false)
|
||||
}
|
||||
default:
|
||||
s.managementRoutesEnabled.Store(!newSecretEmpty)
|
||||
}
|
||||
|
||||
s.applyAccessConfig(oldCfg, cfg)
|
||||
s.cfg = cfg
|
||||
s.handlers.UpdateClients(&cfg.SDKConfig)
|
||||
|
||||
if !cfg.RemoteManagement.DisableControlPanel {
|
||||
staticDir := managementasset.StaticDir(s.configFilePath)
|
||||
go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL)
|
||||
}
|
||||
if s.mgmt != nil {
|
||||
s.mgmt.SetConfig(cfg)
|
||||
s.mgmt.SetAuthManager(s.handlers.AuthManager)
|
||||
@@ -626,7 +715,12 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
codexAPIKeyCount := len(cfg.CodexKey)
|
||||
openAICompatCount := 0
|
||||
for i := range cfg.OpenAICompatibility {
|
||||
openAICompatCount += len(cfg.OpenAICompatibility[i].APIKeys)
|
||||
entry := cfg.OpenAICompatibility[i]
|
||||
if len(entry.APIKeyEntries) > 0 {
|
||||
openAICompatCount += len(entry.APIKeyEntries)
|
||||
continue
|
||||
}
|
||||
openAICompatCount += len(entry.APIKeys)
|
||||
}
|
||||
|
||||
total := authFiles + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -83,15 +84,27 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
|
||||
|
||||
log.Info("Authentication successful.")
|
||||
|
||||
if errSetup := performGeminiCLISetup(ctx, httpClient, storage, strings.TrimSpace(projectID)); errSetup != nil {
|
||||
projects, errProjects := fetchGCPProjects(ctx, httpClient)
|
||||
if errProjects != nil {
|
||||
log.Fatalf("Failed to get project list: %v", errProjects)
|
||||
return
|
||||
}
|
||||
|
||||
promptFn := options.Prompt
|
||||
if promptFn == nil {
|
||||
promptFn = defaultProjectPrompt()
|
||||
}
|
||||
|
||||
selectedProjectID := promptForProjectSelection(projects, strings.TrimSpace(projectID), promptFn)
|
||||
if strings.TrimSpace(selectedProjectID) == "" {
|
||||
log.Fatal("No project selected; aborting login.")
|
||||
return
|
||||
}
|
||||
|
||||
if errSetup := performGeminiCLISetup(ctx, httpClient, storage, selectedProjectID); errSetup != nil {
|
||||
var projectErr *projectSelectionRequiredError
|
||||
if errors.As(errSetup, &projectErr) {
|
||||
log.Error("Failed to start user onboarding: A project ID is required.")
|
||||
projects, errProjects := fetchGCPProjects(ctx, httpClient)
|
||||
if errProjects != nil {
|
||||
log.Fatalf("Failed to get project list: %v", errProjects)
|
||||
return
|
||||
}
|
||||
showProjectSelectionHelp(storage.Email, projects)
|
||||
return
|
||||
}
|
||||
@@ -99,7 +112,7 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
|
||||
return
|
||||
}
|
||||
|
||||
storage.Auto = strings.TrimSpace(projectID) == ""
|
||||
storage.Auto = false
|
||||
|
||||
if !storage.Auto && !storage.Checked {
|
||||
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, storage.ProjectID)
|
||||
@@ -141,11 +154,14 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage
|
||||
"pluginType": "GEMINI",
|
||||
}
|
||||
|
||||
trimmedRequest := strings.TrimSpace(requestedProject)
|
||||
explicitProject := trimmedRequest != ""
|
||||
|
||||
loadReqBody := map[string]any{
|
||||
"metadata": metadata,
|
||||
}
|
||||
if requestedProject != "" {
|
||||
loadReqBody["cloudaicompanionProject"] = requestedProject
|
||||
if explicitProject {
|
||||
loadReqBody["cloudaicompanionProject"] = trimmedRequest
|
||||
}
|
||||
|
||||
var loadResp map[string]any
|
||||
@@ -169,11 +185,18 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage
|
||||
}
|
||||
}
|
||||
|
||||
projectID := strings.TrimSpace(requestedProject)
|
||||
projectID := trimmedRequest
|
||||
if projectID == "" {
|
||||
if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject {
|
||||
projectID = strings.TrimSpace(id)
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject {
|
||||
if id, okID := projectMap["id"].(string); okID {
|
||||
projectID = strings.TrimSpace(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
return &projectSelectionRequiredError{}
|
||||
@@ -195,16 +218,30 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage
|
||||
}
|
||||
|
||||
if done, okDone := onboardResp["done"].(bool); okDone && done {
|
||||
responseProjectID := ""
|
||||
if resp, okResp := onboardResp["response"].(map[string]any); okResp {
|
||||
if project, okProject := resp["cloudaicompanionProject"].(map[string]any); okProject {
|
||||
if id, okID := project["id"].(string); okID && strings.TrimSpace(id) != "" {
|
||||
storage.ProjectID = strings.TrimSpace(id)
|
||||
switch projectValue := resp["cloudaicompanionProject"].(type) {
|
||||
case map[string]any:
|
||||
if id, okID := projectValue["id"].(string); okID {
|
||||
responseProjectID = strings.TrimSpace(id)
|
||||
}
|
||||
case string:
|
||||
responseProjectID = strings.TrimSpace(projectValue)
|
||||
}
|
||||
}
|
||||
storage.ProjectID = strings.TrimSpace(storage.ProjectID)
|
||||
|
||||
finalProjectID := projectID
|
||||
if responseProjectID != "" {
|
||||
if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
|
||||
log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
|
||||
} else {
|
||||
finalProjectID = responseProjectID
|
||||
}
|
||||
}
|
||||
|
||||
storage.ProjectID = strings.TrimSpace(finalProjectID)
|
||||
if storage.ProjectID == "" {
|
||||
storage.ProjectID = projectID
|
||||
storage.ProjectID = strings.TrimSpace(projectID)
|
||||
}
|
||||
if storage.ProjectID == "" {
|
||||
return fmt.Errorf("onboard user completed without project id")
|
||||
@@ -298,6 +335,80 @@ func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interface
|
||||
return projects.Projects, nil
|
||||
}
|
||||
|
||||
// promptForProjectSelection prints available projects and returns the chosen project ID.
|
||||
func promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetID string, promptFn func(string) (string, error)) string {
|
||||
trimmedPreset := strings.TrimSpace(presetID)
|
||||
if len(projects) == 0 {
|
||||
if trimmedPreset != "" {
|
||||
return trimmedPreset
|
||||
}
|
||||
fmt.Println("No Google Cloud projects are available for selection.")
|
||||
return ""
|
||||
}
|
||||
|
||||
fmt.Println("Available Google Cloud projects:")
|
||||
defaultIndex := 0
|
||||
for idx, project := range projects {
|
||||
fmt.Printf("[%d] %s (%s)\n", idx+1, project.ProjectID, project.Name)
|
||||
if trimmedPreset != "" && project.ProjectID == trimmedPreset {
|
||||
defaultIndex = idx
|
||||
}
|
||||
}
|
||||
|
||||
defaultID := projects[defaultIndex].ProjectID
|
||||
|
||||
if trimmedPreset != "" {
|
||||
for _, project := range projects {
|
||||
if project.ProjectID == trimmedPreset {
|
||||
return trimmedPreset
|
||||
}
|
||||
}
|
||||
log.Warnf("Provided project ID %s not found in available projects; please choose from the list.", trimmedPreset)
|
||||
}
|
||||
|
||||
for {
|
||||
promptMsg := fmt.Sprintf("Enter project ID [%s]: ", defaultID)
|
||||
answer, errPrompt := promptFn(promptMsg)
|
||||
if errPrompt != nil {
|
||||
log.Errorf("Project selection prompt failed: %v", errPrompt)
|
||||
return defaultID
|
||||
}
|
||||
answer = strings.TrimSpace(answer)
|
||||
if answer == "" {
|
||||
return defaultID
|
||||
}
|
||||
|
||||
for _, project := range projects {
|
||||
if project.ProjectID == answer {
|
||||
return project.ProjectID
|
||||
}
|
||||
}
|
||||
|
||||
if idx, errAtoi := strconv.Atoi(answer); errAtoi == nil {
|
||||
if idx >= 1 && idx <= len(projects) {
|
||||
return projects[idx-1].ProjectID
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Invalid selection, enter a project ID or a number from the list.")
|
||||
}
|
||||
}
|
||||
|
||||
func defaultProjectPrompt() func(string) (string, error) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
return func(prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
line, errRead := reader.ReadString('\n')
|
||||
if errRead != nil {
|
||||
if errors.Is(errRead, io.EOF) {
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
return "", errRead
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
}
|
||||
|
||||
func showProjectSelectionHelp(email string, projects []interfaces.GCPProjectProjects) {
|
||||
if email != "" {
|
||||
log.Infof("Your account %s needs to specify a project ID.", email)
|
||||
@@ -320,51 +431,62 @@ func showProjectSelectionHelp(email string, projects []interfaces.GCPProjectProj
|
||||
}
|
||||
|
||||
func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
|
||||
payload := fmt.Sprintf(`{"project":"%s","request":{"contents":[{"role":"user","parts":[{"text":"Be concise. What is the capital of France?"}]}],"generationConfig":{"thinkingConfig":{"include_thoughts":false,"thinkingBudget":0}}},"model":"gemini-2.5-flash"}`, projectID)
|
||||
|
||||
url := fmt.Sprintf("%s/%s:%s?alt=sse", geminiCLIEndpoint, geminiCLIVersion, "streamGenerateContent")
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(payload))
|
||||
if errRequest != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
||||
serviceUsageURL := "https://serviceusage.googleapis.com"
|
||||
requiredServices := []string{
|
||||
// "geminicloudassist.googleapis.com", // Gemini Cloud Assist API
|
||||
"cloudaicompanion.googleapis.com", // Gemini for Google Cloud API
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
|
||||
req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
resp, errDo := httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("response body close error: %v", errClose)
|
||||
for _, service := range requiredServices {
|
||||
checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, nil)
|
||||
if errRequest != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
||||
resp, errDo := httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
activationURL := gjson.GetBytes(bodyBytes, "0.error.details.0.metadata.activationUrl").String()
|
||||
if activationURL != "" {
|
||||
log.Warnf("\n\nPlease activate your account with this url:\n\n%s\n\n And execute this command again:\n%s --login --project_id %s", activationURL, os.Args[0], projectID)
|
||||
return false, nil
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" {
|
||||
_ = resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
log.Warnf("\n\nPlease copy this message and create an issue.\n\n%s\n\n", strings.TrimSpace(string(bodyBytes)))
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
// Consume the stream to ensure the request succeeds.
|
||||
}
|
||||
if errScan := scanner.Err(); errScan != nil {
|
||||
return false, fmt.Errorf("stream read failed: %w", errScan)
|
||||
}
|
||||
enableUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service)
|
||||
req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableUrl, strings.NewReader("{}"))
|
||||
if errRequest != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
||||
resp, errDo = httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
errMessage := string(bodyBytes)
|
||||
errMessageResult := gjson.GetBytes(bodyBytes, "error.message")
|
||||
if errMessageResult.Exists() {
|
||||
errMessage = errMessageResult.String()
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
||||
_ = resp.Body.Close()
|
||||
continue
|
||||
} else if resp.StatusCode == http.StatusBadRequest {
|
||||
_ = resp.Body.Close()
|
||||
if strings.Contains(strings.ToLower(errMessage), "already enabled") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("project activation required: %s", errMessage)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,8 @@ type RemoteManagement struct {
|
||||
AllowRemote bool `yaml:"allow-remote"`
|
||||
// SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'.
|
||||
SecretKey string `yaml:"secret-key"`
|
||||
// DisableControlPanel skips serving and syncing the bundled management UI when true.
|
||||
DisableControlPanel bool `yaml:"disable-control-panel"`
|
||||
}
|
||||
|
||||
// QuotaExceeded defines the behavior when API quota limits are exceeded.
|
||||
@@ -107,6 +109,9 @@ type ClaudeKey struct {
|
||||
// BaseURL is the base URL for the Claude API endpoint.
|
||||
// If empty, the default Claude API URL will be used.
|
||||
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||
|
||||
// ProxyURL overrides the global proxy setting for this API key if provided.
|
||||
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
||||
}
|
||||
|
||||
// CodexKey represents the configuration for a Codex API key,
|
||||
@@ -118,6 +123,9 @@ type CodexKey struct {
|
||||
// BaseURL is the base URL for the Codex API endpoint.
|
||||
// If empty, the default Codex API URL will be used.
|
||||
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||
|
||||
// ProxyURL overrides the global proxy setting for this API key if provided.
|
||||
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
||||
}
|
||||
|
||||
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
||||
@@ -130,12 +138,25 @@ type OpenAICompatibility struct {
|
||||
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||
|
||||
// APIKeys are the authentication keys for accessing the external API services.
|
||||
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
||||
// Deprecated: Use APIKeyEntries instead to support per-key proxy configuration.
|
||||
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
|
||||
|
||||
// APIKeyEntries defines API keys with optional per-key proxy configuration.
|
||||
APIKeyEntries []OpenAICompatibilityAPIKey `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"`
|
||||
|
||||
// Models defines the model configurations including aliases for routing.
|
||||
Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
|
||||
}
|
||||
|
||||
// OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting.
|
||||
type OpenAICompatibilityAPIKey struct {
|
||||
// APIKey is the authentication key for accessing the external API services.
|
||||
APIKey string `yaml:"api-key" json:"api-key"`
|
||||
|
||||
// ProxyURL overrides the global proxy setting for this API key if provided.
|
||||
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,
|
||||
// including the actual model name and its alias for API routing.
|
||||
type OpenAICompatibilityModel struct {
|
||||
@@ -198,33 +219,12 @@ func syncInlineAccessProvider(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if len(cfg.Access.Providers) == 0 {
|
||||
if len(cfg.APIKeys) == 0 {
|
||||
return
|
||||
if len(cfg.APIKeys) == 0 {
|
||||
if provider := cfg.ConfigAPIKeyProvider(); provider != nil && len(provider.APIKeys) > 0 {
|
||||
cfg.APIKeys = append([]string(nil), provider.APIKeys...)
|
||||
}
|
||||
cfg.Access.Providers = append(cfg.Access.Providers, config.AccessProvider{
|
||||
Name: config.DefaultAccessProviderName,
|
||||
Type: config.AccessProviderTypeConfigAPIKey,
|
||||
APIKeys: append([]string(nil), cfg.APIKeys...),
|
||||
})
|
||||
return
|
||||
}
|
||||
provider := cfg.ConfigAPIKeyProvider()
|
||||
if provider == nil {
|
||||
if len(cfg.APIKeys) == 0 {
|
||||
return
|
||||
}
|
||||
cfg.Access.Providers = append(cfg.Access.Providers, config.AccessProvider{
|
||||
Name: config.DefaultAccessProviderName,
|
||||
Type: config.AccessProviderTypeConfigAPIKey,
|
||||
APIKeys: append([]string(nil), cfg.APIKeys...),
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(provider.APIKeys) == 0 && len(cfg.APIKeys) > 0 {
|
||||
provider.APIKeys = append([]string(nil), cfg.APIKeys...)
|
||||
}
|
||||
cfg.APIKeys = append([]string(nil), provider.APIKeys...)
|
||||
cfg.Access.Providers = nil
|
||||
}
|
||||
|
||||
// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
|
||||
@@ -245,6 +245,7 @@ func hashSecret(secret string) (string, error) {
|
||||
// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
|
||||
// and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
|
||||
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||
persistCfg := sanitizeConfigForPersist(cfg)
|
||||
// Load original YAML as a node tree to preserve comments and ordering.
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
@@ -263,7 +264,7 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||
}
|
||||
|
||||
// Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from.
|
||||
rendered, err := yaml.Marshal(cfg)
|
||||
rendered, err := yaml.Marshal(persistCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -278,6 +279,9 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||
return fmt.Errorf("expected generated root mapping node")
|
||||
}
|
||||
|
||||
// Remove deprecated auth block before merging to avoid persisting it again.
|
||||
removeMapKey(original.Content[0], "auth")
|
||||
|
||||
// Merge generated into original in-place, preserving comments/order of existing nodes.
|
||||
mergeMappingPreserve(original.Content[0], generated.Content[0])
|
||||
|
||||
@@ -296,6 +300,16 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||
return enc.Close()
|
||||
}
|
||||
|
||||
func sanitizeConfigForPersist(cfg *Config) *Config {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *cfg
|
||||
clone.SDKConfig = cfg.SDKConfig
|
||||
clone.SDKConfig.Access = config.AccessConfig{}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
|
||||
// while preserving comments and positions.
|
||||
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
|
||||
@@ -498,3 +512,15 @@ func copyNodeShallow(dst, src *yaml.Node) {
|
||||
dst.Content = nil
|
||||
}
|
||||
}
|
||||
|
||||
func removeMapKey(mapNode *yaml.Node, key string) {
|
||||
if mapNode == nil || mapNode.Kind != yaml.MappingNode || key == "" {
|
||||
return
|
||||
}
|
||||
for i := 0; i+1 < len(mapNode.Content); i += 2 {
|
||||
if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
|
||||
mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
256
internal/managementasset/updater.go
Normal file
256
internal/managementasset/updater.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package managementasset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest"
|
||||
managementAssetName = "management.html"
|
||||
httpUserAgent = "CLIProxyAPI-management-updater"
|
||||
)
|
||||
|
||||
// ManagementFileName exposes the control panel asset filename.
|
||||
const ManagementFileName = managementAssetName
|
||||
|
||||
func newHTTPClient(proxyURL string) *http.Client {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
sdkCfg := &sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)}
|
||||
util.SetProxy(sdkCfg, client)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
type releaseAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type releaseResponse struct {
|
||||
Assets []releaseAsset `json:"assets"`
|
||||
}
|
||||
|
||||
// StaticDir resolves the directory that stores the management control panel asset.
|
||||
func StaticDir(configFilePath string) string {
|
||||
configFilePath = strings.TrimSpace(configFilePath)
|
||||
if configFilePath == "" {
|
||||
return ""
|
||||
}
|
||||
base := filepath.Dir(configFilePath)
|
||||
return filepath.Join(base, "static")
|
||||
}
|
||||
|
||||
// FilePath resolves the absolute path to the management control panel asset.
|
||||
func FilePath(configFilePath string) string {
|
||||
dir := StaticDir(configFilePath)
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, ManagementFileName)
|
||||
}
|
||||
|
||||
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
|
||||
// The function is designed to run in a background goroutine and will never panic.
|
||||
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
staticDir = strings.TrimSpace(staticDir)
|
||||
if staticDir == "" {
|
||||
log.Debug("management asset sync skipped: empty static directory")
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(staticDir, 0o755); err != nil {
|
||||
log.WithError(err).Warn("failed to prepare static directory for management asset")
|
||||
return
|
||||
}
|
||||
|
||||
client := newHTTPClient(proxyURL)
|
||||
|
||||
localPath := filepath.Join(staticDir, managementAssetName)
|
||||
localHash, err := fileSHA256(localPath)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
log.WithError(err).Debug("failed to read local management asset hash")
|
||||
}
|
||||
localHash = ""
|
||||
}
|
||||
|
||||
asset, remoteHash, err := fetchLatestAsset(ctx, client)
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("failed to fetch latest management release information")
|
||||
return
|
||||
}
|
||||
|
||||
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
|
||||
log.Debug("management asset is already up to date")
|
||||
return
|
||||
}
|
||||
|
||||
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("failed to download management asset")
|
||||
return
|
||||
}
|
||||
|
||||
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
||||
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
||||
}
|
||||
|
||||
if err = atomicWriteFile(localPath, data); err != nil {
|
||||
log.WithError(err).Warn("failed to update management asset on disk")
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("management asset updated successfully (hash=%s)", downloadedHash)
|
||||
}
|
||||
|
||||
func fetchLatestAsset(ctx context.Context, client *http.Client) (*releaseAsset, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, managementReleaseURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create release request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", httpUserAgent)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("execute release request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, "", fmt.Errorf("unexpected release status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var release releaseResponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, "", fmt.Errorf("decode release response: %w", err)
|
||||
}
|
||||
|
||||
for i := range release.Assets {
|
||||
asset := &release.Assets[i]
|
||||
if strings.EqualFold(asset.Name, managementAssetName) {
|
||||
remoteHash := parseDigest(asset.Digest)
|
||||
return asset, remoteHash, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("management asset %s not found in latest release", managementAssetName)
|
||||
}
|
||||
|
||||
func downloadAsset(ctx context.Context, client *http.Client, downloadURL string) ([]byte, string, error) {
|
||||
if strings.TrimSpace(downloadURL) == "" {
|
||||
return nil, "", fmt.Errorf("empty download url")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", httpUserAgent)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("execute download request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read download body: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
return data, hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err = io.Copy(h, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func atomicWriteFile(path string, data []byte) error {
|
||||
tmpFile, err := os.CreateTemp(filepath.Dir(path), "management-*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpName := tmpFile.Name()
|
||||
defer func() {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
}()
|
||||
|
||||
if _, err = tmpFile.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tmpFile.Chmod(0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = os.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDigest(digest string) string {
|
||||
digest = strings.TrimSpace(digest)
|
||||
if digest == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if idx := strings.Index(digest, ":"); idx >= 0 {
|
||||
digest = digest[idx+1:]
|
||||
}
|
||||
|
||||
return strings.ToLower(strings.TrimSpace(digest))
|
||||
}
|
||||
80
internal/provider/gemini-web/conversation/alias.go
Normal file
80
internal/provider/gemini-web/conversation/alias.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
)
|
||||
|
||||
var (
|
||||
aliasOnce sync.Once
|
||||
aliasMap map[string]string
|
||||
)
|
||||
|
||||
// EnsureGeminiWebAliasMap populates the alias map once.
|
||||
func EnsureGeminiWebAliasMap() {
|
||||
aliasOnce.Do(func() {
|
||||
aliasMap = make(map[string]string)
|
||||
for _, m := range registry.GetGeminiModels() {
|
||||
if m.ID == "gemini-2.5-flash-lite" {
|
||||
continue
|
||||
}
|
||||
if m.ID == "gemini-2.5-flash" {
|
||||
aliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
|
||||
}
|
||||
alias := AliasFromModelID(m.ID)
|
||||
aliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MapAliasToUnderlying normalizes a model alias to its underlying identifier.
|
||||
func MapAliasToUnderlying(name string) string {
|
||||
EnsureGeminiWebAliasMap()
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
if n == "" {
|
||||
return n
|
||||
}
|
||||
if u, ok := aliasMap[n]; ok {
|
||||
return u
|
||||
}
|
||||
const suffix = "-web"
|
||||
if strings.HasSuffix(n, suffix) {
|
||||
return strings.TrimSuffix(n, suffix)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AliasFromModelID mirrors the original helper for deriving alias IDs.
|
||||
func AliasFromModelID(modelID string) string {
|
||||
return modelID + "-web"
|
||||
}
|
||||
|
||||
// NormalizeModel returns the canonical identifier used for hashing.
|
||||
func NormalizeModel(model string) string {
|
||||
return MapAliasToUnderlying(model)
|
||||
}
|
||||
|
||||
// GetGeminiWebAliasedModels returns alias metadata for registry exposure.
|
||||
func GetGeminiWebAliasedModels() []*registry.ModelInfo {
|
||||
EnsureGeminiWebAliasMap()
|
||||
aliased := make([]*registry.ModelInfo, 0)
|
||||
for _, m := range registry.GetGeminiModels() {
|
||||
if m.ID == "gemini-2.5-flash-lite" {
|
||||
continue
|
||||
} else if m.ID == "gemini-2.5-flash" {
|
||||
cpy := *m
|
||||
cpy.ID = "gemini-2.5-flash-image-preview"
|
||||
cpy.Name = "gemini-2.5-flash-image-preview"
|
||||
cpy.DisplayName = "Nano Banana"
|
||||
cpy.Description = "Gemini 2.5 Flash Preview Image"
|
||||
aliased = append(aliased, &cpy)
|
||||
}
|
||||
cpy := *m
|
||||
cpy.ID = AliasFromModelID(m.ID)
|
||||
cpy.Name = cpy.ID
|
||||
aliased = append(aliased, &cpy)
|
||||
}
|
||||
return aliased
|
||||
}
|
||||
74
internal/provider/gemini-web/conversation/hash.go
Normal file
74
internal/provider/gemini-web/conversation/hash.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Message represents a minimal role-text pair used for hashing and comparison.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// StoredMessage mirrors the persisted conversation message structure.
|
||||
type StoredMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Sha256Hex computes SHA-256 hex digest for the specified string.
|
||||
func Sha256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ToStoredMessages converts in-memory messages into the persisted representation.
|
||||
func ToStoredMessages(msgs []Message) []StoredMessage {
|
||||
out := make([]StoredMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, StoredMessage{Role: m.Role, Content: m.Text})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StoredToMessages converts stored messages back into the in-memory representation.
|
||||
func StoredToMessages(msgs []StoredMessage) []Message {
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, Message{Role: m.Role, Text: m.Content})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hashMessage normalizes message data and returns a stable digest.
|
||||
func hashMessage(m StoredMessage) string {
|
||||
s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
|
||||
return Sha256Hex(s)
|
||||
}
|
||||
|
||||
// HashConversationWithPrefix computes a conversation hash using the provided prefix (client identifier) and model.
|
||||
func HashConversationWithPrefix(prefix, model string, msgs []StoredMessage) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(strings.ToLower(strings.TrimSpace(prefix)))
|
||||
b.WriteString("|")
|
||||
b.WriteString(strings.ToLower(strings.TrimSpace(model)))
|
||||
for _, m := range msgs {
|
||||
b.WriteString("|")
|
||||
b.WriteString(hashMessage(m))
|
||||
}
|
||||
return Sha256Hex(b.String())
|
||||
}
|
||||
|
||||
// HashConversationForAccount keeps compatibility with the per-account hash previously used.
|
||||
func HashConversationForAccount(clientID, model string, msgs []StoredMessage) string {
|
||||
return HashConversationWithPrefix(clientID, model, msgs)
|
||||
}
|
||||
|
||||
// HashConversationGlobal produces a hash suitable for cross-account lookups.
|
||||
func HashConversationGlobal(model string, msgs []StoredMessage) string {
|
||||
return HashConversationWithPrefix("global", model, msgs)
|
||||
}
|
||||
280
internal/provider/gemini-web/conversation/index.go
Normal file
280
internal/provider/gemini-web/conversation/index.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const (
|
||||
bucketMatches = "matches"
|
||||
defaultIndexFile = "gemini-web-index.bolt"
|
||||
)
|
||||
|
||||
// MatchRecord stores persisted mapping metadata for a conversation prefix.
|
||||
type MatchRecord struct {
|
||||
AccountLabel string `json:"account_label"`
|
||||
Metadata []string `json:"metadata,omitempty"`
|
||||
PrefixLen int `json:"prefix_len"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MatchResult combines a persisted record with the hash that produced it.
|
||||
type MatchResult struct {
|
||||
Hash string
|
||||
Record MatchRecord
|
||||
Model string
|
||||
}
|
||||
|
||||
var (
|
||||
indexOnce sync.Once
|
||||
indexDB *bolt.DB
|
||||
indexErr error
|
||||
)
|
||||
|
||||
func openIndex() (*bolt.DB, error) {
|
||||
indexOnce.Do(func() {
|
||||
path := indexPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
indexErr = err
|
||||
return
|
||||
}
|
||||
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
|
||||
if err != nil {
|
||||
indexErr = err
|
||||
return
|
||||
}
|
||||
indexDB = db
|
||||
})
|
||||
return indexDB, indexErr
|
||||
}
|
||||
|
||||
func indexPath() string {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil || wd == "" {
|
||||
wd = "."
|
||||
}
|
||||
return filepath.Join(wd, "conv", defaultIndexFile)
|
||||
}
|
||||
|
||||
// StoreMatch persists or updates a conversation hash mapping.
|
||||
func StoreMatch(hash string, record MatchRecord) error {
|
||||
if strings.TrimSpace(hash) == "" {
|
||||
return errors.New("gemini-web conversation: empty hash")
|
||||
}
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record.UpdatedAt = time.Now().UTC().Unix()
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte(bucketMatches))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Namespace by account label to avoid cross-account collisions.
|
||||
label := strings.ToLower(strings.TrimSpace(record.AccountLabel))
|
||||
if label == "" {
|
||||
return errors.New("gemini-web conversation: empty account label")
|
||||
}
|
||||
key := []byte(hash + ":" + label)
|
||||
if err := bucket.Put(key, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
// Best-effort cleanup of legacy single-key format (hash -> MatchRecord).
|
||||
// We do not know its label; leave it for lookup fallback/cleanup elsewhere.
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// LookupMatch retrieves a stored mapping.
|
||||
// It prefers namespaced entries (hash:label). If multiple labels exist for the same
|
||||
// hash, it returns not found to avoid redirecting to the wrong credential.
|
||||
// Falls back to legacy single-key entries if present.
|
||||
func LookupMatch(hash string) (MatchRecord, bool, error) {
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return MatchRecord{}, false, err
|
||||
}
|
||||
var foundOne bool
|
||||
var ambiguous bool
|
||||
var firstLabel string
|
||||
var single MatchRecord
|
||||
err = db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketMatches))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
// Scan namespaced keys with prefix "hash:"
|
||||
prefix := []byte(hash + ":")
|
||||
c := bucket.Cursor()
|
||||
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
var rec MatchRecord
|
||||
if err := json.Unmarshal(v, &rec); err != nil {
|
||||
// Ignore malformed; removal is handled elsewhere.
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(rec.AccountLabel) == "" || rec.PrefixLen <= 0 {
|
||||
continue
|
||||
}
|
||||
label := strings.ToLower(strings.TrimSpace(rec.AccountLabel))
|
||||
if !foundOne {
|
||||
firstLabel = label
|
||||
single = rec
|
||||
foundOne = true
|
||||
continue
|
||||
}
|
||||
if label != firstLabel {
|
||||
ambiguous = true
|
||||
// Early exit scan; ambiguity detected.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if foundOne {
|
||||
return nil
|
||||
}
|
||||
// Fallback to legacy single-key format
|
||||
raw := bucket.Get([]byte(hash))
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, &single)
|
||||
})
|
||||
if err != nil {
|
||||
return MatchRecord{}, false, err
|
||||
}
|
||||
if ambiguous {
|
||||
return MatchRecord{}, false, nil
|
||||
}
|
||||
if strings.TrimSpace(single.AccountLabel) == "" || single.PrefixLen <= 0 {
|
||||
return MatchRecord{}, false, nil
|
||||
}
|
||||
return single, true, nil
|
||||
}
|
||||
|
||||
// RemoveMatch deletes all mappings for the given hash (all labels and legacy key).
|
||||
func RemoveMatch(hash string) error {
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketMatches))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
// Delete namespaced entries
|
||||
prefix := []byte(hash + ":")
|
||||
c := bucket.Cursor()
|
||||
for k, _ := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, _ = c.Next() {
|
||||
if err := bucket.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Delete legacy entry
|
||||
_ = bucket.Delete([]byte(hash))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveMatchForLabel deletes the mapping for the given hash and label only.
|
||||
func RemoveMatchForLabel(hash, label string) error {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if strings.TrimSpace(hash) == "" || label == "" {
|
||||
return nil
|
||||
}
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketMatches))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
// Remove namespaced key
|
||||
_ = bucket.Delete([]byte(hash + ":" + label))
|
||||
// If legacy single-key exists and matches label, remove it as well.
|
||||
if raw := bucket.Get([]byte(hash)); len(raw) > 0 {
|
||||
var rec MatchRecord
|
||||
if err := json.Unmarshal(raw, &rec); err == nil {
|
||||
if strings.EqualFold(strings.TrimSpace(rec.AccountLabel), label) {
|
||||
_ = bucket.Delete([]byte(hash))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveMatchesByLabel removes all entries associated with the specified label.
|
||||
func RemoveMatchesByLabel(label string) error {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" {
|
||||
return nil
|
||||
}
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketMatches))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
var record MatchRecord
|
||||
if err := json.Unmarshal(v, &record); err != nil {
|
||||
_ = bucket.Delete(k)
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(record.AccountLabel), label) {
|
||||
if err := bucket.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// StoreConversation updates all hashes representing the provided conversation snapshot.
|
||||
func StoreConversation(label, model string, msgs []Message, metadata []string) error {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" || len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
hashes := BuildStorageHashes(model, msgs)
|
||||
if len(hashes) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, h := range hashes {
|
||||
rec := MatchRecord{
|
||||
AccountLabel: label,
|
||||
Metadata: append([]string(nil), metadata...),
|
||||
PrefixLen: h.PrefixLen,
|
||||
}
|
||||
if err := StoreMatch(h.Hash, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
64
internal/provider/gemini-web/conversation/lookup.go
Normal file
64
internal/provider/gemini-web/conversation/lookup.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package conversation
|
||||
|
||||
import "strings"
|
||||
|
||||
// PrefixHash represents a hash candidate for a specific prefix length.
|
||||
type PrefixHash struct {
|
||||
Hash string
|
||||
PrefixLen int
|
||||
}
|
||||
|
||||
// BuildLookupHashes generates hash candidates ordered from longest to shortest prefix.
|
||||
func BuildLookupHashes(model string, msgs []Message) []PrefixHash {
|
||||
if len(msgs) < 2 {
|
||||
return nil
|
||||
}
|
||||
model = NormalizeModel(model)
|
||||
sanitized := SanitizeAssistantMessages(msgs)
|
||||
result := make([]PrefixHash, 0, len(sanitized))
|
||||
for end := len(sanitized); end >= 2; end-- {
|
||||
tailRole := strings.ToLower(strings.TrimSpace(sanitized[end-1].Role))
|
||||
if tailRole != "assistant" && tailRole != "system" {
|
||||
continue
|
||||
}
|
||||
prefix := sanitized[:end]
|
||||
hash := HashConversationGlobal(model, ToStoredMessages(prefix))
|
||||
result = append(result, PrefixHash{Hash: hash, PrefixLen: end})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// BuildStorageHashes returns hashes representing the full conversation snapshot.
|
||||
func BuildStorageHashes(model string, msgs []Message) []PrefixHash {
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
model = NormalizeModel(model)
|
||||
sanitized := SanitizeAssistantMessages(msgs)
|
||||
if len(sanitized) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]PrefixHash, 0, len(sanitized))
|
||||
seen := make(map[string]struct{}, len(sanitized))
|
||||
for start := 0; start < len(sanitized); start++ {
|
||||
segment := sanitized[start:]
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
tailRole := strings.ToLower(strings.TrimSpace(segment[len(segment)-1].Role))
|
||||
if tailRole != "assistant" && tailRole != "system" {
|
||||
continue
|
||||
}
|
||||
hash := HashConversationGlobal(model, ToStoredMessages(segment))
|
||||
if _, exists := seen[hash]; exists {
|
||||
continue
|
||||
}
|
||||
seen[hash] = struct{}{}
|
||||
result = append(result, PrefixHash{Hash: hash, PrefixLen: len(segment)})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
hash := HashConversationGlobal(model, ToStoredMessages(sanitized))
|
||||
return []PrefixHash{{Hash: hash, PrefixLen: len(sanitized)}}
|
||||
}
|
||||
return result
|
||||
}
|
||||
6
internal/provider/gemini-web/conversation/metadata.go
Normal file
6
internal/provider/gemini-web/conversation/metadata.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package conversation
|
||||
|
||||
const (
|
||||
MetadataMessagesKey = "gemini_web_messages"
|
||||
MetadataMatchKey = "gemini_web_match"
|
||||
)
|
||||
110
internal/provider/gemini-web/conversation/parse.go
Normal file
110
internal/provider/gemini-web/conversation/parse.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// ExtractMessages attempts to build a message list from the inbound request payload.
|
||||
func ExtractMessages(handlerType string, raw []byte) []Message {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if msgs := extractOpenAIStyle(raw); len(msgs) > 0 {
|
||||
return msgs
|
||||
}
|
||||
if msgs := extractGeminiContents(raw); len(msgs) > 0 {
|
||||
return msgs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractOpenAIStyle(raw []byte) []Message {
|
||||
root := gjson.ParseBytes(raw)
|
||||
messages := root.Get("messages")
|
||||
if !messages.Exists() {
|
||||
return nil
|
||||
}
|
||||
out := make([]Message, 0, 8)
|
||||
messages.ForEach(func(_, entry gjson.Result) bool {
|
||||
role := strings.ToLower(strings.TrimSpace(entry.Get("role").String()))
|
||||
if role == "" {
|
||||
return true
|
||||
}
|
||||
if role == "system" {
|
||||
return true
|
||||
}
|
||||
// Ignore OpenAI tool messages to keep hashing aligned with
|
||||
// persistence (which only keeps text/inlineData for Gemini contents).
|
||||
// This avoids mismatches when a tool response is present: the
|
||||
// storage path drops tool payloads while the lookup path would
|
||||
// otherwise include them, causing sticky selection to fail.
|
||||
if role == "tool" {
|
||||
return true
|
||||
}
|
||||
var contentBuilder strings.Builder
|
||||
content := entry.Get("content")
|
||||
if !content.Exists() {
|
||||
out = append(out, Message{Role: role, Text: ""})
|
||||
return true
|
||||
}
|
||||
switch content.Type {
|
||||
case gjson.String:
|
||||
contentBuilder.WriteString(content.String())
|
||||
case gjson.JSON:
|
||||
if content.IsArray() {
|
||||
content.ForEach(func(_, part gjson.Result) bool {
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
if contentBuilder.Len() > 0 {
|
||||
contentBuilder.WriteString("\n")
|
||||
}
|
||||
contentBuilder.WriteString(text.String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, Message{Role: role, Text: contentBuilder.String()})
|
||||
return true
|
||||
})
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractGeminiContents(raw []byte) []Message {
|
||||
contents := gjson.GetBytes(raw, "contents")
|
||||
if !contents.Exists() {
|
||||
return nil
|
||||
}
|
||||
out := make([]Message, 0, 8)
|
||||
contents.ForEach(func(_, entry gjson.Result) bool {
|
||||
role := strings.TrimSpace(entry.Get("role").String())
|
||||
if role == "" {
|
||||
role = "user"
|
||||
} else {
|
||||
role = strings.ToLower(role)
|
||||
if role == "model" {
|
||||
role = "assistant"
|
||||
}
|
||||
}
|
||||
var builder strings.Builder
|
||||
entry.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString(text.String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
out = append(out, Message{Role: role, Text: builder.String()})
|
||||
return true
|
||||
})
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
39
internal/provider/gemini-web/conversation/sanitize.go
Normal file
39
internal/provider/gemini-web/conversation/sanitize.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var reThink = regexp.MustCompile(`(?is)<think>.*?</think>`)
|
||||
|
||||
// RemoveThinkTags strips <think>...</think> blocks and trims whitespace.
|
||||
func RemoveThinkTags(s string) string {
|
||||
return strings.TrimSpace(reThink.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
// SanitizeAssistantMessages removes think tags from assistant messages while leaving others untouched.
|
||||
func SanitizeAssistantMessages(msgs []Message) []Message {
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if strings.EqualFold(strings.TrimSpace(m.Role), "assistant") {
|
||||
out = append(out, Message{Role: m.Role, Text: RemoveThinkTags(m.Text)})
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EqualMessages compares two message slices for equality.
|
||||
func EqualMessages(a, b []Message) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Role != b[i].Role || a[i].Text != b[i].Text {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -4,10 +4,9 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
)
|
||||
|
||||
@@ -105,76 +104,20 @@ const (
|
||||
ErrorIPTemporarilyBlocked = 1060
|
||||
)
|
||||
|
||||
var (
|
||||
GeminiWebAliasOnce sync.Once
|
||||
GeminiWebAliasMap map[string]string
|
||||
)
|
||||
|
||||
func EnsureGeminiWebAliasMap() {
|
||||
GeminiWebAliasOnce.Do(func() {
|
||||
GeminiWebAliasMap = make(map[string]string)
|
||||
for _, m := range registry.GetGeminiModels() {
|
||||
if m.ID == "gemini-2.5-flash-lite" {
|
||||
continue
|
||||
} else if m.ID == "gemini-2.5-flash" {
|
||||
GeminiWebAliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
|
||||
}
|
||||
alias := AliasFromModelID(m.ID)
|
||||
GeminiWebAliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
func EnsureGeminiWebAliasMap() { conversation.EnsureGeminiWebAliasMap() }
|
||||
|
||||
func GetGeminiWebAliasedModels() []*registry.ModelInfo {
|
||||
EnsureGeminiWebAliasMap()
|
||||
aliased := make([]*registry.ModelInfo, 0)
|
||||
for _, m := range registry.GetGeminiModels() {
|
||||
if m.ID == "gemini-2.5-flash-lite" {
|
||||
continue
|
||||
} else if m.ID == "gemini-2.5-flash" {
|
||||
cpy := *m
|
||||
cpy.ID = "gemini-2.5-flash-image-preview"
|
||||
cpy.Name = "gemini-2.5-flash-image-preview"
|
||||
cpy.DisplayName = "Nano Banana"
|
||||
cpy.Description = "Gemini 2.5 Flash Preview Image"
|
||||
aliased = append(aliased, &cpy)
|
||||
}
|
||||
cpy := *m
|
||||
cpy.ID = AliasFromModelID(m.ID)
|
||||
cpy.Name = cpy.ID
|
||||
aliased = append(aliased, &cpy)
|
||||
}
|
||||
return aliased
|
||||
return conversation.GetGeminiWebAliasedModels()
|
||||
}
|
||||
|
||||
func MapAliasToUnderlying(name string) string {
|
||||
EnsureGeminiWebAliasMap()
|
||||
n := strings.ToLower(name)
|
||||
if u, ok := GeminiWebAliasMap[n]; ok {
|
||||
return u
|
||||
}
|
||||
const suffix = "-web"
|
||||
if strings.HasSuffix(n, suffix) {
|
||||
return strings.TrimSuffix(n, suffix)
|
||||
}
|
||||
return name
|
||||
}
|
||||
func MapAliasToUnderlying(name string) string { return conversation.MapAliasToUnderlying(name) }
|
||||
|
||||
func AliasFromModelID(modelID string) string {
|
||||
return modelID + "-web"
|
||||
}
|
||||
func AliasFromModelID(modelID string) string { return conversation.AliasFromModelID(modelID) }
|
||||
|
||||
// Conversation domain structures -------------------------------------------
|
||||
type RoleText struct {
|
||||
Role string
|
||||
Text string
|
||||
}
|
||||
type RoleText = conversation.Message
|
||||
|
||||
type StoredMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
type StoredMessage = conversation.StoredMessage
|
||||
|
||||
type ConversationRecord struct {
|
||||
Model string `json:"model"`
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var (
|
||||
reThink = regexp.MustCompile(`(?s)^\s*<think>.*?</think>\s*`)
|
||||
reXMLAnyTag = regexp.MustCompile(`(?s)<\s*[^>]+>`)
|
||||
)
|
||||
|
||||
@@ -77,20 +77,13 @@ func BuildPrompt(msgs []RoleText, tagged bool, appendAssistant bool) string {
|
||||
|
||||
// RemoveThinkTags strips <think>...</think> blocks from a string.
|
||||
func RemoveThinkTags(s string) string {
|
||||
return strings.TrimSpace(reThink.ReplaceAllString(s, ""))
|
||||
return conversation.RemoveThinkTags(s)
|
||||
}
|
||||
|
||||
// SanitizeAssistantMessages removes think tags from assistant messages.
|
||||
func SanitizeAssistantMessages(msgs []RoleText) []RoleText {
|
||||
out := make([]RoleText, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if strings.ToLower(m.Role) == "assistant" {
|
||||
out = append(out, RoleText{Role: m.Role, Text: RemoveThinkTags(m.Text)})
|
||||
} else {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
cleaned := conversation.SanitizeAssistantMessages(msgs)
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// AppendXMLWrapHintIfNeeded appends an XML wrap hint to messages containing XML-like blocks.
|
||||
|
||||
@@ -3,8 +3,6 @@ package geminiwebapi
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -19,6 +17,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -35,6 +34,7 @@ type GeminiWebState struct {
|
||||
cfg *config.Config
|
||||
token *gemini.GeminiWebTokenStorage
|
||||
storagePath string
|
||||
authLabel string
|
||||
|
||||
stableClientID string
|
||||
accountID string
|
||||
@@ -51,18 +51,28 @@ type GeminiWebState struct {
|
||||
convIndex map[string]string
|
||||
|
||||
lastRefresh time.Time
|
||||
|
||||
pendingMatchMu sync.Mutex
|
||||
pendingMatch *conversation.MatchResult
|
||||
}
|
||||
|
||||
func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath string) *GeminiWebState {
|
||||
type reuseComputation struct {
|
||||
metadata []string
|
||||
history []RoleText
|
||||
overlap int
|
||||
}
|
||||
|
||||
func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath, authLabel string) *GeminiWebState {
|
||||
state := &GeminiWebState{
|
||||
cfg: cfg,
|
||||
token: token,
|
||||
storagePath: storagePath,
|
||||
authLabel: strings.TrimSpace(authLabel),
|
||||
convStore: make(map[string][]string),
|
||||
convData: make(map[string]ConversationRecord),
|
||||
convIndex: make(map[string]string),
|
||||
}
|
||||
suffix := Sha256Hex(token.Secure1PSID)
|
||||
suffix := conversation.Sha256Hex(token.Secure1PSID)
|
||||
if len(suffix) > 16 {
|
||||
suffix = suffix[:16]
|
||||
}
|
||||
@@ -81,6 +91,28 @@ func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage,
|
||||
return state
|
||||
}
|
||||
|
||||
func (s *GeminiWebState) setPendingMatch(match *conversation.MatchResult) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.pendingMatchMu.Lock()
|
||||
s.pendingMatch = match
|
||||
s.pendingMatchMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *GeminiWebState) consumePendingMatch() *conversation.MatchResult {
|
||||
s.pendingMatchMu.Lock()
|
||||
defer s.pendingMatchMu.Unlock()
|
||||
match := s.pendingMatch
|
||||
s.pendingMatch = nil
|
||||
return match
|
||||
}
|
||||
|
||||
// SetPendingMatch makes a cached conversation match available for the next request.
|
||||
func (s *GeminiWebState) SetPendingMatch(match *conversation.MatchResult) {
|
||||
s.setPendingMatch(match)
|
||||
}
|
||||
|
||||
// Label returns a stable account label for logging and persistence.
|
||||
// If a storage file path is known, it uses the file base name (without extension).
|
||||
// Otherwise, it falls back to the stable client ID (e.g., "gemini-web-<hash>").
|
||||
@@ -93,6 +125,9 @@ func (s *GeminiWebState) Label() string {
|
||||
return lbl
|
||||
}
|
||||
}
|
||||
if lbl := strings.TrimSpace(s.authLabel); lbl != "" {
|
||||
return lbl
|
||||
}
|
||||
if s.storagePath != "" {
|
||||
base := strings.TrimSuffix(filepath.Base(s.storagePath), filepath.Ext(s.storagePath))
|
||||
if base != "" {
|
||||
@@ -126,6 +161,78 @@ func (s *GeminiWebState) convPath() string {
|
||||
return ConvBoltPath(base)
|
||||
}
|
||||
|
||||
func cloneRoleTextSlice(in []RoleText) []RoleText {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]RoleText, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringSlice(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
func longestHistoryOverlap(history, incoming []RoleText) int {
|
||||
max := len(history)
|
||||
if len(incoming) < max {
|
||||
max = len(incoming)
|
||||
}
|
||||
for overlap := max; overlap > 0; overlap-- {
|
||||
if conversation.EqualMessages(history[len(history)-overlap:], incoming[:overlap]) {
|
||||
return overlap
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func equalStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func storedMessagesToRoleText(stored []conversation.StoredMessage) []RoleText {
|
||||
if len(stored) == 0 {
|
||||
return nil
|
||||
}
|
||||
converted := make([]RoleText, len(stored))
|
||||
for i, msg := range stored {
|
||||
converted[i] = RoleText{Role: msg.Role, Text: msg.Content}
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
func (s *GeminiWebState) findConversationByMetadata(model string, metadata []string) ([]RoleText, bool) {
|
||||
if len(metadata) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
s.convMu.RLock()
|
||||
defer s.convMu.RUnlock()
|
||||
for _, rec := range s.convData {
|
||||
if !strings.EqualFold(strings.TrimSpace(rec.Model), strings.TrimSpace(model)) {
|
||||
continue
|
||||
}
|
||||
if !equalStringSlice(rec.Metadata, metadata) {
|
||||
continue
|
||||
}
|
||||
return cloneRoleTextSlice(storedMessagesToRoleText(rec.Messages)), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }
|
||||
|
||||
func (s *GeminiWebState) EnsureClient() error {
|
||||
@@ -219,7 +326,7 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
|
||||
}
|
||||
cleaned := SanitizeAssistantMessages(messages)
|
||||
res.cleaned = cleaned
|
||||
fullCleaned := cloneRoleTextSlice(cleaned)
|
||||
res.underlying = MapAliasToUnderlying(modelName)
|
||||
model, err := ModelFromName(res.underlying)
|
||||
if err != nil {
|
||||
@@ -232,15 +339,27 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
||||
mimesSubset := mimes
|
||||
|
||||
if s.useReusableContext() {
|
||||
reuseMeta, remaining := s.findReusableSession(res.underlying, cleaned)
|
||||
if len(reuseMeta) > 0 {
|
||||
reusePlan := s.reuseFromPending(res.underlying, cleaned)
|
||||
if reusePlan == nil {
|
||||
reusePlan = s.findReusableSession(res.underlying, cleaned)
|
||||
}
|
||||
if reusePlan != nil {
|
||||
res.reuse = true
|
||||
meta = reuseMeta
|
||||
if len(remaining) == 1 {
|
||||
useMsgs = []RoleText{remaining[0]}
|
||||
} else if len(remaining) > 1 {
|
||||
useMsgs = remaining
|
||||
} else if len(cleaned) > 0 {
|
||||
meta = cloneStringSlice(reusePlan.metadata)
|
||||
overlap := reusePlan.overlap
|
||||
if overlap > len(cleaned) {
|
||||
overlap = len(cleaned)
|
||||
} else if overlap < 0 {
|
||||
overlap = 0
|
||||
}
|
||||
delta := cloneRoleTextSlice(cleaned[overlap:])
|
||||
if len(reusePlan.history) > 0 {
|
||||
fullCleaned = append(cloneRoleTextSlice(reusePlan.history), delta...)
|
||||
} else {
|
||||
fullCleaned = append(cloneRoleTextSlice(cleaned[:overlap]), delta...)
|
||||
}
|
||||
useMsgs = delta
|
||||
if len(delta) == 0 && len(cleaned) > 0 {
|
||||
useMsgs = []RoleText{cleaned[len(cleaned)-1]}
|
||||
}
|
||||
if len(useMsgs) == 1 && len(messages) > 0 && len(msgFileIdx) == len(messages) {
|
||||
@@ -298,6 +417,8 @@ func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
||||
s.convMu.RUnlock()
|
||||
}
|
||||
|
||||
res.cleaned = fullCleaned
|
||||
|
||||
res.tagged = NeedRoleTags(useMsgs)
|
||||
if res.reuse && len(useMsgs) == 1 {
|
||||
res.tagged = false
|
||||
@@ -421,8 +542,22 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
stableHash := HashConversation(rec.ClientID, prep.underlying, rec.Messages)
|
||||
accountHash := HashConversation(s.accountID, prep.underlying, rec.Messages)
|
||||
label := strings.TrimSpace(s.Label())
|
||||
if label == "" {
|
||||
label = s.accountID
|
||||
}
|
||||
conversationMsgs := conversation.StoredToMessages(rec.Messages)
|
||||
if err := conversation.StoreConversation(label, prep.underlying, conversationMsgs, metadata); err != nil {
|
||||
log.Debugf("gemini web: failed to persist global conversation index: %v", err)
|
||||
}
|
||||
stableHash := conversation.HashConversationForAccount(rec.ClientID, prep.underlying, rec.Messages)
|
||||
accountHash := conversation.HashConversationForAccount(s.accountID, prep.underlying, rec.Messages)
|
||||
|
||||
suffixSeen := make(map[string]struct{})
|
||||
suffixSeen["hash:"+stableHash] = struct{}{}
|
||||
if accountHash != stableHash {
|
||||
suffixSeen["hash:"+accountHash] = struct{}{}
|
||||
}
|
||||
|
||||
s.convMu.Lock()
|
||||
s.convData[stableHash] = rec
|
||||
@@ -430,6 +565,33 @@ func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPr
|
||||
if accountHash != stableHash {
|
||||
s.convIndex["hash:"+accountHash] = stableHash
|
||||
}
|
||||
|
||||
sanitizedHistory := conversation.SanitizeAssistantMessages(conversation.StoredToMessages(rec.Messages))
|
||||
for start := 1; start < len(sanitizedHistory); start++ {
|
||||
segment := sanitizedHistory[start:]
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
tailRole := strings.ToLower(strings.TrimSpace(segment[len(segment)-1].Role))
|
||||
if tailRole != "assistant" && tailRole != "system" {
|
||||
continue
|
||||
}
|
||||
storedSegment := conversation.ToStoredMessages(segment)
|
||||
segmentStableHash := conversation.HashConversationForAccount(rec.ClientID, prep.underlying, storedSegment)
|
||||
keyStable := "hash:" + segmentStableHash
|
||||
if _, exists := suffixSeen[keyStable]; !exists {
|
||||
s.convIndex[keyStable] = stableHash
|
||||
suffixSeen[keyStable] = struct{}{}
|
||||
}
|
||||
segmentAccountHash := conversation.HashConversationForAccount(s.accountID, prep.underlying, storedSegment)
|
||||
if segmentAccountHash != segmentStableHash {
|
||||
keyAccount := "hash:" + segmentAccountHash
|
||||
if _, exists := suffixSeen[keyAccount]; !exists {
|
||||
s.convIndex[keyAccount] = stableHash
|
||||
suffixSeen[keyAccount] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
dataSnapshot := make(map[string]ConversationRecord, len(s.convData))
|
||||
for k, v := range s.convData {
|
||||
dataSnapshot[k] = v
|
||||
@@ -493,12 +655,44 @@ func (s *GeminiWebState) useReusableContext() bool {
|
||||
return s.cfg.GeminiWeb.Context
|
||||
}
|
||||
|
||||
func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) ([]string, []RoleText) {
|
||||
func (s *GeminiWebState) reuseFromPending(modelName string, msgs []RoleText) *reuseComputation {
|
||||
match := s.consumePendingMatch()
|
||||
if match == nil {
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(match.Model), strings.TrimSpace(modelName)) {
|
||||
return nil
|
||||
}
|
||||
metadata := cloneStringSlice(match.Record.Metadata)
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
history, ok := s.findConversationByMetadata(modelName, metadata)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
overlap := longestHistoryOverlap(history, msgs)
|
||||
return &reuseComputation{metadata: metadata, history: history, overlap: overlap}
|
||||
}
|
||||
|
||||
func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) *reuseComputation {
|
||||
s.convMu.RLock()
|
||||
items := s.convData
|
||||
index := s.convIndex
|
||||
s.convMu.RUnlock()
|
||||
return FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
|
||||
rec, metadata, overlap, ok := FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
history := cloneRoleTextSlice(storedMessagesToRoleText(rec.Messages))
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Ensure overlap reflects the actual history alignment.
|
||||
if computed := longestHistoryOverlap(history, msgs); computed > 0 {
|
||||
overlap = computed
|
||||
}
|
||||
return &reuseComputation{metadata: cloneStringSlice(metadata), history: history, overlap: overlap}
|
||||
}
|
||||
|
||||
func (s *GeminiWebState) getConfiguredGem() *Gem {
|
||||
@@ -540,42 +734,6 @@ func appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byt
|
||||
}
|
||||
}
|
||||
|
||||
// Persistence helpers --------------------------------------------------
|
||||
|
||||
// Sha256Hex computes the SHA256 hash of a string and returns its hex representation.
|
||||
func Sha256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func ToStoredMessages(msgs []RoleText) []StoredMessage {
|
||||
out := make([]StoredMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, StoredMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Text,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func HashMessage(m StoredMessage) string {
|
||||
s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
|
||||
return Sha256Hex(s)
|
||||
}
|
||||
|
||||
func HashConversation(clientID, model string, msgs []StoredMessage) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(clientID)
|
||||
b.WriteString("|")
|
||||
b.WriteString(model)
|
||||
for _, m := range msgs {
|
||||
b.WriteString("|")
|
||||
b.WriteString(HashMessage(m))
|
||||
}
|
||||
return Sha256Hex(b.String())
|
||||
}
|
||||
|
||||
// ConvBoltPath returns the BoltDB file path used for both account metadata and conversation data.
|
||||
// Different logical datasets are kept in separate buckets within this single DB file.
|
||||
func ConvBoltPath(tokenFilePath string) string {
|
||||
@@ -790,7 +948,7 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output
|
||||
Model: model,
|
||||
ClientID: clientID,
|
||||
Metadata: metadata,
|
||||
Messages: ToStoredMessages(final),
|
||||
Messages: conversation.ToStoredMessages(final),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -800,9 +958,9 @@ func BuildConversationRecord(model, clientID string, history []RoleText, output
|
||||
// FindByMessageListIn looks up a conversation record by hashed message list.
|
||||
// It attempts both the stable client ID and a legacy email-based ID.
|
||||
func FindByMessageListIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, bool) {
|
||||
stored := ToStoredMessages(msgs)
|
||||
stableHash := HashConversation(stableClientID, model, stored)
|
||||
fallbackHash := HashConversation(email, model, stored)
|
||||
stored := conversation.ToStoredMessages(msgs)
|
||||
stableHash := conversation.HashConversationForAccount(stableClientID, model, stored)
|
||||
fallbackHash := conversation.HashConversationForAccount(email, model, stored)
|
||||
|
||||
// Try stable hash via index indirection first
|
||||
if key, ok := index["hash:"+stableHash]; ok {
|
||||
@@ -840,9 +998,9 @@ func FindConversationIn(items map[string]ConversationRecord, index map[string]st
|
||||
}
|
||||
|
||||
// FindReusableSessionIn returns reusable metadata and the remaining message suffix.
|
||||
func FindReusableSessionIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) ([]string, []RoleText) {
|
||||
func FindReusableSessionIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, []string, int, bool) {
|
||||
if len(msgs) < 2 {
|
||||
return nil, nil
|
||||
return ConversationRecord{}, nil, 0, false
|
||||
}
|
||||
searchEnd := len(msgs)
|
||||
for searchEnd >= 2 {
|
||||
@@ -850,11 +1008,10 @@ func FindReusableSessionIn(items map[string]ConversationRecord, index map[string
|
||||
tail := sub[len(sub)-1]
|
||||
if strings.EqualFold(tail.Role, "assistant") || strings.EqualFold(tail.Role, "system") {
|
||||
if rec, ok := FindConversationIn(items, index, stableClientID, email, model, sub); ok {
|
||||
remain := msgs[searchEnd:]
|
||||
return rec.Metadata, remain
|
||||
return rec, rec.Metadata, searchEnd, true
|
||||
}
|
||||
}
|
||||
searchEnd--
|
||||
}
|
||||
return nil, nil
|
||||
return ConversationRecord{}, nil, 0, false
|
||||
}
|
||||
|
||||
@@ -8,6 +8,14 @@ import "time"
|
||||
// GetClaudeModels returns the standard Claude model definitions
|
||||
func GetClaudeModels() []*ModelInfo {
|
||||
return []*ModelInfo{
|
||||
{
|
||||
ID: "claude-sonnet-4-5-20250929",
|
||||
Object: "model",
|
||||
Created: 1759104000, // 2025-09-29
|
||||
OwnedBy: "anthropic",
|
||||
Type: "claude",
|
||||
DisplayName: "Claude 4.5 Sonnet",
|
||||
},
|
||||
{
|
||||
ID: "claude-opus-4-1-20250805",
|
||||
Object: "model",
|
||||
|
||||
@@ -61,10 +61,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
||||
}
|
||||
applyClaudeHeaders(httpReq, apiKey, false)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
@@ -130,10 +127,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
}
|
||||
applyClaudeHeaders(httpReq, apiKey, true)
|
||||
|
||||
httpClient := &http.Client{Timeout: 0}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -196,10 +190,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
||||
}
|
||||
applyClaudeHeaders(httpReq, apiKey, false)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
|
||||
@@ -76,6 +76,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
||||
}
|
||||
|
||||
body, _ = sjson.SetBytes(body, "stream", true)
|
||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||
|
||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||
recordAPIRequest(ctx, e.cfg, body)
|
||||
@@ -85,10 +86,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
||||
}
|
||||
applyCodexHeaders(httpReq, auth, apiKey)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
@@ -164,6 +162,8 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
}
|
||||
}
|
||||
|
||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||
|
||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||
recordAPIRequest(ctx, e.cfg, body)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
@@ -172,10 +172,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
}
|
||||
applyCodexHeaders(httpReq, auth, apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: 0}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -74,7 +74,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
||||
models = append([]string{req.Model}, models...)
|
||||
}
|
||||
|
||||
httpClient := newHTTPClient(ctx, 0)
|
||||
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||
|
||||
var lastStatus int
|
||||
@@ -155,7 +155,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
||||
models = append([]string{req.Model}, models...)
|
||||
}
|
||||
|
||||
httpClient := newHTTPClient(ctx, 0)
|
||||
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||
|
||||
var lastStatus int
|
||||
@@ -281,7 +281,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
|
||||
models = append([]string{req.Model}, models...)
|
||||
}
|
||||
|
||||
httpClient := newHTTPClient(ctx, 0)
|
||||
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||
|
||||
var lastStatus int
|
||||
@@ -438,15 +438,8 @@ func updateGeminiCLITokenMetadata(auth *cliproxyauth.Auth, base map[string]any,
|
||||
auth.Metadata["token"] = merged
|
||||
}
|
||||
|
||||
func newHTTPClient(ctx context.Context, timeout time.Duration) *http.Client {
|
||||
client := &http.Client{}
|
||||
if timeout > 0 {
|
||||
client.Timeout = timeout
|
||||
}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
client.Transport = rt
|
||||
}
|
||||
return client
|
||||
func newHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
|
||||
return newProxyAwareHTTPClient(ctx, cfg, auth, timeout)
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
|
||||
@@ -103,10 +103,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
||||
httpReq.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
@@ -159,10 +156,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
httpReq.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 0}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -230,10 +224,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
||||
httpReq.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
geminiwebapi "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
@@ -40,12 +41,18 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
||||
if err = state.EnsureClient(); err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
}
|
||||
match := extractGeminiWebMatch(opts.Metadata)
|
||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||
|
||||
mutex := state.GetRequestMutex()
|
||||
if mutex != nil {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
if match != nil {
|
||||
state.SetPendingMatch(match)
|
||||
}
|
||||
} else if match != nil {
|
||||
state.SetPendingMatch(match)
|
||||
}
|
||||
|
||||
payload := bytes.Clone(req.Payload)
|
||||
@@ -72,11 +79,18 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
||||
if err = state.EnsureClient(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
match := extractGeminiWebMatch(opts.Metadata)
|
||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||
|
||||
mutex := state.GetRequestMutex()
|
||||
if mutex != nil {
|
||||
mutex.Lock()
|
||||
if match != nil {
|
||||
state.SetPendingMatch(match)
|
||||
}
|
||||
}
|
||||
if mutex == nil && match != nil {
|
||||
state.SetPendingMatch(match)
|
||||
}
|
||||
|
||||
gemBytes, errMsg, prep := state.Send(ctx, req.Model, bytes.Clone(req.Payload), opts)
|
||||
@@ -182,7 +196,7 @@ func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiwebapi.Gem
|
||||
storagePath = p
|
||||
}
|
||||
}
|
||||
state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath)
|
||||
state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath, auth.Label)
|
||||
runtime := &geminiWebRuntime{state: state}
|
||||
auth.Runtime = runtime
|
||||
return state, nil
|
||||
@@ -242,3 +256,21 @@ func (e geminiWebError) StatusCode() int {
|
||||
}
|
||||
return e.message.StatusCode
|
||||
}
|
||||
|
||||
func extractGeminiWebMatch(metadata map[string]any) *conversation.MatchResult {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
value, ok := metadata[conversation.MetadataMatchKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case *conversation.MatchResult:
|
||||
return v
|
||||
case conversation.MatchResult:
|
||||
return &v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ func (e *OpenAICompatExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.A
|
||||
|
||||
func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
baseURL, apiKey := e.resolveCredentials(auth)
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
|
||||
if baseURL == "" {
|
||||
return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
|
||||
}
|
||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||
|
||||
@@ -60,13 +60,12 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
|
||||
return cliproxyexecutor.Response{}, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
if apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
|
||||
|
||||
httpClient := &http.Client{}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
@@ -92,8 +91,8 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
|
||||
|
||||
func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
|
||||
baseURL, apiKey := e.resolveCredentials(auth)
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
|
||||
if baseURL == "" {
|
||||
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
|
||||
}
|
||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||
from := opts.SourceFormat
|
||||
@@ -110,15 +109,14 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
if apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("Cache-Control", "no-cache")
|
||||
|
||||
httpClient := &http.Client{Timeout: 0}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -177,8 +175,8 @@ func (e *OpenAICompatExecutor) resolveCredentials(auth *cliproxyauth.Auth) (base
|
||||
return "", ""
|
||||
}
|
||||
if auth.Attributes != nil {
|
||||
baseURL = auth.Attributes["base_url"]
|
||||
apiKey = auth.Attributes["api_key"]
|
||||
baseURL = strings.TrimSpace(auth.Attributes["base_url"])
|
||||
apiKey = strings.TrimSpace(auth.Attributes["api_key"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
116
internal/runtime/executor/proxy_helpers.go
Normal file
116
internal/runtime/executor/proxy_helpers.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// newProxyAwareHTTPClient creates an HTTP client with proper proxy configuration priority:
|
||||
// 1. Use auth.ProxyURL if configured (highest priority)
|
||||
// 2. Use cfg.ProxyURL if auth proxy is not configured
|
||||
// 3. Use RoundTripper from context if neither are configured
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context containing optional RoundTripper
|
||||
// - cfg: The application configuration
|
||||
// - auth: The authentication information
|
||||
// - timeout: The client timeout (0 means no timeout)
|
||||
//
|
||||
// Returns:
|
||||
// - *http.Client: An HTTP client with configured proxy or transport
|
||||
func newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
|
||||
httpClient := &http.Client{}
|
||||
if timeout > 0 {
|
||||
httpClient.Timeout = timeout
|
||||
}
|
||||
|
||||
// Priority 1: Use auth.ProxyURL if configured
|
||||
var proxyURL string
|
||||
if auth != nil {
|
||||
proxyURL = strings.TrimSpace(auth.ProxyURL)
|
||||
}
|
||||
|
||||
// Priority 2: Use cfg.ProxyURL if auth proxy is not configured
|
||||
if proxyURL == "" && cfg != nil {
|
||||
proxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||
}
|
||||
|
||||
// If we have a proxy URL configured, set up the transport
|
||||
if proxyURL != "" {
|
||||
transport := buildProxyTransport(proxyURL)
|
||||
if transport != nil {
|
||||
httpClient.Transport = transport
|
||||
return httpClient
|
||||
}
|
||||
// If proxy setup failed, log and fall through to context RoundTripper
|
||||
log.Debugf("failed to setup proxy from URL: %s, falling back to context transport", proxyURL)
|
||||
}
|
||||
|
||||
// Priority 3: Use RoundTripper from context (typically from RoundTripperFor)
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
|
||||
return httpClient
|
||||
}
|
||||
|
||||
// buildProxyTransport creates an HTTP transport configured for the given proxy URL.
|
||||
// It supports SOCKS5, HTTP, and HTTPS proxy protocols.
|
||||
//
|
||||
// Parameters:
|
||||
// - proxyURL: The proxy URL string (e.g., "socks5://user:pass@host:port", "http://host:port")
|
||||
//
|
||||
// Returns:
|
||||
// - *http.Transport: A configured transport, or nil if the proxy URL is invalid
|
||||
func buildProxyTransport(proxyURL string) *http.Transport {
|
||||
if proxyURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedURL, errParse := url.Parse(proxyURL)
|
||||
if errParse != nil {
|
||||
log.Errorf("parse proxy URL failed: %v", errParse)
|
||||
return nil
|
||||
}
|
||||
|
||||
var transport *http.Transport
|
||||
|
||||
// Handle different proxy schemes
|
||||
if parsedURL.Scheme == "socks5" {
|
||||
// Configure SOCKS5 proxy with optional authentication
|
||||
var proxyAuth *proxy.Auth
|
||||
if parsedURL.User != nil {
|
||||
username := parsedURL.User.Username()
|
||||
password, _ := parsedURL.User.Password()
|
||||
proxyAuth = &proxy.Auth{User: username, Password: password}
|
||||
}
|
||||
dialer, errSOCKS5 := proxy.SOCKS5("tcp", parsedURL.Host, proxyAuth, proxy.Direct)
|
||||
if errSOCKS5 != nil {
|
||||
log.Errorf("create SOCKS5 dialer failed: %v", errSOCKS5)
|
||||
return nil
|
||||
}
|
||||
// Set up a custom transport using the SOCKS5 dialer
|
||||
transport = &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
}
|
||||
} else if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" {
|
||||
// Configure HTTP or HTTPS proxy
|
||||
transport = &http.Transport{Proxy: http.ProxyURL(parsedURL)}
|
||||
} else {
|
||||
log.Errorf("unsupported proxy scheme: %s", parsedURL.Scheme)
|
||||
return nil
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
@@ -58,10 +58,7 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
|
||||
}
|
||||
applyQwenHeaders(httpReq, token, false)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
@@ -112,10 +109,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
||||
}
|
||||
applyQwenHeaders(httpReq, token, true)
|
||||
|
||||
httpClient := &http.Client{Timeout: 0}
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
httpClient.Transport = rt
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -25,9 +25,12 @@ func SetProxy(cfg *config.SDKConfig, httpClient *http.Client) *http.Client {
|
||||
// Handle different proxy schemes.
|
||||
if proxyURL.Scheme == "socks5" {
|
||||
// Configure SOCKS5 proxy with optional authentication.
|
||||
username := proxyURL.User.Username()
|
||||
password, _ := proxyURL.User.Password()
|
||||
proxyAuth := &proxy.Auth{User: username, Password: password}
|
||||
var proxyAuth *proxy.Auth
|
||||
if proxyURL.User != nil {
|
||||
username := proxyURL.User.Username()
|
||||
password, _ := proxyURL.User.Password()
|
||||
proxyAuth = &proxy.Auth{User: username, Password: password}
|
||||
}
|
||||
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, proxyAuth, proxy.Direct)
|
||||
if errSOCKS5 != nil {
|
||||
log.Errorf("create SOCKS5 dialer failed: %v", errSOCKS5)
|
||||
|
||||
@@ -430,8 +430,15 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||
}
|
||||
fmt.Printf("config file changed, reloading: %s\n", w.configPath)
|
||||
if w.reloadConfig() {
|
||||
finalHash := newHash
|
||||
if updatedData, errRead := os.ReadFile(w.configPath); errRead == nil && len(updatedData) > 0 {
|
||||
sumUpdated := sha256.Sum256(updatedData)
|
||||
finalHash = hex.EncodeToString(sumUpdated[:])
|
||||
} else if errRead != nil {
|
||||
log.WithError(errRead).Debug("failed to compute updated config hash after reload")
|
||||
}
|
||||
w.clientsMutex.Lock()
|
||||
w.lastConfigHash = newHash
|
||||
w.lastConfigHash = finalHash
|
||||
w.clientsMutex.Unlock()
|
||||
}
|
||||
return
|
||||
@@ -532,6 +539,24 @@ func (w *Watcher) reloadConfig() bool {
|
||||
if oldConfig.RemoteManagement.AllowRemote != newConfig.RemoteManagement.AllowRemote {
|
||||
log.Debugf(" remote-management.allow-remote: %t -> %t", oldConfig.RemoteManagement.AllowRemote, newConfig.RemoteManagement.AllowRemote)
|
||||
}
|
||||
if oldConfig.RemoteManagement.SecretKey != newConfig.RemoteManagement.SecretKey {
|
||||
switch {
|
||||
case oldConfig.RemoteManagement.SecretKey == "" && newConfig.RemoteManagement.SecretKey != "":
|
||||
log.Debug(" remote-management.secret-key: created")
|
||||
case oldConfig.RemoteManagement.SecretKey != "" && newConfig.RemoteManagement.SecretKey == "":
|
||||
log.Debug(" remote-management.secret-key: deleted")
|
||||
default:
|
||||
log.Debug(" remote-management.secret-key: updated")
|
||||
}
|
||||
if newConfig.RemoteManagement.SecretKey == "" {
|
||||
log.Info("management routes will be disabled after secret key removal")
|
||||
} else {
|
||||
log.Info("management routes will be enabled after secret key update")
|
||||
}
|
||||
}
|
||||
if oldConfig.RemoteManagement.DisableControlPanel != newConfig.RemoteManagement.DisableControlPanel {
|
||||
log.Debugf(" remote-management.disable-control-panel: %t -> %t", oldConfig.RemoteManagement.DisableControlPanel, newConfig.RemoteManagement.DisableControlPanel)
|
||||
}
|
||||
if oldConfig.LoggingToFile != newConfig.LoggingToFile {
|
||||
log.Debugf(" logging-to-file: %t -> %t", oldConfig.LoggingToFile, newConfig.LoggingToFile)
|
||||
}
|
||||
@@ -746,11 +771,13 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
if ck.BaseURL != "" {
|
||||
attrs["base_url"] = ck.BaseURL
|
||||
}
|
||||
proxyURL := strings.TrimSpace(ck.ProxyURL)
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: "claude",
|
||||
Label: "claude-apikey",
|
||||
Status: coreauth.StatusActive,
|
||||
ProxyURL: proxyURL,
|
||||
Attributes: attrs,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -772,11 +799,13 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
if ck.BaseURL != "" {
|
||||
attrs["base_url"] = ck.BaseURL
|
||||
}
|
||||
proxyURL := strings.TrimSpace(ck.ProxyURL)
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: "codex",
|
||||
Label: "codex-apikey",
|
||||
Status: coreauth.StatusActive,
|
||||
ProxyURL: proxyURL,
|
||||
Attributes: attrs,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -790,17 +819,79 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
providerName = "openai-compatibility"
|
||||
}
|
||||
base := strings.TrimSpace(compat.BaseURL)
|
||||
for j := range compat.APIKeys {
|
||||
key := strings.TrimSpace(compat.APIKeys[j])
|
||||
if key == "" {
|
||||
continue
|
||||
|
||||
// Handle new APIKeyEntries format (preferred)
|
||||
createdEntries := 0
|
||||
if len(compat.APIKeyEntries) > 0 {
|
||||
for j := range compat.APIKeyEntries {
|
||||
entry := &compat.APIKeyEntries[j]
|
||||
key := strings.TrimSpace(entry.APIKey)
|
||||
proxyURL := strings.TrimSpace(entry.ProxyURL)
|
||||
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
||||
id, token := idGen.next(idKind, key, base, proxyURL)
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
|
||||
"base_url": base,
|
||||
"compat_name": compat.Name,
|
||||
"provider_key": providerName,
|
||||
}
|
||||
if key != "" {
|
||||
attrs["api_key"] = key
|
||||
}
|
||||
if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
|
||||
attrs["models_hash"] = hash
|
||||
}
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: providerName,
|
||||
Label: compat.Name,
|
||||
Status: coreauth.StatusActive,
|
||||
ProxyURL: proxyURL,
|
||||
Attributes: attrs,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
out = append(out, a)
|
||||
createdEntries++
|
||||
}
|
||||
} else {
|
||||
// Handle legacy APIKeys format for backward compatibility
|
||||
for j := range compat.APIKeys {
|
||||
key := strings.TrimSpace(compat.APIKeys[j])
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
||||
id, token := idGen.next(idKind, key, base)
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
|
||||
"base_url": base,
|
||||
"compat_name": compat.Name,
|
||||
"provider_key": providerName,
|
||||
}
|
||||
attrs["api_key"] = key
|
||||
if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
|
||||
attrs["models_hash"] = hash
|
||||
}
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: providerName,
|
||||
Label: compat.Name,
|
||||
Status: coreauth.StatusActive,
|
||||
Attributes: attrs,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
out = append(out, a)
|
||||
createdEntries++
|
||||
}
|
||||
}
|
||||
if createdEntries == 0 {
|
||||
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
||||
id, token := idGen.next(idKind, key, base)
|
||||
id, token := idGen.next(idKind, base)
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
|
||||
"base_url": base,
|
||||
"api_key": key,
|
||||
"compat_name": compat.Name,
|
||||
"provider_key": providerName,
|
||||
}
|
||||
@@ -937,7 +1028,12 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int) {
|
||||
if len(cfg.OpenAICompatibility) > 0 {
|
||||
// Do not construct legacy clients for OpenAI-compat providers; these are handled by the stateless executor.
|
||||
for _, compatConfig := range cfg.OpenAICompatibility {
|
||||
openAICompatCount += len(compatConfig.APIKeys)
|
||||
// Count from new APIKeyEntries format if present, otherwise fall back to legacy APIKeys
|
||||
if len(compatConfig.APIKeyEntries) > 0 {
|
||||
openAICompatCount += len(compatConfig.APIKeyEntries)
|
||||
} else {
|
||||
openAICompatCount += len(compatConfig.APIKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
return glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
|
||||
@@ -980,9 +1076,9 @@ func diffOpenAICompatibility(oldList, newList []config.OpenAICompatibility) []st
|
||||
}
|
||||
switch {
|
||||
case !oldOk:
|
||||
changes = append(changes, fmt.Sprintf("provider added: %s (api-keys=%d, models=%d)", label, countNonEmptyStrings(newEntry.APIKeys), countOpenAIModels(newEntry.Models)))
|
||||
changes = append(changes, fmt.Sprintf("provider added: %s (api-keys=%d, models=%d)", label, countAPIKeys(newEntry), countOpenAIModels(newEntry.Models)))
|
||||
case !newOk:
|
||||
changes = append(changes, fmt.Sprintf("provider removed: %s (api-keys=%d, models=%d)", label, countNonEmptyStrings(oldEntry.APIKeys), countOpenAIModels(oldEntry.Models)))
|
||||
changes = append(changes, fmt.Sprintf("provider removed: %s (api-keys=%d, models=%d)", label, countAPIKeys(oldEntry), countOpenAIModels(oldEntry.Models)))
|
||||
default:
|
||||
if detail := describeOpenAICompatibilityUpdate(oldEntry, newEntry); detail != "" {
|
||||
changes = append(changes, fmt.Sprintf("provider updated: %s %s", label, detail))
|
||||
@@ -993,8 +1089,8 @@ func diffOpenAICompatibility(oldList, newList []config.OpenAICompatibility) []st
|
||||
}
|
||||
|
||||
func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibility) string {
|
||||
oldKeyCount := countNonEmptyStrings(oldEntry.APIKeys)
|
||||
newKeyCount := countNonEmptyStrings(newEntry.APIKeys)
|
||||
oldKeyCount := countAPIKeys(oldEntry)
|
||||
newKeyCount := countAPIKeys(newEntry)
|
||||
oldModelCount := countOpenAIModels(oldEntry.Models)
|
||||
newModelCount := countOpenAIModels(newEntry.Models)
|
||||
details := make([]string, 0, 2)
|
||||
@@ -1010,6 +1106,21 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi
|
||||
return "(" + strings.Join(details, ", ") + ")"
|
||||
}
|
||||
|
||||
func countAPIKeys(entry config.OpenAICompatibility) int {
|
||||
// Prefer new APIKeyEntries format
|
||||
if len(entry.APIKeyEntries) > 0 {
|
||||
count := 0
|
||||
for _, keyEntry := range entry.APIKeyEntries {
|
||||
if strings.TrimSpace(keyEntry.APIKey) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
// Fall back to legacy APIKeys format
|
||||
return countNonEmptyStrings(entry.APIKeys)
|
||||
}
|
||||
|
||||
func countNonEmptyStrings(values []string) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
|
||||
@@ -74,10 +74,9 @@ func BuildProviders(root *config.SDKConfig) ([]Provider, error) {
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
if len(providers) == 0 && len(root.APIKeys) > 0 {
|
||||
config.SyncInlineAPIKeys(root, root.APIKeys)
|
||||
if providerCfg := root.ConfigAPIKeyProvider(); providerCfg != nil {
|
||||
provider, err := BuildProvider(providerCfg, root)
|
||||
if len(providers) == 0 {
|
||||
if inline := config.MakeInlineAPIKeyProvider(root.APIKeys); inline != nil {
|
||||
provider, err := BuildProvider(inline, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
@@ -48,6 +49,8 @@ type BaseAPIHandler struct {
|
||||
Cfg *config.SDKConfig
|
||||
}
|
||||
|
||||
const geminiWebProvider = "gemini-web"
|
||||
|
||||
// NewBaseAPIHandlers creates a new API handlers instance.
|
||||
// It takes a slice of clients and configuration as input.
|
||||
//
|
||||
@@ -137,6 +140,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
||||
if len(providers) == 0 {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
}
|
||||
metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
|
||||
req := coreexecutor.Request{
|
||||
Model: modelName,
|
||||
Payload: cloneBytes(rawJSON),
|
||||
@@ -146,6 +150,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
||||
Alt: alt,
|
||||
OriginalRequest: cloneBytes(rawJSON),
|
||||
SourceFormat: sdktranslator.FromString(handlerType),
|
||||
Metadata: metadata,
|
||||
}
|
||||
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
@@ -161,6 +166,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
||||
if len(providers) == 0 {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
}
|
||||
metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
|
||||
req := coreexecutor.Request{
|
||||
Model: modelName,
|
||||
Payload: cloneBytes(rawJSON),
|
||||
@@ -170,6 +176,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
||||
Alt: alt,
|
||||
OriginalRequest: cloneBytes(rawJSON),
|
||||
SourceFormat: sdktranslator.FromString(handlerType),
|
||||
Metadata: metadata,
|
||||
}
|
||||
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
@@ -188,6 +195,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
||||
close(errChan)
|
||||
return nil, errChan
|
||||
}
|
||||
metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
|
||||
req := coreexecutor.Request{
|
||||
Model: modelName,
|
||||
Payload: cloneBytes(rawJSON),
|
||||
@@ -197,6 +205,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
||||
Alt: alt,
|
||||
OriginalRequest: cloneBytes(rawJSON),
|
||||
SourceFormat: sdktranslator.FromString(handlerType),
|
||||
Metadata: metadata,
|
||||
}
|
||||
chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
@@ -232,6 +241,18 @@ func cloneBytes(src []byte) []byte {
|
||||
return dst
|
||||
}
|
||||
|
||||
func (h *BaseAPIHandler) buildGeminiWebMetadata(handlerType string, providers []string, rawJSON []byte) map[string]any {
|
||||
if !util.InArray(providers, geminiWebProvider) {
|
||||
return nil
|
||||
}
|
||||
meta := make(map[string]any)
|
||||
msgs := conversation.ExtractMessages(handlerType, rawJSON)
|
||||
if len(msgs) > 0 {
|
||||
meta[conversation.MetadataMessagesKey] = msgs
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
|
||||
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
|
||||
status := http.StatusInternalServerError
|
||||
|
||||
125
sdk/cliproxy/auth/selector_rr.go
Normal file
125
sdk/cliproxy/auth/selector_rr.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiWebProviderKey = "gemini-web"
|
||||
)
|
||||
|
||||
type geminiWebStickySelector struct {
|
||||
base Selector
|
||||
}
|
||||
|
||||
func NewGeminiWebStickySelector(base Selector) Selector {
|
||||
if selector, ok := base.(*geminiWebStickySelector); ok {
|
||||
return selector
|
||||
}
|
||||
if base == nil {
|
||||
base = &RoundRobinSelector{}
|
||||
}
|
||||
return &geminiWebStickySelector{base: base}
|
||||
}
|
||||
|
||||
func (m *Manager) EnableGeminiWebStickySelector() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.selector.(*geminiWebStickySelector); ok {
|
||||
return
|
||||
}
|
||||
m.selector = NewGeminiWebStickySelector(m.selector)
|
||||
}
|
||||
|
||||
func (s *geminiWebStickySelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
|
||||
if !strings.EqualFold(provider, geminiWebProviderKey) {
|
||||
if opts.Metadata != nil {
|
||||
delete(opts.Metadata, conversation.MetadataMatchKey)
|
||||
}
|
||||
return s.base.Pick(ctx, provider, model, opts, auths)
|
||||
}
|
||||
|
||||
messages := extractGeminiWebMessages(opts.Metadata)
|
||||
if len(messages) >= 2 {
|
||||
normalizedModel := conversation.NormalizeModel(model)
|
||||
candidates := conversation.BuildLookupHashes(normalizedModel, messages)
|
||||
for _, candidate := range candidates {
|
||||
record, ok, err := conversation.LookupMatch(candidate.Hash)
|
||||
if err != nil {
|
||||
log.Warnf("gemini-web selector: lookup failed for hash %s: %v", candidate.Hash, err)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(record.AccountLabel)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
auth := findAuthByLabel(auths, label)
|
||||
if auth != nil {
|
||||
if opts.Metadata != nil {
|
||||
opts.Metadata[conversation.MetadataMatchKey] = &conversation.MatchResult{
|
||||
Hash: candidate.Hash,
|
||||
Record: record,
|
||||
Model: normalizedModel,
|
||||
}
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
_ = conversation.RemoveMatchForLabel(candidate.Hash, label)
|
||||
}
|
||||
}
|
||||
|
||||
return s.base.Pick(ctx, provider, model, opts, auths)
|
||||
}
|
||||
|
||||
func extractGeminiWebMessages(metadata map[string]any) []conversation.Message {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := metadata[conversation.MetadataMessagesKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case []conversation.Message:
|
||||
return v
|
||||
case *[]conversation.Message:
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func findAuthByLabel(auths []*Auth, label string) *Auth {
|
||||
if len(auths) == 0 {
|
||||
return nil
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(label))
|
||||
for _, auth := range auths {
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(auth.Label)) == normalized {
|
||||
return auth
|
||||
}
|
||||
if auth.Metadata != nil {
|
||||
if v, ok := auth.Metadata["label"].(string); ok && strings.ToLower(strings.TrimSpace(v)) == normalized {
|
||||
return auth
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -33,6 +33,8 @@ type Options struct {
|
||||
OriginalRequest []byte
|
||||
// SourceFormat identifies the inbound schema.
|
||||
SourceFormat sdktranslator.Format
|
||||
// Metadata carries extra execution hints shared across selection and executors.
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// Response wraps either a full provider response or metadata for streaming flows.
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package cliproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// defaultRoundTripperProvider returns a per-auth HTTP RoundTripper based on
|
||||
@@ -25,27 +29,49 @@ func (p *defaultRoundTripperProvider) RoundTripperFor(auth *coreauth.Auth) http.
|
||||
if auth == nil {
|
||||
return nil
|
||||
}
|
||||
proxy := strings.TrimSpace(auth.ProxyURL)
|
||||
if proxy == "" {
|
||||
proxyStr := strings.TrimSpace(auth.ProxyURL)
|
||||
if proxyStr == "" {
|
||||
return nil
|
||||
}
|
||||
p.mu.RLock()
|
||||
rt := p.cache[proxy]
|
||||
rt := p.cache[proxyStr]
|
||||
p.mu.RUnlock()
|
||||
if rt != nil {
|
||||
return rt
|
||||
}
|
||||
// Build HTTP/HTTPS proxy transport; ignore SOCKS for simplicity here.
|
||||
u, err := url.Parse(proxy)
|
||||
if err != nil {
|
||||
// Parse the proxy URL to determine the scheme.
|
||||
proxyURL, errParse := url.Parse(proxyStr)
|
||||
if errParse != nil {
|
||||
log.Errorf("parse proxy URL failed: %v", errParse)
|
||||
return nil
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
var transport *http.Transport
|
||||
// Handle different proxy schemes.
|
||||
if proxyURL.Scheme == "socks5" {
|
||||
// Configure SOCKS5 proxy with optional authentication.
|
||||
username := proxyURL.User.Username()
|
||||
password, _ := proxyURL.User.Password()
|
||||
proxyAuth := &proxy.Auth{User: username, Password: password}
|
||||
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, proxyAuth, proxy.Direct)
|
||||
if errSOCKS5 != nil {
|
||||
log.Errorf("create SOCKS5 dialer failed: %v", errSOCKS5)
|
||||
return nil
|
||||
}
|
||||
// Set up a custom transport using the SOCKS5 dialer.
|
||||
transport = &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
}
|
||||
} else if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
|
||||
// Configure HTTP or HTTPS proxy.
|
||||
transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
} else {
|
||||
log.Errorf("unsupported proxy scheme: %s", proxyURL.Scheme)
|
||||
return nil
|
||||
}
|
||||
transport := &http.Transport{Proxy: http.ProxyURL(u)}
|
||||
p.mu.Lock()
|
||||
p.cache[proxy] = transport
|
||||
p.cache[proxyStr] = transport
|
||||
p.mu.Unlock()
|
||||
return transport
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
geminiwebclient "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
@@ -206,6 +207,23 @@ func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
|
||||
}
|
||||
GlobalModelRegistry().UnregisterClient(id)
|
||||
if existing, ok := s.coreManager.GetByID(id); ok && existing != nil {
|
||||
if strings.EqualFold(existing.Provider, "gemini-web") {
|
||||
// Prefer the stable cookie label stored in metadata when available.
|
||||
var label string
|
||||
if existing.Metadata != nil {
|
||||
if v, ok := existing.Metadata["label"].(string); ok {
|
||||
label = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(existing.Label)
|
||||
}
|
||||
if label != "" {
|
||||
if err := conversation.RemoveMatchesByLabel(label); err != nil {
|
||||
log.Debugf("failed to remove gemini web sticky entries for %s: %v", label, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
existing.Disabled = true
|
||||
existing.Status = coreauth.StatusDisabled
|
||||
if _, err := s.coreManager.Update(ctx, existing); err != nil {
|
||||
@@ -225,6 +243,7 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
|
||||
s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg))
|
||||
case "gemini-web":
|
||||
s.coreManager.RegisterExecutor(executor.NewGeminiWebExecutor(s.cfg))
|
||||
s.coreManager.EnableGeminiWebStickySelector()
|
||||
case "claude":
|
||||
s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
|
||||
case "codex":
|
||||
@@ -515,8 +534,13 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
||||
ms := make([]*ModelInfo, 0, len(compat.Models))
|
||||
for j := range compat.Models {
|
||||
m := compat.Models[j]
|
||||
// Use alias as model ID, fallback to name if alias is empty
|
||||
modelID := m.Alias
|
||||
if modelID == "" {
|
||||
modelID = m.Name
|
||||
}
|
||||
ms = append(ms, &ModelInfo{
|
||||
ID: m.Alias,
|
||||
ID: modelID,
|
||||
Object: "model",
|
||||
Created: time.Now().Unix(),
|
||||
OwnedBy: compat.Name,
|
||||
|
||||
@@ -16,13 +16,13 @@ type SDKConfig struct {
|
||||
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
||||
|
||||
// Access holds request authentication provider configuration.
|
||||
Access AccessConfig `yaml:"auth" json:"auth"`
|
||||
Access AccessConfig `yaml:"auth,omitempty" json:"auth,omitempty"`
|
||||
}
|
||||
|
||||
// AccessConfig groups request authentication providers.
|
||||
type AccessConfig struct {
|
||||
// Providers lists configured authentication providers.
|
||||
Providers []AccessProvider `yaml:"providers" json:"providers"`
|
||||
Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
|
||||
}
|
||||
|
||||
// AccessProvider describes a request authentication provider entry.
|
||||
@@ -51,27 +51,6 @@ const (
|
||||
DefaultAccessProviderName = "config-inline"
|
||||
)
|
||||
|
||||
// SyncInlineAPIKeys updates the inline API key provider and top-level APIKeys field.
|
||||
func SyncInlineAPIKeys(cfg *SDKConfig, keys []string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cloned := append([]string(nil), keys...)
|
||||
cfg.APIKeys = cloned
|
||||
if provider := cfg.ConfigAPIKeyProvider(); provider != nil {
|
||||
if provider.Name == "" {
|
||||
provider.Name = DefaultAccessProviderName
|
||||
}
|
||||
provider.APIKeys = cloned
|
||||
return
|
||||
}
|
||||
cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{
|
||||
Name: DefaultAccessProviderName,
|
||||
Type: AccessProviderTypeConfigAPIKey,
|
||||
APIKeys: cloned,
|
||||
})
|
||||
}
|
||||
|
||||
// ConfigAPIKeyProvider returns the first inline API key provider if present.
|
||||
func (c *SDKConfig) ConfigAPIKeyProvider() *AccessProvider {
|
||||
if c == nil {
|
||||
@@ -87,3 +66,17 @@ func (c *SDKConfig) ConfigAPIKeyProvider() *AccessProvider {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MakeInlineAPIKeyProvider constructs an inline API key provider configuration.
|
||||
// It returns nil when no keys are supplied.
|
||||
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
provider := &AccessProvider{
|
||||
Name: DefaultAccessProviderName,
|
||||
Type: AccessProviderTypeConfigAPIKey,
|
||||
APIKeys: append([]string(nil), keys...),
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user