Compare commits

...

6 Commits

Author SHA1 Message Date
Luis Pater
7b9cfbc3f7 Merge pull request #23 from luispater/dev
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
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
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- 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
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- 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
13 changed files with 1173 additions and 413 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:
```bash
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>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/debug
```
- Response:
```json
{ "status": "ok" }
```
Example (set true): ### Proxy Server URL
```bash - GET `/proxy-url` — Get the proxy URL string
curl -X PUT \ - Request:
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ ```bash
-H 'Content-Type: application/json' \ curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/proxy-url
-d '{"value":true}' \ ```
http://localhost:8317/v0/management/debug - Response:
``` ```json
Response: { "proxy-url": "socks5://user:pass@127.0.0.1:1080/" }
```json ```
{ "status": "ok" } - PUT/PATCH `/proxy-url` — Set the proxy URL string
``` - Request (PUT):
```bash
### Proxy URL curl -X PUT -H 'Content-Type: application/json' \
- GET `/proxy-url` — get proxy URL string -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
- PUT/PATCH `/proxy-url` — set proxy URL string -d '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \
- DELETE `/proxy-url` — clear proxy URL http://localhost:8317/v0/management/proxy-url
```
Example (set): - Request (PATCH):
```bash ```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 '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \ -d '{"value":"http://127.0.0.1:8080"}' \
http://localhost:8317/v0/management/proxy-url http://localhost:8317/v0/management/proxy-url
``` ```
Response: - Response:
```json ```json
{ "status": "ok" } { "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
{ "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:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-project
```
- Response:
```json
{ "switch-project": true }
```
- PUT/PATCH `/quota-exceeded/switch-project` — Boolean
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":false}' \
http://localhost:8317/v0/management/quota-exceeded/switch-project
```
- Response:
```json
{ "status": "ok" }
```
- GET `/quota-exceeded/switch-preview-model` - GET `/quota-exceeded/switch-preview-model`
- PUT/PATCH `/quota-exceeded/switch-preview-model` — boolean - 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
{ "status": "ok" }
```
Example: ### API Keys (proxy service auth)
```bash - GET `/api-keys` — Return the full list
curl -X PUT -H 'Content-Type: application/json' \ - Request:
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ ```bash
-d '{"value":false}' \ curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/api-keys
http://localhost:8317/v0/management/quota-exceeded/switch-project ```
``` - Response:
Response: ```json
```json { "api-keys": ["k1","k2","k3"] }
{ "status": "ok" } ```
``` - PUT `/api-keys` — Replace the full list
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '["k1","k2","k3"]' \
http://localhost:8317/v0/management/api-keys
```
- 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' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"old":"k2","new":"k2b"}' \
http://localhost:8317/v0/management/api-keys
```
- 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'
```
- Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?index=0'
```
- Response:
```json
{ "status": "ok" }
```
### API Keys (proxy server auth) ### Gemini API Key (Generative Language)
- GET `/api-keys` — return the full list
- PUT `/api-keys` — replace the full list
- PATCH `/api-keys` — update one entry (by `old/new` or `index/value`)
- DELETE `/api-keys` — remove one entry (by `?value=` or `?index=`)
Examples:
```bash
# Replace list
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '["k1","k2","k3"]' \
http://localhost:8317/v0/management/api-keys
# Patch: replace k2 -> k2b
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"old":"k2","new":"k2b"}' \
http://localhost:8317/v0/management/api-keys
# Delete by value
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?value=k1'
```
Response (GET):
```json
{ "api-keys": ["k1","k2b","k3"] }
```
### Generative Language API Keys (Gemini)
- 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
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/allow-localhost-unauthenticated
```
- Response:
```json
{ "allow-localhost-unauthenticated": false }
```
- 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" }
```
### Claude API Keys (object array) ### Claude API KEY (object array)
- GET `/claude-api-key`full list - GET `/claude-api-key` — List all
- PUT `/claude-api-key` — replace list - Request:
- PATCH `/claude-api-key` — update one item (by `index` or `match` API key) ```bash
- DELETE `/claude-api-key` — remove one item (`?api-key=` or `?index=`) curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/claude-api-key
```
Object shape: - Response:
```json ```json
{ { "claude-api-key": [ { "api-key": "sk-a", "base-url": "" } ] }
"api-key": "sk-...", ```
"base-url": "https://custom.example.com" // optional - PUT `/claude-api-key` — Replace the list
} - Request:
``` ```bash
curl -X PUT -H 'Content-Type: application/json' \
Examples: -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
```bash -d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \
# Replace list http://localhost:8317/v0/management/claude-api-key
curl -X PUT -H 'Content-Type: application/json' \ ```
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ - Response:
-d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \ ```json
http://localhost:8317/v0/management/claude-api-key { "status": "ok" }
```
# Patch by index - PATCH `/claude-api-key` — Modify one (by `index` or `match`)
curl -X PATCH -H 'Content-Type: application/json' \ - Request (by index):
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ ```bash
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \ curl -X PATCH -H 'Content-Type: application/json' \
http://localhost:8317/v0/management/claude-api-key -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \
# Patch by match (api-key) http://localhost:8317/v0/management/claude-api-key
curl -X PATCH -H 'Content-Type: application/json' \ ```
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ - Request (by match):
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \ ```bash
http://localhost:8317/v0/management/claude-api-key curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
# Delete by api-key -d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2' http://localhost:8317/v0/management/claude-api-key
``` ```
Response (GET): - Response:
```json ```json
{ { "status": "ok" }
"claude-api-key": [ ```
{ "api-key": "sk-a", "base-url": "" } - 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'
```
- Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?index=0'
```
- Response:
```json
{ "status": "ok" }
```
### 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
```
- Response:
```json
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-keys": [], "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"}]}]' \
http://localhost:8317/v0/management/openai-compatibility
```
- 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' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"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":[]}}' \
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'
```
- Response:
```json
{ "status": "ok" }
```
Object shape: ### Auth File Management
```json
{
"name": "openrouter",
"base-url": "https://openrouter.ai/api/v1",
"api-keys": ["sk-..."],
"models": [ {"name": "moonshotai/kimi-k2:free", "alias": "kimi-k2"} ]
}
```
Examples: Manage JSON token files under `auth-dir`: list, download, upload, delete.
```bash
# Replace list
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"}]}]' \
http://localhost:8317/v0/management/openai-compatibility
# Patch by name - GET `/auth-files` — List
curl -X PATCH -H 'Content-Type: application/json' \ - Request:
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \ ```bash
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \ curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/auth-files
http://localhost:8317/v0/management/openai-compatibility ```
# Delete by index
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?index=0'
```
Response (GET):
```json
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "...", "api-keys": [], "models": [] } ] }
```
### Auth Files Management
List JSON token files under `auth-dir`, download/upload/delete.
- GET `/auth-files` — list
- 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,28 +233,60 @@
{ "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 ```json
{ "status": "ok" } { "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
{ "status": "ok" }
```
### 请求重试次数 ### 请求重试次数
- GET `/request-retry` — 获取整数 - GET `/request-retry` — 获取整数

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.
@@ -63,7 +66,8 @@ func NewCodexClient(cfg *config.Config, ts *codex.CodexTokenStorage) (*CodexClie
modelQuotaExceeded: make(map[string]*time.Time), modelQuotaExceeded: make(map[string]*time.Time),
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")
req.Header.Set("Chatgpt-Account-Id", c.tokenStorage.(*codex.CodexTokenStorage).AccountID)
req.Header.Set("Originator", "codex_cli_rs") if c.apiKeyIndex != -1 {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.tokenStorage.(*codex.CodexTokenStorage).AccessToken)) // 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("Originator", "codex_cli_rs")
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
} }
} }
log.Debugf("Use ChatGPT account %s for model %s", c.GetEmail(), modelName) 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)
}
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,18 +232,20 @@ 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 != nil && ts.Expire != "" { if ts, isCodexTS := codexCli.TokenStorage().(*claude.ClaudeTokenStorage); isCodexTS {
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil { if ts != nil && ts.Expire != "" {
if time.Until(expTime) <= 5*24*time.Hour { if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
log.Debugf("refreshing codex tokens for %s", codexCli.GetEmail()) if time.Until(expTime) <= 5*24*time.Hour {
_ = codexCli.RefreshTokens(ctxRefresh) log.Debugf("refreshing codex tokens for %s", codexCli.GetEmail())
_ = codexCli.RefreshTokens(ctxRefresh)
}
} }
} }
} }
} 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

@@ -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 { successfulAuthCount++
return errReadFile } else {
} log.Errorf("failed to create client from file %s: %v", path, err)
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++
} else {
log.Errorf(" failed to decode token 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)
}
} }
} }