Compare commits

..

36 Commits

Author SHA1 Message Date
Luis Pater
6b413a299b Merge pull request #83 from router-for-me/oaifix
fix(cliproxy): Use model name as fallback for ID if alias is empty
2025-10-04 21:18:07 +08:00
hkfires
4657c98821 feat: Add option to disable management control panel 2025-10-04 19:55:07 +08:00
hkfires
dd1e0da155 fix(cliproxy): Use model name as fallback for ID if alias is empty 2025-10-04 19:42:11 +08:00
Luis Pater
cf5476eb23 Merge pull request #82 from router-for-me/mgmt
feat: Implement hot-reloading for management endpoints
2025-10-04 16:32:22 +08:00
hkfires
cf9a748159 fix(watcher): Prevent infinite reload loop on rapid config changes 2025-10-04 13:58:15 +08:00
hkfires
2e328dd462 feat(management): Improve logging for management route status 2025-10-04 13:48:34 +08:00
hkfires
edd4b4d97f refactor(api): Lazily register management routes 2025-10-04 13:41:49 +08:00
hkfires
608d745159 fix(api): Enable management routes based on secret key presence 2025-10-04 13:32:54 +08:00
hkfires
fd795caf76 refactor(api): Use middleware to control management route availability
Previously, management API routes were conditionally registered at server startup based on the presence of the `remote-management-key`. This static approach meant a server restart was required to enable or disable these endpoints.

This commit refactors the route handling by:
1.  Introducing an `atomic.Bool` flag, `managementRoutesEnabled`, to track the state.
2.  Always registering the management routes at startup.
3.  Adding a new `managementAvailabilityMiddleware` to the management route group.

