diff --git a/logs/main.log b/logs/main.log new file mode 100644 index 00000000..c340cadf --- /dev/null +++ b/logs/main.log @@ -0,0 +1,175 @@ +[2025-12-12 02:50:06] [info] [main.go:413] CLIProxyAPI Version: 6.6.5, Commit: fc054db, BuiltAt: 2025-12-12T00:54:34Z +[2025-12-12 02:50:06] [info] [server.go:466] management routes registered after secret key configuration +[2025-12-12 02:50:06] [info] [watcher.go:1000] full client load complete - 5 clients (5 auth files + 0 Gemini API keys + 0 Vertex API keys + 0 Claude API keys + 0 Codex keys + 0 OpenAI-compat) +[2025-12-12 02:50:06] [info] [service.go:543] file watcher started for config and auth directory changes +[2025-12-12 02:50:06] [info] [service.go:549] core auth auto-refresh started (interval=15m0s) +[2025-12-12 02:50:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:18 | 200 | 0s | 127.0.0.1 | GET "/" +[2025-12-12 02:50:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:18 | 200 | 0s | 127.0.0.1 | GET "/v1/models" +[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 0s | ::1 | GET "/" +[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 0s | ::1 | GET "/" +[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 49ms | ::1 | GET "/v0/management/debug" +[2025-12-12 02:50:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:19 | 200 | 50ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/usage" +[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 0s | ::1 | GET "/v1/models" +[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/config.yaml" +[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/auth-files" +[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:50:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:20 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" +[2025-12-12 02:50:32] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:32 | 200 | 0s | 127.0.0.1 | GET "/" +[2025-12-12 02:50:32] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:32 | 200 | 0s | 127.0.0.1 | GET "/v1/models" +[2025-12-12 02:50:42] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:42 | 200 | 2.115s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:50:43] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:43 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:50:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:44 | 200 | 773ms | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:50:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:50:45] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:45 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:50:45] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:45 | 200 | 1.568s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:50:45] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:45 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:50:45] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:50:45 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:50:47] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:47 | 200 | 1.16s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:50:47] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:47 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:50:48] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:48 | 200 | 6.774s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:50:49] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:49 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 7.951s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 0s | ::1 | GET "/" +[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 0s | ::1 | GET "/" +[2025-12-12 02:50:50] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:50 | 200 | 50ms | ::1 | GET "/v0/management/debug" +[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/usage" +[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 0s | ::1 | GET "/v1/models" +[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 53ms | ::1 | GET "/v0/management/auth-files" +[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 53ms | ::1 | GET "/v0/management/config.yaml" +[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" +[2025-12-12 02:50:51] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:51 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:50:52] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:52 | 200 | 2.247s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 13.83s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 3.023s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:50:55] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:50:55 | 200 | 10.921s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:51:00] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:00 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:51:02] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:02 | 200 | 1.16s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:02] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:02 | 200 | 1.254s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:02] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:02 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:51:02] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:02 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:02] [error] [gin_logger.go:60] [GIN] 2025/12/12 - 02:51:02 | 501 | 0s | 127.0.0.1 | POST "/api/provider/agy/v1/messages/count_tokens?beta=true" +[2025-12-12 02:51:03] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:03 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:05] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:05 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:06] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:06 | 200 | 3.412s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:06] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:06 | 200 | 8.706s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:07] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:07 | 200 | 6.799s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:08] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:08 | 200 | 10.614s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:09] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:09 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:10] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:10 | 200 | 1.661s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:11] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:11 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:13 | 200 | 3.155s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:16] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:16 | 200 | 8.675s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:17] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:17 | 200 | 1.132s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:17] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:17 | 200 | 1.16s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:17] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:17 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 2.018s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 2.137s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:18 | 200 | 2.219s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:19] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:19 | 200 | 2.993s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:20 | 200 | 955ms | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:20] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:20 | 200 | 1.468s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:21] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:21 | 200 | 23.465s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:21] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:21 | 200 | 2.506s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:22] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:22 | 200 | 3.102s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/usage" +[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 0s | ::1 | GET "/v1/models" +[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/config.yaml" +[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 50ms | ::1 | GET "/v0/management/auth-files" +[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 49ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:51:23] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:23 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" +[2025-12-12 02:51:29] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:29 | 200 | 6.533s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:30] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:30 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:30] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:30 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:31] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:31 | 200 | 1.337s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:34 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:35] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:35 | 200 | 5.291s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:36] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:36 | 200 | 1.482s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:38] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:38 | 200 | 2.867s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:40] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:40 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:40] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:40 | 200 | 5.468s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:43 | 200 | 7.802s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:45] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:45 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:47] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:47 | 200 | 4.344s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:48] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:48 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:48] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:48 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:52] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:52 | 200 | 4.022s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:52] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:52 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:51:54] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:54 | 200 | 5.541s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:57 | 200 | 3.579s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:58 | 200 | 4.434s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:51:59] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:51:59 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:52:01] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:01 | 200 | 6.851s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:52:03] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:03 | 200 | 2.477s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:52:04] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:04 | 200 | 2.796s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:52:06] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:06 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:52:08] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:08 | 200 | 3.953s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:52:09] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:09 | 200 | 741ms | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:52:11] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:11 | 200 | 1.983s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:52:12] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:12 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:52:12] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:12 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:52:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:52:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:13 | 200 | 3.488s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:52:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:18 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/usage" +[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 0s | ::1 | GET "/v1/models" +[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/config.yaml" +[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/auth-files" +[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:52:34] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:34 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" +[2025-12-12 02:52:42] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:42 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:52:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:52:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:52:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:53:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:53:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:53:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:53:18] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:18 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:53:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:43 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:53:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:53:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:53:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:54:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:54:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:54:13] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:13 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:54:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:43 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:54:43] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:43 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:54:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:54:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:55:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:14 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:55:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:55:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:55:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:44 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:55:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:55:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:55:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:56:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:56:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:14 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:56:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:56:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:56:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:56:44] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:44 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:56:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:57 | 200 | 49ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:56:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:57 | 200 | 49ms | ::1 | GET "/v0/management/usage" +[2025-12-12 02:56:57] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:57 | 200 | 0s | ::1 | GET "/v1/models" +[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/config.yaml" +[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/auth-files" +[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/config" +[2025-12-12 02:56:58] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:56:58 | 200 | 49ms | ::1 | GET "/v0/management/oauth-excluded-models" +[2025-12-12 02:57:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:14 | 200 | 1ms | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:57:14] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:14 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:57:15] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:15 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:57:16] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:16 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:57:22] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:22 | 200 | 0s | 127.0.0.1 | POST "/api/provider/agy/api/event_logging/batch" +[2025-12-12 02:57:22] [info] [gin_logger.go:64] [GIN] 2025/12/12 - 02:57:22 | 200 | 5.549s | 127.0.0.1 | POST "/api/provider/agy/v1/messages?beta=true" +[2025-12-12 02:57:26] [error] [service.go:613] error stopping API server: failed to shutdown HTTP server: context deadline exceeded +[2025-12-12 02:57:26] [error] [service.go:425] service shutdown returned error: failed to shutdown HTTP server: context deadline exceeded diff --git a/scripts/verify-bundle.js b/scripts/verify-bundle.js index 3c2fc8b0..dfd18fc6 100644 --- a/scripts/verify-bundle.js +++ b/scripts/verify-bundle.js @@ -1,8 +1,14 @@ #!/usr/bin/env node /** - * Verify UI bundle size is under 1MB gzipped - * React + shadcn/ui dashboard typically ranges 600-900KB + * Verify UI bundle size stays reasonable + * + * Current stack: React + TanStack Query + Radix UI + shadcn/ui + Recharts + * Expected range: 800KB - 1.5MB gzipped for full-featured dashboard + * + * This is a developer tool, not a public-facing site, so we optimize for + * features over minimal bundle size. The limit is a sanity check to catch + * accidental large dependencies, not a hard performance target. */ const fs = require('fs'); @@ -10,7 +16,7 @@ const path = require('path'); const zlib = require('zlib'); const UI_DIR = path.join(__dirname, '../dist/ui'); -const MAX_SIZE = 1024 * 1024; // 1MB +const MAX_SIZE = 1.5 * 1024 * 1024; // 1.5MB - reasonable for full-featured React dashboard function getGzipSize(filePath) { const content = fs.readFileSync(filePath); @@ -40,9 +46,10 @@ if (!fs.existsSync(UI_DIR)) { const totalSize = walkDir(UI_DIR); const sizeKB = (totalSize / 1024).toFixed(1); +const maxKB = (MAX_SIZE / 1024).toFixed(0); if (totalSize > MAX_SIZE) { - console.log(`[X] Bundle too large: ${sizeKB}KB gzipped (max: 1024KB)`); + console.log(`[X] Bundle too large: ${sizeKB}KB gzipped (max: ${maxKB}KB)`); process.exit(1); } else { console.log(`[OK] Bundle size: ${sizeKB}KB gzipped`); diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index f1b53d71..b1fb5dc7 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -130,11 +130,54 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void { }); } +/** + * Sync registry with actual token files + * Removes stale entries where token file no longer exists + * Called automatically when loading accounts + */ +function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean { + const authDir = getAuthDir(); + let modified = false; + + for (const [_providerName, providerAccounts] of Object.entries(registry.providers)) { + if (!providerAccounts) continue; + + const staleIds: string[] = []; + + for (const [accountId, meta] of Object.entries(providerAccounts.accounts)) { + const tokenPath = path.join(authDir, meta.tokenFile); + if (!fs.existsSync(tokenPath)) { + staleIds.push(accountId); + } + } + + // Remove stale accounts + for (const id of staleIds) { + delete providerAccounts.accounts[id]; + modified = true; + + // Update default if deleted + if (providerAccounts.default === id) { + const remainingIds = Object.keys(providerAccounts.accounts); + providerAccounts.default = remainingIds[0] || 'default'; + } + } + } + + return modified; +} + /** * Get all accounts for a provider */ export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] { const registry = loadAccountsRegistry(); + + // Sync with actual token files (removes stale entries) + if (syncRegistryWithTokenFiles(registry)) { + saveAccountsRegistry(registry); + } + const providerAccounts = registry.providers[provider]; if (!providerAccounts) { diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index c267bc29..2bc103db 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -23,7 +23,17 @@ interface ProviderSettings { export const CLIPROXY_DEFAULT_PORT = 8317; /** Internal API key for CCS-managed requests */ -const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; +export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; + +/** Simple secret key for Control Panel login (user-facing) */ +export const CCS_CONTROL_PANEL_SECRET = 'ccs'; + +/** + * Config version - bump when config format changes to trigger regeneration + * v1: Initial config (port, auth-dir, api-keys only) + * v2: Full-featured config with dashboard, quota mgmt, simplified key + */ +export const CLIPROXY_CONFIG_VERSION = 2; /** Provider display names (static metadata) */ const PROVIDER_DISPLAY_NAMES: Record = { @@ -112,26 +122,68 @@ export function getBinDir(): string { function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories - // Unified config with all providers - const config = `# CLIProxyAPI unified config generated by CCS -# Supports: gemini, codex, agy, qwen (concurrent usage) + // Unified config with enhanced CLIProxyAPI features + const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} +# Supports: gemini, codex, agy, qwen, iflow (concurrent usage) # Generated: ${new Date().toISOString()} +# +# This config is auto-managed by CCS. Manual edits may be overwritten. +# Use 'ccs doctor' to regenerate with latest settings. + +# ============================================================================= +# Server Settings +# ============================================================================= port: ${port} debug: false -logging-to-file: false -usage-statistics-enabled: false -# CCS internal authentication +# ============================================================================= +# Logging +# ============================================================================= + +# Write logs to file (stored in ~/.ccs/cliproxy/logs/) +logging-to-file: true + +# Log individual API requests for debugging/analytics +request-log: true + +# ============================================================================= +# Dashboard & Management +# ============================================================================= + +# Enable usage statistics for CCS dashboard analytics +usage-statistics-enabled: true + +# Remote management API for CCS dashboard integration +remote-management: + allow-remote: true + secret-key: "${CCS_CONTROL_PANEL_SECRET}" + disable-control-panel: false + +# ============================================================================= +# Reliability & Quota Management +# ============================================================================= + +# Auto-retry on transient errors (403, 408, 500, 502, 503, 504) +request-retry: 0 +max-retry-interval: 0 + +# Auto-switch accounts on quota exceeded (429) +# This enables seamless multi-account rotation when rate limited +quota-exceeded: + switch-project: true + switch-preview-model: true + +# ============================================================================= +# Authentication +# ============================================================================= + +# API keys for CCS internal requests api-keys: - "${CCS_INTERNAL_API_KEY}" -# OAuth tokens stored in auth/ directory -# CLIProxyAPI auto-discovers auth files in subdirectories -auth-dir: "${authDir.replace(/\\/g, '/')}" - -# All providers configured - routes by model name -# No provider-specific sections needed - OAuth auth files provide credentials +# OAuth tokens directory (auto-discovered by CLIProxyAPI) +auth-dir: "${authDir.replace(/\\\\/g, '/')}" `; return config; @@ -162,6 +214,67 @@ export function generateConfig( return configPath; } +/** + * Force regenerate config.yaml with latest settings + * Deletes existing config and creates fresh one with current port + * @returns Path to new config file + */ +export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { + const configPath = getConfigPath(); + + // Read existing port if config exists (preserve user's port choice) + let effectivePort = port; + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf-8'); + const portMatch = content.match(/^port:\s*(\d+)/m); + if (portMatch) { + effectivePort = parseInt(portMatch[1], 10); + } + } catch { + // Use default port if reading fails + } + // Delete existing config + fs.unlinkSync(configPath); + } + + // Ensure directories exist + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 }); + + // Generate fresh config + const configContent = generateUnifiedConfigContent(effectivePort); + fs.writeFileSync(configPath, configContent, { mode: 0o600 }); + + return configPath; +} + +/** + * Check if config needs regeneration (version mismatch) + * @returns true if config should be regenerated + */ +export function configNeedsRegeneration(): boolean { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + return false; // Will be created on first use + } + + try { + const content = fs.readFileSync(configPath, 'utf-8'); + + // Check for version marker + const versionMatch = content.match(/CCS v(\d+)/); + if (!versionMatch) { + return true; // No version marker = old config + } + + const configVersion = parseInt(versionMatch[1], 10); + return configVersion < CLIPROXY_CONFIG_VERSION; + } catch { + return true; // Error reading = regenerate + } +} + /** * Check if config exists for provider */ diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index f7f855e5..b3863907 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -48,6 +48,8 @@ export { // Config generation export { generateConfig, + regenerateConfig, + configNeedsRegeneration, getClaudeEnvVars, getEffectiveEnvVars, getProviderSettingsPath, @@ -62,6 +64,7 @@ export { configExists, deleteConfig, CLIPROXY_DEFAULT_PORT, + CLIPROXY_CONFIG_VERSION, } from './config-generator'; // Base config loader (for reading config/base-*.settings.json) @@ -98,3 +101,23 @@ export { getProviderTokenDir, displayAuthStatus, } from './auth-handler'; + +// Stats fetcher +export type { CliproxyStats } from './stats-fetcher'; +export { fetchCliproxyStats, isCliproxyRunning } from './stats-fetcher'; + +// OpenAI compatibility layer +export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager'; +export { + listOpenAICompatProviders, + getOpenAICompatProvider, + addOpenAICompatProvider, + updateOpenAICompatProvider, + removeOpenAICompatProvider, + OPENROUTER_TEMPLATE, + TOGETHER_TEMPLATE, +} from './openai-compat-manager'; + +// Service manager (background CLIProxy for dashboard) +export type { ServiceStartResult } from './service-manager'; +export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager'; diff --git a/src/cliproxy/openai-compat-manager.ts b/src/cliproxy/openai-compat-manager.ts new file mode 100644 index 00000000..5254a156 --- /dev/null +++ b/src/cliproxy/openai-compat-manager.ts @@ -0,0 +1,223 @@ +/** + * OpenAI Compatibility Layer Manager + * + * Manages OpenAI-compatible providers (OpenRouter, Together, etc.) + * in CLIProxyAPI's config.yaml. + */ + +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { getConfigPath } from './config-generator'; + +/** Model alias configuration */ +export interface OpenAICompatModel { + name: string; // Upstream model name + alias: string; // Client-visible alias +} + +/** OpenAI-compatible provider configuration */ +export interface OpenAICompatProvider { + name: string; + baseUrl: string; + apiKey: string; + models: OpenAICompatModel[]; +} + +/** Config.yaml structure for parsing */ +interface ConfigYaml { + port?: number; + 'api-keys'?: string[]; + 'auth-dir'?: string; + 'openai-compatibility'?: Array<{ + name: string; + 'base-url': string; + headers?: Record; + 'api-key-entries': Array<{ + 'api-key': string; + 'proxy-url'?: string; + }>; + models?: Array<{ + name: string; + alias: string; + }>; + }>; + [key: string]: unknown; +} + +/** + * Load current config.yaml + */ +function loadConfig(): ConfigYaml { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const content = fs.readFileSync(configPath, 'utf-8'); + return (yaml.load(content) as ConfigYaml) || {}; + } catch { + return {}; + } +} + +/** + * Save config.yaml with proper formatting + */ +function saveConfig(config: ConfigYaml): void { + const configPath = getConfigPath(); + const content = yaml.dump(config, { + lineWidth: -1, // Disable line wrapping + quotingType: '"', + forceQuotes: false, + }); + + fs.writeFileSync(configPath, content, { mode: 0o600 }); +} + +/** + * List all configured OpenAI-compatible providers + */ +export function listOpenAICompatProviders(): OpenAICompatProvider[] { + const config = loadConfig(); + const providers = config['openai-compatibility'] || []; + + return providers.map((p) => ({ + name: p.name, + baseUrl: p['base-url'], + apiKey: p['api-key-entries']?.[0]?.['api-key'] || '', + models: (p.models || []).map((m) => ({ + name: m.name, + alias: m.alias, + })), + })); +} + +/** + * Get a specific provider by name + */ +export function getOpenAICompatProvider(name: string): OpenAICompatProvider | null { + const providers = listOpenAICompatProviders(); + return providers.find((p) => p.name === name) || null; +} + +/** + * Add a new OpenAI-compatible provider + * @throws Error if provider with same name already exists + */ +export function addOpenAICompatProvider(provider: OpenAICompatProvider): void { + const config = loadConfig(); + + // Initialize array if not exists + if (!config['openai-compatibility']) { + config['openai-compatibility'] = []; + } + + // Check for duplicate + const existing = config['openai-compatibility'].find((p) => p.name === provider.name); + if (existing) { + throw new Error(`Provider '${provider.name}' already exists`); + } + + // Add new provider + config['openai-compatibility'].push({ + name: provider.name, + 'base-url': provider.baseUrl, + 'api-key-entries': [{ 'api-key': provider.apiKey }], + models: provider.models.map((m) => ({ + name: m.name, + alias: m.alias, + })), + }); + + saveConfig(config); +} + +/** + * Update an existing provider + * @throws Error if provider doesn't exist + */ +export function updateOpenAICompatProvider( + name: string, + updates: Partial +): void { + const config = loadConfig(); + + if (!config['openai-compatibility']) { + throw new Error(`Provider '${name}' not found`); + } + + const index = config['openai-compatibility'].findIndex((p) => p.name === name); + if (index === -1) { + throw new Error(`Provider '${name}' not found`); + } + + const provider = config['openai-compatibility'][index]; + + // Apply updates + if (updates.baseUrl) { + provider['base-url'] = updates.baseUrl; + } + if (updates.apiKey) { + provider['api-key-entries'] = [{ 'api-key': updates.apiKey }]; + } + if (updates.models) { + provider.models = updates.models.map((m) => ({ + name: m.name, + alias: m.alias, + })); + } + if (updates.name && updates.name !== name) { + provider.name = updates.name; + } + + saveConfig(config); +} + +/** + * Remove a provider + * @returns true if removed, false if not found + */ +export function removeOpenAICompatProvider(name: string): boolean { + const config = loadConfig(); + + if (!config['openai-compatibility']) { + return false; + } + + const index = config['openai-compatibility'].findIndex((p) => p.name === name); + if (index === -1) { + return false; + } + + config['openai-compatibility'].splice(index, 1); + + // Remove empty array + if (config['openai-compatibility'].length === 0) { + delete config['openai-compatibility']; + } + + saveConfig(config); + return true; +} + +/** Pre-configured OpenRouter template */ +export const OPENROUTER_TEMPLATE: Omit = { + name: 'openrouter', + baseUrl: 'https://openrouter.ai/api/v1', + models: [ + { name: 'anthropic/claude-3.5-sonnet', alias: 'claude-sonnet' }, + { name: 'anthropic/claude-3-opus', alias: 'claude-opus' }, + { name: 'google/gemini-pro-1.5', alias: 'gemini-pro' }, + ], +}; + +/** Pre-configured Together template */ +export const TOGETHER_TEMPLATE: Omit = { + name: 'together', + baseUrl: 'https://api.together.xyz/v1', + models: [ + { name: 'meta-llama/Llama-3-70b-chat-hf', alias: 'llama-70b' }, + { name: 'mistralai/Mixtral-8x7B-Instruct-v0.1', alias: 'mixtral' }, + ], +}; diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts new file mode 100644 index 00000000..a515dcc0 --- /dev/null +++ b/src/cliproxy/service-manager.ts @@ -0,0 +1,244 @@ +/** + * CLIProxy Service Manager + * + * Manages CLIProxyAPI as a background service for the CCS dashboard. + * Ensures the proxy is running when needed for: + * - Control Panel integration (management.html) + * - Stats fetching + * - OAuth flows + * + * Unlike cliproxy-executor.ts which runs proxy per-session, + * this module manages a persistent background instance. + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as net from 'net'; +import { ensureCLIProxyBinary } from './binary-manager'; +import { + generateConfig, + regenerateConfig, + configNeedsRegeneration, + CLIPROXY_DEFAULT_PORT, +} from './config-generator'; +import { isCliproxyRunning } from './stats-fetcher'; + +/** Background proxy process reference */ +let proxyProcess: ChildProcess | null = null; + +/** Cleanup registered flag */ +let cleanupRegistered = false; + +/** + * Wait for TCP port to become available + */ +async function waitForPort( + port: number, + timeout: number = 5000, + pollInterval: number = 100 +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + await new Promise((resolve, reject) => { + const socket = net.createConnection({ port, host: '127.0.0.1' }, () => { + socket.destroy(); + resolve(); + }); + + socket.on('error', (err) => { + socket.destroy(); + reject(err); + }); + + socket.setTimeout(500, () => { + socket.destroy(); + reject(new Error('Connection timeout')); + }); + }); + + return true; // Connection successful + } catch { + await new Promise((r) => setTimeout(r, pollInterval)); + } + } + + return false; +} + +/** + * Register cleanup handlers to stop proxy on process exit + */ +function registerCleanup(): void { + if (cleanupRegistered) return; + + const cleanup = () => { + if (proxyProcess && !proxyProcess.killed) { + proxyProcess.kill('SIGTERM'); + proxyProcess = null; + } + }; + + process.once('exit', cleanup); + process.once('SIGTERM', cleanup); + process.once('SIGINT', cleanup); + + cleanupRegistered = true; +} + +export interface ServiceStartResult { + started: boolean; + alreadyRunning: boolean; + port: number; + configRegenerated?: boolean; + error?: string; +} + +/** + * Ensure CLIProxy service is running + * + * If proxy is already running, returns immediately. + * If not, spawns a new background instance. + * + * @param port CLIProxy port (default: 8317) + * @param verbose Show debug output + * @returns Result indicating success and whether it was already running + */ +export async function ensureCliproxyService( + port: number = CLIPROXY_DEFAULT_PORT, + verbose: boolean = false +): Promise { + const log = (msg: string) => { + if (verbose) { + console.error(`[cliproxy-service] ${msg}`); + } + }; + + // Check if already running (from another process or previous start) + log(`Checking if CLIProxy is running on port ${port}...`); + const running = await isCliproxyRunning(port); + + // Check if config needs update (even if running) + let configRegenerated = false; + if (configNeedsRegeneration()) { + log('Config outdated, regenerating...'); + regenerateConfig(port); + configRegenerated = true; + } + + if (running) { + log('CLIProxy already running'); + if (configRegenerated) { + log('Config was updated - running instance will use new config on next restart'); + } + return { started: true, alreadyRunning: true, port, configRegenerated }; + } + + // Need to start new instance + log('CLIProxy not running, starting background instance...'); + + // 1. Ensure binary exists + let binaryPath: string; + try { + binaryPath = await ensureCLIProxyBinary(verbose); + log(`Binary ready: ${binaryPath}`); + } catch (error) { + const err = error as Error; + return { + started: false, + alreadyRunning: false, + port, + error: `Failed to prepare binary: ${err.message}`, + }; + } + + // 2. Ensure/regenerate config if needed + let configPath: string; + if (configNeedsRegeneration()) { + log('Config needs regeneration, updating...'); + configPath = regenerateConfig(port); + } else { + // generateConfig only creates if doesn't exist + configPath = generateConfig('gemini', port); // Provider doesn't matter for unified config + } + log(`Config ready: ${configPath}`); + + // 3. Spawn background process + const proxyArgs = ['--config', configPath]; + + log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); + + proxyProcess = spawn(binaryPath, proxyArgs, { + stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], + detached: true, // Allow process to run independently + }); + + // Forward output in verbose mode + if (verbose) { + proxyProcess.stdout?.on('data', (data: Buffer) => { + process.stderr.write(`[cliproxy] ${data.toString()}`); + }); + proxyProcess.stderr?.on('data', (data: Buffer) => { + process.stderr.write(`[cliproxy-err] ${data.toString()}`); + }); + } + + // Don't let this process prevent parent from exiting + proxyProcess.unref(); + + // Handle spawn errors + proxyProcess.on('error', (error) => { + log(`Spawn error: ${error.message}`); + }); + + // Register cleanup handlers + registerCleanup(); + + // 4. Wait for proxy to be ready + log(`Waiting for CLIProxy on port ${port}...`); + const ready = await waitForPort(port, 5000); + + if (!ready) { + // Kill failed process + if (proxyProcess && !proxyProcess.killed) { + proxyProcess.kill('SIGTERM'); + proxyProcess = null; + } + + return { + started: false, + alreadyRunning: false, + port, + error: `CLIProxy failed to start within 5s on port ${port}`, + }; + } + + log(`CLIProxy service started on port ${port}`); + return { started: true, alreadyRunning: false, port }; +} + +/** + * Stop the managed CLIProxy service + */ +export function stopCliproxyService(): boolean { + if (proxyProcess && !proxyProcess.killed) { + proxyProcess.kill('SIGTERM'); + proxyProcess = null; + return true; + } + return false; +} + +/** + * Get service status + */ +export async function getServiceStatus(port: number = CLIPROXY_DEFAULT_PORT): Promise<{ + running: boolean; + managedByUs: boolean; + port: number; +}> { + const running = await isCliproxyRunning(port); + const managedByUs = proxyProcess !== null && !proxyProcess.killed; + + return { running, managedByUs, port }; +} diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts new file mode 100644 index 00000000..2002151a --- /dev/null +++ b/src/cliproxy/stats-fetcher.ts @@ -0,0 +1,216 @@ +/** + * CLIProxyAPI Stats Fetcher + * + * Fetches usage statistics from CLIProxyAPI's management API. + * Requires usage-statistics-enabled: true in config.yaml. + */ + +import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator'; + +/** Usage statistics from CLIProxyAPI */ +export interface CliproxyStats { + /** Total number of requests processed */ + totalRequests: number; + /** Token counts */ + tokens: { + input: number; + output: number; + total: number; + }; + /** Requests grouped by model */ + requestsByModel: Record; + /** Requests grouped by provider */ + requestsByProvider: Record; + /** Number of quota exceeded (429) events */ + quotaExceededCount: number; + /** Number of request retries */ + retryCount: number; + /** Timestamp of stats collection */ + collectedAt: string; +} + +/** Usage API response from CLIProxyAPI /v0/management/usage endpoint */ +interface UsageApiResponse { + failed_requests?: number; + usage?: { + total_requests?: number; + success_count?: number; + failure_count?: number; + total_tokens?: number; + apis?: Record< + string, + { + total_requests?: number; + total_tokens?: number; + models?: Record< + string, + { + total_requests?: number; + total_tokens?: number; + } + >; + } + >; + }; +} + +/** + * Fetch usage statistics from CLIProxyAPI management API + * @param port CLIProxyAPI port (default: 8317) + * @returns Stats object or null if unavailable + */ +export async function fetchCliproxyStats( + port: number = CLIPROXY_DEFAULT_PORT +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout + + const response = await fetch(`http://127.0.0.1:${port}/v0/management/usage`, { + signal: controller.signal, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as UsageApiResponse; + const usage = data.usage; + + // Extract models and providers from the nested API structure + const requestsByModel: Record = {}; + const requestsByProvider: Record = {}; + + if (usage?.apis) { + for (const [provider, providerData] of Object.entries(usage.apis)) { + requestsByProvider[provider] = providerData.total_requests ?? 0; + if (providerData.models) { + for (const [model, modelData] of Object.entries(providerData.models)) { + requestsByModel[model] = modelData.total_requests ?? 0; + } + } + } + } + + // Normalize the response to our interface + return { + totalRequests: usage?.total_requests ?? 0, + tokens: { + input: 0, // API doesn't provide input/output breakdown + output: 0, + total: usage?.total_tokens ?? 0, + }, + requestsByModel, + requestsByProvider, + quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0, + retryCount: 0, // API doesn't track retries separately + collectedAt: new Date().toISOString(), + }; + } catch { + // CLIProxyAPI not running or stats endpoint not available + return null; + } +} + +/** OpenAI-compatible model object from /v1/models endpoint */ +export interface CliproxyModel { + id: string; + object: string; + created: number; + owned_by: string; +} + +/** Response from /v1/models endpoint */ +interface ModelsApiResponse { + data: CliproxyModel[]; + object: string; +} + +/** Categorized models response for UI */ +export interface CliproxyModelsResponse { + models: CliproxyModel[]; + byCategory: Record; + totalCount: number; +} + +/** + * Fetch available models from CLIProxyAPI /v1/models endpoint + * @param port CLIProxyAPI port (default: 8317) + * @returns Categorized models or null if unavailable + */ +export async function fetchCliproxyModels( + port: number = CLIPROXY_DEFAULT_PORT +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + const response = await fetch(`http://127.0.0.1:${port}/v1/models`, { + signal: controller.signal, + headers: { + Accept: 'application/json', + // Use the internal API key for /v1 endpoints + Authorization: 'Bearer ccs-internal-managed', + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as ModelsApiResponse; + + // Group models by owned_by field + const byCategory: Record = {}; + for (const model of data.data) { + const category = model.owned_by || 'other'; + if (!byCategory[category]) { + byCategory[category] = []; + } + byCategory[category].push(model); + } + + // Sort models within each category alphabetically + for (const category of Object.keys(byCategory)) { + byCategory[category].sort((a, b) => a.id.localeCompare(b.id)); + } + + return { + models: data.data, + byCategory, + totalCount: data.data.length, + }; + } catch { + return null; + } +} + +/** + * Check if CLIProxyAPI is running and responsive + * @param port CLIProxyAPI port (default: 8317) + * @returns true if proxy is running + */ +export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout + + // Use root endpoint - CLIProxyAPI returns server info at / + const response = await fetch(`http://127.0.0.1:${port}/`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + return response.ok; + } catch { + return false; + } +} diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 3dba6c6e..838ee899 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -2,6 +2,7 @@ * Config Command Handler * * Launches web-based configuration dashboard. + * Ensures CLIProxy service is running for dashboard features. * Usage: ccs config [--port PORT] [--dev] */ @@ -9,7 +10,9 @@ import getPort from 'get-port'; import open from 'open'; import { startServer } from '../web-server'; import { setupGracefulShutdown } from '../web-server/shutdown'; -import { initUI, header, ok, info } from '../utils/ui'; +import { ensureCliproxyService } from '../cliproxy/service-manager'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; +import { initUI, header, ok, info, warn } from '../utils/ui'; interface ConfigOptions { port?: number; @@ -72,10 +75,31 @@ export async function handleConfigCommand(args: string[]): Promise { await initUI(); const options = parseArgs(args); + const verbose = options.dev || false; console.log(header('CCS Config Dashboard')); console.log(''); - console.log(info('Starting server...')); + + // Ensure CLIProxy service is running for dashboard features + console.log(info('Starting CLIProxy service...')); + const cliproxyResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose); + + if (cliproxyResult.started) { + if (cliproxyResult.alreadyRunning) { + console.log(ok(`CLIProxy already running on port ${cliproxyResult.port}`)); + if (cliproxyResult.configRegenerated) { + console.log(warn('Config updated - restart CLIProxy to apply changes')); + } + } else { + console.log(ok(`CLIProxy started on port ${cliproxyResult.port}`)); + } + } else { + console.log(warn(`CLIProxy not available: ${cliproxyResult.error}`)); + console.log(info('Dashboard will work but Control Panel/Stats may be limited')); + } + console.log(''); + + console.log(info('Starting dashboard server...')); // Find available port const port = diff --git a/src/management/doctor.ts b/src/management/doctor.ts index cfb101f9..778e0a51 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -17,6 +17,9 @@ import { getConfigPath, getInstalledCliproxyVersion, CLIPROXY_DEFAULT_PORT, + configNeedsRegeneration, + regenerateConfig, + CLIPROXY_CONFIG_VERSION, } from '../cliproxy'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import { getEnvironmentDiagnostics } from './environment-diagnostics'; @@ -919,17 +922,38 @@ class Doctor { ); } - // 2. Config file exists? + // 2. Config file exists and is up-to-date? const configSpinner = ora('Checking CLIProxy config').start(); const configPath = getConfigPath(); if (fs.existsSync(configPath)) { - configSpinner.succeed(); - console.log(` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml`); - this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { - status: 'OK', - info: 'cliproxy/config.yaml', - }); + // Check if config needs regeneration (version mismatch or missing features) + if (configNeedsRegeneration()) { + configSpinner.warn(); + console.log( + ` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...` + ); + + // Regenerate config with new features + regenerateConfig(); + + console.log( + ` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}` + ); + this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { + status: 'OK', + info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`, + }); + } else { + configSpinner.succeed(); + console.log( + ` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})` + ); + this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { + status: 'OK', + info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`, + }); + } } else { configSpinner.info(); console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`); diff --git a/src/types/config.ts b/src/types/config.ts index 3ff6e2dc..c5aed9c2 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -52,12 +52,26 @@ export interface Config { export type EnvValue = string; export type EnvVars = Record; +/** + * Model preset configuration + * Stores named model mappings for quick switching + */ +export interface ModelPreset { + name: string; + default: string; + opus: string; + sonnet: string; + haiku: string; +} + /** * Claude CLI settings.json structure * Located at: ~/.claude/settings.json or profile-specific */ export interface Settings { env?: EnvVars; + /** Saved model presets for this provider */ + presets?: ModelPreset[]; [key: string]: unknown; // Allow other settings } diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index c5a16511..8108563e 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -11,7 +11,26 @@ import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../utils/con import { Config, Settings } from '../types/config'; import { expandPath } from '../utils/helpers'; import { runHealthChecks, fixHealthIssue } from './health-service'; -import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler'; +import { + getAllAuthStatus, + getOAuthConfig, + initializeAccounts, + triggerOAuth, +} from '../cliproxy/auth-handler'; +import { + fetchCliproxyStats, + fetchCliproxyModels, + isCliproxyRunning, +} from '../cliproxy/stats-fetcher'; +import { + listOpenAICompatProviders, + getOpenAICompatProvider, + addOpenAICompatProvider, + updateOpenAICompatProvider, + removeOpenAICompatProvider, + OPENROUTER_TEMPLATE, + TOGETHER_TEMPLATE, +} from '../cliproxy/openai-compat-manager'; import { getAllAccountsSummary, getProviderAccounts, @@ -19,6 +38,7 @@ import { removeAccount as removeAccountFn, } from '../cliproxy/account-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; +import { getClaudeEnvVars } from '../cliproxy/config-generator'; // Unified config imports import { hasUnifiedConfig, @@ -186,12 +206,25 @@ function updateSettingsFile( /** * Helper: Create cliproxy variant settings + * Includes base URL and auth token for proper Claude CLI integration */ -function createCliproxySettings(name: string, model?: string): string { +function createCliproxySettings(name: string, provider: CLIProxyProvider, model?: string): string { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + // Get base env vars from provider config (includes BASE_URL, AUTH_TOKEN) + const baseEnv = getClaudeEnvVars(provider); + const settings: Settings = { - env: model ? { ANTHROPIC_MODEL: model } : {}, + env: { + ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', + ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', + ANTHROPIC_MODEL: model || (baseEnv.ANTHROPIC_MODEL as string) || '', + ANTHROPIC_DEFAULT_OPUS_MODEL: model || (baseEnv.ANTHROPIC_DEFAULT_OPUS_MODEL as string) || '', + ANTHROPIC_DEFAULT_SONNET_MODEL: + model || (baseEnv.ANTHROPIC_DEFAULT_SONNET_MODEL as string) || '', + ANTHROPIC_DEFAULT_HAIKU_MODEL: + (baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL as string) || model || '', + }, }; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); @@ -342,7 +375,7 @@ apiRoutes.post('/cliproxy', (req: Request, res: Response): void => { } // Create settings file for variant - const settingsPath = createCliproxySettings(name, model); + const settingsPath = createCliproxySettings(name, provider as CLIProxyProvider, model); // Include account if specified (defaults to 'default' if not provided) config.cliproxy[name] = { @@ -520,10 +553,34 @@ apiRoutes.post('/cliproxy/accounts/:provider/default', (req: Request, res: Respo /** * DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account */ -apiRoutes.delete( - '/api/cliproxy/accounts/:provider/:accountId', - (req: Request, res: Response): void => { - const { provider, accountId } = req.params; +apiRoutes.delete('/cliproxy/accounts/:provider/:accountId', (req: Request, res: Response): void => { + const { provider, accountId } = req.params; + + // Validate provider + const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + const success = removeAccountFn(provider as CLIProxyProvider, accountId); + + if (success) { + res.json({ provider, accountId, deleted: true }); + } else { + res.status(404).json({ error: 'Account not found' }); + } +}); + +/** + * POST /api/cliproxy/auth/:provider/start - Start OAuth flow for a provider + * Opens browser for authentication and returns account info when complete + */ +apiRoutes.post( + '/cliproxy/auth/:provider/start', + async (req: Request, res: Response): Promise => { + const { provider } = req.params; + const { nickname } = req.body; // Validate provider const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; @@ -532,12 +589,30 @@ apiRoutes.delete( return; } - const success = removeAccountFn(provider as CLIProxyProvider, accountId); + try { + // Trigger OAuth flow - this opens browser and waits for completion + const account = await triggerOAuth(provider as CLIProxyProvider, { + add: true, // Always add mode from UI + headless: false, // Force interactive mode + nickname: nickname || undefined, + }); - if (success) { - res.json({ provider, accountId, deleted: true }); - } else { - res.status(404).json({ error: 'Account not found' }); + if (account) { + res.json({ + success: true, + account: { + id: account.id, + email: account.email, + nickname: account.nickname, + provider: account.provider, + isDefault: account.isDefault, + }, + }); + } else { + res.status(400).json({ error: 'Authentication failed or was cancelled' }); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } } ); @@ -658,6 +733,93 @@ apiRoutes.put('/settings/:profile', (req: Request, res: Response): void => { }); }); +// ==================== Presets ==================== + +/** + * GET /api/settings/:profile/presets - Get saved presets for a provider + */ +apiRoutes.get('/settings/:profile/presets', (req: Request, res: Response): void => { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + res.json({ presets: [] }); + return; + } + + const settings = loadSettings(settingsPath); + res.json({ presets: settings.presets || [] }); +}); + +/** + * POST /api/settings/:profile/presets - Create a new preset + */ +apiRoutes.post('/settings/:profile/presets', (req: Request, res: Response): void => { + const { profile } = req.params; + const { name, default: defaultModel, opus, sonnet, haiku } = req.body; + + if (!name || !defaultModel) { + res.status(400).json({ error: 'Missing required fields: name, default' }); + return; + } + + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + // Create settings file if it doesn't exist + if (!fs.existsSync(settingsPath)) { + fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); + } + + const settings = loadSettings(settingsPath); + settings.presets = settings.presets || []; + + // Check for duplicate name + if (settings.presets.some((p) => p.name === name)) { + res.status(409).json({ error: 'Preset with this name already exists' }); + return; + } + + const preset = { + name, + default: defaultModel, + opus: opus || defaultModel, + sonnet: sonnet || defaultModel, + haiku: haiku || defaultModel, + }; + + settings.presets.push(preset); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + res.status(201).json({ preset }); +}); + +/** + * DELETE /api/settings/:profile/presets/:name - Delete a preset + */ +apiRoutes.delete('/settings/:profile/presets/:name', (req: Request, res: Response): void => { + const { profile, name } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const settings = loadSettings(settingsPath); + if (!settings.presets || !settings.presets.some((p) => p.name === name)) { + res.status(404).json({ error: 'Preset not found' }); + return; + } + + settings.presets = settings.presets.filter((p) => p.name !== name); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + res.json({ success: true }); +}); + // ==================== Accounts ==================== /** @@ -1023,3 +1185,215 @@ apiRoutes.get('/files', (_req: Request, res: Response): void => { res.status(500).json({ error: (error as Error).message }); } }); + +/** + * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics + * Returns: CliproxyStats or error if proxy not running + */ +apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise => { + try { + // Check if proxy is running first + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxyAPI not running', + message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats', + }); + return; + } + + // Fetch stats from management API + const stats = await fetchCliproxyStats(); + if (!stats) { + res.status(503).json({ + error: 'Stats unavailable', + message: 'CLIProxyAPI is running but stats endpoint not responding', + }); + return; + } + + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/status - Check CLIProxyAPI running status + * Returns: { running: boolean } + */ +apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(); + res.json({ running }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/models - Get available models from CLIProxyAPI + * Returns: { models: CliproxyModel[], byCategory: Record, totalCount: number } + */ +apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise => { + try { + // Check if proxy is running first + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxyAPI not running', + message: 'Start a CLIProxy session (gemini, codex, agy) to fetch available models', + }); + return; + } + + // Fetch models from /v1/models endpoint + const modelsResponse = await fetchCliproxyModels(); + if (!modelsResponse) { + res.status(503).json({ + error: 'Models unavailable', + message: 'CLIProxyAPI is running but /v1/models endpoint not responding', + }); + return; + } + + res.json(modelsResponse); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +// ============================================ +// OpenAI Compatibility Layer Routes +// ============================================ + +/** + * GET /api/cliproxy/openai-compat - List all OpenAI-compatible providers + */ +apiRoutes.get('/cliproxy/openai-compat', (_req: Request, res: Response): void => { + try { + const providers = listOpenAICompatProviders(); + // Mask API keys for security + const masked = providers.map((p) => ({ + ...p, + apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '', + })); + res.json({ providers: masked }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/openai-compat/templates - Get pre-configured provider templates + */ +apiRoutes.get('/cliproxy/openai-compat/templates', (_req: Request, res: Response): void => { + res.json({ + templates: [ + { ...OPENROUTER_TEMPLATE, description: 'OpenRouter - Access multiple AI models' }, + { ...TOGETHER_TEMPLATE, description: 'Together AI - Open source models' }, + ], + }); +}); + +/** + * GET /api/cliproxy/openai-compat/:name - Get a specific provider + */ +apiRoutes.get('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => { + try { + const provider = getOpenAICompatProvider(req.params.name); + if (!provider) { + res.status(404).json({ error: `Provider '${req.params.name}' not found` }); + return; + } + // Mask API key + res.json({ + ...provider, + apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '', + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/openai-compat - Add a new provider + * Body: { name, baseUrl, apiKey, models: [{ name, alias }] } + */ +apiRoutes.post('/cliproxy/openai-compat', (req: Request, res: Response): void => { + try { + const { name, baseUrl, apiKey, models } = req.body; + + // Validation + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'name is required' }); + return; + } + if (!baseUrl || typeof baseUrl !== 'string') { + res.status(400).json({ error: 'baseUrl is required' }); + return; + } + if (!apiKey || typeof apiKey !== 'string') { + res.status(400).json({ error: 'apiKey is required' }); + return; + } + + addOpenAICompatProvider({ + name, + baseUrl, + apiKey, + models: models || [], + }); + + res.status(201).json({ success: true, name }); + } catch (error) { + const message = (error as Error).message; + if (message.includes('already exists')) { + res.status(409).json({ error: message }); + } else { + res.status(500).json({ error: message }); + } + } +}); + +/** + * PUT /api/cliproxy/openai-compat/:name - Update a provider + * Body: { baseUrl?, apiKey?, models?, name? (for rename) } + */ +apiRoutes.put('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => { + try { + const { baseUrl, apiKey, models, name: newName } = req.body; + + updateOpenAICompatProvider(req.params.name, { + baseUrl, + apiKey, + models, + name: newName, + }); + + res.json({ success: true }); + } catch (error) { + const message = (error as Error).message; + if (message.includes('not found')) { + res.status(404).json({ error: message }); + } else { + res.status(500).json({ error: message }); + } + } +}); + +/** + * DELETE /api/cliproxy/openai-compat/:name - Remove a provider + */ +apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => { + try { + const removed = removeOpenAICompatProvider(req.params.name); + if (!removed) { + res.status(404).json({ error: `Provider '${req.params.name}' not found` }); + return; + } + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/ui/bun.lock b/ui/bun.lock index c677c243..4a0d6dae 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -29,11 +29,14 @@ "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", + "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.10.1", "react-simple-code-editor": "^0.14.1", + "react-virtuoso": "^4.17.0", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "yaml": "^2.8.2", "zod": "^4.1.13", }, "devDependencies": { @@ -708,6 +711,8 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-resizable-panels": ["react-resizable-panels@3.0.6", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew=="], + "react-router": ["react-router@7.10.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw=="], "react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="], @@ -720,6 +725,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "react-virtuoso": ["react-virtuoso@4.17.0", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], @@ -788,6 +795,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], diff --git a/ui/package.json b/ui/package.json index 9b90a795..e44e696d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -40,11 +40,14 @@ "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", + "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.10.1", "react-simple-code-editor": "^0.14.1", + "react-virtuoso": "^4.17.0", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "yaml": "^2.8.2", "zod": "^4.1.13" }, "devDependencies": { diff --git a/ui/public/assets/providers/agy.png b/ui/public/assets/providers/agy.png new file mode 100644 index 00000000..093fa6ef Binary files /dev/null and b/ui/public/assets/providers/agy.png differ diff --git a/ui/public/assets/providers/gemini-color.svg b/ui/public/assets/providers/gemini-color.svg new file mode 100644 index 00000000..f1cf3575 --- /dev/null +++ b/ui/public/assets/providers/gemini-color.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/ui/public/assets/providers/openai.svg b/ui/public/assets/providers/openai.svg new file mode 100644 index 00000000..50d94d6c --- /dev/null +++ b/ui/public/assets/providers/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/ui/public/assets/providers/qwen-color.svg b/ui/public/assets/providers/qwen-color.svg new file mode 100644 index 00000000..33b3f645 --- /dev/null +++ b/ui/public/assets/providers/qwen-color.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8b4801b3..2cb49a79 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -20,6 +20,9 @@ const ApiPage = lazy(() => import('@/pages/api').then((m) => ({ default: m.ApiPa const CliproxyPage = lazy(() => import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage })) ); +const CliproxyControlPanelPage = lazy(() => + import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage })) +); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -71,6 +74,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/add-account-dialog.tsx b/ui/src/components/add-account-dialog.tsx index 0fac8fdc..f9a80af7 100644 --- a/ui/src/components/add-account-dialog.tsx +++ b/ui/src/components/add-account-dialog.tsx @@ -1,8 +1,6 @@ /** * Add Account Dialog Component - * Simple dialog to add another OAuth account to a provider - * - * Shows auth command + refresh button (no variant creation) + * Triggers OAuth flow server-side to add another account to a provider */ import { useState } from 'react'; @@ -14,9 +12,10 @@ import { DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { Copy, Check, RefreshCw, Terminal } from 'lucide-react'; -import { useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Loader2, ExternalLink, User } from 'lucide-react'; +import { useStartAuth } from '@/hooks/use-cliproxy'; interface AddAccountDialogProps { open: boolean; @@ -26,69 +25,82 @@ interface AddAccountDialogProps { } export function AddAccountDialog({ open, onClose, provider, displayName }: AddAccountDialogProps) { - const [copied, setCopied] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); - const { refetch } = useCliproxyAuth(); + const [nickname, setNickname] = useState(''); + const startAuthMutation = useStartAuth(); - const authCommand = `ccs ${provider} --auth --add`; - - const copyCommand = async () => { - await navigator.clipboard.writeText(authCommand); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const handleStartAuth = () => { + startAuthMutation.mutate( + { provider, nickname: nickname.trim() || undefined }, + { + onSuccess: () => { + setNickname(''); + onClose(); + }, + } + ); }; - const handleRefresh = async () => { - setIsRefreshing(true); - await refetch(); - setIsRefreshing(false); - onClose(); + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen && !startAuthMutation.isPending) { + setNickname(''); + onClose(); + } }; return ( - + Add {displayName} Account - Run the command below in your terminal to authenticate a new account + Click the button below to authenticate a new account. A browser window will open for + OAuth.
- - -
- - Run this command: -
-
- - {authCommand} - - -
-
- This will open your browser to authenticate with {displayName} -
-
-
+
+ +
+ + setNickname(e.target.value)} + placeholder="e.g., work, personal" + disabled={startAuthMutation.isPending} + className="flex-1" + /> +
+

