From db43930b986ac2e95d8c6d7e8414f71ca9533e9e Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 31 Aug 2025 04:14:43 +0800 Subject: [PATCH] Add management API handlers for config and auth file management - Implemented CRUD operations for authentication files. - Added endpoints for managing API keys, quotas, proxy settings, and other configurations. - Enhanced management access with robust validation, remote access control, and persistence support. - Updated README with new configuration details. Fixed OpenAI Chat Completions for codex --- MANAGEMENT_API.md | 248 +++++++++ MANAGEMENT_API_CN.md | 488 ++++++++++++++++++ README.md | 61 ++- README_CN.md | 18 +- config.example.yaml | 13 +- .../api/handlers/management/auth_files.go | 139 +++++ .../api/handlers/management/config_basic.go | 41 ++ .../api/handlers/management/config_lists.go | 252 +++++++++ internal/api/handlers/management/handler.go | 140 +++++ internal/api/handlers/management/quota.go | 18 + internal/api/server.go | 97 +++- internal/cmd/run.go | 2 +- internal/config/config.go | 350 +++++++++++++ .../codex/openai/codex_openai_request.go | 6 +- internal/watcher/watcher.go | 6 + 15 files changed, 1848 insertions(+), 31 deletions(-) create mode 100644 MANAGEMENT_API.md create mode 100644 MANAGEMENT_API_CN.md create mode 100644 internal/api/handlers/management/auth_files.go create mode 100644 internal/api/handlers/management/config_basic.go create mode 100644 internal/api/handlers/management/config_lists.go create mode 100644 internal/api/handlers/management/handler.go create mode 100644 internal/api/handlers/management/quota.go diff --git a/MANAGEMENT_API.md b/MANAGEMENT_API.md new file mode 100644 index 00000000..a18fca99 --- /dev/null +++ b/MANAGEMENT_API.md @@ -0,0 +1,248 @@ +# Management API + +Base URL: `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 hot‑reloaded by the server. + +Note: The following options cannot be changed via API and must be edited in the config file, then restart if needed: +- `allow-remote-management` +- `remote-management-key` (stored as bcrypt hash after startup if plaintext was provided) + +## Authentication + +- All requests (including localhost) must include a management key. +- Remote access additionally requires `allow-remote-management: true` in config. +- Provide the key via one of: + - `Authorization: Bearer ` + - `X-Management-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/*`). + +## Request/Response Conventions + +- Content type: `application/json` unless noted. +- Boolean/int/string updates use body: `{ "value": }`. +- Array PUT bodies can be either a raw array (e.g. `["a","b"]`) or `{ "items": [ ... ] }`. +- Array PATCH accepts either `{ "old": "k1", "new": "k2" }` or `{ "index": 0, "value": "k2" }`. +- Object-array PATCH supports either index or key match (documented per endpoint). + +## Endpoints + +### Debug +- GET `/debug` — get current debug flag +- PUT/PATCH `/debug` — set debug (boolean) + +Example (set true): +```bash +curl -X PUT \ +-H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"value":true}' \ + http://localhost:8317/v0/management/debug +``` +Response: +```json +{ "status": "ok" } +``` + +### Proxy URL +- GET `/proxy-url` — get proxy URL string +- PUT/PATCH `/proxy-url` — set proxy URL string +- DELETE `/proxy-url` — clear proxy URL + +Example (set): +```bash +curl -X PATCH -H 'Content-Type: application/json' \ +-H 'Authorization: Bearer ' \ + -d '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \ + http://localhost:8317/v0/management/proxy-url +``` +Response: +```json +{ "status": "ok" } +``` + +### Quota Exceeded Behavior +- GET `/quota-exceeded/switch-project` +- PUT/PATCH `/quota-exceeded/switch-project` — boolean +- GET `/quota-exceeded/switch-preview-model` +- PUT/PATCH `/quota-exceeded/switch-preview-model` — boolean + +Example: +```bash +curl -X PUT -H 'Content-Type: application/json' \ +-H 'Authorization: Bearer ' \ + -d '{"value":false}' \ + http://localhost:8317/v0/management/quota-exceeded/switch-project +``` +Response: +```json +{ "status": "ok" } +``` + +### API Keys (proxy server auth) +- 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 ' \ + -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 ' \ + -d '{"old":"k2","new":"k2b"}' \ + http://localhost:8317/v0/management/api-keys + +# Delete by value +curl -H 'Authorization: Bearer ' -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` +- PUT `/generative-language-api-key` +- PATCH `/generative-language-api-key` +- DELETE `/generative-language-api-key` + +Same request/response shapes as API keys. + +### Request Logging +- GET `/request-log` — get boolean +- PUT/PATCH `/request-log` — set boolean + +### Request Retry +- GET `/request-retry` — get integer +- PUT/PATCH `/request-retry` — set integer + +### Allow Localhost Unauthenticated +- GET `/allow-localhost-unauthenticated` — get boolean +- PUT/PATCH `/allow-localhost-unauthenticated` — set boolean + +### Claude API Keys (object array) +- GET `/claude-api-key` — full list +- PUT `/claude-api-key` — replace list +- 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 +{ + "api-key": "sk-...", + "base-url": "https://custom.example.com" // optional +} +``` + +Examples: +```bash +# Replace list +curl -X PUT -H 'Content-Type: application/json' \ +-H 'Authorization: Bearer ' \ + -d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \ + http://localhost:8317/v0/management/claude-api-key + +# Patch by index +curl -X PATCH -H 'Content-Type: application/json' \ +-H 'Authorization: Bearer ' \ + -d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \ + http://localhost:8317/v0/management/claude-api-key + +# Patch by match (api-key) +curl -X PATCH -H 'Content-Type: application/json' \ +-H 'Authorization: Bearer ' \ + -d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \ + http://localhost:8317/v0/management/claude-api-key + +# Delete by api-key +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2' +``` +Response (GET): +```json +{ + "claude-api-key": [ + { "api-key": "sk-a", "base-url": "" } + ] +} +``` + +### OpenAI Compatibility Providers (object array) +- GET `/openai-compatibility` — full list +- PUT `/openai-compatibility` — replace list +- PATCH `/openai-compatibility` — update one item by `index` or `name` +- DELETE `/openai-compatibility` — remove by `?name=` or `?index=` + +Object shape: +```json +{ + "name": "openrouter", + "base-url": "https://openrouter.ai/api/v1", + "api-keys": ["sk-..."], + "models": [ {"name": "moonshotai/kimi-k2:free", "alias": "kimi-k2"} ] +} +``` + +Examples: +```bash +# Replace list +curl -X PUT -H 'Content-Type: application/json' \ +-H 'Authorization: Bearer ' \ + -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 +curl -X PATCH -H 'Content-Type: application/json' \ +-H 'Authorization: Bearer ' \ + -d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \ + http://localhost:8317/v0/management/openai-compatibility + +# Delete by index +curl -H 'Authorization: Bearer ' -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: + ```json + { "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z" } ] } + ``` + +- GET `/auth-files/download?name=` — download a single file + +- POST `/auth-files` — upload + - Multipart form: field `file` (must be `.json`) + - Or raw JSON body with `?name=` + - Response: `{ "status": "ok" }` + +- DELETE `/auth-files?name=` — delete a single file +- DELETE `/auth-files?all=true` — delete all `.json` files in `auth-dir` + +## Error Responses + +Generic error shapes: +- 400 Bad Request: `{ "error": "invalid body" }` +- 401 Unauthorized: `{ "error": "missing management key" }` or `{ "error": "invalid management key" }` +- 403 Forbidden: `{ "error": "remote management disabled" }` +- 404 Not Found: `{ "error": "item not found" }` or `{ "error": "file not found" }` +- 500 Internal Server Error: `{ "error": "failed to save config: ..." }` + +## Notes + +- Changes are written to the YAML configuration file and picked up by the server’s file watcher to hot-reload clients and settings. +- `allow-remote-management` and `remote-management-key` must be edited in the configuration file and cannot be changed via the API. + diff --git a/MANAGEMENT_API_CN.md b/MANAGEMENT_API_CN.md new file mode 100644 index 00000000..34f835f6 --- /dev/null +++ b/MANAGEMENT_API_CN.md @@ -0,0 +1,488 @@ +# 管理 API(简体中文) + +基础路径:`http://localhost:8317/v0/management` + +该 API 用于管理 CLI Proxy API 的运行时配置与认证文件。所有变更会持久化写入 YAML 配置文件,并由服务自动热重载。 + +注意:以下选项不能通过 API 修改,需在配置文件中设置(如有必要可重启): +- `allow-remote-management` +- `remote-management-key`(若在启动时检测到明文,会自动进行 bcrypt 加密并写回配置) + +## 认证 + +- 本地访问(`127.0.0.1`、`::1`):无需管理密钥。 +- 远程访问:需要同时满足: + - 配置中 `allow-remote-management: true` + - 通过以下任意方式提供管理密钥(明文): + - `Authorization: Bearer ` + - `X-Management-Key: ` + +若在启动时检测到配置中的管理密钥为明文,会自动使用 bcrypt 加密并回写到配置文件中。 + +## 请求/响应约定 + +- Content-Type:`application/json`(除非另有说明)。 +- 布尔/整数/字符串更新:请求体为 `{ "value": }`。 +- 数组 PUT:既可使用原始数组(如 `["a","b"]`),也可使用 `{ "items": [ ... ] }`。 +- 数组 PATCH:支持 `{ "old": "k1", "new": "k2" }` 或 `{ "index": 0, "value": "k2" }`。 +- 对象数组 PATCH:支持按索引或按关键字段匹配(各端点中单独说明)。 + +## 端点说明 + +### Debug +- GET `/debug` — 获取当前 debug 状态 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/debug + ``` + - 响应: + ```json + { "debug": false } + ``` +- PUT/PATCH `/debug` — 设置 debug(布尔值) + - 请求: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"value":true}' \ + http://localhost:8317/v0/management/debug + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### 代理服务器 URL +- GET `/proxy-url` — 获取代理 URL 字符串 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/proxy-url + ``` + - 响应: + ```json + { "proxy-url": "socks5://user:pass@127.0.0.1:1080/" } + ``` +- PUT/PATCH `/proxy-url` — 设置代理 URL 字符串 + - 请求(PUT): + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \ + http://localhost:8317/v0/management/proxy-url + ``` + - 请求(PATCH): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"value":"http://127.0.0.1:8080"}' \ + http://localhost:8317/v0/management/proxy-url + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- DELETE `/proxy-url` — 清空代理 URL + - 请求: + ```bash +curl -H 'Authorization: Bearer ' -X DELETE http://localhost:8317/v0/management/proxy-url + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### 超出配额行为 +- GET `/quota-exceeded/switch-project` + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/quota-exceeded/switch-project + ``` + - 响应: + ```json + { "switch-project": true } + ``` +- PUT/PATCH `/quota-exceeded/switch-project` — 布尔值 + - 请求: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"value":false}' \ + http://localhost:8317/v0/management/quota-exceeded/switch-project + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- GET `/quota-exceeded/switch-preview-model` + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/quota-exceeded/switch-preview-model + ``` + - 响应: + ```json + { "switch-preview-model": true } + ``` +- PUT/PATCH `/quota-exceeded/switch-preview-model` — 布尔值 + - 请求: + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"value":true}' \ + http://localhost:8317/v0/management/quota-exceeded/switch-preview-model + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### API Keys(代理服务认证) +- GET `/api-keys` — 返回完整列表 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/api-keys + ``` + - 响应: + ```json + { "api-keys": ["k1","k2","k3"] } + ``` +- PUT `/api-keys` — 完整改写列表 + - 请求: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '["k1","k2","k3"]' \ + http://localhost:8317/v0/management/api-keys + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- PATCH `/api-keys` — 修改其中一个(`old/new` 或 `index/value`) + - 请求(按 old/new): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"old":"k2","new":"k2b"}' \ + http://localhost:8317/v0/management/api-keys + ``` + - 请求(按 index/value): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"index":0,"value":"k1b"}' \ + http://localhost:8317/v0/management/api-keys + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- DELETE `/api-keys` — 删除其中一个(`?value=` 或 `?index=`) + - 请求(按值删除): + ```bash +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/api-keys?value=k1' + ``` + - 请求(按索引删除): + ```bash +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/api-keys?index=0' + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### Gemini API Key(生成式语言) +- GET `/generative-language-api-key` + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/generative-language-api-key + ``` + - 响应: + ```json + { "generative-language-api-key": ["AIzaSy...01","AIzaSy...02"] } + ``` +- PUT `/generative-language-api-key` + - 请求: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '["AIzaSy-1","AIzaSy-2"]' \ + http://localhost:8317/v0/management/generative-language-api-key + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- PATCH `/generative-language-api-key` + - 请求: + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"old":"AIzaSy-1","new":"AIzaSy-1b"}' \ + http://localhost:8317/v0/management/generative-language-api-key + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- DELETE `/generative-language-api-key` + - 请求: + ```bash +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/generative-language-api-key?value=AIzaSy-2' + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### 开启请求日志 +- GET `/request-log` — 获取布尔值 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/request-log + ``` + - 响应: + ```json + { "request-log": true } + ``` +- PUT/PATCH `/request-log` — 设置布尔值 + - 请求: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"value":true}' \ + http://localhost:8317/v0/management/request-log + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### 请求重试次数 +- GET `/request-retry` — 获取整数 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/request-retry + ``` + - 响应: + ```json + { "request-retry": 3 } + ``` +- PUT/PATCH `/request-retry` — 设置整数 + - 请求: + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"value":5}' \ + http://localhost:8317/v0/management/request-retry + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### 允许本地未认证访问 +- GET `/allow-localhost-unauthenticated` — 获取布尔值 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/allow-localhost-unauthenticated + ``` + - 响应: + ```json + { "allow-localhost-unauthenticated": false } + ``` +- PUT/PATCH `/allow-localhost-unauthenticated` — 设置布尔值 + - 请求: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"value":true}' \ + http://localhost:8317/v0/management/allow-localhost-unauthenticated + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### Claude API KEY(对象数组) +- GET `/claude-api-key` — 列出全部 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/claude-api-key + ``` + - 响应: + ```json + { "claude-api-key": [ { "api-key": "sk-a", "base-url": "" } ] } + ``` +- PUT `/claude-api-key` — 完整改写列表 + - 请求: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '[{"api-key":"sk-a"},{"api-key":"sk-b","base-url":"https://c.example.com"}]' \ + http://localhost:8317/v0/management/claude-api-key + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- PATCH `/claude-api-key` — 修改其中一个(按 `index` 或 `match`) + - 请求(按索引): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com"}}' \ + http://localhost:8317/v0/management/claude-api-key + ``` + - 请求(按匹配): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":""}}' \ + http://localhost:8317/v0/management/claude-api-key + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- DELETE `/claude-api-key` — 删除其中一个(`?api-key=` 或 `?index=`) + - 请求(按 api-key): + ```bash +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2' + ``` + - 请求(按索引): + ```bash +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?index=0' + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### OpenAI 兼容提供商(对象数组) +- GET `/openai-compatibility` — 列出全部 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/openai-compatibility + ``` + - 响应: + ```json + { "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-keys": [], "models": [] } ] } + ``` +- PUT `/openai-compatibility` — 完整改写列表 + - 请求: + ```bash + curl -X PUT -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -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 + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- PATCH `/openai-compatibility` — 修改其中一个(按 `index` 或 `name`) + - 请求(按名称): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \ + http://localhost:8317/v0/management/openai-compatibility + ``` + - 请求(按索引): + ```bash + curl -X PATCH -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"index":0,"value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":[],"models":[]}}' \ + http://localhost:8317/v0/management/openai-compatibility + ``` + - 响应: + ```json + { "status": "ok" } + ``` +- DELETE `/openai-compatibility` — 删除(`?name=` 或 `?index=`) + - 请求(按名称): + ```bash +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?name=openrouter' + ``` + - 请求(按索引): + ```bash +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?index=0' + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +### 认证文件管理 + +管理 `auth-dir` 下的 JSON 令牌文件:列出、下载、上传、删除。 + +- GET `/auth-files` — 列表 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' http://localhost:8317/v0/management/auth-files + ``` + - 响应: + ```json + { "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z" } ] } + ``` + +- GET `/auth-files/download?name=` — 下载单个文件 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' -OJ 'http://localhost:8317/v0/management/auth-files/download?name=acc1.json' + ``` + +- POST `/auth-files` — 上传 + - 请求(multipart): + ```bash + curl -X POST -F 'file=@/path/to/acc1.json' \ + -H 'Authorization: Bearer ' \ + http://localhost:8317/v0/management/auth-files + ``` + - 请求(原始 JSON): + ```bash + curl -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d @/path/to/acc1.json \ + 'http://localhost:8317/v0/management/auth-files?name=acc1.json' + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +- DELETE `/auth-files?name=` — 删除单个文件 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/auth-files?name=acc1.json' + ``` + - 响应: + ```json + { "status": "ok" } + ``` + +- DELETE `/auth-files?all=true` — 删除 `auth-dir` 下所有 `.json` 文件 + - 请求: + ```bash +curl -H 'Authorization: Bearer ' -X DELETE 'http://localhost:8317/v0/management/auth-files?all=true' + ``` + - 响应: + ```json + { "status": "ok", "deleted": 3 } + ``` + +## 错误响应 + +通用错误格式: +- 400 Bad Request: `{ "error": "invalid body" }` +- 401 Unauthorized: `{ "error": "missing management key" }` 或 `{ "error": "invalid management key" }` +- 403 Forbidden: `{ "error": "remote management disabled" }` +- 404 Not Found: `{ "error": "item not found" }` 或 `{ "error": "file not found" }` +- 500 Internal Server Error: `{ "error": "failed to save config: ..." }` + +## 说明 + +- 变更会写回 YAML 配置文件,并由文件监控器热重载配置与客户端。 +- `allow-remote-management` 与 `remote-management-key` 不能通过 API 修改,需在配置文件中设置。 + diff --git a/README.md b/README.md index 6b561496..69f81bdb 100644 --- a/README.md +++ b/README.md @@ -239,28 +239,30 @@ The server uses a YAML configuration file (`config.yaml`) located in the project ### Configuration Options -| Parameter | Type | Default | Description | -|-----------------------------------------|----------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `port` | integer | 8317 | The port number on which the server will listen. | -| `auth-dir` | string | "~/.cli-proxy-api" | Directory where authentication tokens are stored. Supports using `~` for the home directory. If you use Windows, please set the directory like this: `C:/cli-proxy-api/` | -| `proxy-url` | string | "" | Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/ | -| `request-retry` | integer | 0 | Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504. | -| `quota-exceeded` | object | {} | Configuration for handling quota exceeded. | -| `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded. | -| `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded. | -| `debug` | boolean | false | Enable debug mode for verbose logging. | -| `api-keys` | string[] | [] | List of API keys that can be used to authenticate requests. | -| `generative-language-api-key` | string[] | [] | List of Generative Language API keys. | -| `claude-api-key` | object | {} | List of Claude API keys. | -| `claude-api-key.api-key` | string | "" | Claude API key. | -| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. | -| `openai-compatibility` | object[] | [] | Upstream OpenAI-compatible providers configuration (name, base-url, api-keys, models). | -| `openai-compatibility.*.name` | string | "" | The name of the provider. It will be used in the user agent and other places. | -| `openai-compatibility.*.base-url` | string | "" | The base URL of the provider. | -| `openai-compatibility.*.api-keys` | string[] | [] | The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed. | -| `openai-compatibility.*.models` | object[] | [] | The actual model name. | -| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. | -| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. | +| Parameter | Type | Default | Description | +|-----------------------------------------|----------|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `port` | integer | 8317 | The port number on which the server will listen. | +| `auth-dir` | string | "~/.cli-proxy-api" | Directory where authentication tokens are stored. Supports using `~` for the home directory. If you use Windows, please set the directory like this: `C:/cli-proxy-api/` | +| `proxy-url` | string | "" | Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/ | +| `request-retry` | integer | 0 | Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504. | +| `remote-management.allow-remote` | boolean | false | Whether to allow remote (non-localhost) access to the management API. If false, only localhost can access. A management key is still required for localhost. | +| `remote-management.secret-key` | string | "" | Management key. If a plaintext value is provided, it will be hashed on startup using bcrypt and persisted back to the config file. If empty, the entire management API is disabled (404). | +| `quota-exceeded` | object | {} | Configuration for handling quota exceeded. | +| `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded. | +| `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded. | +| `debug` | boolean | false | Enable debug mode for verbose logging. | +| `api-keys` | string[] | [] | List of API keys that can be used to authenticate requests. | +| `generative-language-api-key` | string[] | [] | List of Generative Language API keys. | +| `claude-api-key` | object | {} | List of Claude API keys. | +| `claude-api-key.api-key` | string | "" | Claude API key. | +| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. | +| `openai-compatibility` | object[] | [] | Upstream OpenAI-compatible providers configuration (name, base-url, api-keys, models). | +| `openai-compatibility.*.name` | string | "" | The name of the provider. It will be used in the user agent and other places. | +| `openai-compatibility.*.base-url` | string | "" | The base URL of the provider. | +| `openai-compatibility.*.api-keys` | string[] | [] | The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed. | +| `openai-compatibility.*.models` | object[] | [] | The actual model name. | +| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. | +| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. | ### Example Configuration File @@ -268,6 +270,17 @@ The server uses a YAML configuration file (`config.yaml`) located in the project # Server port port: 8317 +# Management API settings +remote-management: + # Whether to allow remote (non-localhost) management access. + # When false, only localhost can access management endpoints (a key is still required). + allow-remote: false + + # Management key. If a plaintext value is provided here, it will be hashed on startup. + # All management requests (even from localhost) require this key. + # Leave empty to disable the Management API entirely (404 for all /v0/management routes). + secret-key: "" + # Authentication directory (supports ~ for home directory). If you use Windows, please set the directory like this: `C:/cli-proxy-api/` auth-dir: "~/.cli-proxy-api" @@ -315,6 +328,10 @@ openai-compatibility: alias: "kimi-k2" # The alias used in the API. ``` +## Management API + +see [MANAGEMENT_API.md](MANAGEMENT_API.md) + ### OpenAI Compatibility Providers Configure upstream OpenAI-compatible providers (e.g., OpenRouter) via `openai-compatibility`. diff --git a/README_CN.md b/README_CN.md index 1809e8ea..df064bc4 100644 --- a/README_CN.md +++ b/README_CN.md @@ -96,6 +96,10 @@ 默认情况下,服务器在端口 8317 上运行。 +## 管理 API 文档 + +请参见 [MANAGEMENT_API_CN.md](MANAGEMENT_API_CN.md) + ### API 端点 #### 列出模型 @@ -233,7 +237,7 @@ console.log(await claudeResponse.json()); 服务器默认使用位于项目根目录的 YAML 配置文件(`config.yaml`)。您可以使用 `--config` 标志指定不同的配置文件路径: ```bash -./cli-proxy-api --config /path/to/your/config.yaml + ./cli-proxy-api --config /path/to/your/config.yaml ``` ### 配置选项 @@ -244,6 +248,8 @@ console.log(await claudeResponse.json()); | `auth-dir` | string | "~/.cli-proxy-api" | 存储身份验证令牌的目录。支持使用 `~` 来表示主目录。如果你使用Windows,建议设置成`C:/cli-proxy-api/`。 | | `proxy-url` | string | "" | 代理URL。支持socks5/http/https协议。例如:socks5://user:pass@192.168.1.1:1080/ | | `request-retry` | integer | 0 | 请求重试次数。如果HTTP响应码为403、408、500、502、503或504,将会触发重试。 | +| `remote-management.allow-remote` | boolean | false | 是否允许远程(非localhost)访问管理接口。为false时仅允许本地访问;本地访问同样需要管理密钥。 | +| `remote-management.secret-key` | string | "" | 管理密钥。若配置为明文,启动时会自动进行bcrypt加密并写回配置文件。若为空,管理接口整体不可用(404)。 | | `quota-exceeded` | object | {} | 用于处理配额超限的配置。 | | `quota-exceeded.switch-project` | boolean | true | 当配额超限时,是否自动切换到另一个项目。 | | `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时,是否自动切换到预览模型。 | @@ -267,6 +273,16 @@ console.log(await claudeResponse.json()); # 服务器端口 port: 8317 +# 管理 API 设置 +remote-management: + # 是否允许远程(非localhost)访问管理接口。为false时仅允许本地访问(但本地访问同样需要管理密钥)。 + allow-remote: false + + # 管理密钥。若配置为明文,启动时会自动进行bcrypt加密并写回配置文件。 + # 所有管理请求(包括本地)都需要该密钥。 + # 若为空,/v0/management 整体处于 404(禁用)。 + secret-key: "" + # 身份验证目录(支持 ~ 表示主目录)。如果你使用Windows,建议设置成`C:/cli-proxy-api/`。 auth-dir: "~/.cli-proxy-api" diff --git a/config.example.yaml b/config.example.yaml index 98b4b94a..25c2d3ca 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,6 +1,17 @@ # Server port port: 8317 +# Management API settings +remote-management: + # Whether to allow remote (non-localhost) management access. + # When false, only localhost can access management endpoints (a key is still required). + allow-remote: false + + # Management key. If a plaintext value is provided here, it will be hashed on startup. + # All management requests (even from localhost) require this key. + # Leave empty to disable the Management API entirely (404 for all /v0/management routes). + secret-key: "" + # Authentication directory (supports ~ for home directory) auth-dir: "~/.cli-proxy-api" @@ -45,4 +56,4 @@ openai-compatibility: - "sk-or-v1-...b781" models: # The models supported by the provider. - name: "moonshotai/kimi-k2:free" # The actual model name. - alias: "kimi-k2" # The alias used in the API. \ No newline at end of file + alias: "kimi-k2" # The alias used in the API. diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go new file mode 100644 index 00000000..6095389e --- /dev/null +++ b/internal/api/handlers/management/auth_files.go @@ -0,0 +1,139 @@ +package management + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// List auth files +func (h *Handler) ListAuthFiles(c *gin.Context) { + entries, err := os.ReadDir(h.cfg.AuthDir) + if err != nil { + c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)}) + return + } + files := make([]gin.H, 0) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(strings.ToLower(name), ".json") { + continue + } + if info, errInfo := e.Info(); errInfo == nil { + files = append(files, gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}) + } + } + c.JSON(200, gin.H{"files": files}) +} + +// Download single auth file by name +func (h *Handler) DownloadAuthFile(c *gin.Context) { + name := c.Query("name") + if name == "" || strings.Contains(name, string(os.PathSeparator)) { + c.JSON(400, gin.H{"error": "invalid name"}) + return + } + if !strings.HasSuffix(strings.ToLower(name), ".json") { + c.JSON(400, gin.H{"error": "name must end with .json"}) + return + } + full := filepath.Join(h.cfg.AuthDir, name) + data, err := os.ReadFile(full) + if err != nil { + if os.IsNotExist(err) { + c.JSON(404, gin.H{"error": "file not found"}) + } else { + c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)}) + } + return + } + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name)) + c.Data(200, "application/json", data) +} + +// Upload auth file: multipart or raw JSON with ?name= +func (h *Handler) UploadAuthFile(c *gin.Context) { + if file, err := c.FormFile("file"); err == nil && file != nil { + name := filepath.Base(file.Filename) + if !strings.HasSuffix(strings.ToLower(name), ".json") { + c.JSON(400, gin.H{"error": "file must be .json"}) + return + } + dst := filepath.Join(h.cfg.AuthDir, name) + if errSave := c.SaveUploadedFile(file, dst); errSave != nil { + c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)}) + return + } + c.JSON(200, gin.H{"status": "ok"}) + return + } + name := c.Query("name") + if name == "" || strings.Contains(name, string(os.PathSeparator)) { + c.JSON(400, gin.H{"error": "invalid name"}) + return + } + if !strings.HasSuffix(strings.ToLower(name), ".json") { + c.JSON(400, gin.H{"error": "name must end with .json"}) + return + } + data, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(400, gin.H{"error": "failed to read body"}) + return + } + dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name)) + if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil { + c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)}) + return + } + c.JSON(200, gin.H{"status": "ok"}) +} + +// Delete auth files: single by name or all +func (h *Handler) DeleteAuthFile(c *gin.Context) { + if all := c.Query("all"); all == "true" || all == "1" || all == "*" { + entries, err := os.ReadDir(h.cfg.AuthDir) + if err != nil { + c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)}) + return + } + deleted := 0 + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(strings.ToLower(name), ".json") { + continue + } + full := filepath.Join(h.cfg.AuthDir, name) + if err = os.Remove(full); err == nil { + deleted++ + } + } + c.JSON(200, gin.H{"status": "ok", "deleted": deleted}) + return + } + name := c.Query("name") + if name == "" || strings.Contains(name, string(os.PathSeparator)) { + c.JSON(400, gin.H{"error": "invalid name"}) + return + } + full := filepath.Join(h.cfg.AuthDir, filepath.Base(name)) + if err := os.Remove(full); err != nil { + if os.IsNotExist(err) { + c.JSON(404, gin.H{"error": "file not found"}) + } else { + c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)}) + } + return + } + c.JSON(200, gin.H{"status": "ok"}) +} diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go new file mode 100644 index 00000000..ff4a43fc --- /dev/null +++ b/internal/api/handlers/management/config_basic.go @@ -0,0 +1,41 @@ +package management + +import ( + "github.com/gin-gonic/gin" +) + +// Debug +func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) } +func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) } + +// Request log +func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) } +func (h *Handler) PutRequestLog(c *gin.Context) { + h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v }) +} + +// Request retry +func (h *Handler) GetRequestRetry(c *gin.Context) { + c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry}) +} +func (h *Handler) PutRequestRetry(c *gin.Context) { + h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v }) +} + +// Allow localhost unauthenticated +func (h *Handler) GetAllowLocalhost(c *gin.Context) { + c.JSON(200, gin.H{"allow-localhost-unauthenticated": h.cfg.AllowLocalhostUnauthenticated}) +} +func (h *Handler) PutAllowLocalhost(c *gin.Context) { + h.updateBoolField(c, func(v bool) { h.cfg.AllowLocalhostUnauthenticated = v }) +} + +// Proxy URL +func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) } +func (h *Handler) PutProxyURL(c *gin.Context) { + h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v }) +} +func (h *Handler) DeleteProxyURL(c *gin.Context) { + h.cfg.ProxyURL = "" + h.persist(c) +} diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go new file mode 100644 index 00000000..d62771fa --- /dev/null +++ b/internal/api/handlers/management/config_lists.go @@ -0,0 +1,252 @@ +package management + +import ( + "encoding/json" + "fmt" + + "github.com/gin-gonic/gin" + "github.com/luispater/CLIProxyAPI/internal/config" +) + +// Generic helpers for list[string] +func (h *Handler) putStringList(c *gin.Context, set func([]string)) { + data, err := c.GetRawData() + if err != nil { + c.JSON(400, gin.H{"error": "failed to read body"}) + return + } + var arr []string + if err = json.Unmarshal(data, &arr); err != nil { + var obj struct { + Items []string `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 + } + set(arr) + h.persist(c) +} + +func (h *Handler) patchStringList(c *gin.Context, target *[]string) { + var body struct { + Old *string `json:"old"` + New *string `json:"new"` + Index *int `json:"index"` + Value *string `json:"value"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) { + (*target)[*body.Index] = *body.Value + h.persist(c) + return + } + if body.Old != nil && body.New != nil { + for i := range *target { + if (*target)[i] == *body.Old { + (*target)[i] = *body.New + h.persist(c) + return + } + } + *target = append(*target, *body.New) + h.persist(c) + return + } + c.JSON(400, gin.H{"error": "missing fields"}) +} + +func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string) { + if idxStr := c.Query("index"); idxStr != "" { + var idx int + _, err := fmt.Sscanf(idxStr, "%d", &idx) + if err == nil && idx >= 0 && idx < len(*target) { + *target = append((*target)[:idx], (*target)[idx+1:]...) + h.persist(c) + return + } + } + if val := c.Query("value"); val != "" { + out := make([]string, 0, len(*target)) + for _, v := range *target { + if v != val { + out = append(out, v) + } + } + *target = out + h.persist(c) + return + } + c.JSON(400, gin.H{"error": "missing index or value"}) +} + +// api-keys +func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) } +func (h *Handler) PutAPIKeys(c *gin.Context) { + h.putStringList(c, func(v []string) { h.cfg.APIKeys = v }) +} +func (h *Handler) PatchAPIKeys(c *gin.Context) { h.patchStringList(c, &h.cfg.APIKeys) } +func (h *Handler) DeleteAPIKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.APIKeys) } + +// generative-language-api-key +func (h *Handler) GetGlKeys(c *gin.Context) { + c.JSON(200, gin.H{"generative-language-api-key": h.cfg.GlAPIKey}) +} +func (h *Handler) PutGlKeys(c *gin.Context) { + h.putStringList(c, func(v []string) { h.cfg.GlAPIKey = v }) +} +func (h *Handler) PatchGlKeys(c *gin.Context) { h.patchStringList(c, &h.cfg.GlAPIKey) } +func (h *Handler) DeleteGlKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.GlAPIKey) } + +// claude-api-key: []ClaudeKey +func (h *Handler) GetClaudeKeys(c *gin.Context) { + c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey}) +} +func (h *Handler) PutClaudeKeys(c *gin.Context) { + data, err := c.GetRawData() + if err != nil { + c.JSON(400, gin.H{"error": "failed to read body"}) + return + } + var arr []config.ClaudeKey + if err = json.Unmarshal(data, &arr); err != nil { + var obj struct { + Items []config.ClaudeKey `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.ClaudeKey = arr + h.persist(c) +} +func (h *Handler) PatchClaudeKey(c *gin.Context) { + var body struct { + Index *int `json:"index"` + Match *string `json:"match"` + Value *config.ClaudeKey `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.ClaudeKey) { + h.cfg.ClaudeKey[*body.Index] = *body.Value + h.persist(c) + return + } + if body.Match != nil { + for i := range h.cfg.ClaudeKey { + if h.cfg.ClaudeKey[i].APIKey == *body.Match { + h.cfg.ClaudeKey[i] = *body.Value + h.persist(c) + return + } + } + } + c.JSON(404, gin.H{"error": "item not found"}) +} +func (h *Handler) DeleteClaudeKey(c *gin.Context) { + if val := c.Query("api-key"); val != "" { + out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey)) + for _, v := range h.cfg.ClaudeKey { + if v.APIKey != val { + out = append(out, v) + } + } + h.cfg.ClaudeKey = 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.ClaudeKey) { + h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...) + h.persist(c) + return + } + } + c.JSON(400, gin.H{"error": "missing api-key or index"}) +} + +// openai-compatibility: []OpenAICompatibility +func (h *Handler) GetOpenAICompat(c *gin.Context) { + c.JSON(200, gin.H{"openai-compatibility": h.cfg.OpenAICompatibility}) +} +func (h *Handler) PutOpenAICompat(c *gin.Context) { + data, err := c.GetRawData() + if err != nil { + c.JSON(400, gin.H{"error": "failed to read body"}) + return + } + var arr []config.OpenAICompatibility + if err = json.Unmarshal(data, &arr); err != nil { + var obj struct { + Items []config.OpenAICompatibility `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.OpenAICompatibility = arr + h.persist(c) +} +func (h *Handler) PatchOpenAICompat(c *gin.Context) { + var body struct { + Name *string `json:"name"` + Index *int `json:"index"` + Value *config.OpenAICompatibility `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.OpenAICompatibility) { + h.cfg.OpenAICompatibility[*body.Index] = *body.Value + h.persist(c) + return + } + if body.Name != nil { + for i := range h.cfg.OpenAICompatibility { + if h.cfg.OpenAICompatibility[i].Name == *body.Name { + h.cfg.OpenAICompatibility[i] = *body.Value + h.persist(c) + return + } + } + } + c.JSON(404, gin.H{"error": "item not found"}) +} +func (h *Handler) DeleteOpenAICompat(c *gin.Context) { + if name := c.Query("name"); name != "" { + out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility)) + for _, v := range h.cfg.OpenAICompatibility { + if v.Name != name { + out = append(out, v) + } + } + h.cfg.OpenAICompatibility = 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.OpenAICompatibility) { + h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...) + h.persist(c) + return + } + } + c.JSON(400, gin.H{"error": "missing name or index"}) +} diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go new file mode 100644 index 00000000..65b0f5f1 --- /dev/null +++ b/internal/api/handlers/management/handler.go @@ -0,0 +1,140 @@ +// Package management provides the management API handlers and middleware +// for configuring the server and managing auth files. +package management + +import ( + "fmt" + "net/http" + "strings" + "sync" + + "github.com/gin-gonic/gin" + "github.com/luispater/CLIProxyAPI/internal/config" + "golang.org/x/crypto/bcrypt" +) + +// Handler aggregates config reference, persistence path and helpers. +type Handler struct { + cfg *config.Config + configFilePath string + mu sync.Mutex +} + +// NewHandler creates a new management handler instance. +func NewHandler(cfg *config.Config, configFilePath string) *Handler { + return &Handler{cfg: cfg, configFilePath: configFilePath} +} + +// SetConfig updates the in-memory config reference when the server hot-reloads. +func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg } + +// Middleware enforces access control for management endpoints. +// All requests (local and remote) require a valid management key. +// Additionally, remote access requires allow-remote-management=true. +func (h *Handler) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + clientIP := c.ClientIP() + + // Remote access control: when not loopback, must be enabled + if !(clientIP == "127.0.0.1" || clientIP == "::1") { + allowRemote := h.cfg.RemoteManagement.AllowRemote + if !allowRemote { + allowRemote = true + } + if !allowRemote { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"}) + return + } + } + secret := h.cfg.RemoteManagement.SecretKey + if secret == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"}) + return + } + + // Accept either Authorization: Bearer or X-Management-Key + var provided string + if ah := c.GetHeader("Authorization"); ah != "" { + parts := strings.SplitN(ah, " ", 2) + if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { + provided = parts[1] + } else { + provided = ah + } + } + if provided == "" { + provided = c.GetHeader("X-Management-Key") + } + if provided == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"}) + return + } + + c.Next() + } +} + +// persist saves the current in-memory config to disk. +func (h *Handler) persist(c *gin.Context) bool { + h.mu.Lock() + defer h.mu.Unlock() + // Preserve comments when writing + if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)}) + return false + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + return true +} + +// Helper methods for simple types +func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) { + var body struct { + Value *bool `json:"value"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil { + var m map[string]any + if err2 := c.ShouldBindJSON(&m); err2 == nil { + for _, v := range m { + if b, ok := v.(bool); ok { + set(b) + h.persist(c) + return + } + } + } + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + set(*body.Value) + h.persist(c) +} + +func (h *Handler) updateIntField(c *gin.Context, set func(int)) { + var body struct { + Value *int `json:"value"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + set(*body.Value) + h.persist(c) +} + +func (h *Handler) updateStringField(c *gin.Context, set func(string)) { + var body struct { + Value *string `json:"value"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + set(*body.Value) + h.persist(c) +} diff --git a/internal/api/handlers/management/quota.go b/internal/api/handlers/management/quota.go new file mode 100644 index 00000000..c7efd217 --- /dev/null +++ b/internal/api/handlers/management/quota.go @@ -0,0 +1,18 @@ +package management + +import "github.com/gin-gonic/gin" + +// Quota exceeded toggles +func (h *Handler) GetSwitchProject(c *gin.Context) { + c.JSON(200, gin.H{"switch-project": h.cfg.QuotaExceeded.SwitchProject}) +} +func (h *Handler) PutSwitchProject(c *gin.Context) { + h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v }) +} + +func (h *Handler) GetSwitchPreviewModel(c *gin.Context) { + c.JSON(200, gin.H{"switch-preview-model": h.cfg.QuotaExceeded.SwitchPreviewModel}) +} +func (h *Handler) PutSwitchPreviewModel(c *gin.Context) { + h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v }) +} diff --git a/internal/api/server.go b/internal/api/server.go index 7216707a..10eba22e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -15,6 +15,7 @@ import ( "github.com/luispater/CLIProxyAPI/internal/api/handlers" "github.com/luispater/CLIProxyAPI/internal/api/handlers/claude" "github.com/luispater/CLIProxyAPI/internal/api/handlers/gemini" + managementHandlers "github.com/luispater/CLIProxyAPI/internal/api/handlers/management" "github.com/luispater/CLIProxyAPI/internal/api/handlers/openai" "github.com/luispater/CLIProxyAPI/internal/api/middleware" "github.com/luispater/CLIProxyAPI/internal/config" @@ -40,6 +41,12 @@ type Server struct { // requestLogger is the request logger instance for dynamic configuration updates. requestLogger *logging.FileRequestLogger + + // configFilePath is the absolute path to the YAML config file for persistence. + configFilePath string + + // management handler + mgmt *managementHandlers.Handler } // NewServer creates and initializes a new API server instance. @@ -51,7 +58,7 @@ type Server struct { // // Returns: // - *Server: A new server instance -func NewServer(cfg *config.Config, cliClients []interfaces.Client) *Server { +func NewServer(cfg *config.Config, cliClients []interfaces.Client, configFilePath string) *Server { // Set gin mode if !cfg.Debug { gin.SetMode(gin.ReleaseMode) @@ -72,11 +79,14 @@ func NewServer(cfg *config.Config, cliClients []interfaces.Client) *Server { // Create server instance s := &Server{ - engine: engine, - handlers: handlers.NewBaseAPIHandlers(cliClients, cfg), - cfg: cfg, - requestLogger: requestLogger, + engine: engine, + handlers: handlers.NewBaseAPIHandlers(cliClients, cfg), + cfg: cfg, + requestLogger: requestLogger, + configFilePath: configFilePath, } + // Initialize management handler + s.mgmt = managementHandlers.NewHandler(cfg, configFilePath) // Setup routes s.setupRoutes() @@ -130,6 +140,68 @@ func (s *Server) setupRoutes() { }) }) s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler) + + // Management API routes (delegated to management handlers) + // New logic: if remote-management-key is empty, do not expose any management endpoint (404). + if s.cfg.RemoteManagement.SecretKey != "" { + mgmt := s.engine.Group("/v0/management") + mgmt.Use(s.mgmt.Middleware()) + { + mgmt.GET("/debug", s.mgmt.GetDebug) + mgmt.PUT("/debug", s.mgmt.PutDebug) + mgmt.PATCH("/debug", s.mgmt.PutDebug) + + mgmt.GET("/proxy-url", s.mgmt.GetProxyURL) + mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL) + mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL) + mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL) + + mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject) + mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject) + mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject) + + mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel) + mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel) + mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel) + + mgmt.GET("/api-keys", s.mgmt.GetAPIKeys) + mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys) + mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys) + mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys) + + mgmt.GET("/generative-language-api-key", s.mgmt.GetGlKeys) + mgmt.PUT("/generative-language-api-key", s.mgmt.PutGlKeys) + mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys) + mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys) + + mgmt.GET("/request-log", s.mgmt.GetRequestLog) + mgmt.PUT("/request-log", s.mgmt.PutRequestLog) + mgmt.PATCH("/request-log", s.mgmt.PutRequestLog) + + mgmt.GET("/request-retry", s.mgmt.GetRequestRetry) + mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry) + mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry) + + mgmt.GET("/allow-localhost-unauthenticated", s.mgmt.GetAllowLocalhost) + mgmt.PUT("/allow-localhost-unauthenticated", s.mgmt.PutAllowLocalhost) + mgmt.PATCH("/allow-localhost-unauthenticated", s.mgmt.PutAllowLocalhost) + + mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys) + mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys) + mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey) + mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey) + + mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat) + mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat) + mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat) + mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat) + + mgmt.GET("/auth-files", s.mgmt.ListAuthFiles) + mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile) + mgmt.POST("/auth-files", s.mgmt.UploadAuthFile) + mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile) + } + } } // unifiedModelsHandler creates a unified handler for the /v1/models endpoint @@ -220,11 +292,26 @@ func (s *Server) UpdateClients(clients []interfaces.Client, cfg *config.Config) log.Debugf("request logging updated from %t to %t", s.cfg.RequestLog, cfg.RequestLog) } + // Update log level dynamically when debug flag changes + if s.cfg.Debug != cfg.Debug { + if cfg.Debug { + log.SetLevel(log.DebugLevel) + } else { + log.SetLevel(log.InfoLevel) + } + log.Debugf("debug mode updated from %t to %t", s.cfg.Debug, cfg.Debug) + } + s.cfg = cfg s.handlers.UpdateClients(clients, cfg) + if s.mgmt != nil { + s.mgmt.SetConfig(cfg) + } log.Infof("server clients and configuration updated: %d clients", len(clients)) } +// (management handlers moved to internal/api/handlers/management) + // AuthMiddleware returns a Gin middleware handler that authenticates requests // using API keys. If no API keys are configured, it allows all requests. // diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 9e741d15..c86ca3eb 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -163,7 +163,7 @@ func StartService(cfg *config.Config, configPath string) { } // Create and start the API server with the pool of clients in a separate goroutine. - apiServer := api.NewServer(cfg, cliClients) + apiServer := api.NewServer(cfg, cliClients, configPath) 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. diff --git a/internal/config/config.go b/internal/config/config.go index a4b09990..f6d2859e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( "fmt" "os" + "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" ) @@ -48,6 +49,17 @@ type Config struct { // AllowLocalhostUnauthenticated allows unauthenticated requests from localhost. AllowLocalhostUnauthenticated bool `yaml:"allow-localhost-unauthenticated"` + + // RemoteManagement nests management-related options under 'remote-management'. + RemoteManagement RemoteManagement `yaml:"remote-management"` +} + +// RemoteManagement holds management API configuration under 'remote-management'. +type RemoteManagement struct { + // AllowRemote toggles remote (non-localhost) access to management API. + AllowRemote bool `yaml:"allow-remote"` + // SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'. + SecretKey string `yaml:"secret-key"` } // QuotaExceeded defines the behavior when API quota limits are exceeded. @@ -120,6 +132,344 @@ func LoadConfig(configFile string) (*Config, error) { return nil, fmt.Errorf("failed to parse config file: %w", err) } + // Hash remote management key if plaintext is detected (nested) + // We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix). + if config.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(config.RemoteManagement.SecretKey) { + hashed, errHash := hashSecret(config.RemoteManagement.SecretKey) + if errHash != nil { + return nil, fmt.Errorf("failed to hash remote management key: %w", errHash) + } + config.RemoteManagement.SecretKey = hashed + + // Persist the hashed value back to the config file to avoid re-hashing on next startup. + // Preserve YAML comments and ordering; update only the nested key. + _ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{"remote-management", "secret-key"}, hashed) + } + // Return the populated configuration struct. return &config, nil } + +// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash. +func looksLikeBcrypt(s string) bool { + return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$") +} + +// hashSecret hashes the given secret using bcrypt. +func hashSecret(secret string) (string, error) { + // Use default cost for simplicity. + hashedBytes, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hashedBytes), nil +} + +// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments +// and key ordering by loading the original file into a yaml.Node tree and updating values in-place. +func SaveConfigPreserveComments(configFile string, cfg *Config) error { + // Load original YAML as a node tree to preserve comments and ordering. + data, err := os.ReadFile(configFile) + if err != nil { + return err + } + + var original yaml.Node + if err = yaml.Unmarshal(data, &original); err != nil { + return err + } + if original.Kind != yaml.DocumentNode || len(original.Content) == 0 { + return fmt.Errorf("invalid yaml document structure") + } + if original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode { + return fmt.Errorf("expected root mapping node") + } + + // Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from. + rendered, err := yaml.Marshal(cfg) + if err != nil { + return err + } + var generated yaml.Node + if err = yaml.Unmarshal(rendered, &generated); err != nil { + return err + } + if generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil { + return fmt.Errorf("invalid generated yaml structure") + } + if generated.Content[0].Kind != yaml.MappingNode { + return fmt.Errorf("expected generated root mapping node") + } + + // Merge generated into original in-place, preserving comments/order of existing nodes. + mergeMappingPreserve(original.Content[0], generated.Content[0]) + + // Write back. + f, err := os.Create(configFile) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + enc := yaml.NewEncoder(f) + enc.SetIndent(2) + if err = enc.Encode(&original); err != nil { + _ = enc.Close() + return err + } + return enc.Close() +} + +// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"] +// while preserving comments and positions. +func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error { + data, err := os.ReadFile(configFile) + if err != nil { + return err + } + var root yaml.Node + if err = yaml.Unmarshal(data, &root); err != nil { + return err + } + if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { + return fmt.Errorf("invalid yaml document structure") + } + node := root.Content[0] + // descend mapping nodes following path + for i, key := range path { + if i == len(path)-1 { + // set final scalar + v := getOrCreateMapValue(node, key) + v.Kind = yaml.ScalarNode + v.Tag = "!!str" + v.Value = value + } else { + next := getOrCreateMapValue(node, key) + if next.Kind != yaml.MappingNode { + next.Kind = yaml.MappingNode + next.Tag = "!!map" + } + node = next + } + } + f, err := os.Create(configFile) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + enc := yaml.NewEncoder(f) + enc.SetIndent(2) + if err = enc.Encode(&root); err != nil { + _ = enc.Close() + return err + } + return enc.Close() +} + +// getOrCreateMapValue finds the value node for a given key in a mapping node. +// If not found, it appends a new key/value pair and returns the new value node. +func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node { + if mapNode.Kind != yaml.MappingNode { + mapNode.Kind = yaml.MappingNode + mapNode.Tag = "!!map" + mapNode.Content = nil + } + for i := 0; i+1 < len(mapNode.Content); i += 2 { + k := mapNode.Content[i] + if k.Value == key { + return mapNode.Content[i+1] + } + } + // append new key/value + mapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}) + val := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""} + mapNode.Content = append(mapNode.Content, 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 +// 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. +func mergeMappingPreserve(dst, src *yaml.Node) { + if dst == nil || src == nil { + return + } + if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode { + // If kinds do not match, prefer replacing dst with src semantics in-place + // but keep dst node object to preserve any attached comments at the parent level. + copyNodeShallow(dst, src) + return + } + // Build a lookup of existing keys in dst + for i := 0; i+1 < len(src.Content); i += 2 { + sk := src.Content[i] + sv := src.Content[i+1] + idx := findMapKeyIndex(dst, sk.Value) + if idx >= 0 { + // Merge into existing value node + dv := dst.Content[idx+1] + mergeNodePreserve(dv, sv) + } else { + // Append new key/value pair by deep-copying from src + dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv)) + } + } +} + +// mergeNodePreserve merges src into dst for scalars, mappings and sequences while +// reusing destination nodes to keep comments and anchors. For sequences, it updates +// in-place by index. +func mergeNodePreserve(dst, src *yaml.Node) { + if dst == nil || src == nil { + return + } + switch src.Kind { + case yaml.MappingNode: + if dst.Kind != yaml.MappingNode { + copyNodeShallow(dst, src) + } + mergeMappingPreserve(dst, src) + case yaml.SequenceNode: + // Preserve explicit null style if dst was null and src is empty sequence + if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 { + // Keep as null to preserve original style + return + } + if dst.Kind != yaml.SequenceNode { + dst.Kind = yaml.SequenceNode + dst.Tag = "!!seq" + dst.Content = nil + } + // Update elements in place + minContent := len(dst.Content) + if len(src.Content) < minContent { + minContent = len(src.Content) + } + for i := 0; i < minContent; i++ { + if dst.Content[i] == nil { + dst.Content[i] = deepCopyNode(src.Content[i]) + continue + } + mergeNodePreserve(dst.Content[i], src.Content[i]) + } + // Append any extra items from src + for i := len(dst.Content); i < len(src.Content); i++ { + dst.Content = append(dst.Content, deepCopyNode(src.Content[i])) + } + // Truncate if dst has extra items not in src + if len(src.Content) < len(dst.Content) { + dst.Content = dst.Content[:len(src.Content)] + } + case yaml.ScalarNode, yaml.AliasNode: + // For scalars, update Tag and Value but keep Style from dst to preserve quoting + dst.Kind = src.Kind + dst.Tag = src.Tag + dst.Value = src.Value + // Keep dst.Style as-is intentionally + case 0: + // Unknown/empty kind; do nothing + default: + // Fallback: replace shallowly + copyNodeShallow(dst, src) + } +} + +// findMapKeyIndex returns the index of key node in dst mapping (index of key, not value). +// Returns -1 when not found. +func findMapKeyIndex(mapNode *yaml.Node, key string) int { + if mapNode == nil || mapNode.Kind != yaml.MappingNode { + return -1 + } + for i := 0; i+1 < len(mapNode.Content); i += 2 { + if mapNode.Content[i] != nil && mapNode.Content[i].Value == key { + return i + } + } + return -1 +} + +// deepCopyNode creates a deep copy of a yaml.Node graph. +func deepCopyNode(n *yaml.Node) *yaml.Node { + if n == nil { + return nil + } + cp := *n + if len(n.Content) > 0 { + cp.Content = make([]*yaml.Node, len(n.Content)) + for i := range n.Content { + cp.Content[i] = deepCopyNode(n.Content[i]) + } + } + return &cp +} + +// copyNodeShallow copies type/tag/value and resets content to match src, but +// keeps the same destination node pointer to preserve parent relations/comments. +func copyNodeShallow(dst, src *yaml.Node) { + if dst == nil || src == nil { + return + } + dst.Kind = src.Kind + dst.Tag = src.Tag + dst.Value = src.Value + // Replace content with deep copy from src + if len(src.Content) > 0 { + dst.Content = make([]*yaml.Node, len(src.Content)) + for i := range src.Content { + dst.Content[i] = deepCopyNode(src.Content[i]) + } + } else { + dst.Content = nil + } +} diff --git a/internal/translator/codex/openai/codex_openai_request.go b/internal/translator/codex/openai/codex_openai_request.go index da953d6b..842cb7e3 100644 --- a/internal/translator/codex/openai/codex_openai_request.go +++ b/internal/translator/codex/openai/codex_openai_request.go @@ -54,8 +54,12 @@ func ConvertOpenAIRequestToCodex(modelName string, rawJSON []byte, stream bool) // Map reasoning effort if v := gjson.GetBytes(rawJSON, "reasoning_effort"); v.Exists() { out, _ = sjson.Set(out, "reasoning.effort", v.Value()) - out, _ = sjson.Set(out, "reasoning.summary", "auto") + } else { + out, _ = sjson.Set(out, "reasoning.effort", "low") } + out, _ = sjson.Set(out, "parallel_tool_calls", true) + out, _ = sjson.Set(out, "reasoning.summary", "auto") + out, _ = sjson.Set(out, "include", []string{"reasoning.encrypted_content"}) // Model out, _ = sjson.Set(out, "model", modelName) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 650b4f14..f8bf488a 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -185,6 +185,12 @@ func (w *Watcher) reloadConfig() { if len(oldConfig.ClaudeKey) != len(newConfig.ClaudeKey) { log.Debugf(" claude-api-key count: %d -> %d", len(oldConfig.ClaudeKey), len(newConfig.ClaudeKey)) } + if oldConfig.AllowLocalhostUnauthenticated != newConfig.AllowLocalhostUnauthenticated { + log.Debugf(" allow-localhost-unauthenticated: %t -> %t", oldConfig.AllowLocalhostUnauthenticated, newConfig.AllowLocalhostUnauthenticated) + } + if oldConfig.RemoteManagement.AllowRemote != newConfig.RemoteManagement.AllowRemote { + log.Debugf(" remote-management.allow-remote: %t -> %t", oldConfig.RemoteManagement.AllowRemote, newConfig.RemoteManagement.AllowRemote) + } } log.Infof("config successfully reloaded, triggering client reload")