Compare commits

...

11 Commits

Author SHA1 Message Date
Luis Pater
7b9cfbc3f7 Merge pull request #23 from luispater/dev
Improve hot reloading and fix api response logging
2025-09-03 21:30:22 +08:00
Luis Pater
70e916942e Refactor cliCancel calls to remove unused resp argument across handlers. 2025-09-03 21:13:22 +08:00
hkfires
f60ef0b2e7 feat(watcher): implement incremental client hot-reloading 2025-09-03 20:47:43 +08:00
Luis Pater
6d2f7e3ce0 Enhance parseArgsToMap with tolerant JSON parsing
- Introduced `tolerantParseJSONMap` to handle bareword values in streamed tool calls.
- Added robust handling for JSON strings, objects, arrays, and numerical values.
- Improved fallback mechanisms to ensure reliable parsing.
2025-09-03 16:11:26 +08:00
Luis Pater
caf386c877 Update MANAGEMENT_API.md with expanded documentation for endpoints
- Refined API documentation structure and enhanced clarity for various endpoints, including `/debug`, `/proxy-url`, `/quota-exceeded`, and authentication management.
- Added comprehensive examples for request and response bodies across endpoints.
- Detailed object-array API key management for Codex, Gemini, Claude, and OpenAI compatibility providers.
- Enhanced descriptions for request retry logic and request logging endpoints.
- Improved authentication key handling descriptions and updated examples for YAML configuration impacts.
2025-09-03 09:07:43 +08:00
Luis Pater
c4a42eb1f0 Add support for Codex API key authentication
- Introduced functionality to handle Codex API keys, including initialization and management via new endpoints in the management API.
- Updated Codex client to support both OAuth and API key authentication.
- Documented Codex API key configuration in both English and Chinese README files.
- Enhanced logging to distinguish between API key and OAuth usage scenarios.
2025-09-03 03:36:56 +08:00
Luis Pater
b6f8677b01 Remove commented debug logging in ConvertOpenAIResponsesRequestToGeminiCLI 2025-09-03 03:03:07 +08:00
Luis Pater
36ee21ea8f Update README to include Codex support and multi-account load balancing details
- Added OpenAI Codex (GPT models) support in both English and Chinese README versions.
- Documented multi-account load balancing for Codex in the feature list.
2025-09-03 02:47:53 +08:00
Luis Pater
30d5d87ca6 Update README to include Codex support and multi-account load balancing details
- Added OpenAI Codex (GPT models) support in both English and Chinese README versions.
- Documented multi-account load balancing for Codex in the feature list.
2025-09-03 02:16:56 +08:00
Luis Pater
67e0b71c18 Add Codex load balancing documentation and refine JSON handling logic
- Updated README and README_CN to include a guide for configuring multiple account load balancing with CLI Proxy API.
- Enhanced JSON handling in gemini translators by differentiating object and string outputs.
- Added commented debug logging for Gemini CLI response conversion.
2025-09-03 01:33:26 +08:00
Luis Pater
ce5d2bad97 Add OpenAI Responses support 2025-09-03 00:09:23 +08:00
16 changed files with 1248 additions and 419 deletions

View File