This middleware checks the `managementRoutesEnabled` flag for each request, rejecting it if management is disabled. This change provides the same initial behavior but creates a more flexible architecture that will allow for dynamically enabling or disabling management routes at runtime in the future.
2025-10-04 13:08:08 +08:00
Luis Pater
9e2d76f3ce refactor(login): enhance project ID normalization and onboarding logic
- Introduced `trimmedRequest` for consistent project ID trimming.
- Improved handling of project ID retrieval from Gemini onboarding responses.
- Added safeguards to maintain the requested project ID when discrepancies occur.
2025-10-04 00:27:14 +08:00
Luis Pater
ae646fba4b refactor(login): disable geminicloudassist API check in required services list 2025-10-03 16:53:49 +08:00
Luis Pater
2eef6875e9 feat(auth): improve OpenAI compatibility normalization and API key handling
- Refined trimming and normalization logic for `baseURL` and `apiKey` attributes.
- Updated `Authorization` header logic to omit empty API keys.
- Enhanced compatibility processing by handling empty `api-key-entries`.
- Improved legacy format fallback and added safeguards for empty credentials across executor paths.
2025-10-03 02:38:30 +08:00
Luis Pater
12c09f1a46 feat(runtime): remove previous_response_id from Codex executor request body
- Implemented logic to delete `previous_response_id` property from the request body in Codex executor.
- Applied changes consistently across relevant Codex executor paths.
2025-10-02 12:00:06 +08:00
Luis Pater
4a31f763af feat(management): add proxy support for management asset synchronization
- Introduced `proxyURL` parameter for `EnsureLatestManagementHTML` to enable proxy configuration.
- Refactored HTTP client initialization with new `newHTTPClient` to support proxy-aware requests.
- Updated asset download and fetch logic to utilize the proxy-aware HTTP client.
- Adjusted `server.go` to pass `cfg.ProxyURL` for management asset synchronization calls.
2025-10-01 20:18:26 +08:00
Luis Pater
6629cadb87 refactor(server): remove unused context and managementasset references
- Dropped unused `context` and `managementasset` imports from `main.go`.
- Removed logic for management control panel asset synchronization, including `EnsureLatestManagementHTML`.
- Simplified server initialization by eliminating related control panel functionality.
2025-10-01 03:44:30 +08:00
Luis Pater
41975c9e2b docs(management): document remote-management.disable-control-panel option
- Added explanation for the `remote-management.disable-control-panel` configuration in both `README.md` and `README_CN.md`.
- Clarified its functionality to disable the bundled management UI and return 404 for `/management.html`.
2025-10-01 03:26:06 +08:00
Luis Pater
c589c0d998 feat(management): add support for control panel asset synchronization
- Introduced `EnsureLatestManagementHTML` to sync `management.html` asset from the latest GitHub release.
- Added config option `DisableControlPanel` to toggle control panel functionality.
- Serve management control panel via `/management.html` endpoint, with automatic download and update mechanism.
- Updated `.gitignore` to include `static/*` directory for control panel assets.
2025-10-01 03:18:39 +08:00
Luis Pater
7c157d6ab1 refactor(auth): simplify inline API key provider logic and improve configuration consistency
- Replaced `SyncInlineAPIKeys` with `MakeInlineAPIKeyProvider` for better clarity and reduced redundancy.
- Removed legacy logic for inline API key syncing and migration.
- Enhanced provider synchronization logic to handle empty states consistently.
- Added normalization to API key handling across configurations.
- Updated handlers to reflect streamlined provider update logic.
2025-10-01 00:55:09 +08:00
Luis Pater
7c642bee09 feat(auth): normalize OpenAI compatibility entries and enhance proxy configuration
- Added automatic trimming of API keys and migration of legacy `api-keys` to `api-key-entries`.
- Introduced per-key `proxy-url` handling across OpenAI, Codex, and Claude API configurations.
- Updated documentation to clarify usage of `proxy-url` with examples, ensuring backward compatibility.
- Added normalization logic to reduce duplication and improve configuration consistency.
2025-09-30 23:36:22 +08:00
Luis Pater
beba2a7aa0 Merge pull request #78 from router-for-me/gemini
feat: add multi-account polling for Gemini web
2025-09-30 20:47:29 +08:00
hkfires
f2201dabfa feat(gemini-web): Index and look up conversations by suffix 2025-09-30 12:21:51 +08:00
hkfires
108dcb7f70 fix(gemini-web): Correct history on conversation reuse 2025-09-30 12:21:51 +08:00
hkfires
8858e07d8b feat(gemini-web): Add support for custom auth labels 2025-09-30 12:21:51 +08:00
hkfires
d33a89b89f fix(gemini-web): Ignore tool messages to fix sticky selection 2025-09-30 12:21:51 +08:00
hkfires
1d70336a91 fix(gemini-web): Correct ambiguity check in conversation lookup 2025-09-30 12:21:51 +08:00
hkfires
6080527e9e feat(gemini-web): Namespace conversation index by account label 2025-09-30 12:21:51 +08:00
hkfires
82187bffba feat(gemini-web): Add conversation affinity selector 2025-09-30 12:21:51 +08:00
hkfires
f4977e5ef6 Ignore GEMINI.md file 2025-09-30 12:21:51 +08:00
Luis Pater
832268cae7 refactor(proxy): improve SOCKS5 proxy authentication handling
- Added nil check for proxy user credentials to prevent potential nil pointer dereference.
- Enhanced authentication logic for SOCKS5 proxies in `proxy_helpers.go` and `proxy.go`.
2025-09-30 11:23:39 +08:00
Luis Pater
f6de2a709f feat(auth): add per-key proxy support and enhance API key configuration handling
- Introduced `ProxyURL` field to Claude and Codex API key configurations.
- Added support for `api-key-entries` in OpenAI compatibility section with per-key proxy configuration.
- Maintained backward compatibility for legacy `api-keys` format.
- Updated logic to prioritize `api-key-entries` where applicable.
- Improved documentation and examples to reflect new proxy support.
2025-09-30 09:24:40 +08:00
Luis Pater
de796ac1c2 feat(runtime): introduce newProxyAwareHTTPClient for enhanced proxy handling
- Added `newProxyAwareHTTPClient` to centralize proxy configuration with priority on `auth.ProxyURL` and `cfg.ProxyURL`.
- Integrated enhanced proxy support across executors for HTTP, HTTPS, and SOCKS5 protocols.
- Refactored redundant HTTP client initialization to use `newProxyAwareHTTPClient` for consistent behavior.
2025-09-30 09:04:15 +08:00
Luis Pater
6b5aefc27a feat(proxy): add SOCKS5 support and improve proxy handling
- Added SOCKS5 proxy support, including authentication.
- Improved handling of proxy schemes and associated error logging.
- Enhanced transport creation for HTTP, HTTPS, and SOCKS5 proxies with better configuration management.
2025-09-30 08:56:30 +08:00
Luis Pater
5010b09329 chore(gitignore): add cli-proxy-api to ignored files 2025-09-30 02:45:48 +08:00
Luis Pater
368fd27393 docs: add Claude 4.5 Sonnet model to README and README_CN
- Updated documentation to include the newly supported Claude 4.5 Sonnet model.
2025-09-30 02:04:04 +08:00
Luis Pater
b2ca49376c feat(models): add support for Claude 4.5 Sonnet model in registry
- Introduced new model definition for `Claude 4.5 Sonnet` with metadata and creation details.
- Ensures compatibility and access to the latest Claude model variant.
2025-09-30 01:58:16 +08:00
Luis Pater
6d98a71796 feat(login): add interactive project selection and improve onboarding flow
- Introduced `promptForProjectSelection` to enable interactive project selection for better user onboarding.
- Improved project validation and handling when no preset project ID is provided.
- Added a default project prompt mechanism to guide users through project selection seamlessly.
- Refined error handling for onboarding and project selection failures.
2025-09-30 01:52:03 +08:00
40 changed files with 2364 additions and 560 deletions

5
.gitignore vendored
View File

@@ -10,5 +10,8 @@ auths/*
.serena/*
AGENTS.md
CLAUDE.md
GEMINI.md
*.exe
temp/*
temp/*
cli-proxy-api
static/*

View File

@@ -95,7 +95,7 @@ If a plaintext key is detected in the config at startup, it will be bcrypthas
```
- Response:
```json
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01", "AI...02", "AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api"},{"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

View File

@@ -95,7 +95,7 @@
```
- 响应:
```json
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01", "AI...02", "AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api"},{"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

View File

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

View File

@@ -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-entriesAPI密钥条目列表支持可选的每密钥代理配置推荐
- 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:

View File

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

View File

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

View File

@@ -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(&copyEntry)
out[i] = copyEntry
}
return out
}

View File

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

View File

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

View File

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

View 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))
}

View 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
}

View 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)
}

View 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
}

View 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
}

View File

@@ -0,0 +1,6 @@
package conversation
const (
MetadataMessagesKey = "gemini_web_messages"
MetadataMatchKey = "gemini_web_match"
)

View 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
}

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

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