+ A friendly name to identify this account. Auto-generated from email if left empty. +

+
-
- -
+ + {startAuthMutation.isPending && ( +

+ Complete the OAuth flow in your browser... +

+ )}
diff --git a/ui/src/components/analytics/cliproxy-stats-card.tsx b/ui/src/components/analytics/cliproxy-stats-card.tsx new file mode 100644 index 00000000..ba21ec9c --- /dev/null +++ b/ui/src/components/analytics/cliproxy-stats-card.tsx @@ -0,0 +1,252 @@ +/** + * CLIProxy Stats Card Component + * + * Displays CLIProxyAPI usage statistics with: + * - Status indicator (running/offline) + * - Total requests with success rate ring + * - Total tokens usage + * - Model breakdown with usage bars + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Server, Zap, Cpu, Coins } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; + +interface CliproxyStatsCardProps { + className?: string; +} + +export function CliproxyStatsCard({ className }: CliproxyStatsCardProps) { + const { data: status, isLoading: statusLoading } = useCliproxyStatus(); + const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running); + + const isLoading = statusLoading || (status?.running && statsLoading); + + if (isLoading) { + return ( + + + + + CLIProxy Stats + + + +
+ + +
+
+
+ ); + } + + // Proxy not running + if (!status?.running) { + return ( + + +
+ + + CLIProxy Stats + + + Offline + +
+
+ +

+ Start a CLIProxy session (gemini, codex, agy) to collect stats. +

+
+
+ ); + } + + // Error fetching stats + if (error) { + return ( + + +
+ + + CLIProxy Stats + + + Error + +
+
+ +

{error.message}

+
+
+ ); + } + + // Calculate stats + const totalRequests = stats?.totalRequests ?? 0; + const failedRequests = stats?.quotaExceededCount ?? 0; + const successRequests = totalRequests - failedRequests; + const successRate = totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100; + const totalTokens = stats?.tokens?.total ?? 0; + + // Get model breakdown sorted by usage + const models = Object.entries(stats?.requestsByModel ?? {}) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4); // Top 4 models + + const maxModelRequests = models.length > 0 ? models[0][1] : 1; + + return ( + + +
+ + + CLIProxy Stats + + + + Running + +
+
+ + +
+ {/* Key metrics row */} +
+ {/* Requests with success ring */} +
+
+ + + = 90 ? 'text-green-500' : 'text-amber-500'} + /> + + + {successRate}% + +
+
+
+ {formatNumber(totalRequests)} +
+
+ {failedRequests > 0 ? `${failedRequests} failed` : 'All success'} +
+
+
+ + {/* Tokens */} +
+
+ +
+
+
{formatNumber(totalTokens)}
+
Total tokens
+
+
+
+ + {/* Model breakdown */} + {models.length > 0 && ( +
+
+ + Models Used +
+
+ {models.map(([model, count]) => { + const percentage = Math.round((count / maxModelRequests) * 100); + const displayName = formatModelName(model); + return ( +
+
+ + {displayName} + + {count} +
+
+
+
+
+ ); + })} +
+
+ )} +
+ + + + ); +} + +/** Format large numbers with K/M suffix */ +function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + +/** Format model names for display (remove prefixes, shorten) */ +function formatModelName(model: string): string { + // Remove common prefixes + let name = model + .replace(/^gemini-claude-/, '') + .replace(/^gemini-/, '') + .replace(/^claude-/, '') + .replace(/^anthropic\./, '') + .replace(/-thinking$/, ' Thinking'); + + // Capitalize first letter of each word + name = name + .split(/[-_]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + // Shorten long names + if (name.length > 20) { + name = name.slice(0, 18) + '...'; + } + + return name; +} diff --git a/ui/src/components/analytics/usage-insights-card.tsx b/ui/src/components/analytics/usage-insights-card.tsx index b7b48585..7c11461a 100644 --- a/ui/src/components/analytics/usage-insights-card.tsx +++ b/ui/src/components/analytics/usage-insights-card.tsx @@ -1,4 +1,4 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; import { CheckCircle2, Zap, Gauge, DollarSign, Database, Lightbulb } from 'lucide-react'; @@ -17,33 +17,33 @@ const ANOMALY_CONFIG: Record< { icon: React.ComponentType<{ className?: string }>; color: string; + bgColor: string; label: string; - description: string; } > = { high_input: { icon: Zap, - color: 'text-yellow-600 dark:text-yellow-400', - label: 'High Input', - description: 'Unusually high input token usage detected.', + color: 'text-amber-600 dark:text-amber-400', + bgColor: 'bg-amber-100 dark:bg-amber-900/20', + label: 'High Input Volume', }, high_io_ratio: { icon: Gauge, color: 'text-orange-600 dark:text-orange-400', + bgColor: 'bg-orange-100 dark:bg-orange-900/20', label: 'High I/O Ratio', - description: 'Output tokens are significantly higher than input tokens.', }, cost_spike: { icon: DollarSign, color: 'text-red-600 dark:text-red-400', - label: 'Cost Spike', - description: 'Daily cost is significantly higher than average.', + bgColor: 'bg-red-100 dark:bg-red-900/20', + label: 'Cost Spike Detected', }, high_cache_read: { icon: Database, color: 'text-cyan-600 dark:text-cyan-400', - label: 'Heavy Caching', - description: 'High volume of cache read operations.', + bgColor: 'bg-cyan-100 dark:bg-cyan-900/20', + label: 'Heavy Cache Usage', }, }; @@ -55,114 +55,119 @@ export function UsageInsightsCard({ }: UsageInsightsCardProps) { if (isLoading) { return ( - - - - - Usage Insights - - - -
-
-
+ +
+
+ +
- +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
); } const hasAnomalies = summary && summary.totalAnomalies > 0; - return ( - - -
- - - Usage Insights - - {hasAnomalies ? ( - - Attention Needed - - ) : ( - - Healthy - - )} -
-
- - - {hasAnomalies ? ( - -
- {anomalies.map((anomaly, index) => { - const config = ANOMALY_CONFIG[anomaly.type]; - const Icon = config.icon; - - return ( -
-
-
- -
-
-
-

{config.label}

- - {anomaly.date} - -
-

- {anomaly.message} -

- {anomaly.model && ( -
- - {anomaly.model} - -
- )} -
-
-
- ); - })} -
-
- ) : ( -
-
- -
-

No anomalies detected

-

- Your usage patterns look normal for the selected period. -

+ if (!hasAnomalies) { + return ( + +
+
+
- )} - +

All Systems Nominal

+

+ Your usage patterns are within normal ranges for the selected period. +

+
+
+ ); + } + + return ( + +
+
+ +

Usage Insights

+
+ + {summary.totalAnomalies} {summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'} + +
+ + +
+ {anomalies.map((anomaly, index) => { + const config = ANOMALY_CONFIG[anomaly.type]; + const Icon = config.icon; + + return ( +
+
+
+ +
+ +
+
+

{config.label}

+ + {anomaly.date} + +
+ +

+ {anomaly.message} +

+ + {anomaly.model && ( +
+ + {anomaly.model} + +
+ )} +
+
+
+ ); + })} +
+
); } + +function SkeletonIcon() { + return
; +} diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index 7bdb8343..69f73400 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -46,20 +46,20 @@ export function UsageTrendChart({ }, [data, granularity]); if (isLoading) { - return ; + return ; } if (!data || data.length === 0) { return ( -
+

No usage data available

); } return ( -
- +
+ diff --git a/ui/src/components/app-sidebar.tsx b/ui/src/components/app-sidebar.tsx index d1bd4cc3..ec12c94c 100644 --- a/ui/src/components/app-sidebar.tsx +++ b/ui/src/components/app-sidebar.tsx @@ -9,6 +9,7 @@ import { FolderOpen, ChevronRight, BarChart3, + Gauge, } from 'lucide-react'; import { Sidebar, @@ -43,7 +44,16 @@ const navGroups = [ title: 'Identity & Access', items: [ { path: '/api', icon: Key, label: 'API Profiles' }, - { path: '/cliproxy', icon: Zap, label: 'CLIProxy' }, + { + path: '/cliproxy', + icon: Zap, + label: 'CLIProxy', + isCollapsible: true, + children: [ + { path: '/cliproxy', label: 'Overview' }, + { path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' }, + ], + }, { path: '/accounts', icon: Users, @@ -69,12 +79,15 @@ export function AppSidebar() { const location = useLocation(); const { state } = useSidebar(); - // Helper to check if a route is active (including sub-routes if needed) + // Helper to check if a route is active (exact match) const isRouteActive = (path: string) => location.pathname === path; // Helper to check if a group/parent should be open based on active child + // Also handles sub-routes (e.g., /cliproxy/control-panel matches /cliproxy) const isParentActive = (children: { path: string }[]) => { - return children.some((child) => isRouteActive(child.path)); + return children.some( + (child) => isRouteActive(child.path) || location.pathname.startsWith(child.path + '/') + ); }; return ( diff --git a/ui/src/components/cliproxy-stats-overview.tsx b/ui/src/components/cliproxy-stats-overview.tsx new file mode 100644 index 00000000..b6ff362c --- /dev/null +++ b/ui/src/components/cliproxy-stats-overview.tsx @@ -0,0 +1,339 @@ +/** + * CLIProxy Stats Overview Component + * + * Full-width dashboard section showing comprehensive CLIProxyAPI statistics. + * Features: + * - Status indicator with uptime + * - Request metrics with success rate visualization + * - Token usage with cost estimation + * - Model breakdown with usage distribution + * - Session history + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Server, + Zap, + ZapOff, + Activity, + Coins, + Cpu, + CheckCircle2, + XCircle, + TrendingUp, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; + +interface CliproxyStatsOverviewProps { + className?: string; +} + +export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) { + const { data: status, isLoading: statusLoading } = useCliproxyStatus(); + const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running); + + const isLoading = statusLoading || (status?.running && statsLoading); + + // Loading state + if (isLoading) { + return ( +
+
+
+ + +
+ +
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+ ); + } + + // Offline state + if (!status?.running) { + return ( +
+
+
+

+ + Session Statistics +

+

+ Real-time usage metrics from CLIProxyAPI +

+
+ + + Offline + +
+ + + +
+ +
+

No Active Session

+

+ Start a CLIProxy session using{' '} + ccs gemini,{' '} + ccs codex, + or ccs agy{' '} + to view real-time statistics. +

+
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+ + + +
+

Failed to Load Statistics

+

{error.message}

+
+
+
+
+ ); + } + + // Calculate derived stats + const totalRequests = stats?.totalRequests ?? 0; + const failedRequests = stats?.quotaExceededCount ?? 0; + const successRequests = totalRequests - failedRequests; + const successRate = totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100; + const totalTokens = stats?.tokens?.total ?? 0; + + // Get model breakdown sorted by usage + const models = Object.entries(stats?.requestsByModel ?? {}).sort((a, b) => b[1] - a[1]); + const maxModelRequests = models.length > 0 ? models[0][1] : 1; + + // Define color palette for models + const modelColors = [ + 'bg-blue-500', + 'bg-purple-500', + 'bg-amber-500', + 'bg-emerald-500', + 'bg-rose-500', + 'bg-cyan-500', + ]; + + return ( +
+ {/* Header */} +
+
+

+ + Session Statistics +

+

Real-time usage metrics from CLIProxyAPI

+
+ + + Running + +
+ + {/* Stats Grid */} +
+ {/* Requests Card */} + + +
+
+

Total Requests

+

{formatNumber(totalRequests)}

+
+ + {successRequests} success +
+
+
+ +
+
+
+
+ + {/* Success Rate Card */} + + +
+
+

Success Rate

+

{successRate}%

+
+
= 90 ? 'bg-green-500' : 'bg-amber-500' + )} + style={{ width: `${successRate}%` }} + /> +
+
+
= 90 + ? 'bg-green-100 dark:bg-green-900/20' + : 'bg-amber-100 dark:bg-amber-900/20' + )} + > + {successRate >= 90 ? ( + + ) : ( + + )} +
+
+ + + + {/* Tokens Card */} + + +
+
+