@@ -1,240 +1,511 @@
# Management API # Management API
Base URL: `http://localhost:8317/v0/management` Base path: `http://localhost:8317/v0/management`
This API manages runtime configuration and authentication files for the CLI Proxy API. All changes persist to the YAML config file and are hotreloaded by the server. This API manages the CLI Proxy APIs runtime configuration and authentication files. All changes are persisted to the YAML config file and hotreloaded by the service.
Note: The following options cannot be changed via API and must be edited in the config file, then restart if needed: Note: The following options cannot be modified via API and must be set in the config file (restart if needed):
- `allow-remote-management` - `allow-remote-management`
- `remote-management-key` (stored as bcrypt hash after startup if plaintext was provided) - `remote-management-key` (if plaintext is detected at startup, it is automatically bcrypthashed and written back to the config)
## Authentication ## Authentication
- All requests (including localhost) must include a management key. - All requests (including localhost) must provide a valid management key.
- Remote access additionally requires `allow-remote-management: true` in config. - Remote access requires enabling remote management in the config: `allow-remote-management: true`.
- Provide the key via one of: - Provide the management key (in plaintext) via either:
- `Authorization: Bearer <plaintext-key>` - `Authorization: Bearer <plaintext-key>`
- `X-Management-Key: <plaintext-key>` - `X-Management-Key: <plaintext-key>`
If a plaintext key is present in the config on startup, it is bcrypt-hashed and written back to the config file automatically. If `remote-management-key` is empty, the Management API is entirely disabled (404 for `/v0/management/*`). If a plaintext key is detected in the config at startup, it will be bcrypthashed and written back to the config file automatically.
## Request/Response Conventions ## Request/Response Conventions
- Content type: `application/json` unless noted. - Content-Type: `application/json` (unless otherwise noted).
- Boolean/int/string updates use body: `{ "value": <type> }`. - Boolean/int/string updates: request body is `{ "value": <type> }`.
- Array PUT bodies can be either a raw array (e.g. `["a","b"]`) or `{ "items": [ ... ] }`. - Array PUT: either a raw array (e.g. `["a","b"]`) or `{ "items": [ ... ] }`.
- Array PATCH accepts either `{ "old": "k1", "new": "k2" }` or `{ "index": 0, "value": "k2" }`. - Array PATCH: supports `{ "old": "k1", "new": "k2" }` or `{ "index": 0, "value": "k2" }`.
- Object-array PATCH supports either index or key match (documented per endpoint). - Object-array PATCH: supports matching by index or by key field (specified per endpoint).
## Endpoints ## Endpoints
### Debug ### Debug
- GET `/debug`get current debug flag - GET `/debug`Get the current debug state
- PUT/PATCH `/debug` — set debug (boolean) - Request:
Example (set true):
```bash ```bash
curl -X PUT \ curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/debug
```
- Response:
```json
{ "debug": false }
```
- PUT/PATCH `/debug` — Set debug (boolean)
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-H 'Content-Type: application/json' \
-d '{"value":true}' \ -d '{"value":true}' \
http://localhost:8317/v0/management/debug http://localhost:8317/v0/management/debug
``` ```
Response: - Response:
```json ```json
{ "status": "ok" } { "status": "ok" }
``` ```
### Proxy URL ### Proxy Server URL
- GET `/proxy-url`get proxy URL string - GET `/proxy-url` — Get the proxy URL string
- PUT/PATCH `/proxy-url` — set proxy URL string - Request:
- DELETE `/proxy-url` — clear proxy URL
Example (set):
```bash ```bash
curl -X PATCH -H 'Content-Type: application/json' \ curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/proxy-url
```
- Response:
```json
{ "proxy-url": "socks5://user:pass@127.0.0.1:1080/" }
```
- PUT/PATCH `/proxy-url` — Set the proxy URL string
- Request (PUT):
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \ -d '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \
http://localhost:8317/v0/management/proxy-url http://localhost:8317/v0/management/proxy-url
``` ```
Response: - Request (PATCH):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":"http://127.0.0.1:8080"}' \
http://localhost:8317/v0/management/proxy-url
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/proxy-url` — Clear the proxy URL
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE http://localhost:8317/v0/management/proxy-url
```
- Response:
```json ```json
{ "status": "ok" } { "status": "ok" }
``` ```
### Quota Exceeded Behavior ### Quota Exceeded Behavior
- GET `/quota-exceeded/switch-project` - GET `/quota-exceeded/switch-project`
- PUT/PATCH `/quota-exceeded/switch-project` — boolean - Request:
- GET `/quota-exceeded/switch-preview-model` ```bash
- PUT/PATCH `/quota-exceeded/switch-preview-model` — boolean curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-project
```
Example: - Response:
```json
{ "switch-project": true }
```
- PUT/PATCH `/quota-exceeded/switch-project` — Boolean
- Request:
```bash ```bash
curl -X PUT -H 'Content-Type: application/json' \ curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":false}' \ -d '{"value":false}' \
http://localhost:8317/v0/management/quota-exceeded/switch-project http://localhost:8317/v0/management/quota-exceeded/switch-project
``` ```
Response: - Response:
```json
{ "status": "ok" }
```
- GET `/quota-exceeded/switch-preview-model`
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
```
- Response:
```json
{ "switch-preview-model": true }
```
- PUT/PATCH `/quota-exceeded/switch-preview-model` — Boolean
- Request:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
```
- Response:
```json ```json
{ "status": "ok" } { "status": "ok" }
``` ```
### API Keys (proxy server auth) ### API Keys (proxy service auth)
- GET `/api-keys`return the full list - GET `/api-keys` — Return the full list
- PUT `/api-keys` — replace the full list - Request:
- PATCH `/api-keys` — update one entry (by `old/new` or `index/value`) ```bash
- DELETE `/api-keys` — remove one entry (by `?value=` or `?index=`) curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/api-keys
```
Examples: - Response:
```json
{ "api-keys": ["k1","k2","k3"] }
```
- PUT `/api-keys` — Replace the full list
- Request:
```bash ```bash
# Replace list
curl -X PUT -H 'Content-Type: application/json' \ curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '["k1","k2","k3"]' \ -d '["k1","k2","k3"]' \
http://localhost:8317/v0/management/api-keys http://localhost:8317/v0/management/api-keys
```
# Patch: replace k2 -> k2b - Response:
```json
{ "status": "ok" }
```
- PATCH `/api-keys` — Modify one item (`old/new` or `index/value`)
- Request (by old/new):
```bash
curl -X PATCH -H 'Content-Type: application/json' \ curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"old":"k2","new":"k2b"}' \ -d '{"old":"k2","new":"k2b"}' \
http://localhost:8317/v0/management/api-keys http://localhost:8317/v0/management/api-keys
```
# Delete by value - Request (by index/value):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":0,"value":"k1b"}' \
http://localhost:8317/v0/management/api-keys
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/api-keys` — Delete one (`?value=` or `?index=`)
- Request (by value):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?value=k1' curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?value=k1'
``` ```
Response (GET): - Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?index=0'
```
- Response:
```json ```json
{ "api-keys": ["k1","k2b","k3"] } { "status": "ok" }
``` ```
### Generative Language API Keys (Gemini) ### Gemini API Key (Generative Language)
- GET `/generative-language-api-key` - GET `/generative-language-api-key`
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/generative-language-api-key
```
- Response:
```json
{ "generative-language-api-key": ["AIzaSy...01","AIzaSy...02"] }
```
- PUT `/generative-language-api-key` - PUT `/generative-language-api-key`
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '["AIzaSy-1","AIzaSy-2"]' \
http://localhost:8317/v0/management/generative-language-api-key
```
- Response:
```json
{ "status": "ok" }
```
- PATCH `/generative-language-api-key` - PATCH `/generative-language-api-key`
- Request:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"old":"AIzaSy-1","new":"AIzaSy-1b"}' \
http://localhost:8317/v0/management/generative-language-api-key
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/generative-language-api-key` - DELETE `/generative-language-api-key`
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/generative-language-api-key?value=AIzaSy-2'
```
- Response:
```json
{ "status": "ok" }
```
Same request/response shapes as API keys. ### Codex API KEY (object array)
- GET `/codex-api-key` — List all
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/codex-api-key
```
- Response:
```json
{ "codex-api-key": [ { "api-key": "sk-a", "base-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"}]' \
http://localhost:8317/v0/management/codex-api-key
```
- Response:
```json
{ "status": "ok" }
```
- PATCH `/codex-api-key` — Modify one (by `index` or `match`)
- Request (by index):
```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/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":""}}' \
http://localhost:8317/v0/management/codex-api-key
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/codex-api-key` — Delete one (`?api-key=` or `?index=`)
- Request (by api-key):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?api-key=sk-b2'
```
- Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?index=0'
```
- Response:
```json
{ "status": "ok" }
```
### Request Logging ### Request Retry Count
- GET `/request-log`get boolean - GET `/request-retry`Get integer
- PUT/PATCH `/request-log` — set boolean - Request:
```bash
### Request Retry curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-retry
- GET `/request-retry` — get integer ```
- PUT/PATCH `/request-retry` — set integer - Response:
```json
{ "request-retry": 3 }
```
- PUT/PATCH `/request-retry` — Set integer
- Request:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":5}' \
http://localhost:8317/v0/management/request-retry
```
- Response:
```json
{ "status": "ok" }
```
### Allow Localhost Unauthenticated ### Allow Localhost Unauthenticated
- GET `/allow-localhost-unauthenticated`get boolean - GET `/allow-localhost-unauthenticated` — Get boolean
- PUT/PATCH `/allow-localhost-unauthenticated` — set boolean - Request:
```bash
### Claude API Keys (object array) curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/allow-localhost-unauthenticated
- GET `/claude-api-key` — full list ```
- PUT `/claude-api-key` — replace list - Response:
- PATCH `/claude-api-key` — update one item (by `index` or `match` API key)
- DELETE `/claude-api-key` — remove one item (`?api-key=` or `?index=`)
Object shape:
```json ```json
{ { "allow-localhost-unauthenticated": false }
"api-key": "sk-...", ```
"base-url": "https://custom.example.com" // optional - PUT/PATCH `/allow-localhost-unauthenticated` — Set boolean
} - Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/allow-localhost-unauthenticated
```
- Response:
```json
{ "status": "ok" }
``` ```
Examples: ### 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": "" } ] }
```
- PUT `/claude-api-key` — Replace the list
- Request:
```bash ```bash
# Replace list
curl -X PUT -H 'Content-Type: application/json' \ curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -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"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
http://localhost:8317/v0/management/claude-api-key http://localhost:8317/v0/management/claude-api-key
```
# Patch by index - Response:
```json
{ "status": "ok" }
```
- PATCH `/claude-api-key` — Modify one (by `index` or `match`)
- Request (by index):
```bash
curl -X PATCH -H 'Content-Type: application/json' \ curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -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"}}' \
http://localhost:8317/v0/management/claude-api-key http://localhost:8317/v0/management/claude-api-key
```
# Patch by match (api-key) - Request (by match):
```bash
curl -X PATCH -H 'Content-Type: application/json' \ curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -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":""}}' \
http://localhost:8317/v0/management/claude-api-key http://localhost:8317/v0/management/claude-api-key
```
# Delete by api-key - Response:
```json
{ "status": "ok" }
```
- DELETE `/claude-api-key` — Delete one (`?api-key=` or `?index=`)
- Request (by api-key):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2' curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2'
``` ```
Response (GET): - Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?index=0'
```
- Response:
```json ```json
{ { "status": "ok" }
"claude-api-key": [
{ "api-key": "sk-a", "base-url": "" }
]
}
``` ```
### OpenAI Compatibility Providers (object array) ### OpenAI Compatibility Providers (object array)
- GET `/openai-compatibility`full list - GET `/openai-compatibility` — List all
- PUT `/openai-compatibility` — replace list - Request:
- PATCH `/openai-compatibility` — update one item by `index` or `name` ```bash
- DELETE `/openai-compatibility` — remove by `?name=` or `?index=` curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/openai-compatibility
```
Object shape: - Response:
```json ```json
{ { "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-keys": [], "models": [] } ] }
"name": "openrouter", ```
"base-url": "https://openrouter.ai/api/v1", - PUT `/openai-compatibility` — Replace the list
"api-keys": ["sk-..."], - Request:
"models": [ {"name": "moonshotai/kimi-k2:free", "alias": "kimi-k2"} ]
}
```
Examples:
```bash ```bash
# Replace list
curl -X PUT -H 'Content-Type: application/json' \ curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -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-keys":["sk"],"models":[{"name":"m","alias":"a"}]}]' \
http://localhost:8317/v0/management/openai-compatibility http://localhost:8317/v0/management/openai-compatibility
```
# Patch by name - Response:
```json
{ "status": "ok" }
```
- PATCH `/openai-compatibility` — Modify one (by `index` or `name`)
- Request (by name):
```bash
curl -X PATCH -H 'Content-Type: application/json' \ curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -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-keys":[],"models":[]}}' \
http://localhost:8317/v0/management/openai-compatibility http://localhost:8317/v0/management/openai-compatibility
```
# Delete by index - 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":[]}}' \
http://localhost:8317/v0/management/openai-compatibility
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/openai-compatibility` — Delete (`?name=` or `?index=`)
- Request (by name):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?name=openrouter'
```
- Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?index=0' curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?index=0'
``` ```
Response (GET): - Response:
```json ```json
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "...", "api-keys": [], "models": [] } ] } { "status": "ok" }
``` ```
### Auth Files Management ### Auth File Management
List JSON token files under `auth-dir`, download/upload/delete. Manage JSON token files under `auth-dir`: list, download, upload, delete.
- GET `/auth-files`list - GET `/auth-files` — List
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/auth-files
```
- Response: - Response:
```json ```json
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z" } ] } { "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z" } ] }
``` ```
- GET `/auth-files/download?name=<file.json>` — download a single file - GET `/auth-files/download?name=<file.json>` — Download a single file
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -OJ 'http://localhost:8317/v0/management/auth-files/download?name=acc1.json'
```
- POST `/auth-files` — upload - POST `/auth-files` — Upload
- Multipart form: field `file` (must be `.json`) - Request (multipart):
- Or raw JSON body with `?name=<file.json>` ```bash
- Response: `{ "status": "ok" }` curl -X POST -F 'file=@/path/to/acc1.json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
http://localhost:8317/v0/management/auth-files
```
- Request (raw JSON):
```bash
curl -X POST -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d @/path/to/acc1.json \
'http://localhost:8317/v0/management/auth-files?name=acc1.json'
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/auth-files?name=<file.json>` — delete a single file - DELETE `/auth-files?name=<file.json>` — Delete a single file
- DELETE `/auth-files?all=true` — delete all `.json` files in `auth-dir` - Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?name=acc1.json'
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/auth-files?all=true` — Delete all `.json` files under `auth-dir`
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?all=true'
```
- Response:
```json
{ "status": "ok", "deleted": 3 }
```
## Error Responses ## Error Responses
Generic error shapes: Generic error format:
- 400 Bad Request: `{ "error": "invalid body" }` - 400 Bad Request: `{ "error": "invalid body" }`
- 401 Unauthorized: `{ "error": "missing management key" }` or `{ "error": "invalid management key" }` - 401 Unauthorized: `{ "error": "missing management key" }` or `{ "error": "invalid management key" }`
- 403 Forbidden: `{ "error": "remote management disabled" }` - 403 Forbidden: `{ "error": "remote management disabled" }`
@@ -243,6 +514,6 @@ Generic error shapes:
## Notes ## Notes
- Changes are written to the YAML configuration file and picked up by the servers file watcher to hot-reload clients and settings. - Changes are written back to the YAML config file and hotreloaded by the file watcher and clients.
- `allow-remote-management` and `remote-management-key` must be edited in the configuration file and cannot be changed via the API. - `allow-remote-management` and `remote-management-key` cannot be changed via the API; configure them in the config file.

View File

@@ -233,23 +233,55 @@
{ "status": "ok" } { "status": "ok" }
``` ```
### 开启请求日志 ### Codex API KEY对象数组
- GET `/request-log` — 获取布尔值 - GET `/codex-api-key` — 列出全部
- 请求: - 请求:
```bash ```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-log curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/codex-api-key
``` ```
- 响应: - 响应:
```json ```json
{ "request-log": true } { "codex-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
``` ```
- PUT/PATCH `/request-log` — 设置布尔值 - PUT `/codex-api-key` — 完整改写列表
- 请求: - 请求:
```bash ```bash
curl -X PUT -H 'Content-Type: application/json' \ curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \ -d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
http://localhost:8317/v0/management/request-log http://localhost:8317/v0/management/codex-api-key
```
- 响应:
```json
{ "status": "ok" }
```
- PATCH `/codex-api-key` — 修改其中一个(按 `index` 或 `match`
- 请求(按索引):
```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/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":""}}' \
http://localhost:8317/v0/management/codex-api-key
```
- 响应:
```json
{ "status": "ok" }
```
- DELETE `/codex-api-key` — 删除其中一个(`?api-key=` 或 `?index=`
- 请求(按 api-key
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?api-key=sk-b2'
```
- 请求(按索引):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?index=0'
``` ```
- 响应: - 响应:
```json ```json

View File

@@ -2,11 +2,11 @@
English | [中文](README_CN.md) English | [中文](README_CN.md)
A proxy server that provides OpenAI/Gemini/Claude compatible API interfaces for CLI. A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth. It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
So you can use local or multi-account CLI access with OpenAI-compatible clients and SDKs. So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
The first Chinese provider has now been added: [Qwen Code](https://github.com/QwenLM/qwen-code). The first Chinese provider has now been added: [Qwen Code](https://github.com/QwenLM/qwen-code).
@@ -25,6 +25,7 @@ The first Chinese provider has now been added: [Qwen Code](https://github.com/Qw
- Gemini CLI multi-account load balancing - Gemini CLI multi-account load balancing
- Claude Code multi-account load balancing - Claude Code multi-account load balancing
- Qwen Code multi-account load balancing - Qwen Code multi-account load balancing
- OpenAI Codex multi-account load balancing
- OpenAI-compatible upstream providers via config (e.g., OpenRouter) - OpenAI-compatible upstream providers via config (e.g., OpenRouter)
## Installation ## Installation
@@ -430,6 +431,29 @@ export ANTHROPIC_MODEL=qwen3-coder-plus
export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash
``` ```
## Codex with multiple account load balancing
Start CLI Proxy API server, and then edit the `~/.codex/config.toml` and `~/.codex/auth.json` files.
config.toml:
```toml
model_provider = "cliproxyapi"
model = "gpt-5" # You can use any of the models that we support.
model_reasoning_effort = "high"
[model_providers.cliproxyapi]
name = "cliproxyapi"
base_url = "http://127.0.0.1:8317/v1"
wire_api = "responses"
```
auth.json:
```json
{
"OPENAI_API_KEY": "sk-dummy"
}
```
## Run with Docker ## Run with Docker
Run the following command to login (Gemini OAuth on port 8085): Run the following command to login (Gemini OAuth on port 8085):

View File

@@ -1,18 +1,36 @@
# 写给所有中国网友的
对于项目前期的确有很多用户使用上遇到各种各样的奇怪问题,大部分是因为配置或我说明文档不全导致的。
对说明文档我已经尽可能的修补,有些重要的地方我甚至已经写到了打包的配置文件里。
已经写在 README 中的功能,都是**可用**的,经过**验证**的,并且我自己**每天**都在使用的。
可能在某些场景中使用上效果并不是很出色,但那基本上是模型和工具的原因,比如用 Claude Code 的时候,有的模型就无法正确使用工具,比如 Gemini就在 Claude Code 和 Codex 的下使用的相当扭捏,有时能完成大部分工作,但有时候却只说不做。
目前来说 Claude 和 GPT-5 是目前使用各种第三方CLI工具运用的最好的模型我自己也是多个账号做均衡负载使用。
实事求是的说,最初的几个版本我根本就没有中文文档,我至今所有文档也都是使用英文更新让后让 Gemini 翻译成中文的。但是无论如何都不会出现中文文档无法理解的问题。因为所有的中英文文档我都是再三校对,并且发现未及时更改的更新的地方都快速更新掉了。
最后,烦请在发 Issue 之前请认真阅读这篇文档。
另外中文需要交流的用户可以加 QQ 群188637136
# CLI 代理 API # CLI 代理 API
[English](README.md) | 中文 [English](README.md) | 中文
一个为 CLI 提供 OpenAI/Gemini/Claude 兼容 API 接口的代理服务器。 一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
现已支持通过 OAuth 登录接入 OpenAI CodexGPT 系列)和 Claude Code。 现已支持通过 OAuth 登录接入 OpenAI CodexGPT 系列)和 Claude Code。
您可以使用本地或多账户的CLI方式通过任何与OpenAI兼容的客户端和SDK进行访问。 您可以使用本地或多账户的CLI方式通过任何与 OpenAI包括Responses/Gemini/Claude 兼容的客户端和SDK进行访问。
现已新增首个中国提供商:[Qwen Code](https://github.com/QwenLM/qwen-code)。 现已新增首个中国提供商:[Qwen Code](https://github.com/QwenLM/qwen-code)。
## 功能特性 ## 功能特性
- 为 CLI 模型提供 OpenAI/Gemini/Claude 兼容的 API 端点 - 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
- 新增 OpenAI CodexGPT 系列支持OAuth 登录) - 新增 OpenAI CodexGPT 系列支持OAuth 登录)
- 新增 Claude Code 支持OAuth 登录) - 新增 Claude Code 支持OAuth 登录)
- 新增 Qwen Code 支持OAuth 登录) - 新增 Qwen Code 支持OAuth 登录)
@@ -25,6 +43,7 @@
- 支持 Gemini CLI 多账户轮询 - 支持 Gemini CLI 多账户轮询
- 支持 Claude Code 多账户轮询 - 支持 Claude Code 多账户轮询
- 支持 Qwen Code 多账户轮询 - 支持 Qwen Code 多账户轮询
- 支持 OpenAI Codex 多账户轮询
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter - 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter
## 安装 ## 安装
@@ -424,6 +443,28 @@ export ANTHROPIC_MODEL=qwen3-coder-plus
export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash
``` ```
## Codex 多账户负载均衡
启动 CLI Proxy API 服务器, 修改 `~/.codex/config.toml` 和 `~/.codex/auth.json` 文件。
config.toml:
```toml
model_provider = "cliproxyapi"
model = "gpt-5" # 你可以使用任何我们支持的模型
model_reasoning_effort = "high"
[model_providers.cliproxyapi]
name = "cliproxyapi"
base_url = "http://127.0.0.1:8317/v1"
wire_api = "responses"
```
auth.json:
```json
{
"OPENAI_API_KEY": "sk-dummy"
}
```
## 使用 Docker 运行 ## 使用 Docker 运行

View File

@@ -292,7 +292,7 @@ func (h *GeminiCLIAPIHandler) handleInternalGenerateContent(c *gin.Context, rawJ
break break
} else { } else {
_, _ = c.Writer.Write(resp) _, _ = c.Writer.Write(resp)
cliCancel(resp) cliCancel()
break break
} }
} }

View File

@@ -405,7 +405,7 @@ func (h *GeminiAPIHandler) handleGenerateContent(c *gin.Context, modelName strin
break break
} else { } else {
_, _ = c.Writer.Write(resp) _, _ = c.Writer.Write(resp)
cliCancel(resp) cliCancel()
break break
} }
} }

View File