Total Tokens

+

{formatNumber(totalTokens)}

+

+ ~${estimateCost(totalTokens).toFixed(2)} estimated +

+
+
+ +
+
+
+
+ + {/* Models Card */} + + +
+
+

Models Used

+

{models.length}

+

+ {models.length > 0 ? formatModelName(models[0][0]) : 'None'} +

+
+
+ +
+
+
+
+
+ + {/* Model Breakdown */} + {models.length > 0 && ( + + + + + Model Usage Distribution + + + +
+ {models.map(([model, count], index) => { + const percentage = Math.round((count / totalRequests) * 100); + const barPercentage = Math.round((count / maxModelRequests) * 100); + const displayName = formatModelName(model); + const colorClass = modelColors[index % modelColors.length]; + + return ( +
+
+
+
+ + {displayName} + +
+
+ {count} requests + {percentage}% +
+
+
+
+
+
+ ); + })} +
+ + + )} +
+ ); +} + +/** Format large numbers with K/M suffix */ +function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + +/** Format model names for display */ +function formatModelName(model: string): string { + let name = model + .replace(/^gemini-claude-/, '') + .replace(/^gemini-/, '') + .replace(/^claude-/, '') + .replace(/^anthropic\./, '') + .replace(/-thinking$/, ' Thinking'); + + name = name + .split(/[-_]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + if (name.length > 25) { + name = name.slice(0, 23) + '...'; + } + + return name; +} + +/** Estimate cost based on token count (rough average) */ +function estimateCost(tokens: number): number { + // Average cost per 1M tokens across models (~$3 for input, ~$15 for output) + // Assuming 30% input, 70% output ratio + const avgCostPerMillion = 3 * 0.3 + 15 * 0.7; + return (tokens / 1000000) * avgCostPerMillion; +} diff --git a/ui/src/components/cliproxy/categorized-model-selector.tsx b/ui/src/components/cliproxy/categorized-model-selector.tsx new file mode 100644 index 00000000..4418ed5b --- /dev/null +++ b/ui/src/components/cliproxy/categorized-model-selector.tsx @@ -0,0 +1,143 @@ +/** + * Categorized Model Selector + * Groups models by provider (owned_by) with model counts + */ + +import { useMemo } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Cpu } from 'lucide-react'; +import type { CliproxyModelsResponse } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; + +/** Provider display configuration */ +const CATEGORY_CONFIG: Record = { + google: { name: 'Google (Gemini)', color: 'text-blue-600' }, + openai: { name: 'OpenAI (GPT)', color: 'text-green-600' }, + anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600' }, + antigravity: { name: 'Antigravity', color: 'text-purple-600' }, + other: { name: 'Other', color: 'text-gray-600' }, +}; + +/** Get display name for category */ +function getCategoryDisplay(category: string) { + return ( + CATEGORY_CONFIG[category.toLowerCase()] || { + name: category.charAt(0).toUpperCase() + category.slice(1), + color: 'text-gray-600', + } + ); +} + +interface CategorizedModelSelectorProps { + /** Models data from API */ + modelsData: CliproxyModelsResponse | undefined; + /** Whether data is loading */ + isLoading?: boolean; + /** Currently selected model */ + value: string | undefined; + /** Callback when model changes */ + onChange: (model: string) => void; + /** Whether the selector is disabled */ + disabled?: boolean; + /** Placeholder text */ + placeholder?: string; + /** Custom width class */ + className?: string; +} + +export function CategorizedModelSelector({ + modelsData, + isLoading, + value, + onChange, + disabled, + placeholder = 'Select a model', + className, +}: CategorizedModelSelectorProps) { + // Sort categories by model count (descending) + const sortedCategories = useMemo(() => { + if (!modelsData?.byCategory) return []; + return Object.entries(modelsData.byCategory) + .sort(([, a], [, b]) => b.length - a.length) + .map(([category, models]) => ({ + category, + display: getCategoryDisplay(category), + models, + })); + }, [modelsData]); + + if (isLoading) { + return ; + } + + if (!modelsData || modelsData.totalCount === 0) { + return ( +
+ + No models available +
+ ); + } + + return ( + + ); +} + +/** Compact variant for inline usage */ +export function CategorizedModelSelectorCompact({ + modelsData, + isLoading, + value, + onChange, + disabled, +}: Omit) { + return ( + + ); +} diff --git a/ui/src/components/cliproxy/cliproxy-header.tsx b/ui/src/components/cliproxy/cliproxy-header.tsx new file mode 100644 index 00000000..c610c60c --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-header.tsx @@ -0,0 +1,187 @@ +/** + * CLIProxy Header Component + * Fixed header with OAuth login buttons, status indicator, and refresh + */ + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { RefreshCw, Loader2 } from 'lucide-react'; +import { useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; +import { cn } from '@/lib/utils'; + +interface LoginButtonProps { + provider: string; + displayName: string; + isAuthenticated: boolean; + accountCount: number; + isAuthenticating: boolean; + onLogin: () => void; +} + +function LoginButton({ + displayName, + isAuthenticated, + accountCount, + isAuthenticating, + onLogin, +}: LoginButtonProps) { + if (isAuthenticating) { + return ( + + ); + } + + if (isAuthenticated) { + return ( + + ); + } + + return ( + + ); +} + +// Helper to format relative time +function formatRelativeTime(date: Date): string { + const diff = Date.now() - date.getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes === 1) return '1m ago'; + return `${minutes}m ago`; +} + +// Hook for relative time display that updates periodically +function useRelativeTime(date: Date | undefined): string | null { + const [text, setText] = useState(() => (date ? formatRelativeTime(date) : null)); + + useEffect(() => { + if (!date) return; + + // Update every 30 seconds via interval only + const interval = setInterval(() => { + setText(formatRelativeTime(date)); + }, 30000); + + return () => clearInterval(interval); + }, [date]); + + // Compute current value on each render if date changes + // This is the pure computation part + const currentText = date ? formatRelativeTime(date) : null; + + // Return the more recent of computed or state-based value + // State value will be updated by interval + return date ? currentText : text; +} + +interface CliproxyHeaderProps { + onRefresh: () => void; + isRefreshing: boolean; + lastUpdated?: Date; + isRunning?: boolean; +} + +export function CliproxyHeader({ + onRefresh, + isRefreshing, + lastUpdated, + isRunning = true, +}: CliproxyHeaderProps) { + const { data: authData } = useCliproxyAuth(); + const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow(); + const lastUpdatedText = useRelativeTime(lastUpdated); + + const providers = [ + { id: 'claude', displayName: 'Claude' }, + { id: 'gemini', displayName: 'Gemini' }, + { id: 'codex', displayName: 'Codex' }, + { id: 'agy', displayName: 'Agy' }, + ]; + + const getProviderStatus = (providerId: string) => { + const status = authData?.authStatus.find((s) => s.provider === providerId); + return { + isAuthenticated: status?.authenticated ?? false, + accountCount: status?.accounts?.length ?? 0, + }; + }; + + return ( +
+ {/* Top row: Title and Login Buttons */} +
+
+

CLIProxy

+

+ Manage OAuth providers and configuration +

+
+ + {/* Login Buttons - Wrap on mobile */} +
+ {providers.map((p) => { + const status = getProviderStatus(p.id); + return ( + startAuth(p.id)} + /> + ); + })} +
+
+ + {/* Bottom row: Status and Refresh */} +
+ + + {isRunning ? 'Running' : 'Offline'} + + + {lastUpdatedText && ( + {lastUpdatedText} + )} + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/cliproxy-tabs.tsx b/ui/src/components/cliproxy/cliproxy-tabs.tsx new file mode 100644 index 00000000..f7dbf560 --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-tabs.tsx @@ -0,0 +1,55 @@ +/** + * CLIProxy Tabs Component + * Tab navigation wrapper for Overview, Config, and Logs tabs + */ + +import type { ReactNode } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { LayoutDashboard, FileCode, ScrollText } from 'lucide-react'; + +export type CliproxyTabValue = 'overview' | 'config' | 'logs'; + +interface CliproxyTabsProps { + activeTab: CliproxyTabValue; + onTabChange: (tab: CliproxyTabValue) => void; + children: { + overview: ReactNode; + config: ReactNode; + logs: ReactNode; + }; +} + +const TAB_CONFIG = [ + { value: 'overview' as const, label: 'Overview', icon: LayoutDashboard }, + { value: 'config' as const, label: 'Config', icon: FileCode }, + { value: 'logs' as const, label: 'Logs', icon: ScrollText }, +]; + +export function CliproxyTabs({ activeTab, onTabChange, children }: CliproxyTabsProps) { + return ( + onTabChange(v as CliproxyTabValue)} + className="w-full" + > + + {TAB_CONFIG.map(({ value, label, icon: Icon }) => ( + + + {label} + + ))} + + + + {children.overview} + + + {children.config} + + + {children.logs} + + + ); +} diff --git a/ui/src/components/cliproxy/config/config-split-view.tsx b/ui/src/components/cliproxy/config/config-split-view.tsx new file mode 100644 index 00000000..e2b195bf --- /dev/null +++ b/ui/src/components/cliproxy/config/config-split-view.tsx @@ -0,0 +1,166 @@ +/** + * Config Split View Container + * Main split layout for Config tab with file tree and YAML editor + */ + +import { useState, useEffect } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { Button } from '@/components/ui/button'; +import { Save, RotateCcw, GitCompare, Loader2 } from 'lucide-react'; +import { FileTree } from './file-tree'; +import { buildFileTree } from './file-tree-utils'; +import { YamlEditor, EditorStatusBar } from './yaml-editor'; +import { DiffDialog } from './diff-dialog'; +import { useCliproxyConfig, useCliproxyAuthFile } from '@/hooks/use-cliproxy-config'; + +export function ConfigSplitView() { + const [selectedFile, setSelectedFile] = useState('config.yaml'); + const [showDiff, setShowDiff] = useState(false); + + const { + content, + originalContent, + isDirty, + validation, + isLoading, + authFiles, + updateContent, + resetContent, + saveContent, + isSaving, + } = useCliproxyConfig(); + + // For viewing auth files (read-only) + const [viewingAuthFile, setViewingAuthFile] = useState(null); + const { data: authFileContent } = useCliproxyAuthFile(viewingAuthFile); + + const handleFileSelect = (path: string) => { + if (path === 'config.yaml') { + setSelectedFile(path); + setViewingAuthFile(null); + } else if (path.startsWith('auths/')) { + const fileName = path.replace('auths/', ''); + setSelectedFile(path); + setViewingAuthFile(fileName); + } + }; + + // Build file tree + const fileTree = buildFileTree(isDirty, authFiles); + + // Keyboard shortcut for save + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + if (isDirty && validation.valid) { + saveContent(); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isDirty, validation.valid, saveContent]); + + const isEditingConfig = selectedFile === 'config.yaml'; + const currentContent = isEditingConfig ? content : (authFileContent ?? ''); + const isReadonly = !isEditingConfig; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ {selectedFile} + {isDirty && isEditingConfig && ( + + Modified + + )} +
+ {isEditingConfig && ( +
+ + + +
+ )} +
+ + {/* Split Panels */} + + + + + + + + +
+
+ +
+ +
+
+
+ + {/* Diff Dialog */} + setShowDiff(false)} + original={originalContent} + modified={content} + onConfirmSave={() => { + saveContent(); + setShowDiff(false); + }} + isSaving={isSaving} + /> +
+ ); +} diff --git a/ui/src/components/cliproxy/config/diff-dialog.tsx b/ui/src/components/cliproxy/config/diff-dialog.tsx new file mode 100644 index 00000000..0ffe97db --- /dev/null +++ b/ui/src/components/cliproxy/config/diff-dialog.tsx @@ -0,0 +1,90 @@ +/** + * Diff Dialog Component + * Shows before/after comparison before saving config + */ + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface DiffDialogProps { + open: boolean; + onClose: () => void; + original: string; + modified: string; + onConfirmSave: () => void; + isSaving: boolean; +} + +export function DiffDialog({ + open, + onClose, + original, + modified, + onConfirmSave, + isSaving, +}: DiffDialogProps) { + const originalLines = original.split('\n'); + const modifiedLines = modified.split('\n'); + + // Simple line-by-line diff + const renderDiff = () => { + const maxLines = Math.max(originalLines.length, modifiedLines.length); + const rows = []; + + for (let i = 0; i < maxLines; i++) { + const origLine = originalLines[i] ?? ''; + const modLine = modifiedLines[i] ?? ''; + const isChanged = origLine !== modLine; + + rows.push( +
+
{i + 1}
+
+
+ {origLine} +
+
{modLine}
+
+
+ ); + } + + return rows; + }; + + return ( + + + + Review Changes + + +
+
+
Original
+
Modified
+
+ +
{renderDiff()}
+
+
+ + + + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/config/file-tree-utils.ts b/ui/src/components/cliproxy/config/file-tree-utils.ts new file mode 100644 index 00000000..874dbb00 --- /dev/null +++ b/ui/src/components/cliproxy/config/file-tree-utils.ts @@ -0,0 +1,47 @@ +/** + * File Tree Utilities + * Helper functions for building file tree structure + */ + +export interface FileNode { + name: string; + path: string; + type: 'file' | 'folder'; + children?: FileNode[]; + modified?: boolean; + icon?: 'yaml' | 'json' | 'key'; +} + +// Helper to build file tree from flat list +export function buildFileTree( + configModified: boolean, + authFiles: Array<{ name: string; provider?: string }> +): FileNode[] { + return [ + { + name: 'config', + path: 'config', + type: 'folder', + children: [ + { + name: 'config.yaml', + path: 'config.yaml', + type: 'file', + icon: 'yaml', + modified: configModified, + }, + ], + }, + { + name: 'auths', + path: 'auths', + type: 'folder', + children: authFiles.map((f) => ({ + name: f.name, + path: `auths/${f.name}`, + type: 'file' as const, + icon: 'key' as const, + })), + }, + ]; +} diff --git a/ui/src/components/cliproxy/config/file-tree.tsx b/ui/src/components/cliproxy/config/file-tree.tsx new file mode 100644 index 00000000..6b6c4f07 --- /dev/null +++ b/ui/src/components/cliproxy/config/file-tree.tsx @@ -0,0 +1,125 @@ +/** + * File Tree Component + * Left panel file browser for Config tab + */ + +import { useState } from 'react'; +import { ChevronRight, ChevronDown, FileText, Folder, Key } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import type { FileNode } from './file-tree-utils'; + +interface FileTreeProps { + files: FileNode[]; + selectedFile: string | null; + onSelect: (path: string) => void; +} + +function FileIcon({ type, icon }: { type: 'file' | 'folder'; icon?: string }) { + if (type === 'folder') { + return ; + } + if (icon === 'key') { + return ; + } + return ; +} + +function TreeNode({ + node, + depth, + selectedFile, + onSelect, + expandedFolders, + onToggleFolder, +}: { + node: FileNode; + depth: number; + selectedFile: string | null; + onSelect: (path: string) => void; + expandedFolders: Set; + onToggleFolder: (path: string) => void; +}) { + const isExpanded = expandedFolders.has(node.path); + const isSelected = selectedFile === node.path; + + return ( +
+ + + {node.type === 'folder' && isExpanded && node.children && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function FileTree({ files, selectedFile, onSelect }: FileTreeProps) { + const [expandedFolders, setExpandedFolders] = useState>(new Set(['config', 'auths'])); + + const toggleFolder = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + return ( + +
+ {files.map((node) => ( + + ))} +
+
+ ); +} diff --git a/ui/src/components/cliproxy/config/yaml-editor.tsx b/ui/src/components/cliproxy/config/yaml-editor.tsx new file mode 100644 index 00000000..ff4ce276 --- /dev/null +++ b/ui/src/components/cliproxy/config/yaml-editor.tsx @@ -0,0 +1,129 @@ +/** + * YAML Editor Component + * Right panel YAML editor with syntax highlighting and validation + */ + +import { useState, useCallback } from 'react'; +import Editor from 'react-simple-code-editor'; +import { Highlight, themes } from 'prism-react-renderer'; +import { useTheme } from '@/hooks/use-theme'; +import { cn } from '@/lib/utils'; +import { AlertCircle, CheckCircle2 } from 'lucide-react'; + +interface YamlEditorProps { + value: string; + onChange: (value: string) => void; + readonly?: boolean; + errorLine?: number; + className?: string; +} + +export function YamlEditor({ + value, + onChange, + readonly = false, + errorLine, + className, +}: YamlEditorProps) { + const { isDark } = useTheme(); + const [isFocused, setIsFocused] = useState(false); + + const highlightCode = useCallback( + (code: string) => ( + + {({ tokens, getLineProps, getTokenProps }) => ( + <> + {tokens.map((line, i) => ( +
+ + {i + 1} + + {line.map((token, key) => ( + + ))} +
+ ))} + + )} +
+ ), + [isDark, errorLine] + ); + + return ( +
+ {} : onChange} + highlight={highlightCode} + key={isDark ? 'dark' : 'light'} + padding={12} + disabled={readonly} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + textareaClassName="focus:outline-none font-mono text-sm leading-6" + preClassName="font-mono text-sm leading-6" + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.875rem', + minHeight: '100%', + }} + /> +
+ ); +} + +interface EditorStatusBarProps { + validation: { valid: boolean; error?: string; line?: number }; + isDirty: boolean; + cursorLine?: number; + cursorCol?: number; +} + +export function EditorStatusBar({ + validation, + isDirty, + cursorLine, + cursorCol, +}: EditorStatusBarProps) { + return ( +
+
+ {validation.valid ? ( + + + Valid YAML + + ) : ( + + + {validation.error} + {validation.line && ` (line ${validation.line})`} + + )} + + {isDirty && Unsaved changes} +
+ + {cursorLine && cursorCol && ( + + Ln {cursorLine}, Col {cursorCol} + + )} +
+ ); +} diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx new file mode 100644 index 00000000..393a9e64 --- /dev/null +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -0,0 +1,163 @@ +/** + * CLIProxy Control Panel Embed + * + * Embeds the CLIProxy management.html with auto-authentication. + * Uses postMessage to inject credentials into the iframe. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-react'; + +/** CLIProxyAPI default port */ +const CLIPROXY_DEFAULT_PORT = 8317; + +/** CCS Control Panel secret - must match config-generator.ts CCS_CONTROL_PANEL_SECRET */ +const CCS_CONTROL_PANEL_SECRET = 'ccs'; + +interface ControlPanelEmbedProps { + port?: number; +} + +export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) { + const iframeRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [showLoginHint, setShowLoginHint] = useState(true); + + const managementUrl = `http://localhost:${port}/management.html`; + + // Check if CLIProxy is running + useEffect(() => { + const checkConnection = async () => { + try { + const response = await fetch(`http://localhost:${port}/`, { + signal: AbortSignal.timeout(2000), + }); + if (response.ok) { + setIsConnected(true); + setError(null); + } else { + setIsConnected(false); + setError('CLIProxy returned an error'); + } + } catch { + setIsConnected(false); + setError('CLIProxy is not running'); + } + }; + + checkConnection(); + }, [port]); + + // Handle iframe load - attempt to auto-login via postMessage + const handleIframeLoad = useCallback(() => { + setIsLoading(false); + + // Try to inject credentials via postMessage + // The management.html needs to listen for this message + // If it doesn't support it, user will see the login page + if (iframeRef.current?.contentWindow) { + try { + // Send credentials to iframe + iframeRef.current.contentWindow.postMessage( + { + type: 'ccs-auto-login', + apiBase: `http://localhost:${port}`, + managementKey: CCS_CONTROL_PANEL_SECRET, + }, + `http://localhost:${port}` + ); + } catch { + // Cross-origin restriction - expected if not same origin + console.debug('[ControlPanelEmbed] postMessage failed - cross-origin'); + } + } + }, [port]); + + const handleRefresh = () => { + setIsLoading(true); + if (iframeRef.current) { + iframeRef.current.src = managementUrl; + } + }; + + // Show error state if CLIProxy is not running + if (!isConnected && error) { + return ( +
+
+
+ +

CLIProxy Control Panel

+
+ +
+
+
+
+ +
+

CLIProxy Not Available

+

{error}

+

+ Start a CLIProxy session with{' '} + ccs gemini or run{' '} + ccs config which auto-starts it. +

+
+
+
+ ); + } + + return ( +
+ {/* Login hint banner */} + {showLoginHint && !isLoading && ( +
+
+ + + Key:{' '} + + ccs + + + +
+
+ )} + + {/* Loading overlay */} + {isLoading && ( +
+
+ +

Loading Control Panel...

+
+
+ )} + + {/* Iframe */} +