@@ -250,3 +250,77 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
} }
c.JSON(400, gin.H{"error": "missing name or index"}) c.JSON(400, gin.H{"error": "missing name or index"})
} }
// codex-api-key: []CodexKey
func (h *Handler) GetCodexKeys(c *gin.Context) {
c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
}
func (h *Handler) PutCodexKeys(c *gin.Context) {
data, err := c.GetRawData()
if err != nil {
c.JSON(400, gin.H{"error": "failed to read body"})
return
}
var arr []config.CodexKey
if err = json.Unmarshal(data, &arr); err != nil {
var obj struct {
Items []config.CodexKey `json:"items"`
}
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
arr = obj.Items
}
h.cfg.CodexKey = arr
h.persist(c)
}
func (h *Handler) PatchCodexKey(c *gin.Context) {
var body struct {
Index *int `json:"index"`
Match *string `json:"match"`
Value *config.CodexKey `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
h.cfg.CodexKey[*body.Index] = *body.Value
h.persist(c)
return
}
if body.Match != nil {
for i := range h.cfg.CodexKey {
if h.cfg.CodexKey[i].APIKey == *body.Match {
h.cfg.CodexKey[i] = *body.Value
h.persist(c)
return
}
}
}
c.JSON(404, gin.H{"error": "item not found"})
}
func (h *Handler) DeleteCodexKey(c *gin.Context) {
if val := c.Query("api-key"); val != "" {
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
for _, v := range h.cfg.CodexKey {
if v.APIKey != val {
out = append(out, v)
}
}
h.cfg.CodexKey = out
h.persist(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
var idx int
_, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
h.persist(c)
return
}
}
c.JSON(400, gin.H{"error": "missing api-key or index"})
}

View File

@@ -427,7 +427,7 @@ func (h *OpenAIAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []
break break
} else { } else {
_, _ = c.Writer.Write(resp) _, _ = c.Writer.Write(resp)
cliCancel(resp) cliCancel()
break break
} }
} }
@@ -597,7 +597,7 @@ func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context,
// Convert chat completions response back to completions format // Convert chat completions response back to completions format
completionsResp := convertChatCompletionsResponseToCompletions(resp) completionsResp := convertChatCompletionsResponseToCompletions(resp)
_, _ = c.Writer.Write(completionsResp) _, _ = c.Writer.Write(completionsResp)
cliCancel(completionsResp) cliCancel()
break break
} }
} }

View File

@@ -155,7 +155,7 @@ func (h *OpenAIResponsesAPIHandler) handleNonStreamingResponse(c *gin.Context, r
break break
} else { } else {
_, _ = c.Writer.Write(resp) _, _ = c.Writer.Write(resp)
cliCancel(resp) cliCancel()
break break
} }
} }

View File

@@ -193,6 +193,11 @@ func (s *Server) setupRoutes() {
mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey) mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey)
mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey) 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.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat) mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat) mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
@@ -287,7 +292,8 @@ func corsMiddleware() gin.HandlerFunc {
// Parameters: // Parameters:
// - clients: The new slice of AI service clients // - clients: The new slice of AI service clients
// - cfg: The new application configuration // - cfg: The new application configuration
func (s *Server) UpdateClients(clients []interfaces.Client, cfg *config.Config) { func (s *Server) UpdateClients(clients map[string]interfaces.Client, cfg *config.Config) {
clientSlice := s.clientsToSlice(clients)
// Update request logger enabled state if it has changed // Update request logger enabled state if it has changed
if s.requestLogger != nil && s.cfg.RequestLog != cfg.RequestLog { if s.requestLogger != nil && s.cfg.RequestLog != cfg.RequestLog {
s.requestLogger.SetEnabled(cfg.RequestLog) s.requestLogger.SetEnabled(cfg.RequestLog)
@@ -305,11 +311,11 @@ func (s *Server) UpdateClients(clients []interfaces.Client, cfg *config.Config)
} }
s.cfg = cfg s.cfg = cfg
s.handlers.UpdateClients(clients, cfg) s.handlers.UpdateClients(clientSlice, cfg)
if s.mgmt != nil { if s.mgmt != nil {
s.mgmt.SetConfig(cfg) s.mgmt.SetConfig(cfg)
} }
log.Infof("server clients and configuration updated: %d clients", len(clients)) log.Infof("server clients and configuration updated: %d clients", len(clientSlice))
} }
// (management handlers moved to internal/api/handlers/management) // (management handlers moved to internal/api/handlers/management)
@@ -379,3 +385,11 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
c.Next() c.Next()
} }
} }
func (s *Server) clientsToSlice(clientMap map[string]interfaces.Client) []interfaces.Client {
slice := make([]interfaces.Client, 0, len(clientMap))
for _, v := range clientMap {
slice = append(slice, v)
}
return slice
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/luispater/CLIProxyAPI/internal/auth" "github.com/luispater/CLIProxyAPI/internal/auth"
"github.com/luispater/CLIProxyAPI/internal/auth/codex" "github.com/luispater/CLIProxyAPI/internal/auth/codex"
"github.com/luispater/CLIProxyAPI/internal/auth/empty"
"github.com/luispater/CLIProxyAPI/internal/config" "github.com/luispater/CLIProxyAPI/internal/config"
. "github.com/luispater/CLIProxyAPI/internal/constant" . "github.com/luispater/CLIProxyAPI/internal/constant"
"github.com/luispater/CLIProxyAPI/internal/interfaces" "github.com/luispater/CLIProxyAPI/internal/interfaces"
@@ -38,9 +39,11 @@ const (
type CodexClient struct { type CodexClient struct {
ClientBase ClientBase
codexAuth *codex.CodexAuth codexAuth *codex.CodexAuth
// apiKeyIndex is the index of the API key to use from the config, -1 if not using API keys
apiKeyIndex int
} }
// NewCodexClient creates a new OpenAI client instance // NewCodexClient creates a new OpenAI client instance using token-based authentication
// //
// Parameters: // Parameters:
// - cfg: The application configuration. // - cfg: The application configuration.
@@ -64,6 +67,7 @@ func NewCodexClient(cfg *config.Config, ts *codex.CodexTokenStorage) (*CodexClie
tokenStorage: ts, tokenStorage: ts,
}, },
codexAuth: codex.NewCodexAuth(cfg), codexAuth: codex.NewCodexAuth(cfg),
apiKeyIndex: -1,
} }
// Initialize model registry and register OpenAI models // Initialize model registry and register OpenAI models
@@ -73,6 +77,41 @@ func NewCodexClient(cfg *config.Config, ts *codex.CodexTokenStorage) (*CodexClie
return client, nil return client, nil
} }
// NewCodexClientWithKey creates a new Codex client instance using API key authentication.
// It initializes the client with the provided configuration and selects the API key
// at the specified index from the configuration.
//
// Parameters:
// - cfg: The application configuration.
// - apiKeyIndex: The index of the API key to use from the configuration.
//
// Returns:
// - *CodexClient: A new Codex client instance.
func NewCodexClientWithKey(cfg *config.Config, apiKeyIndex int) *CodexClient {
httpClient := util.SetProxy(cfg, &http.Client{})
// Generate unique client ID for API key client
clientID := fmt.Sprintf("codex-apikey-%d-%d", apiKeyIndex, time.Now().UnixNano())
client := &CodexClient{
ClientBase: ClientBase{
RequestMutex: &sync.Mutex{},
httpClient: httpClient,
cfg: cfg,
modelQuotaExceeded: make(map[string]*time.Time),
tokenStorage: &empty.EmptyStorage{},
},
codexAuth: codex.NewCodexAuth(cfg),
apiKeyIndex: apiKeyIndex,
}
// Initialize model registry and register OpenAI models
client.InitializeModelRegistry(clientID)
client.RegisterModels("codex", registry.GetOpenAIModels())
return client
}
// Type returns the client type // Type returns the client type
func (c *CodexClient) Type() string { func (c *CodexClient) Type() string {
return CODEX return CODEX
@@ -102,6 +141,16 @@ func (c *CodexClient) CanProvideModel(modelName string) bool {
return util.InArray(models, modelName) return util.InArray(models, modelName)
} }
// GetAPIKey returns the API key for Codex API requests.
// If an API key index is specified, it returns the corresponding key from the configuration.
// Otherwise, it returns an empty string, indicating token-based authentication should be used.
func (c *CodexClient) GetAPIKey() string {
if c.apiKeyIndex != -1 {
return c.cfg.CodexKey[c.apiKeyIndex].APIKey
}
return ""
}
// GetUserAgent returns the user agent string for OpenAI API requests // GetUserAgent returns the user agent string for OpenAI API requests
func (c *CodexClient) GetUserAgent() string { func (c *CodexClient) GetUserAgent() string {
return "codex-cli" return "codex-cli"
@@ -283,6 +332,11 @@ func (c *CodexClient) SaveTokenToFile() error {
// Returns: // Returns:
// - error: An error if the refresh operation fails, nil otherwise. // - error: An error if the refresh operation fails, nil otherwise.
func (c *CodexClient) RefreshTokens(ctx context.Context) error { func (c *CodexClient) RefreshTokens(ctx context.Context) error {
// Check if we have a valid refresh token
if c.apiKeyIndex != -1 {
return fmt.Errorf("no refresh token available")
}
if c.tokenStorage == nil || c.tokenStorage.(*codex.CodexTokenStorage).RefreshToken == "" { if c.tokenStorage == nil || c.tokenStorage.(*codex.CodexTokenStorage).RefreshToken == "" {
return fmt.Errorf("no refresh token available") return fmt.Errorf("no refresh token available")
} }
@@ -364,6 +418,18 @@ func (c *CodexClient) APIRequest(ctx context.Context, modelName, endpoint string
} }
url := fmt.Sprintf("%s%s", chatGPTEndpoint, endpoint) url := fmt.Sprintf("%s%s", chatGPTEndpoint, endpoint)
accessToken := ""
if c.apiKeyIndex != -1 {
// Using API key authentication - use configured base URL if provided
if c.cfg.CodexKey[c.apiKeyIndex].BaseURL != "" {
url = fmt.Sprintf("%s%s", c.cfg.CodexKey[c.apiKeyIndex].BaseURL, endpoint)
}
accessToken = c.cfg.CodexKey[c.apiKeyIndex].APIKey
} else {
// Using OAuth token authentication - use ChatGPT endpoint
accessToken = c.tokenStorage.(*codex.CodexTokenStorage).AccessToken
}
// log.Debug(string(jsonBody)) // log.Debug(string(jsonBody))
// log.Debug(url) // log.Debug(url)
@@ -381,9 +447,16 @@ func (c *CodexClient) APIRequest(ctx context.Context, modelName, endpoint string
req.Header.Set("Openai-Beta", "responses=experimental") req.Header.Set("Openai-Beta", "responses=experimental")
req.Header.Set("Session_id", sessionID) req.Header.Set("Session_id", sessionID)
req.Header.Set("Accept", "text/event-stream") req.Header.Set("Accept", "text/event-stream")
if c.apiKeyIndex != -1 {
// Using API key authentication
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
} else {
// Using OAuth token authentication - include ChatGPT specific headers
req.Header.Set("Chatgpt-Account-Id", c.tokenStorage.(*codex.CodexTokenStorage).AccountID) req.Header.Set("Chatgpt-Account-Id", c.tokenStorage.(*codex.CodexTokenStorage).AccountID)
req.Header.Set("Originator", "codex_cli_rs") req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.tokenStorage.(*codex.CodexTokenStorage).AccessToken)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
}
if c.cfg.RequestLog { if c.cfg.RequestLog {
if ginContext, ok := ctx.Value("gin").(*gin.Context); ok { if ginContext, ok := ctx.Value("gin").(*gin.Context); ok {
@@ -391,7 +464,11 @@ func (c *CodexClient) APIRequest(ctx context.Context, modelName, endpoint string
} }
} }
if c.apiKeyIndex != -1 {
log.Debugf("Use Codex API key %s for model %s", util.HideAPIKey(c.cfg.CodexKey[c.apiKeyIndex].APIKey), modelName)
} else {
log.Debugf("Use ChatGPT account %s for model %s", c.GetEmail(), modelName) log.Debugf("Use ChatGPT account %s for model %s", c.GetEmail(), modelName)
}
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -413,7 +490,11 @@ func (c *CodexClient) APIRequest(ctx context.Context, modelName, endpoint string
} }
// GetEmail returns the email associated with the client's token storage. // GetEmail returns the email associated with the client's token storage.
// If the client is using API key authentication, it returns the API key.
func (c *CodexClient) GetEmail() string { func (c *CodexClient) GetEmail() string {
if c.apiKeyIndex != -1 {
return c.cfg.CodexKey[c.apiKeyIndex].APIKey
}
return c.tokenStorage.(*codex.CodexTokenStorage).Email return c.tokenStorage.(*codex.CodexTokenStorage).Email
} }

View File

@@ -49,7 +49,7 @@ import (
// - configPath: The path to the configuration file for watching changes // - configPath: The path to the configuration file for watching changes
func StartService(cfg *config.Config, configPath string) { func StartService(cfg *config.Config, configPath string) {
// Create a pool of API clients, one for each token file found. // Create a pool of API clients, one for each token file found.
cliClients := make([]interfaces.Client, 0) cliClients := make(map[string]interfaces.Client)
err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error { err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
@@ -88,7 +88,7 @@ func StartService(cfg *config.Config, configPath string) {
// Add the new client to the pool. // Add the new client to the pool.
cliClient := client.NewGeminiCLIClient(httpClient, &ts, cfg) cliClient := client.NewGeminiCLIClient(httpClient, &ts, cfg)
cliClients = append(cliClients, cliClient) cliClients[path] = cliClient
} }
} else if tokenType == "codex" { } else if tokenType == "codex" {
var ts codex.CodexTokenStorage var ts codex.CodexTokenStorage
@@ -102,7 +102,7 @@ func StartService(cfg *config.Config, configPath string) {
return errGetClient return errGetClient
} }
log.Info("Authentication successful.") log.Info("Authentication successful.")
cliClients = append(cliClients, codexClient) cliClients[path] = codexClient
} }
} else if tokenType == "claude" { } else if tokenType == "claude" {
var ts claude.ClaudeTokenStorage var ts claude.ClaudeTokenStorage
@@ -111,7 +111,7 @@ func StartService(cfg *config.Config, configPath string) {
log.Info("Initializing claude authentication for token...") log.Info("Initializing claude authentication for token...")
claudeClient := client.NewClaudeClient(cfg, &ts) claudeClient := client.NewClaudeClient(cfg, &ts)
log.Info("Authentication successful.") log.Info("Authentication successful.")
cliClients = append(cliClients, claudeClient) cliClients[path] = claudeClient
} }
} else if tokenType == "qwen" { } else if tokenType == "qwen" {
var ts qwen.QwenTokenStorage var ts qwen.QwenTokenStorage
@@ -120,7 +120,7 @@ func StartService(cfg *config.Config, configPath string) {
log.Info("Initializing qwen authentication for token...") log.Info("Initializing qwen authentication for token...")
qwenClient := client.NewQwenClient(cfg, &ts) qwenClient := client.NewQwenClient(cfg, &ts)
log.Info("Authentication successful.") log.Info("Authentication successful.")
cliClients = append(cliClients, qwenClient) cliClients[path] = qwenClient
} }
} }
} }
@@ -130,6 +130,8 @@ func StartService(cfg *config.Config, configPath string) {
log.Fatalf("Error walking auth directory: %v", err) log.Fatalf("Error walking auth directory: %v", err)
} }
clientSlice := clientsToSlice(cliClients)
if len(cfg.GlAPIKey) > 0 { if len(cfg.GlAPIKey) > 0 {
// Initialize clients with Generative Language API Keys if provided in configuration. // Initialize clients with Generative Language API Keys if provided in configuration.
for i := 0; i < len(cfg.GlAPIKey); i++ { for i := 0; i < len(cfg.GlAPIKey); i++ {
@@ -137,7 +139,7 @@ func StartService(cfg *config.Config, configPath string) {
log.Debug("Initializing with Generative Language API Key...") log.Debug("Initializing with Generative Language API Key...")
cliClient := client.NewGeminiClient(httpClient, cfg, cfg.GlAPIKey[i]) cliClient := client.NewGeminiClient(httpClient, cfg, cfg.GlAPIKey[i])
cliClients = append(cliClients, cliClient) clientSlice = append(clientSlice, cliClient)
} }
} }
@@ -146,7 +148,16 @@ func StartService(cfg *config.Config, configPath string) {
for i := 0; i < len(cfg.ClaudeKey); i++ { for i := 0; i < len(cfg.ClaudeKey); i++ {
log.Debug("Initializing with Claude API Key...") log.Debug("Initializing with Claude API Key...")
cliClient := client.NewClaudeClientWithKey(cfg, i) cliClient := client.NewClaudeClientWithKey(cfg, i)
cliClients = append(cliClients, cliClient) clientSlice = append(clientSlice, cliClient)
}
}
if len(cfg.CodexKey) > 0 {
// Initialize clients with Codex API Keys if provided in configuration.
for i := 0; i < len(cfg.CodexKey); i++ {
log.Debug("Initializing with Codex API Key...")
cliClient := client.NewCodexClientWithKey(cfg, i)
clientSlice = append(clientSlice, cliClient)
} }
} }
@@ -158,12 +169,12 @@ func StartService(cfg *config.Config, configPath string) {
if errClient != nil { if errClient != nil {
log.Fatalf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient) log.Fatalf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
} }
cliClients = append(cliClients, compatClient) clientSlice = append(clientSlice, compatClient)
} }
} }
// Create and start the API server with the pool of clients in a separate goroutine. // Create and start the API server with the pool of clients in a separate goroutine.
apiServer := api.NewServer(cfg, cliClients, configPath) apiServer := api.NewServer(cfg, clientSlice, configPath)
log.Infof("Starting API server on port %d", cfg.Port) log.Infof("Starting API server on port %d", cfg.Port)
// Start the API server in a goroutine so it doesn't block the main thread. // Start the API server in a goroutine so it doesn't block the main thread.
@@ -178,7 +189,7 @@ func StartService(cfg *config.Config, configPath string) {
log.Info("API server started successfully") log.Info("API server started successfully")
// Setup file watcher for config and auth directory changes to enable hot-reloading. // Setup file watcher for config and auth directory changes to enable hot-reloading.
fileWatcher, errNewWatcher := watcher.NewWatcher(configPath, cfg.AuthDir, func(newClients []interfaces.Client, newCfg *config.Config) { fileWatcher, errNewWatcher := watcher.NewWatcher(configPath, cfg.AuthDir, func(newClients map[string]interfaces.Client, newCfg *config.Config) {
// Update the API server with new clients and configuration when files change. // Update the API server with new clients and configuration when files change.
apiServer.UpdateClients(newClients, newCfg) apiServer.UpdateClients(newClients, newCfg)
}) })
@@ -221,9 +232,10 @@ func StartService(cfg *config.Config, configPath string) {
// Function to check and refresh tokens for all client types before they expire. // Function to check and refresh tokens for all client types before they expire.
checkAndRefresh := func() { checkAndRefresh := func() {
for i := 0; i < len(cliClients); i++ { clientSlice := clientsToSlice(cliClients)
if codexCli, ok := cliClients[i].(*client.CodexClient); ok { for i := 0; i < len(clientSlice); i++ {
ts := codexCli.TokenStorage().(*codex.CodexTokenStorage) if codexCli, ok := clientSlice[i].(*client.CodexClient); ok {
if ts, isCodexTS := codexCli.TokenStorage().(*claude.ClaudeTokenStorage); isCodexTS {
if ts != nil && ts.Expire != "" { if ts != nil && ts.Expire != "" {
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil { if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
if time.Until(expTime) <= 5*24*time.Hour { if time.Until(expTime) <= 5*24*time.Hour {
@@ -232,7 +244,8 @@ func StartService(cfg *config.Config, configPath string) {
} }
} }
} }
} else if claudeCli, isOK := cliClients[i].(*client.ClaudeClient); isOK { }
} else if claudeCli, isOK := clientSlice[i].(*client.ClaudeClient); isOK {
if ts, isCluadeTS := claudeCli.TokenStorage().(*claude.ClaudeTokenStorage); isCluadeTS { if ts, isCluadeTS := claudeCli.TokenStorage().(*claude.ClaudeTokenStorage); isCluadeTS {
if ts != nil && ts.Expire != "" { if ts != nil && ts.Expire != "" {
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil { if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
@@ -243,7 +256,7 @@ func StartService(cfg *config.Config, configPath string) {
} }
} }
} }
} else if qwenCli, isQwenOK := cliClients[i].(*client.QwenClient); isQwenOK { } else if qwenCli, isQwenOK := clientSlice[i].(*client.QwenClient); isQwenOK {
if ts, isQwenTS := qwenCli.TokenStorage().(*qwen.QwenTokenStorage); isQwenTS { if ts, isQwenTS := qwenCli.TokenStorage().(*qwen.QwenTokenStorage); isQwenTS {
if ts != nil && ts.Expire != "" { if ts != nil && ts.Expire != "" {
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil { if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
@@ -296,3 +309,11 @@ func StartService(cfg *config.Config, configPath string) {
} }
} }
} }
func clientsToSlice(clientMap map[string]interfaces.Client) []interfaces.Client {
s := make([]interfaces.Client, 0, len(clientMap))
for _, v := range clientMap {
s = append(s, v)
}
return s
}

View File

@@ -44,6 +44,9 @@ type Config struct {
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file. // ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
ClaudeKey []ClaudeKey `yaml:"claude-api-key"` ClaudeKey []ClaudeKey `yaml:"claude-api-key"`
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
CodexKey []CodexKey `yaml:"codex-api-key"`
// OpenAICompatibility defines OpenAI API compatibility configurations for external providers. // OpenAICompatibility defines OpenAI API compatibility configurations for external providers.
OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility"` OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility"`
@@ -83,6 +86,17 @@ type ClaudeKey struct {
BaseURL string `yaml:"base-url"` BaseURL string `yaml:"base-url"`
} }
// CodexKey represents the configuration for a Codex API key,
// including the API key itself and an optional base URL for the API endpoint.
type CodexKey struct {
// APIKey is the authentication key for accessing Codex API services.
APIKey string `yaml:"api-key"`
// 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"`
}
// OpenAICompatibility represents the configuration for OpenAI API compatibility // OpenAICompatibility represents the configuration for OpenAI API compatibility
// with external providers, allowing model aliases to be routed through OpenAI API format. // with external providers, allowing model aliases to be routed through OpenAI API format.
type OpenAICompatibility struct { type OpenAICompatibility struct {
@@ -286,58 +300,6 @@ func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
return val return val
} }
// Helpers to update sequences in place to preserve existing comments/anchors
func setStringListInPlace(mapNode *yaml.Node, key string, arr []string) {
if len(arr) == 0 {
setNullValue(mapNode, key)
return
}
v := getOrCreateMapValue(mapNode, key)
if v.Kind != yaml.SequenceNode {
v.Kind = yaml.SequenceNode
v.Tag = "!!seq"
v.Content = nil
}
// Update in place
oldLen := len(v.Content)
minLen := oldLen
if len(arr) < minLen {
minLen = len(arr)
}
for i := 0; i < minLen; i++ {
if v.Content[i] == nil {
v.Content[i] = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str"}
}
v.Content[i].Kind = yaml.ScalarNode
v.Content[i].Tag = "!!str"
v.Content[i].Value = arr[i]
}
if len(arr) > oldLen {
for i := oldLen; i < len(arr); i++ {
v.Content = append(v.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: arr[i]})
}
} else if len(arr) < oldLen {
v.Content = v.Content[:len(arr)]
}
}
func setMappingScalar(mapNode *yaml.Node, key string, val string) {
v := getOrCreateMapValue(mapNode, key)
v.Kind = yaml.ScalarNode
v.Tag = "!!str"
v.Value = val
}
// setNullValue ensures a mapping key exists and is set to an explicit null scalar,
// so that it renders as `key:` without `[]`.
func setNullValue(mapNode *yaml.Node, key string) {
// Represent as YAML null scalar without explicit value so it renders as `key:`
v := getOrCreateMapValue(mapNode, key)
v.Kind = yaml.ScalarNode
v.Tag = "!!null"
v.Value = ""
}
// mergeMappingPreserve merges keys from src into dst mapping node while preserving // mergeMappingPreserve merges keys from src into dst mapping node while preserving
// key order and comments of existing keys in dst. Unknown keys from src are appended // key order and comments of existing keys in dst. Unknown keys from src are appended
// to dst at the end, copying their node structure from src. // to dst at the end, copying their node structure from src.

View File

@@ -109,7 +109,11 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
// Parse output JSON string and set as response content // Parse output JSON string and set as response content
if output != "" { if output != "" {
outputResult := gjson.Parse(output) outputResult := gjson.Parse(output)
functionResponse, _ = sjson.SetRaw(functionResponse, "functionResponse.response.content", outputResult.Raw) if outputResult.IsObject() {
functionResponse, _ = sjson.SetRaw(functionResponse, "functionResponse.response.content", outputResult.String())
} else {
functionResponse, _ = sjson.Set(functionResponse, "functionResponse.response.content", outputResult.String())
}
} }
functionContent, _ = sjson.SetRaw(functionContent, "parts.-1", functionResponse) functionContent, _ = sjson.SetRaw(functionContent, "parts.-1", functionResponse)

View File

@@ -8,6 +8,7 @@ package gemini
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"strconv"
"strings" "strings"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
@@ -248,14 +249,253 @@ func parseArgsToMap(argsStr string) map[string]interface{} {
if trimmed == "" || trimmed == "{}" { if trimmed == "" || trimmed == "{}" {
return map[string]interface{}{} return map[string]interface{}{}
} }
// First try strict JSON
var out map[string]interface{} var out map[string]interface{}
if err := json.Unmarshal([]byte(trimmed), &out); err == nil { if errUnmarshal := json.Unmarshal([]byte(trimmed), &out); errUnmarshal == nil {
return out return out
} }
// Tolerant parse: handle streams where values are barewords (e.g., 北京, celsius)
tolerant := tolerantParseJSONMap(trimmed)
if len(tolerant) > 0 {
return tolerant
}
// Fallback: return empty object when parsing fails // Fallback: return empty object when parsing fails
return map[string]interface{}{} return map[string]interface{}{}
} }
// tolerantParseJSONMap attempts to parse a JSON-like object string into a map, tolerating
// bareword values (unquoted strings) commonly seen during streamed tool calls.
// Example input: {"location": 北京, "unit": celsius}
func tolerantParseJSONMap(s string) map[string]interface{} {
// Ensure we operate within the outermost braces if present
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start == -1 || end == -1 || start >= end {
return map[string]interface{}{}
}
content := s[start+1 : end]
runes := []rune(content)
n := len(runes)
i := 0
result := make(map[string]interface{})
for i < n {
// Skip whitespace and commas
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t' || runes[i] == ',') {
i++
}
if i >= n {
break
}
// Expect quoted key
if runes[i] != '"' {
// Unable to parse this segment reliably; skip to next comma
for i < n && runes[i] != ',' {
i++
}
continue
}
// Parse JSON string for key
keyToken, nextIdx := parseJSONStringRunes(runes, i)
if nextIdx == -1 {
break
}
keyName := jsonStringTokenToRawString(keyToken)
i = nextIdx
// Skip whitespace
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
i++
}
if i >= n || runes[i] != ':' {
break
}
i++ // skip ':'
// Skip whitespace
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
i++
}
if i >= n {
break
}
// Parse value (string, number, object/array, bareword)
var value interface{}
switch runes[i] {
case '"':
// JSON string
valToken, ni := parseJSONStringRunes(runes, i)
if ni == -1 {
// Malformed; treat as empty string
value = ""
i = n
} else {
value = jsonStringTokenToRawString(valToken)
i = ni
}
case '{', '[':
// Bracketed value: attempt to capture balanced structure
seg, ni := captureBracketed(runes, i)
if ni == -1 {
i = n
} else {
var anyVal interface{}
if errUnmarshal := json.Unmarshal([]byte(seg), &anyVal); errUnmarshal == nil {
value = anyVal
} else {
value = seg
}
i = ni
}
default:
// Bare token until next comma or end
j := i
for j < n && runes[j] != ',' {
j++
}
token := strings.TrimSpace(string(runes[i:j]))
// Interpret common JSON atoms and numbers; otherwise treat as string
if token == "true" {
value = true
} else if token == "false" {
value = false
} else if token == "null" {
value = nil
} else if numVal, ok := tryParseNumber(token); ok {
value = numVal
} else {
value = token
}
i = j
}
result[keyName] = value
// Skip trailing whitespace and optional comma before next pair
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
i++
}
if i < n && runes[i] == ',' {
i++
}
}
return result
}
// parseJSONStringRunes returns the JSON string token (including quotes) and the index just after it.
func parseJSONStringRunes(runes []rune, start int) (string, int) {
if start >= len(runes) || runes[start] != '"' {
return "", -1
}
i := start + 1
escaped := false
for i < len(runes) {
r := runes[i]
if r == '\\' && !escaped {
escaped = true
i++
continue
}
if r == '"' && !escaped {
return string(runes[start : i+1]), i + 1
}
escaped = false
i++
}
return string(runes[start:]), -1
}
// jsonStringTokenToRawString converts a JSON string token (including quotes) to a raw Go string value.
func jsonStringTokenToRawString(token string) string {
var s string
if errUnmarshal := json.Unmarshal([]byte(token), &s); errUnmarshal == nil {
return s
}
// Fallback: strip surrounding quotes if present
if len(token) >= 2 && token[0] == '"' && token[len(token)-1] == '"' {
return token[1 : len(token)-1]
}
return token
}
// captureBracketed captures a balanced JSON object/array starting at index i.
// Returns the segment string and the index just after it; -1 if malformed.
func captureBracketed(runes []rune, i int) (string, int) {
if i >= len(runes) {
return "", -1
}
startRune := runes[i]
var endRune rune
if startRune == '{' {
endRune = '}'
} else if startRune == '[' {
endRune = ']'
} else {
return "", -1
}
depth := 0
j := i
inStr := false
escaped := false
for j < len(runes) {
r := runes[j]
if inStr {
if r == '\\' && !escaped {
escaped = true
j++
continue
}
if r == '"' && !escaped {
inStr = false
} else {
escaped = false
}
j++
continue
}
if r == '"' {
inStr = true
j++
continue
}
if r == startRune {
depth++
} else if r == endRune {
depth--
if depth == 0 {
return string(runes[i : j+1]), j + 1
}
}
j++
}
return string(runes[i:]), -1
}
// tryParseNumber attempts to parse a string as an int or float.
func tryParseNumber(s string) (interface{}, bool) {
if s == "" {
return nil, false
}
// Try integer
if i64, errParseInt := strconv.ParseInt(s, 10, 64); errParseInt == nil {
return i64, true
}
if u64, errParseUInt := strconv.ParseUint(s, 10, 64); errParseUInt == nil {
return u64, true
}
if f64, errParseFloat := strconv.ParseFloat(s, 64); errParseFloat == nil {
return f64, true
}
return nil, false
}
// ConvertOpenAIResponseToGeminiNonStream converts a non-streaming OpenAI response to a non-streaming Gemini response. // ConvertOpenAIResponseToGeminiNonStream converts a non-streaming OpenAI response to a non-streaming Gemini response.
// //
// Parameters: // Parameters:

View File

@@ -34,14 +34,14 @@ type Watcher struct {
configPath string configPath string
authDir string authDir string
config *config.Config config *config.Config
clients []interfaces.Client clients map[string]interfaces.Client
clientsMutex sync.RWMutex clientsMutex sync.RWMutex
reloadCallback func([]interfaces.Client, *config.Config) reloadCallback func(map[string]interfaces.Client, *config.Config)
watcher *fsnotify.Watcher watcher *fsnotify.Watcher
} }
// NewWatcher creates a new file watcher instance // NewWatcher creates a new file watcher instance
func NewWatcher(configPath, authDir string, reloadCallback func([]interfaces.Client, *config.Config)) (*Watcher, error) { func NewWatcher(configPath, authDir string, reloadCallback func(map[string]interfaces.Client, *config.Config)) (*Watcher, error) {
watcher, errNewWatcher := fsnotify.NewWatcher() watcher, errNewWatcher := fsnotify.NewWatcher()
if errNewWatcher != nil { if errNewWatcher != nil {
return nil, errNewWatcher return nil, errNewWatcher
@@ -52,6 +52,7 @@ func NewWatcher(configPath, authDir string, reloadCallback func([]interfaces.Cli
authDir: authDir, authDir: authDir,
reloadCallback: reloadCallback, reloadCallback: reloadCallback,
watcher: watcher, watcher: watcher,
clients: make(map[string]interfaces.Client),
}, nil }, nil
} }
@@ -90,7 +91,7 @@ func (w *Watcher) SetConfig(cfg *config.Config) {
} }
// SetClients updates the current client list // SetClients updates the current client list
func (w *Watcher) SetClients(clients []interfaces.Client) { func (w *Watcher) SetClients(clients map[string]interfaces.Client) {
w.clientsMutex.Lock() w.clientsMutex.Lock()
defer w.clientsMutex.Unlock() defer w.clientsMutex.Unlock()
w.clients = clients w.clients = clients
@@ -119,7 +120,6 @@ func (w *Watcher) processEvents(ctx context.Context) {
// handleEvent processes individual file system events // handleEvent processes individual file system events
func (w *Watcher) handleEvent(event fsnotify.Event) { func (w *Watcher) handleEvent(event fsnotify.Event) {
now := time.Now() now := time.Now()
log.Debugf("file system event detected: %s %s", event.Op.String(), event.Name) log.Debugf("file system event detected: %s %s", event.Op.String(), event.Name)
// Handle config file changes // Handle config file changes
@@ -130,13 +130,14 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
return return
} }
// Handle auth directory changes (only for .json files) // Handle auth directory changes incrementally
// Simplified: reload on any change to .json files in auth directory
if strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") { if strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") {
log.Infof("auth file changed (%s): %s, reloading clients", event.Op.String(), filepath.Base(event.Name)) log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
log.Debugf("auth file change details - operation: %s, file: %s, timestamp: %s", if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write {
event.Op.String(), filepath.Base(event.Name), now.Format("2006-01-02 15:04:05.000")) w.addOrUpdateClient(event.Name)
w.reloadClients() } else if event.Op&fsnotify.Remove == fsnotify.Remove {
w.removeClient(event.Name)
}
} }
} }
@@ -185,6 +186,9 @@ func (w *Watcher) reloadConfig() {
if len(oldConfig.ClaudeKey) != len(newConfig.ClaudeKey) { if len(oldConfig.ClaudeKey) != len(newConfig.ClaudeKey) {
log.Debugf(" claude-api-key count: %d -> %d", len(oldConfig.ClaudeKey), len(newConfig.ClaudeKey)) log.Debugf(" claude-api-key count: %d -> %d", len(oldConfig.ClaudeKey), len(newConfig.ClaudeKey))
} }
if len(oldConfig.CodexKey) != len(newConfig.CodexKey) {
log.Debugf(" codex-api-key count: %d -> %d", len(oldConfig.CodexKey), len(newConfig.CodexKey))
}
if oldConfig.AllowLocalhostUnauthenticated != newConfig.AllowLocalhostUnauthenticated { if oldConfig.AllowLocalhostUnauthenticated != newConfig.AllowLocalhostUnauthenticated {
log.Debugf(" allow-localhost-unauthenticated: %t -> %t", oldConfig.AllowLocalhostUnauthenticated, newConfig.AllowLocalhostUnauthenticated) log.Debugf(" allow-localhost-unauthenticated: %t -> %t", oldConfig.AllowLocalhostUnauthenticated, newConfig.AllowLocalhostUnauthenticated)
} }
@@ -198,9 +202,10 @@ func (w *Watcher) reloadConfig() {
w.reloadClients() w.reloadClients()
} }
// reloadClients reloads all authentication clients // reloadClients performs a full scan of the auth directory and reloads all clients.
// This is used for initial startup and for handling config file reloads.
func (w *Watcher) reloadClients() { func (w *Watcher) reloadClients() {
log.Debugf("starting client reload process") log.Debugf("starting full client reload process")
w.clientsMutex.RLock() w.clientsMutex.RLock()
cfg := w.config cfg := w.config
@@ -212,25 +217,24 @@ func (w *Watcher) reloadClients() {
return return
} }
log.Debugf("scanning auth directory: %s", cfg.AuthDir) log.Debugf("scanning auth directory for initial load or full reload: %s", cfg.AuthDir)
// Create new client list // Create new client map
newClients := make([]interfaces.Client, 0) newClients := make(map[string]interfaces.Client)
authFileCount := 0 authFileCount := 0
successfulAuthCount := 0 successfulAuthCount := 0
// Handle tilde expansion for auth directory
if strings.HasPrefix(cfg.AuthDir, "~") { if strings.HasPrefix(cfg.AuthDir, "~") {
home, errUserHomeDir := os.UserHomeDir() home, errUserHomeDir := os.UserHomeDir()
if errUserHomeDir != nil { if errUserHomeDir != nil {
log.Fatalf("failed to get home directory: %v", errUserHomeDir) log.Fatalf("failed to get home directory: %v", errUserHomeDir)
} }
// Reconstruct the path by replacing the tilde with the user's home directory.
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator)) parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
if len(parts) > 1 { if len(parts) > 1 {
parts[0] = home parts[0] = home
cfg.AuthDir = path.Join(parts...) cfg.AuthDir = path.Join(parts...)
} else { } else {
// If the path is just "~", set it to the home directory.
cfg.AuthDir = home cfg.AuthDir = home
} }
} }
@@ -241,91 +245,14 @@ func (w *Watcher) reloadClients() {
log.Debugf("error accessing path %s: %v", path, err) log.Debugf("error accessing path %s: %v", path, err)
return err return err
} }
// Process only JSON files in the auth directory
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") { if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
authFileCount++ authFileCount++
log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path)) log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path))
if client, err := w.createClientFromFile(path, cfg); err == nil {
data, errReadFile := os.ReadFile(path) newClients[path] = client
if errReadFile != nil {
return errReadFile
}
tokenType := "gemini"
typeResult := gjson.GetBytes(data, "type")
if typeResult.Exists() {
tokenType = typeResult.String()
}
// Decode the token storage file
if tokenType == "gemini" {
var ts gemini.GeminiTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
// For each valid token, create an authenticated client
clientCtx := context.Background()
log.Debugf(" initializing gemini authentication for token from %s...", filepath.Base(path))
geminiAuth := gemini.NewGeminiAuth()
httpClient, errGetClient := geminiAuth.GetAuthenticatedClient(clientCtx, &ts, cfg)
if errGetClient != nil {
log.Errorf(" failed to get authenticated client for token %s: %v", path, errGetClient)
return nil // Continue processing other files
}
log.Debugf(" authentication successful for token from %s", filepath.Base(path))
// Add the new client to the pool
cliClient := client.NewGeminiCLIClient(httpClient, &ts, cfg)
newClients = append(newClients, cliClient)
successfulAuthCount++ successfulAuthCount++
} else { } else {
log.Errorf(" failed to decode token file %s: %v", path, err) log.Errorf("failed to create client from file %s: %v", path, err)
}
} else if tokenType == "codex" {
var ts codex.CodexTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
// For each valid token, create an authenticated client
log.Debugf(" initializing codex authentication for token from %s...", filepath.Base(path))
codexClient, errGetClient := client.NewCodexClient(cfg, &ts)
if errGetClient != nil {
log.Errorf(" failed to get authenticated client for token %s: %v", path, errGetClient)
return nil // Continue processing other files
}
log.Debugf(" authentication successful for token from %s", filepath.Base(path))
// Add the new client to the pool
newClients = append(newClients, codexClient)
successfulAuthCount++
} else {
log.Errorf(" failed to decode token file %s: %v", path, err)
}
} else if tokenType == "claude" {
var ts claude.ClaudeTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
// For each valid token, create an authenticated client
log.Debugf(" initializing claude authentication for token from %s...", filepath.Base(path))
claudeClient := client.NewClaudeClient(cfg, &ts)
log.Debugf(" authentication successful for token from %s", filepath.Base(path))
// Add the new client to the pool
newClients = append(newClients, claudeClient)
successfulAuthCount++
} else {
log.Errorf(" failed to decode token file %s: %v", path, err)
}
} else if tokenType == "qwen" {
var ts qwen.QwenTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
// For each valid token, create an authenticated client
log.Debugf(" initializing qwen authentication for token from %s...", filepath.Base(path))
qwenClient := client.NewQwenClient(cfg, &ts)
log.Debugf(" authentication successful for token from %s", filepath.Base(path))
// Add the new client to the pool
newClients = append(newClients, qwenClient)
successfulAuthCount++
} else {
log.Errorf(" failed to decode token file %s: %v", path, err)
}
} }
} }
return nil return nil
@@ -334,37 +261,50 @@ func (w *Watcher) reloadClients() {
log.Errorf("error walking auth directory: %v", errWalk) log.Errorf("error walking auth directory: %v", errWalk)
return return
} }
log.Debugf("auth directory scan complete - found %d .json files, %d successful authentications", authFileCount, successfulAuthCount) log.Debugf("auth directory scan complete - found %d .json files, %d successful authentications", authFileCount, successfulAuthCount)
// Note: API key-based clients are not stored in the map as they don't correspond to a file.
// They are re-created each time, which is lightweight.
clientSlice := w.clientsToSlice(newClients)
// Add clients for Generative Language API keys if configured // Add clients for Generative Language API keys if configured
glAPIKeyCount := 0 glAPIKeyCount := 0
if len(cfg.GlAPIKey) > 0 { if len(cfg.GlAPIKey) > 0 {
log.Debugf("processing %d Generative Language API Keys", len(cfg.GlAPIKey)) log.Debugf("processing %d Generative Language API Keys", len(cfg.GlAPIKey))
for i := 0; i < len(cfg.GlAPIKey); i++ { for i := 0; i < len(cfg.GlAPIKey); i++ {
httpClient := util.SetProxy(cfg, &http.Client{}) httpClient := util.SetProxy(cfg, &http.Client{})
log.Debugf("Initializing with Generative Language API Key %d...", i+1) log.Debugf("Initializing with Generative Language API Key %d...", i+1)
cliClient := client.NewGeminiClient(httpClient, cfg, cfg.GlAPIKey[i]) cliClient := client.NewGeminiClient(httpClient, cfg, cfg.GlAPIKey[i])
newClients = append(newClients, cliClient) clientSlice = append(clientSlice, cliClient)
glAPIKeyCount++ glAPIKeyCount++
} }
log.Debugf("Successfully initialized %d Generative Language API Key clients", glAPIKeyCount) log.Debugf("Successfully initialized %d Generative Language API Key clients", glAPIKeyCount)
} }
// ... (Claude, Codex, OpenAI-compat clients are handled similarly) ...
claudeAPIKeyCount := 0 claudeAPIKeyCount := 0
if len(cfg.ClaudeKey) > 0 { if len(cfg.ClaudeKey) > 0 {
log.Debugf("processing %d Claude API Keys", len(cfg.ClaudeKey)) log.Debugf("processing %d Claude API Keys", len(cfg.ClaudeKey))
for i := 0; i < len(cfg.ClaudeKey); i++ { for i := 0; i < len(cfg.ClaudeKey); i++ {
log.Debugf("Initializing with Claude API Key %d...", i+1) log.Debugf("Initializing with Claude API Key %d...", i+1)
cliClient := client.NewClaudeClientWithKey(cfg, i) cliClient := client.NewClaudeClientWithKey(cfg, i)
newClients = append(newClients, cliClient) clientSlice = append(clientSlice, cliClient)
claudeAPIKeyCount++ claudeAPIKeyCount++
} }
log.Debugf("Successfully initialized %d Claude API Key clients", claudeAPIKeyCount) log.Debugf("Successfully initialized %d Claude API Key clients", claudeAPIKeyCount)
} }
// Add clients for OpenAI compatibility providers if configured codexAPIKeyCount := 0
if len(cfg.CodexKey) > 0 {
log.Debugf("processing %d Codex API Keys", len(cfg.CodexKey))
for i := 0; i < len(cfg.CodexKey); i++ {
log.Debugf("Initializing with Codex API Key %d...", i+1)
cliClient := client.NewCodexClientWithKey(cfg, i)
clientSlice = append(clientSlice, cliClient)
codexAPIKeyCount++
}
log.Debugf("Successfully initialized %d Codex API Key clients", codexAPIKeyCount)
}
openAICompatCount := 0 openAICompatCount := 0
if len(cfg.OpenAICompatibility) > 0 { if len(cfg.OpenAICompatibility) > 0 {
log.Debugf("processing %d OpenAI-compatibility providers", len(cfg.OpenAICompatibility)) log.Debugf("processing %d OpenAI-compatibility providers", len(cfg.OpenAICompatibility))
@@ -375,38 +315,163 @@ func (w *Watcher) reloadClients() {
log.Errorf(" failed to create OpenAI-compatibility client for %s: %v", compat.Name, errClient) log.Errorf(" failed to create OpenAI-compatibility client for %s: %v", compat.Name, errClient)
continue continue
} }
newClients = append(newClients, compatClient) clientSlice = append(clientSlice, compatClient)
openAICompatCount++ openAICompatCount++
} }
log.Debugf("Successfully initialized %d OpenAI-compatibility clients", openAICompatCount) log.Debugf("Successfully initialized %d OpenAI-compatibility clients", openAICompatCount)
} }
// Unregister old clients from the model registry if supported // Unregister all old clients
w.clientsMutex.RLock() w.clientsMutex.RLock()
for i := 0; i < len(w.clients); i++ { for _, oldClient := range w.clients {
if u, ok := any(w.clients[i]).(interface{ UnregisterClient() }); ok { if u, ok := any(oldClient).(interface{ UnregisterClient() }); ok {
u.UnregisterClient() u.UnregisterClient()
} }
} }
w.clientsMutex.RUnlock() w.clientsMutex.RUnlock()
// Update the client list // Update the client map
w.clientsMutex.Lock() w.clientsMutex.Lock()
w.clients = newClients w.clients = newClients
w.clientsMutex.Unlock() w.clientsMutex.Unlock()
log.Infof("client reload complete - old: %d clients, new: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d OpenAI-compat)", log.Infof("full client reload complete - old: %d clients, new: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
oldClientCount, oldClientCount,
len(newClients), len(clientSlice),
successfulAuthCount, successfulAuthCount,
glAPIKeyCount, glAPIKeyCount,
claudeAPIKeyCount, claudeAPIKeyCount,
codexAPIKeyCount,
openAICompatCount, openAICompatCount,
) )
// Trigger the callback to update the server // Trigger the callback to update the server
if w.reloadCallback != nil { if w.reloadCallback != nil {
log.Debugf("triggering server update callback") log.Debugf("triggering server update callback")
w.reloadCallback(newClients, cfg) // Note: The callback signature expects a map now, but the API server internally works with a slice.
// We pass the map directly, and the server will handle converting it.
w.reloadCallback(w.clients, cfg)
}
}
// createClientFromFile creates a single client instance from a given token file path.
func (w *Watcher) createClientFromFile(path string, cfg *config.Config) (interfaces.Client, error) {
data, errReadFile := os.ReadFile(path)
if errReadFile != nil {
return nil, errReadFile
}
// If the file is empty, it's likely an intermediate state (e.g., after touch, before write).
// Silently ignore it and wait for a subsequent write event with content.
if len(data) == 0 {
return nil, nil // Not an error, just nothing to process yet.
}
tokenType := "gemini"
typeResult := gjson.GetBytes(data, "type")
if typeResult.Exists() {
tokenType = typeResult.String()
}
var err error
if tokenType == "gemini" {
var ts gemini.GeminiTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
clientCtx := context.Background()
geminiAuth := gemini.NewGeminiAuth()
httpClient, errGetClient := geminiAuth.GetAuthenticatedClient(clientCtx, &ts, cfg)
if errGetClient != nil {
return nil, errGetClient
}
return client.NewGeminiCLIClient(httpClient, &ts, cfg), nil
}
} else if tokenType == "codex" {
var ts codex.CodexTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
return client.NewCodexClient(cfg, &ts)
}
} else if tokenType == "claude" {
var ts claude.ClaudeTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
return client.NewClaudeClient(cfg, &ts), nil
}
} else if tokenType == "qwen" {
var ts qwen.QwenTokenStorage
if err = json.Unmarshal(data, &ts); err == nil {
return client.NewQwenClient(cfg, &ts), nil
}
}
return nil, err
}
// clientsToSlice converts the client map to a slice.
func (w *Watcher) clientsToSlice(clientMap map[string]interfaces.Client) []interfaces.Client {
s := make([]interfaces.Client, 0, len(clientMap))
for _, v := range clientMap {
s = append(s, v)
}
return s
}
// addOrUpdateClient handles the addition or update of a single client.
func (w *Watcher) addOrUpdateClient(path string) {
w.clientsMutex.Lock()
defer w.clientsMutex.Unlock()
cfg := w.config
if cfg == nil {
log.Error("config is nil, cannot add or update client")
return
}
// Unregister old client if it exists
if oldClient, ok := w.clients[path]; ok {
if u, canUnregister := any(oldClient).(interface{ UnregisterClient() }); canUnregister {
log.Debugf("unregistering old client for updated file: %s", filepath.Base(path))
u.UnregisterClient()
}
}
newClient, err := w.createClientFromFile(path, cfg)
if err != nil {
log.Errorf("failed to create/update client for %s: %v", filepath.Base(path), err)
// If creation fails, ensure the old client is removed from the map
delete(w.clients, path)
} else if newClient != nil { // Only update if a client was actually created
log.Debugf("successfully created/updated client for %s", filepath.Base(path))
w.clients[path] = newClient
} else {
// This case handles the empty file scenario gracefully
log.Debugf("ignoring empty auth file: %s", filepath.Base(path))
return // Do not trigger callback for an empty file
}
if w.reloadCallback != nil {
log.Debugf("triggering server update callback after add/update")
w.reloadCallback(w.clients, cfg)
}
}
// removeClient handles the removal of a single client.
func (w *Watcher) removeClient(path string) {
w.clientsMutex.Lock()
defer w.clientsMutex.Unlock()
cfg := w.config
// Unregister client if it exists
if oldClient, ok := w.clients[path]; ok {
if u, canUnregister := any(oldClient).(interface{ UnregisterClient() }); canUnregister {
log.Debugf("unregistering client for removed file: %s", filepath.Base(path))
u.UnregisterClient()
}
delete(w.clients, path)
log.Debugf("removed client for %s", filepath.Base(path))
if w.reloadCallback != nil {
log.Debugf("triggering server update callback after removal")
w.reloadCallback(w.clients, cfg)
}
} }
} }