Merge pull request #83 from kaitranntt/kai/feat/cliproxy-deep-integration

feat(cliproxy): implement comprehensive UI and backend enhancements
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-12 03:10:39 -05:00
committed by GitHub
52 changed files with 6659 additions and 466 deletions
+175
View File
@@ -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
+11 -4
View File
@@ -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`);
+43
View File
@@ -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) {
+126 -13
View File
@@ -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<CLIProxyProvider, string> = {
@@ -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
*/
+23
View File
@@ -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';
+223
View File
@@ -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<string, string>;
'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<OpenAICompatProvider>
): 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<OpenAICompatProvider, 'apiKey'> = {
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<OpenAICompatProvider, 'apiKey'> = {
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' },
],
};
+244
View File
@@ -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<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
await new Promise<void>((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<ServiceStartResult> {
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 };
}
+216
View File
@@ -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<string, number>;
/** Requests grouped by provider */
requestsByProvider: Record<string, number>;
/** 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<CliproxyStats | null> {
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<string, number> = {};
const requestsByProvider: Record<string, number> = {};
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<string, CliproxyModel[]>;
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<CliproxyModelsResponse | null> {
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<string, CliproxyModel[]> = {};
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<boolean> {
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;
}
}
+26 -2
View File
@@ -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<void> {
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 =
+31 -7
View File
@@ -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)`);
+14
View File
@@ -52,12 +52,26 @@ export interface Config {
export type EnvValue = string;
export type EnvVars = Record<string, EnvValue>;
/**
* 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
}
+387 -13
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<string, CliproxyModel[]>, totalCount: number }
*/
apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise<void> => {
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 });
}
});
+9
View File
@@ -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=="],
+3
View File
@@ -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": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+4
View File
@@ -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() {
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/api" element={<ApiPage />} />
<Route path="/cliproxy" element={<CliproxyPage />} />
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/health" element={<HealthPage />} />
+62 -50
View File
@@ -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 (
<Dialog open={open} onOpenChange={onClose}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add {displayName} Account</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Terminal className="w-4 h-4" />
Run this command:
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm">
{authCommand}
</code>
<Button variant="outline" size="icon" onClick={copyCommand}>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
<div className="text-xs text-muted-foreground">
This will open your browser to authenticate with {displayName}
</div>
</CardContent>
</Card>
<div className="space-y-2">
<Label htmlFor="nickname">Nickname (optional)</Label>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" />
<Input
id="nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="e.g., work, personal"
disabled={startAuthMutation.isPending}
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
A friendly name to identify this account. Auto-generated from email if left empty.
</p>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose} disabled={startAuthMutation.isPending}>
Cancel
</Button>
<Button onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Checking...' : 'I ran the command'}
<Button onClick={handleStartAuth} disabled={startAuthMutation.isPending}>
{startAuthMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Authenticating...
</>
) : (
<>
<ExternalLink className="w-4 h-4 mr-2" />
Authenticate
</>
)}
</Button>
</div>
{startAuthMutation.isPending && (
<p className="text-sm text-center text-muted-foreground">
Complete the OAuth flow in your browser...
</p>
)}
</div>
</DialogContent>
</Dialog>
@@ -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 (
<Card className={cn('flex flex-col h-full', className)}>
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
CLIProxy Stats
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1">
<div className="space-y-3">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-16 w-full" />
</div>
</CardContent>
</Card>
);
}
// Proxy not running
if (!status?.running) {
return (
<Card className={cn('flex flex-col h-full border-dashed', className)}>
<CardHeader className="px-3 py-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
CLIProxy Stats
</CardTitle>
<Badge variant="secondary" className="text-[10px] h-5">
Offline
</Badge>
</div>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
<p className="text-xs text-muted-foreground text-center">
Start a CLIProxy session (gemini, codex, agy) to collect stats.
</p>
</CardContent>
</Card>
);
}
// Error fetching stats
if (error) {
return (
<Card className={cn('flex flex-col h-full border-destructive/50', className)}>
<CardHeader className="px-3 py-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
CLIProxy Stats
</CardTitle>
<Badge variant="destructive" className="text-[10px] h-5">
Error
</Badge>
</div>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1">
<p className="text-xs text-destructive">{error.message}</p>
</CardContent>
</Card>
);
}
// 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 (
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
<CardHeader className="px-3 py-2 border-b bg-muted/5">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
CLIProxy Stats
</CardTitle>
<Badge
variant="outline"
className="text-[10px] h-5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
>
<Zap className="h-3 w-3 mr-0.5" />
Running
</Badge>
</div>
</CardHeader>
<CardContent className="p-0 flex-1 min-h-0">
<ScrollArea className="h-full">
<div className="p-3 space-y-3">
{/* Key metrics row */}
<div className="grid grid-cols-2 gap-2">
{/* Requests with success ring */}
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
<div className="relative">
<svg className="w-10 h-10 -rotate-90" viewBox="0 0 36 36">
<circle
cx="18"
cy="18"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-muted/30"
/>
<circle
cx="18"
cy="18"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray={`${successRate * 0.88} 88`}
strokeLinecap="round"
className={successRate >= 90 ? 'text-green-500' : 'text-amber-500'}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[8px] font-bold">
{successRate}%
</span>
</div>
<div className="min-w-0">
<div className="text-lg font-bold leading-none">
{formatNumber(totalRequests)}
</div>
<div className="text-[9px] text-muted-foreground mt-0.5">
{failedRequests > 0 ? `${failedRequests} failed` : 'All success'}
</div>
</div>
</div>
{/* Tokens */}
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
<div className="p-1.5 rounded-md bg-purple-100 dark:bg-purple-900/20">
<Coins className="h-4 w-4 text-purple-600" />
</div>
<div className="min-w-0">
<div className="text-lg font-bold leading-none">{formatNumber(totalTokens)}</div>
<div className="text-[9px] text-muted-foreground mt-0.5">Total tokens</div>
</div>
</div>
</div>
{/* Model breakdown */}
{models.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-medium text-muted-foreground">
<Cpu className="h-3 w-3" />
Models Used
</div>
<div className="space-y-1">
{models.map(([model, count]) => {
const percentage = Math.round((count / maxModelRequests) * 100);
const displayName = formatModelName(model);
return (
<div key={model} className="group">
<div className="flex items-center justify-between text-[10px] mb-0.5">
<span className="truncate font-medium" title={model}>
{displayName}
</span>
<span className="text-muted-foreground shrink-0 ml-2">{count}</span>
</div>
<div className="h-1 bg-muted/50 rounded-full overflow-hidden">
<div
className="h-full bg-accent/70 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
/** 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;
}
@@ -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 (
<Card className={cn('flex flex-col h-full', className)}>
<CardHeader className="px-4 py-3 border-b">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-muted-foreground" />
Usage Insights
</CardTitle>
</CardHeader>
<CardContent className="p-4 flex-1 flex items-center justify-center">
<div className="animate-pulse flex flex-col items-center gap-2 opacity-50">
<div className="h-8 w-8 bg-muted rounded-full" />
<div className="h-4 w-32 bg-muted rounded" />
<Card
className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}
>
<div className="px-1 py-2">
<div className="flex items-center gap-2 mb-4">
<SkeletonIcon />
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
</div>
</CardContent>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3">
<div className="w-8 h-8 rounded-lg bg-muted animate-pulse shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3 w-3/4 bg-muted rounded animate-pulse" />
<div className="h-2 w-1/2 bg-muted rounded animate-pulse" />
</div>
</div>
))}
</div>
</div>
</Card>
);
}
const hasAnomalies = summary && summary.totalAnomalies > 0;
return (
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
<CardHeader className="px-4 py-3 border-b bg-muted/5">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Lightbulb
className={cn('w-4 h-4', hasAnomalies ? 'text-amber-500' : 'text-green-500')}
/>
Usage Insights
</CardTitle>
{hasAnomalies ? (
<Badge
variant="destructive"
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider"
>
Attention Needed
</Badge>
) : (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
>
Healthy
</Badge>
)}
</div>
</CardHeader>
<CardContent className="p-0 flex-1 min-h-0 flex flex-col">
{hasAnomalies ? (
<ScrollArea className="flex-1">
<div className="divide-y">
{anomalies.map((anomaly, index) => {
const config = ANOMALY_CONFIG[anomaly.type];
const Icon = config.icon;
return (
<div key={index} className="p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-start gap-3">
<div
className={cn(
'p-2 rounded-lg shrink-0 bg-muted/30',
config.color
.replace('text-', 'bg-')
.replace('600', '100')
.replace('400', '900/20')
)}
>
<Icon className={cn('h-4 w-4', config.color)} />
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-sm">{config.label}</p>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{anomaly.date}
</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{anomaly.message}
</p>
{anomaly.model && (
<div className="pt-1">
<Badge
variant="secondary"
className="text-[10px] px-1 py-0 h-5 font-mono"
>
{anomaly.model}
</Badge>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center text-muted-foreground">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3">
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<p className="font-medium text-foreground">No anomalies detected</p>
<p className="text-xs mt-1 max-w-[200px]">
Your usage patterns look normal for the selected period.
</p>
if (!hasAnomalies) {
return (
<Card
className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}
>
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center text-muted-foreground min-h-[200px]">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3 ring-4 ring-green-50 dark:ring-green-900/10">
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
)}
</CardContent>
<h3 className="font-medium text-foreground text-sm">All Systems Nominal</h3>
<p className="text-xs mt-1.5 max-w-[200px] leading-relaxed">
Your usage patterns are within normal ranges for the selected period.
</p>
</div>
</Card>
);
}
return (
<Card className={cn('flex flex-col h-full border-none shadow-none bg-transparent', className)}>
<div className="px-1 py-2 flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-amber-500" />
<h3 className="font-semibold text-sm">Usage Insights</h3>
</div>
<Badge
variant="outline"
className="h-5 px-2 text-[10px] font-medium border-amber-200 bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-400"
>
{summary.totalAnomalies} {summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'}
</Badge>
</div>
<ScrollArea className="flex-1 -mx-2 px-2">
<div className="space-y-1 pb-2">
{anomalies.map((anomaly, index) => {
const config = ANOMALY_CONFIG[anomaly.type];
const Icon = config.icon;
return (
<div
key={index}
className="group p-3 rounded-lg hover:bg-muted/50 transition-colors border border-transparent hover:border-border/50"
>
<div className="flex items-start gap-3">
<div
className={cn(
'p-2 rounded-md shrink-0 ring-1 ring-inset ring-black/5 dark:ring-white/10',
config.bgColor
)}
>
<Icon className={cn('h-3.5 w-3.5', config.color)} />
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-sm text-foreground/90">{config.label}</p>
<span className="text-[10px] text-muted-foreground tabular-nums opacity-60 group-hover:opacity-100 transition-opacity">
{anomaly.date}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{anomaly.message}
</p>
{anomaly.model && (
<div className="pt-1.5">
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-5 font-mono bg-muted/50 text-muted-foreground group-hover:bg-background group-hover:text-foreground transition-colors"
>
{anomaly.model}
</Badge>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
</Card>
);
}
function SkeletonIcon() {
return <div className="w-4 h-4 bg-muted rounded-full animate-pulse" />;
}
@@ -46,20 +46,20 @@ export function UsageTrendChart({
}, [data, granularity]);
if (isLoading) {
return <Skeleton className={cn('h-[300px] w-full', className)} />;
return <Skeleton className={cn('h-full w-full', className)} />;
}
if (!data || data.length === 0) {
return (
<div className={cn('h-[300px] flex items-center justify-center', className)}>
<div className={cn('h-full flex items-center justify-center', className)}>
<p className="text-muted-foreground">No usage data available</p>
</div>
);
}
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={300}>
<div className={cn('w-full h-full', className)}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<defs>
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
+16 -3
View File
@@ -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 (
@@ -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 (
<div className={cn('space-y-4', className)}>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-8 w-24" />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
</div>
);
}
// Offline state
if (!status?.running) {
return (
<div className={cn('space-y-4', className)}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<Activity className="h-5 w-5" />
Session Statistics
</h2>
<p className="text-sm text-muted-foreground">
Real-time usage metrics from CLIProxyAPI
</p>
</div>
<Badge variant="secondary" className="w-fit gap-1.5">
<ZapOff className="h-3.5 w-3.5" />
Offline
</Badge>
</div>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Server className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="font-medium mb-1">No Active Session</h3>
<p className="text-sm text-muted-foreground max-w-md">
Start a CLIProxy session using{' '}
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs gemini</code>,{' '}
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs codex</code>,
or <code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs agy</code>{' '}
to view real-time statistics.
</p>
</CardContent>
</Card>
</div>
);
}
// Error state
if (error) {
return (
<div className={cn('space-y-4', className)}>
<Card className="border-destructive/50">
<CardContent className="flex items-center gap-4 py-6">
<XCircle className="h-8 w-8 text-destructive shrink-0" />
<div>
<h3 className="font-medium">Failed to Load Statistics</h3>
<p className="text-sm text-muted-foreground">{error.message}</p>
</div>
</CardContent>
</Card>
</div>
);
}
// 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 (
<div className={cn('space-y-4', className)}>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<Activity className="h-5 w-5" />
Session Statistics
</h2>
<p className="text-sm text-muted-foreground">Real-time usage metrics from CLIProxyAPI</p>
</div>
<Badge
variant="outline"
className="w-fit gap-1.5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
>
<Zap className="h-3.5 w-3.5" />
Running
</Badge>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Requests Card */}
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Total Requests</p>
<p className="text-2xl font-bold">{formatNumber(totalRequests)}</p>
<div className="flex items-center gap-1.5 text-[10px]">
<CheckCircle2 className="h-3 w-3 text-green-500" />
<span className="text-muted-foreground">{successRequests} success</span>
</div>
</div>
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/20">
<TrendingUp className="h-4 w-4 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
{/* Success Rate Card */}
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<p className="text-xs font-medium text-muted-foreground">Success Rate</p>
<p className="text-2xl font-bold">{successRate}%</p>
<div className="h-1.5 mt-2 bg-muted/50 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
successRate >= 90 ? 'bg-green-500' : 'bg-amber-500'
)}
style={{ width: `${successRate}%` }}
/>
</div>
</div>
<div
className={cn(
'p-2 rounded-lg',
successRate >= 90
? 'bg-green-100 dark:bg-green-900/20'
: 'bg-amber-100 dark:bg-amber-900/20'
)}
>
{successRate >= 90 ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-amber-600" />
)}
</div>
</div>
</CardContent>
</Card>
{/* Tokens Card */}
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Total Tokens</p>
<p className="text-2xl font-bold">{formatNumber(totalTokens)}</p>
<p className="text-[10px] text-muted-foreground">
~${estimateCost(totalTokens).toFixed(2)} estimated
</p>
</div>
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/20">
<Coins className="h-4 w-4 text-purple-600" />
</div>
</div>
</CardContent>
</Card>
{/* Models Card */}
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Models Used</p>
<p className="text-2xl font-bold">{models.length}</p>
<p className="text-[10px] text-muted-foreground">
{models.length > 0 ? formatModelName(models[0][0]) : 'None'}
</p>
</div>
<div className="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/20">
<Cpu className="h-4 w-4 text-cyan-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Model Breakdown */}
{models.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" />
Model Usage Distribution
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
{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 (
<div key={model} className="group">
<div className="flex items-center justify-between text-sm mb-1.5">
<div className="flex items-center gap-2 min-w-0">
<div className={cn('w-2 h-2 rounded-full shrink-0', colorClass)} />
<span className="font-medium truncate" title={model}>
{displayName}
</span>
</div>
<div className="flex items-center gap-3 text-muted-foreground shrink-0">
<span>{count} requests</span>
<span className="text-xs font-medium w-10 text-right">{percentage}%</span>
</div>
</div>
<div className="h-2 bg-muted/50 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
colorClass
)}
style={{ width: `${barPercentage}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
);
}
/** 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;
}
@@ -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<string, { name: string; color: string }> = {
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 <Skeleton className={cn('h-9 w-[280px]', className)} />;
}
if (!modelsData || modelsData.totalCount === 0) {
return (
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
<Cpu className="w-4 h-4" />
<span>No models available</span>
</div>
);
}
return (
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className={cn('w-[320px]', className)}>
<SelectValue placeholder={placeholder}>
{value && (
<div className="flex items-center gap-2">
<span className="truncate">{value}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[400px]">
{sortedCategories.map(({ category, display, models }) => (
<SelectGroup key={category}>
<SelectLabel className="flex items-center justify-between px-2 py-1.5">
<span className={cn('font-semibold', display.color)}>{display.name}</span>
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
{models.length}
</Badge>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.id} value={model.id} className="pl-4">
<span className="truncate">{model.id}</span>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
);
}
/** Compact variant for inline usage */
export function CategorizedModelSelectorCompact({
modelsData,
isLoading,
value,
onChange,
disabled,
}: Omit<CategorizedModelSelectorProps, 'placeholder' | 'className'>) {
return (
<CategorizedModelSelector
modelsData={modelsData}
isLoading={isLoading}
value={value}
onChange={onChange}
disabled={disabled}
placeholder="Model..."
className="w-[200px]"
/>
);
}
@@ -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 (
<Button variant="outline" size="sm" disabled className="gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
{displayName}
</Button>
);
}
if (isAuthenticated) {
return (
<Button
variant="outline"
size="sm"
className="gap-2 border-green-500/30 text-green-600 dark:text-green-400"
>
<span className="w-2 h-2 rounded-full bg-green-500" />
{displayName}
{accountCount > 1 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-[10px]">
{accountCount}
</Badge>
)}
</Button>
);
}
return (
<Button variant="default" size="sm" className="gap-2" onClick={onLogin}>
+ {displayName}
</Button>
);
}
// 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<string | null>(() => (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 (
<div className="flex flex-col gap-4 pb-4 border-b">
{/* Top row: Title and Login Buttons */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">CLIProxy</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage OAuth providers and configuration
</p>
</div>
{/* Login Buttons - Wrap on mobile */}
<div className="flex flex-wrap items-center gap-2">
{providers.map((p) => {
const status = getProviderStatus(p.id);
return (
<LoginButton
key={p.id}
provider={p.id}
displayName={p.displayName}
isAuthenticated={status.isAuthenticated}
accountCount={status.accountCount}
isAuthenticating={authProvider === p.id && isAuthenticating}
onLogin={() => startAuth(p.id)}
/>
);
})}
</div>
</div>
{/* Bottom row: Status and Refresh */}
<div className="flex items-center gap-3">
<Badge variant={isRunning ? 'default' : 'secondary'} className="gap-1.5">
<span
className={cn(
'w-2 h-2 rounded-full',
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground'
)}
/>
{isRunning ? 'Running' : 'Offline'}
</Badge>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground">{lastUpdatedText}</span>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
</Button>
</div>
</div>
);
}
@@ -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 (
<Tabs
value={activeTab}
onValueChange={(v) => onTabChange(v as CliproxyTabValue)}
className="w-full"
>
<TabsList className="grid w-full grid-cols-3 max-w-md">
{TAB_CONFIG.map(({ value, label, icon: Icon }) => (
<TabsTrigger key={value} value={value} className="gap-2">
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{label}</span>
</TabsTrigger>
))}
</TabsList>
<TabsContent value="overview" className="mt-6">
{children.overview}
</TabsContent>
<TabsContent value="config" className="mt-6">
{children.config}
</TabsContent>
<TabsContent value="logs" className="mt-6">
{children.logs}
</TabsContent>
</Tabs>
);
}
@@ -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<string>('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<string | null>(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 (
<div className="flex items-center justify-center h-[500px] border rounded-lg">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{selectedFile}</span>
{isDirty && isEditingConfig && (
<span className="text-xs text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded">
Modified
</span>
)}
</div>
{isEditingConfig && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDiff(true)}
disabled={!isDirty}
className="h-7 gap-1.5"
>
<GitCompare className="w-3.5 h-3.5" />
Diff
</Button>
<Button
variant="ghost"
size="sm"
onClick={resetContent}
disabled={!isDirty}
className="h-7 gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="default"
size="sm"
onClick={saveContent}
disabled={!isDirty || !validation.valid || isSaving}
className="h-7 gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
)}
</div>
{/* Split Panels */}
<PanelGroup direction="horizontal" className="h-[500px]">
<Panel defaultSize={20} minSize={15} maxSize={30}>
<FileTree files={fileTree} selectedFile={selectedFile} onSelect={handleFileSelect} />
</Panel>
<PanelResizeHandle className="w-1 bg-border hover:bg-accent transition-colors cursor-col-resize" />
<Panel>
<div className="flex flex-col h-full">
<div className="flex-1 overflow-auto">
<YamlEditor
value={currentContent}
onChange={updateContent}
readonly={isReadonly}
errorLine={validation.line}
/>
</div>
<EditorStatusBar validation={validation} isDirty={isDirty && isEditingConfig} />
</div>
</Panel>
</PanelGroup>
{/* Diff Dialog */}
<DiffDialog
open={showDiff}
onClose={() => setShowDiff(false)}
original={originalContent}
modified={content}
onConfirmSave={() => {
saveContent();
setShowDiff(false);
}}
isSaving={isSaving}
/>
</div>
);
}
@@ -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(
<div key={i} className={`flex text-xs font-mono ${isChanged ? 'bg-amber-500/10' : ''}`}>
<div className="w-8 text-right pr-2 text-muted-foreground/50 border-r">{i + 1}</div>
<div className="flex-1 grid grid-cols-2">
<div className={`px-2 ${isChanged ? 'bg-red-500/10 line-through' : ''}`}>
{origLine}
</div>
<div className={`px-2 border-l ${isChanged ? 'bg-green-500/10' : ''}`}>{modLine}</div>
</div>
</div>
);
}
return rows;
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Review Changes</DialogTitle>
</DialogHeader>
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-2 text-xs font-medium bg-muted p-2 border-b">
<div className="text-red-600">Original</div>
<div className="text-green-600 border-l pl-2">Modified</div>
</div>
<ScrollArea className="h-[400px]">
<div className="divide-y">{renderDiff()}</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={onConfirmSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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,
})),
},
];
}
@@ -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 <Folder className="w-4 h-4 text-amber-500" />;
}
if (icon === 'key') {
return <Key className="w-4 h-4 text-green-500" />;
}
return <FileText className="w-4 h-4 text-blue-500" />;
}
function TreeNode({
node,
depth,
selectedFile,
onSelect,
expandedFolders,
onToggleFolder,
}: {
node: FileNode;
depth: number;
selectedFile: string | null;
onSelect: (path: string) => void;
expandedFolders: Set<string>;
onToggleFolder: (path: string) => void;
}) {
const isExpanded = expandedFolders.has(node.path);
const isSelected = selectedFile === node.path;
return (
<div>
<button
className={cn(
'flex items-center gap-1.5 w-full px-2 py-1 text-sm text-left rounded-md transition-colors',
'hover:bg-muted/50',
isSelected && 'bg-accent/10 border-l-2 border-accent'
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => {
if (node.type === 'folder') {
onToggleFolder(node.path);
} else {
onSelect(node.path);
}
}}
>
{node.type === 'folder' &&
(isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
))}
<FileIcon type={node.type} icon={node.icon} />
<span className="truncate flex-1">{node.name}</span>
{node.modified && <span className="text-amber-500 ml-1">*</span>}
</button>
{node.type === 'folder' && isExpanded && node.children && (
<div className="animate-in slide-in-from-top-1 duration-150">
{node.children.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
selectedFile={selectedFile}
onSelect={onSelect}
expandedFolders={expandedFolders}
onToggleFolder={onToggleFolder}
/>
))}
</div>
)}
</div>
);
}
export function FileTree({ files, selectedFile, onSelect }: FileTreeProps) {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(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 (
<ScrollArea className="h-full">
<div className="p-2">
{files.map((node) => (
<TreeNode
key={node.path}
node={node}
depth={0}
selectedFile={selectedFile}
onSelect={onSelect}
expandedFolders={expandedFolders}
onToggleFolder={toggleFolder}
/>
))}
</div>
</ScrollArea>
);
}
@@ -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) => (
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language="yaml">
{({ tokens, getLineProps, getTokenProps }) => (
<>
{tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line })}
className={cn(
'px-2',
errorLine === i + 1 && 'bg-destructive/20 border-l-2 border-destructive'
)}
>
<span className="inline-block w-8 text-right mr-4 text-muted-foreground/50 select-none text-xs">
{i + 1}
</span>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</>
)}
</Highlight>
),
[isDark, errorLine]
);
return (
<div
className={cn(
'relative rounded-md border overflow-hidden bg-muted/30 h-full',
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
readonly && 'opacity-70',
className
)}
>
<Editor
value={value}
onValueChange={readonly ? () => {} : 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%',
}}
/>
</div>
);
}
interface EditorStatusBarProps {
validation: { valid: boolean; error?: string; line?: number };
isDirty: boolean;
cursorLine?: number;
cursorCol?: number;
}
export function EditorStatusBar({
validation,
isDirty,
cursorLine,
cursorCol,
}: EditorStatusBarProps) {
return (
<div className="flex items-center justify-between px-3 py-1.5 border-t bg-muted/30 text-xs">
<div className="flex items-center gap-4">
{validation.valid ? (
<span className="flex items-center gap-1 text-muted-foreground">
<CheckCircle2 className="w-3 h-3 text-green-500" />
Valid YAML
</span>
) : (
<span className="flex items-center gap-1 text-destructive">
<AlertCircle className="w-3 h-3" />
{validation.error}
{validation.line && ` (line ${validation.line})`}
</span>
)}
{isDirty && <span className="text-amber-500">Unsaved changes</span>}
</div>
{cursorLine && cursorCol && (
<span className="text-muted-foreground">
Ln {cursorLine}, Col {cursorCol}
</span>
)}
</div>
);
}
@@ -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<HTMLIFrameElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Gauge className="w-5 h-5 text-primary" />
<h2 className="font-semibold">CLIProxy Control Panel</h2>
</div>
<button
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
onClick={handleRefresh}
>
<RefreshCw className="w-4 h-4" />
Retry
</button>
</div>
<div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center max-w-md px-8">
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
<AlertCircle className="w-8 h-8 text-destructive" />
</div>
<h3 className="text-lg font-semibold mb-2">CLIProxy Not Available</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<p className="text-sm text-muted-foreground">
Start a CLIProxy session with{' '}
<code className="bg-muted px-1 rounded">ccs gemini</code> or run{' '}
<code className="bg-muted px-1 rounded">ccs config</code> which auto-starts it.
</p>
</div>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col relative">
{/* Login hint banner */}
{showLoginHint && !isLoading && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
<Key className="h-3.5 w-3.5 text-blue-600" />
<span>
Key:{' '}
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
ccs
</code>
</span>
<button
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
onClick={() => setShowLoginHint(false)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground">Loading Control Panel...</p>
</div>
</div>
)}
{/* Iframe */}
<iframe
ref={iframeRef}
src={managementUrl}
className="flex-1 w-full border-0"
title="CLIProxy Management Panel"
onLoad={handleIframeLoad}
/>
</div>
);
}
@@ -0,0 +1,172 @@
/**
* Credential Health List Component
* Auth status indicators for CLIProxy Overview tab
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils';
type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled';
interface CredentialRowProps {
name: string;
provider: string;
status: CredentialStatus;
statusMessage: string;
email?: string;
expiresAt?: string;
onRefresh?: () => void;
}
function CredentialRow({
name,
provider,
status,
statusMessage,
email,
expiresAt,
onRefresh,
}: CredentialRowProps) {
const statusConfig = {
ready: {
icon: CheckCircle2,
color: 'text-green-600 dark:text-green-400',
bg: 'bg-green-500/10',
},
warning: {
icon: AlertCircle,
color: 'text-amber-600 dark:text-amber-400',
bg: 'bg-amber-500/10',
},
error: {
icon: XCircle,
color: 'text-red-600 dark:text-red-400',
bg: 'bg-red-500/10',
},
disabled: {
icon: MinusCircle,
color: 'text-muted-foreground',
bg: 'bg-muted',
},
};
const config = statusConfig[status];
const Icon = config.icon;
const formatExpiry = (date?: string) => {
if (!date) return 'Never';
const expiry = new Date(date);
const now = new Date();
const diff = expiry.getTime() - now.getTime();
if (diff < 0) return 'Expired';
const hours = Math.floor(diff / 3600000);
if (hours < 1) return 'Soon';
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
};
return (
<div className="flex items-center justify-between p-3 border-b last:border-0">
<div className="flex items-center gap-3">
<div className={cn('p-2 rounded-full', config.bg)}>
<Icon className={cn('w-4 h-4', config.color)} />
</div>
<div>
<div className="font-medium text-sm">{email ?? name}</div>
<div className="text-xs text-muted-foreground capitalize">{provider}</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<Badge
variant={
status === 'ready' ? 'outline' : status === 'warning' ? 'secondary' : 'destructive'
}
className="text-xs"
>
{statusMessage}
</Badge>
<div className="text-xs text-muted-foreground mt-0.5">
Expires: {formatExpiry(expiresAt)}
</div>
</div>
{status === 'warning' && onRefresh && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onRefresh}>
<RefreshCw className="w-4 h-4" />
</Button>
)}
</div>
</div>
);
}
function CredentialHealthSkeleton() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
))}
</div>
</CardContent>
</Card>
);
}
export function CredentialHealthList() {
const { data: authData, isLoading } = useCliproxyAuth();
if (isLoading) {
return <CredentialHealthSkeleton />;
}
// Flatten accounts from all providers
const credentials =
authData?.authStatus.flatMap((status) =>
(status.accounts ?? []).map((account) => ({
name: account.id,
provider: status.provider,
status: (account as { status?: CredentialStatus }).status ?? 'ready',
statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready',
email: account.email,
expiresAt: (account as { expiresAt?: string }).expiresAt,
}))
) ?? [];
if (credentials.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
No credentials configured. Use the login buttons above to authenticate.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Credential Health</CardTitle>
</CardHeader>
<CardContent className="p-0">
{credentials.map((cred, i) => (
<CredentialRow key={`${cred.provider}-${cred.name}-${i}`} {...cred} />
))}
</CardContent>
</Card>
);
}
@@ -0,0 +1,141 @@
/**
* Model Preferences Grid Component
* Displays all available models categorized by provider source
*/
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Cpu, AlertCircle } from 'lucide-react';
import { useCliproxyModels } from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils';
/** Category display configuration */
const CATEGORY_CONFIG: Record<string, { name: string; color: string; bgColor: string }> = {
google: { name: 'Google (Gemini)', color: 'text-blue-600', bgColor: 'bg-blue-50' },
openai: { name: 'OpenAI (GPT)', color: 'text-green-600', bgColor: 'bg-green-50' },
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600', bgColor: 'bg-orange-50' },
antigravity: { name: 'Antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' },
other: { name: 'Other', color: 'text-gray-600', bgColor: 'bg-gray-50' },
};
function getCategoryDisplay(category: string) {
return (
CATEGORY_CONFIG[category.toLowerCase()] || {
name: category.charAt(0).toUpperCase() + category.slice(1),
color: 'text-gray-600',
bgColor: 'bg-gray-50',
}
);
}
function ModelPreferencesSkeleton() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Available Models</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
</CardContent>
</Card>
);
}
function EmptyModelsState() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Available Models</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
<AlertCircle className="w-6 h-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground mb-1">No models available</p>
<p className="text-xs text-muted-foreground">
Start a CLIProxy session to fetch available models
</p>
</div>
</CardContent>
</Card>
);
}
export function ModelPreferencesGrid() {
const { data: modelsData, isLoading, isError } = useCliproxyModels();
// Sort categories by model count
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 <ModelPreferencesSkeleton />;
}
if (isError || !modelsData || modelsData.totalCount === 0) {
return <EmptyModelsState />;
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Cpu className="w-4 h-4" />
Available Models
<Badge variant="secondary" className="text-xs">
{modelsData.totalCount} total
</Badge>
</CardTitle>
<CardDescription>Models available through CLIProxyAPI, grouped by provider</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedCategories.map(({ category, display, models }) => (
<div
key={category}
className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')}
>
<div className="flex items-center justify-between mb-3">
<h3 className={cn('font-medium text-sm', display.color)}>{display.name}</h3>
<Badge variant="outline" className="text-xs">
{models.length}
</Badge>
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{models.slice(0, 5).map((model) => (
<div
key={model.id}
className="text-xs text-muted-foreground truncate"
title={model.id}
>
{model.id}
</div>
))}
{models.length > 5 && (
<div className="text-xs text-muted-foreground italic">
+{models.length - 5} more
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,86 @@
/**
* Quick Stats Row Component
* Compact stats display for CLIProxy Overview tab
*/
import type { ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Activity, CheckCircle2, Coins, Cpu } from 'lucide-react';
import { useCliproxyStats } from '@/hooks/use-cliproxy';
interface StatCardProps {
icon: ReactNode;
label: string;
value: string | number;
}
function StatCard({ icon, label, value }: StatCardProps) {
return (
<Card className="py-3">
<CardContent className="p-0 px-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted text-muted-foreground">{icon}</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
</CardContent>
</Card>
);
}
function StatsSkeleton() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="py-3">
<CardContent className="p-0 px-4">
<div className="h-12 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
);
}
export function QuickStatsRow() {
const { data: stats, isLoading } = useCliproxyStats();
if (isLoading) {
return <StatsSkeleton />;
}
const usage = stats?.usage ?? {};
const totalRequests = (usage.total_requests as number) ?? 0;
const successCount = (usage.success_count as number) ?? 0;
const totalTokens = (usage.total_tokens as number) ?? 0;
const successRate = totalRequests > 0 ? ((successCount / totalRequests) * 100).toFixed(1) : '0';
const tokenDisplay =
totalTokens > 1000 ? `${(totalTokens / 1000).toFixed(1)}K` : String(totalTokens);
const apis = (usage.apis as Record<string, { models?: Record<string, unknown> }>) ?? {};
const modelCount = Object.keys(apis).reduce((count, api) => {
const apiData = apis[api];
return count + Object.keys(apiData?.models ?? {}).length;
}, 0);
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon={<Activity className="w-4 h-4" />}
label="Total Requests"
value={totalRequests}
/>
<StatCard
icon={<CheckCircle2 className="w-4 h-4" />}
label="Success Rate"
value={`${successRate}%`}
/>
<StatCard icon={<Coins className="w-4 h-4" />} label="Total Tokens" value={tokenDisplay} />
<StatCard icon={<Cpu className="w-4 h-4" />} label="Active Models" value={modelCount} />
</div>
);
}
@@ -0,0 +1,897 @@
/**
* Provider Editor Component
* Split-view editor for CLIProxy provider settings
* Similar to ProfileEditor but tailored for provider configuration
*/
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { Separator } from '@/components/ui/separator';
import { CopyButton } from '@/components/ui/copy-button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Save,
Loader2,
Code2,
Trash2,
RefreshCw,
Info,
X,
Shield,
User,
Plus,
Star,
MoreHorizontal,
Clock,
Sparkles,
Zap,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from 'sonner';
import { ProviderLogo } from './provider-logo';
import { FlexibleModelSelector } from './provider-model-selector';
import type { ProviderCatalog } from './provider-model-selector';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import {
useCliproxyModels,
usePresets,
useCreatePreset,
useDeletePreset,
} from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils';
// Lazy load CodeEditor
const CodeEditor = lazy(() =>
import('@/components/code-editor').then((m) => ({ default: m.CodeEditor }))
);
interface SettingsResponse {
profile: string;
settings: {
env?: Record<string, string>;
};
mtime: number;
path: string;
}
interface ProviderEditorProps {
provider: string;
displayName: string;
authStatus: AuthStatus;
catalog?: ProviderCatalog;
/** Provider type for logo display (defaults to provider) */
logoProvider?: string;
onAddAccount: () => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
isRemovingAccount?: boolean;
}
export function ProviderEditor({
provider,
displayName,
authStatus,
catalog,
logoProvider,
onAddAccount,
onSetDefault,
onRemoveAccount,
isRemovingAccount,
}: ProviderEditorProps) {
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
const [conflictDialog, setConflictDialog] = useState(false);
const [customPresetOpen, setCustomPresetOpen] = useState(false);
const queryClient = useQueryClient();
// Fetch available models from CLIProxy API
const { data: modelsData } = useCliproxyModels();
// Fetch saved presets for this provider
const { data: presetsData } = usePresets(provider);
const createPresetMutation = useCreatePreset();
const deletePresetMutation = useDeletePreset();
const savedPresets = presetsData?.presets || [];
// Get models for this provider based on owned_by field
const providerModels = useMemo(() => {
if (!modelsData?.models) return [];
const ownerMap: Record<string, string[]> = {
gemini: ['google'],
agy: ['antigravity'],
codex: ['openai'],
qwen: ['alibaba', 'qwen'],
iflow: ['iflow'],
};
const owners = ownerMap[provider.toLowerCase()] || [provider.toLowerCase()];
return modelsData.models.filter((m) =>
owners.some((o) => m.owned_by.toLowerCase().includes(o))
);
}, [modelsData, provider]);
// Fetch settings for this provider
const { data, isLoading, refetch } = useQuery<SettingsResponse>({
queryKey: ['settings', provider],
queryFn: async () => {
const res = await fetch(`/api/settings/${provider}/raw`);
if (!res.ok) {
// Return empty settings for unconfigured providers
return {
profile: provider,
settings: { env: {} },
mtime: Date.now(),
path: `~/.ccs/profiles/${provider}/settings.json`,
};
}
return res.json();
},
});
const settings = data?.settings;
// Derive raw JSON content
const rawJsonContent = useMemo(() => {
if (rawJsonEdits !== null) return rawJsonEdits;
if (settings) return JSON.stringify(settings, null, 2);
return '{\n "env": {}\n}';
}, [rawJsonEdits, settings]);
const handleRawJsonChange = useCallback((value: string) => {
setRawJsonEdits(value);
}, []);
// Parse current settings from JSON
const currentSettings = useMemo(() => {
try {
return JSON.parse(rawJsonContent);
} catch {
return settings || { env: {} };
}
}, [rawJsonContent, settings]);
// Extract model values from settings
const currentModel = currentSettings?.env?.ANTHROPIC_MODEL;
const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL;
const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL;
const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL;
// Update a setting value
const updateEnvValue = (key: string, value: string) => {
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
const newSettings = { ...currentSettings, env: newEnv };
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
};
// Batch update multiple env values at once
const updateEnvValues = (updates: Record<string, string>) => {
const newEnv = { ...(currentSettings?.env || {}), ...updates };
const newSettings = { ...currentSettings, env: newEnv };
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
};
// Check if JSON is valid
const isRawJsonValid = useMemo(() => {
try {
JSON.parse(rawJsonContent);
return true;
} catch {
return false;
}
}, [rawJsonContent]);
// Check for unsaved changes
const hasChanges = useMemo(() => {
if (rawJsonEdits === null) return false;
return rawJsonEdits !== JSON.stringify(settings, null, 2);
}, [rawJsonEdits, settings]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
const settingsToSave = JSON.parse(rawJsonContent);
const res = await fetch(`/api/settings/${provider}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settings: settingsToSave,
expectedMtime: data?.mtime,
}),
});
if (res.status === 409) throw new Error('CONFLICT');
if (!res.ok) throw new Error('Failed to save');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', provider] });
setRawJsonEdits(null);
toast.success('Settings saved');
},
onError: (error: Error) => {
if (error.message === 'CONFLICT') {
setConflictDialog(true);
} else {
toast.error(error.message);
}
},
});
const handleConflictResolve = async (overwrite: boolean) => {
setConflictDialog(false);
if (overwrite) {
await refetch();
saveMutation.mutate();
} else {
setRawJsonEdits(null);
}
};
const accounts = authStatus.accounts || [];
// Render Left Column - Model Config + Info tabs
const renderFriendlyUI = () => (
<div className="h-full flex flex-col">
<Tabs defaultValue="config" className="h-full flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
<TabsTrigger value="config" className="flex-1">
Model Config
</TabsTrigger>
<TabsTrigger value="info" className="flex-1">
Info & Usage
</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
{/* Model Config Tab */}
<TabsContent
value="config"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
>
<ScrollArea className="flex-1">
<div className="p-4 space-y-6">
{/* Quick Presets */}
{(catalog && catalog.models.length > 0) || savedPresets.length > 0 ? (
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Presets
</h3>
<p className="text-xs text-muted-foreground mb-3">
Apply pre-configured model mappings
</p>
<div className="flex flex-wrap gap-2">
{/* Recommended presets from catalog */}
{catalog?.models.slice(0, 3).map((model) => (
<Button
key={model.id}
variant="outline"
size="sm"
className="text-xs h-7 gap-1"
onClick={() => {
const mapping = model.presetMapping || {
default: model.id,
opus: model.id,
sonnet: model.id,
haiku: model.id,
};
updateEnvValues({
ANTHROPIC_MODEL: mapping.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
});
toast.success(`Applied "${model.name}" preset`);
}}
>
<Zap className="w-3 h-3" />
{model.name}
</Button>
))}
{/* User saved presets */}
{savedPresets.map((preset) => (
<div key={preset.name} className="group relative">
<Button
variant="secondary"
size="sm"
className="text-xs h-7 gap-1 pr-6"
onClick={() => {
updateEnvValues({
ANTHROPIC_MODEL: preset.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
});
toast.success(`Applied "${preset.name}" preset`);
}}
>
<Star className="w-3 h-3 fill-current" />
{preset.name}
</Button>
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
deletePresetMutation.mutate({ profile: provider, name: preset.name });
}}
disabled={deletePresetMutation.isPending}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button
variant="ghost"
size="sm"
className="text-xs h-7 gap-1 border border-dashed"
onClick={() => setCustomPresetOpen(true)}
>
<Plus className="w-3 h-3" />
Custom
</Button>
</div>
</div>
) : null}
<Separator />
{/* Model Mapping */}
<div>
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
<p className="text-xs text-muted-foreground mb-4">
Configure which models to use for each tier
</p>
<div className="space-y-4">
<FlexibleModelSelector
label="Default Model"
description="Used when no specific tier is requested"
value={currentModel}
onChange={(model) => updateEnvValue('ANTHROPIC_MODEL', model)}
catalog={catalog}
allModels={providerModels}
/>
<FlexibleModelSelector
label="Opus (Most capable)"
description="For complex reasoning tasks"
value={opusModel}
onChange={(model) => updateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)}
catalog={catalog}
allModels={providerModels}
/>
<FlexibleModelSelector
label="Sonnet (Balanced)"
description="Balance of speed and capability"
value={sonnetModel}
onChange={(model) => updateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)}
catalog={catalog}
allModels={providerModels}
/>
<FlexibleModelSelector
label="Haiku (Fast)"
description="Quick responses for simple tasks"
value={haikuModel}
onChange={(model) => updateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)}
catalog={catalog}
allModels={providerModels}
/>
</div>
</div>
<Separator />
{/* Accounts Section */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium flex items-center gap-2">
<User className="w-4 h-4" />
Accounts
{accounts.length > 0 && (
<Badge variant="secondary" className="text-xs">
{accounts.length}
</Badge>
)}
</h3>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={onAddAccount}
>
<Plus className="w-3 h-3" />
Add
</Button>
</div>
{accounts.length > 0 ? (
<div className="space-y-2">
{accounts.map((account) => (
<AccountItem
key={account.id}
account={account}
onSetDefault={() => onSetDefault(account.id)}
onRemove={() => onRemoveAccount(account.id)}
isRemoving={isRemovingAccount}
/>
))}
</div>
) : (
<div className="py-6 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed">
<User className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No accounts connected</p>
<p className="text-xs opacity-70">Add an account to get started</p>
</div>
)}
</div>
</div>
</ScrollArea>
</TabsContent>
{/* Info Tab */}
<TabsContent
value="info"
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
>
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
{/* Provider Information */}
<div>
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
Provider Information
</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Provider</span>
<span className="font-mono">{displayName}</span>
</div>
{data && (
<>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">File Path</span>
<div className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{data.path}
</code>
<CopyButton value={data.path} size="icon" className="h-5 w-5" />
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Last Modified</span>
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
</div>
</>
)}
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Status</span>
{authStatus.authenticated ? (
<Badge
variant="outline"
className="w-fit text-green-600 border-green-200 bg-green-50"
>
<Shield className="w-3 h-3 mr-1" />
Authenticated
</Badge>
) : (
<Badge variant="outline" className="w-fit text-muted-foreground">
Not connected
</Badge>
)}
</div>
</div>
</div>
{/* Quick Usage */}
<div>
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<UsageCommand
label="Run with prompt"
command={`ccs ${provider} "your prompt"`}
/>
<UsageCommand label="Change model" command={`ccs ${provider} --config`} />
<UsageCommand label="Add account" command={`ccs ${provider} --add`} />
<UsageCommand label="List accounts" command={`ccs ${provider} --accounts`} />
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
</div>
</Tabs>
</div>
);
// Render Right Column - Raw JSON Editor
const renderRawEditor = () => (
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading editor...</span>
</div>
}
>
<div className="h-full flex flex-col">
{!isRawJsonValid && rawJsonEdits !== null && (
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
<X className="w-4 h-4" />
Invalid JSON syntax
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
onChange={handleRawJsonChange}
language="json"
minHeight="100%"
/>
</div>
</div>
</div>
</Suspense>
);
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<ProviderLogo provider={logoProvider || provider} size="lg" />
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{displayName}</h2>
{data && (
<Badge variant="outline" className="text-xs">
{data.path.replace(/^.*\//, '')}
</Badge>
)}
</div>
{data && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified: {new Date(data.mtime).toLocaleString()}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => refetch()} disabled={isLoading}>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !hasChanges || !isRawJsonValid}
>
{saveMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-1" />
Save
</>
)}
</Button>
</div>
</div>
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">Loading settings...</span>
</div>
) : (
// Split Layout (40% Left / 60% Right)
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
{/* Left Column: Friendly UI */}
<div className="flex flex-col overflow-hidden bg-muted/5">{renderFriendlyUI()}</div>
{/* Right Column: Raw Editor */}
<div className="flex flex-col overflow-hidden">
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
Raw Configuration (JSON)
</span>
</div>
{renderRawEditor()}
</div>
</div>
)}
<ConfirmDialog
open={conflictDialog}
title="File Modified Externally"
description="This settings file was modified by another process. Overwrite with your changes or discard?"
confirmText="Overwrite"
variant="destructive"
onConfirm={() => handleConflictResolve(true)}
onCancel={() => handleConflictResolve(false)}
/>
{/* Custom Preset Dialog */}
<CustomPresetDialog
open={customPresetOpen}
onClose={() => setCustomPresetOpen(false)}
currentValues={{
default: currentModel || '',
opus: opusModel || '',
sonnet: sonnetModel || '',
haiku: haikuModel || '',
}}
onApply={(values, presetName) => {
updateEnvValues({
ANTHROPIC_MODEL: values.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: values.haiku,
});
toast.success(`Applied ${presetName ? `"${presetName}"` : 'custom'} preset`);
setCustomPresetOpen(false);
}}
onSave={(values, presetName) => {
if (!presetName) {
toast.error('Please enter a preset name to save');
return;
}
createPresetMutation.mutate({
profile: provider,
data: {
name: presetName,
default: values.default,
opus: values.opus,
sonnet: values.sonnet,
haiku: values.haiku,
},
});
setCustomPresetOpen(false);
}}
isSaving={createPresetMutation.isPending}
catalog={catalog}
allModels={providerModels}
/>
</div>
);
}
/** Account item component */
function AccountItem({
account,
onSetDefault,
onRemove,
isRemoving,
}: {
account: OAuthAccount;
onSetDefault: () => void;
onRemove: () => void;
isRemoving?: boolean;
}) {
return (
<div
className={cn(
'flex items-center justify-between p-3 rounded-lg border transition-colors',
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30'
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{account.email || account.id}</span>
{account.isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" />
Default
</Badge>
)}
</div>
{account.lastUsedAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Clock className="w-3 h-3" />
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
</div>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
/** Usage command with copy button */
function UsageCommand({ label, command }: { label: string; command: string }) {
return (
<div>
<label className="text-xs text-muted-foreground">{label}</label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
{command}
</code>
<CopyButton value={command} size="icon" className="h-6 w-6" />
</div>
</div>
);
}
/** Custom Preset Dialog - Configure all model mappings at once */
interface CustomPresetDialogProps {
open: boolean;
onClose: () => void;
currentValues: {
default: string;
opus: string;
sonnet: string;
haiku: string;
};
onApply: (
values: { default: string; opus: string; sonnet: string; haiku: string },
presetName?: string
) => void;
onSave?: (
values: { default: string; opus: string; sonnet: string; haiku: string },
presetName?: string
) => void;
isSaving?: boolean;
catalog?: ProviderCatalog;
allModels: { id: string; owned_by: string }[];
}
function CustomPresetDialog({
open,
onClose,
currentValues,
onApply,
onSave,
isSaving,
catalog,
allModels,
}: CustomPresetDialogProps) {
const [values, setValues] = useState(currentValues);
const [presetName, setPresetName] = useState('');
// Reset values when dialog opens with current values
const handleOpenChange = (isOpen: boolean) => {
if (isOpen) {
setValues(currentValues);
setPresetName('');
} else {
onClose();
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Custom Preset
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="preset-name">Preset Name (optional)</Label>
<Input
id="preset-name"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="e.g., My Custom Config"
className="text-sm"
/>
</div>
<Separator />
<FlexibleModelSelector
label="Default Model"
description="Used when no specific tier is requested"
value={values.default}
onChange={(model) => setValues({ ...values, default: model })}
catalog={catalog}
allModels={allModels}
/>
<FlexibleModelSelector
label="Opus (Most capable)"
description="For complex reasoning tasks"
value={values.opus}
onChange={(model) => setValues({ ...values, opus: model })}
catalog={catalog}
allModels={allModels}
/>
<FlexibleModelSelector
label="Sonnet (Balanced)"
description="Balance of speed and capability"
value={values.sonnet}
onChange={(model) => setValues({ ...values, sonnet: model })}
catalog={catalog}
allModels={allModels}
/>
<FlexibleModelSelector
label="Haiku (Fast)"
description="Quick responses for simple tasks"
value={values.haiku}
onChange={(model) => setValues({ ...values, haiku: model })}
catalog={catalog}
allModels={allModels}
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
{onSave && (
<Button
variant="secondary"
onClick={() => onSave(values, presetName || undefined)}
disabled={isSaving || !presetName.trim()}
>
{isSaving ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Star className="w-4 h-4 mr-1" />
)}
Save Preset
</Button>
)}
<Button onClick={() => onApply(values, presetName || undefined)}>
<Zap className="w-4 h-4 mr-1" />
Apply Preset
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,79 @@
/**
* Provider Logo Component
* Uses actual provider logos with fallback to styled letters
*/
import { cn } from '@/lib/utils';
interface ProviderLogoProps {
provider: string;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
/** Provider image assets mapping */
const PROVIDER_IMAGES: Record<string, string> = {
gemini: '/assets/providers/gemini-color.svg',
codex: '/assets/providers/openai.svg',
agy: '/assets/providers/agy.png',
qwen: '/assets/providers/qwen-color.svg',
};
/** Provider color configuration (for fallback only - no background for image logos) */
const PROVIDER_CONFIG: Record<string, { text: string; letter: string }> = {
gemini: { text: 'text-blue-600', letter: 'G' },
claude: { text: 'text-orange-600', letter: 'C' },
codex: { text: 'text-emerald-600', letter: 'X' },
agy: { text: 'text-violet-600', letter: 'A' },
qwen: { text: 'text-cyan-600', letter: 'Q' },
iflow: { text: 'text-indigo-600', letter: 'i' },
};
/** Size configuration */
const SIZE_CONFIG = {
sm: { container: 'w-6 h-6', icon: 'w-4 h-4', text: 'text-xs' },
md: { container: 'w-8 h-8', icon: 'w-5 h-5', text: 'text-sm' },
lg: { container: 'w-12 h-12', icon: 'w-8 h-8', text: 'text-lg' },
};
export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoProps) {
const providerKey = provider.toLowerCase();
const config = PROVIDER_CONFIG[providerKey] || {
text: 'text-gray-600',
letter: provider[0]?.toUpperCase() || '?',
};
const sizeConfig = SIZE_CONFIG[size];
const imageSrc = PROVIDER_IMAGES[providerKey];
return (
<div
className={cn(
'flex items-center justify-center rounded-md',
imageSrc && 'bg-white p-1',
sizeConfig.container,
className
)}
>
{imageSrc ? (
<img
src={imageSrc}
alt={`${provider} logo`}
className={cn(sizeConfig.icon, 'object-contain')}
/>
) : (
<span className={cn('font-semibold', config.text, sizeConfig.text)}>{config.letter}</span>
)}
</div>
);
}
/** Inline variant for use in text */
export function ProviderLogoInline({
provider,
className,
}: {
provider: string;
className?: string;
}) {
return <ProviderLogo provider={provider} size="sm" className={className} />;
}
@@ -0,0 +1,325 @@
/**
* Provider Model Selector Component
* Per-provider model selection using model-catalog.ts
* Includes tier badges, broken/deprecated status indicators
*/
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 { AlertTriangle, AlertCircle, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
/** Model entry from catalog */
export interface ModelEntry {
id: string;
name: string;
tier?: 'free' | 'paid';
description?: string;
broken?: boolean;
issueUrl?: string;
deprecated?: boolean;
deprecationReason?: string;
/** Optional preset mapping for different tiers (if different from id) */
presetMapping?: {
default: string;
opus: string;
sonnet: string;
haiku: string;
};
}
/** Provider catalog */
export interface ProviderCatalog {
provider: string;
displayName: string;
models: ModelEntry[];
defaultModel: string;
}
interface ProviderModelSelectorProps {
/** Provider catalog data */
catalog: ProviderCatalog | undefined;
/** Loading state */
isLoading?: boolean;
/** Currently selected model */
value: string | undefined;
/** Callback when model changes */
onChange: (model: string) => void;
/** Disabled state */
disabled?: boolean;
/** Placeholder text */
placeholder?: string;
/** Custom className */
className?: string;
}
export function ProviderModelSelector({
catalog,
isLoading,
value,
onChange,
disabled,
placeholder = 'Select a model',
className,
}: ProviderModelSelectorProps) {
// Group models by tier
const groupedModels = useMemo(() => {
if (!catalog?.models) return { free: [], paid: [] };
return {
free: catalog.models.filter((m) => !m.tier || m.tier === 'free'),
paid: catalog.models.filter((m) => m.tier === 'paid'),
};
}, [catalog]);
const selectedModel = useMemo(() => {
return catalog?.models.find((m) => m.id === value);
}, [catalog, value]);
if (isLoading) {
return <Skeleton className={cn('h-9 w-full', className)} />;
}
if (!catalog || catalog.models.length === 0) {
return (
<div className={cn('text-sm text-muted-foreground py-2', className)}>
No models available for this provider
</div>
);
}
const renderModelItem = (model: ModelEntry) => (
<SelectItem
key={model.id}
value={model.id}
className={cn('pl-4', model.broken && 'opacity-60', model.deprecated && 'opacity-60')}
>
<div className="flex items-center gap-2">
<span className="truncate">{model.name}</span>
{model.broken && (
<Badge variant="destructive" className="text-[9px] h-4 px-1">
BROKEN
</Badge>
)}
{model.deprecated && (
<Badge variant="secondary" className="text-[9px] h-4 px-1">
DEPRECATED
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
);
return (
<div className={cn('space-y-2', className)}>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder}>
{selectedModel && (
<div className="flex items-center gap-2">
<span className="truncate">{selectedModel.name}</span>
{selectedModel.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
PAID
</Badge>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{groupedModels.free.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">Free Tier</SelectLabel>
{groupedModels.free.map(renderModelItem)}
</SelectGroup>
)}
{groupedModels.paid.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-amber-600">Paid Tier</SelectLabel>
{groupedModels.paid.map(renderModelItem)}
</SelectGroup>
)}
</SelectContent>
</Select>
{/* Warning for broken/deprecated models */}
{selectedModel?.broken && (
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md text-xs text-destructive">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div>
<p className="font-medium">This model has known issues</p>
{selectedModel.issueUrl && (
<a
href={selectedModel.issueUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
View issue details
</a>
)}
</div>
</div>
)}
{selectedModel?.deprecated && (
<div className="flex items-start gap-2 p-2 bg-amber-500/10 rounded-md text-xs text-amber-700">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div>
<p className="font-medium">This model is deprecated</p>
{selectedModel.deprecationReason && (
<p className="opacity-80">{selectedModel.deprecationReason}</p>
)}
</div>
</div>
)}
{/* Model description */}
{selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (
<p className="text-xs text-muted-foreground">{selectedModel.description}</p>
)}
</div>
);
}
/** Model Mapping Selector - For Opus/Sonnet/Haiku mapping */
interface ModelMappingSelectorProps {
catalog: ProviderCatalog | undefined;
label: string;
value: string | undefined;
onChange: (model: string) => void;
disabled?: boolean;
}
export function ModelMappingSelector({
catalog,
label,
value,
onChange,
disabled,
}: ModelMappingSelectorProps) {
if (!catalog) return null;
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{label}</label>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select model">
{value && <span className="truncate font-mono text-xs">{value}</span>}
</SelectValue>
</SelectTrigger>
<SelectContent>
{catalog.models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate text-sm">{model.name}</span>
{model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
PAID
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
/** Flexible Model Selector - Combines catalog recommendations with full model list */
interface FlexibleModelSelectorProps {
label: string;
description?: string;
value: string | undefined;
onChange: (model: string) => void;
catalog?: ProviderCatalog;
allModels: { id: string; owned_by: string }[];
disabled?: boolean;
}
export function FlexibleModelSelector({
label,
description,
value,
onChange,
catalog,
allModels,
disabled,
}: FlexibleModelSelectorProps) {
// Combine catalog models (recommended) with all available models
const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []);
return (
<div className="space-y-1.5">
<div>
<label className="text-xs font-medium">{label}</label>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select model">
{value && <span className="truncate font-mono text-xs">{value}</span>}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{/* Recommended models from catalog */}
{catalog && catalog.models.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-primary">Recommended</SelectLabel>
{catalog.models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
PAID
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
))}
</SelectGroup>
)}
{/* All available models (excluding already shown) */}
{allModels.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">
All Models ({allModels.length})
</SelectLabel>
{allModels
.filter((m) => !catalogModelIds.has(m.id))
.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
))}
</SelectGroup>
)}
{/* Fallback if no models available */}
{(!catalog || catalog.models.length === 0) && allModels.length === 0 && (
<div className="py-2 px-3 text-xs text-muted-foreground">No models available</div>
)}
</SelectContent>
</Select>
</div>
);
}
+3 -19
View File
@@ -4,7 +4,7 @@
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
*/
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useState, useCallback, useMemo } from 'react';
import Editor from 'react-simple-code-editor';
import { Highlight, themes } from 'prism-react-renderer';
import { useTheme } from '@/hooks/use-theme';
@@ -74,18 +74,6 @@ export function CodeEditor({
const { isDark } = useTheme();
const [isFocused, setIsFocused] = useState(false);
const [isMasked, setIsMasked] = useState(true);
// Force Editor remount when theme changes (works around react-simple-code-editor caching)
const [editorKey, setEditorKey] = useState(0);
const isFirstRender = useRef(true);
useEffect(() => {
// Skip first render, only trigger on theme changes
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setEditorKey((k) => k + 1);
}, [isDark]);
// Validate on every change for JSON
const validation = useMemo(() => {
@@ -138,11 +126,7 @@ export function CodeEditor({
// Reset flag on commas or new keys (handled by property check),
// but persist through colons and whitespace
else if (token.types.includes('punctuation')) {
if (
token.content !== ':' &&
token.content !== '[' &&
token.content !== '{'
) {
if (token.content !== ':' && token.content !== '[' && token.content !== '{') {
nextValueIsSensitive = false;
}
}
@@ -185,7 +169,7 @@ export function CodeEditor({
value={value}
onValueChange={readonly ? () => {} : onChange}
highlight={highlightCode}
key={editorKey}
key={isDark ? 'dark-editor' : 'light-editor'}
padding={12}
disabled={readonly}
onFocus={() => setIsFocused(true)}
+199 -40
View File
@@ -17,6 +17,13 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Copy,
Check,
@@ -26,9 +33,12 @@ import {
Terminal,
User,
Sparkles,
ExternalLink,
Loader2,
} from 'lucide-react';
import { useCliproxyAuth, useCreateVariant } from '@/hooks/use-cliproxy';
import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
interface QuickSetupWizardProps {
open: boolean;
@@ -53,9 +63,11 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
const [modelName, setModelName] = useState('');
const [copied, setCopied] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isAddingNewAccount, setIsAddingNewAccount] = useState(false); // Track explicit "add account" action
const { data: authData, refetch } = useCliproxyAuth();
const createMutation = useCreateVariant();
const startAuthMutation = useStartAuth();
// Get auth status for selected provider
const providerAuth = authData?.authStatus.find(
@@ -73,26 +85,22 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
setVariantName('');
setModelName('');
setCopied(false);
setIsAddingNewAccount(false);
}, 0);
return () => clearTimeout(timer);
}
}, [open]);
// Auto-advance from auth step when account detected
// Use timeout to avoid synchronous setState in effect (React lint rule)
// BUT only if user didn't explicitly click "Add new account"
useEffect(() => {
if (step === 'auth' && accounts.length > 0) {
if (step === 'auth' && accounts.length > 0 && !isAddingNewAccount) {
const timer = setTimeout(() => {
if (accounts.length === 1) {
setSelectedAccount(accounts[0]);
setStep('variant');
} else {
setStep('account');
}
setStep('account');
}, 0);
return () => clearTimeout(timer);
}
}, [step, accounts]);
}, [step, accounts, isAddingNewAccount]);
const copyCommand = async (cmd: string) => {
await navigator.clipboard.writeText(cmd);
@@ -106,17 +114,32 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
setIsRefreshing(false);
};
const handleStartAuth = () => {
startAuthMutation.mutate(
{ provider: selectedProvider },
{
onSuccess: (data) => {
// Account created, select it and advance to variant step
if (data.account) {
setSelectedAccount(data.account as OAuthAccount);
setStep('variant');
}
refetch(); // Refresh auth status
},
}
);
};
const handleProviderSelect = (providerId: string) => {
setSelectedProvider(providerId);
const auth = authData?.authStatus.find((s: AuthStatus) => s.provider === providerId);
const provAccounts = auth?.accounts || [];
if (provAccounts.length === 0) {
// No accounts - go to auth step to add first account
setStep('auth');
} else if (provAccounts.length === 1) {
setSelectedAccount(provAccounts[0]);
setStep('variant');
} else {
// Has accounts - always show account selection (includes "Add new account" option)
setStep('account');
}
};
@@ -152,9 +175,36 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
};
const currentProgress = getStepProgress(step);
// Prevent accidental close when user has made progress
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
// Allow closing from success step or provider step (no progress yet)
if (step === 'success' || step === 'provider') {
onClose();
return;
}
// For other steps, require explicit close via Cancel/Back
// The X button still works, but clicking outside doesn't close
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-lg"
onPointerDownOutside={(e) => {
// Prevent closing on outside click when user has made progress
if (step !== 'success' && step !== 'provider') {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
// Prevent ESC close during auth or variant creation
if (startAuthMutation.isPending || createMutation.isPending) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
@@ -192,6 +242,48 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
{/* Step: Authentication */}
{step === 'auth' && (
<div className="space-y-4">
{/* Primary: OAuth Button */}
<div className="text-center space-y-3">
<p className="text-sm text-muted-foreground">
Authenticate with {providers.find((p) => p.id === selectedProvider)?.name} to add
an account
</p>
<Button
onClick={handleStartAuth}
disabled={startAuthMutation.isPending}
className="w-full gap-2"
size="lg"
>
{startAuthMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Authenticating...
</>
) : (
<>
<ExternalLink className="w-4 h-4" />
Authenticate in Browser
</>
)}
</Button>
{startAuthMutation.isPending && (
<p className="text-xs text-muted-foreground">
Complete the OAuth flow in your browser...
</p>
)}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or use terminal</span>
</div>
</div>
{/* Secondary: CLI Command */}
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -210,21 +302,25 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
)}
</Button>
</div>
<div className="text-xs text-muted-foreground">
This will open your browser to authenticate with{' '}
{providers.find((p) => p.id === selectedProvider)?.name}
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setStep('provider')}>
<Button
variant="ghost"
onClick={() => setStep('provider')}
disabled={startAuthMutation.isPending}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button onClick={handleRefresh} disabled={isRefreshing}>
<Button
variant="outline"
onClick={handleRefresh}
disabled={isRefreshing || startAuthMutation.isPending}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Checking...' : 'I ran the command'}
{isRefreshing ? 'Checking...' : 'Refresh Status'}
</Button>
</div>
</div>
@@ -233,15 +329,23 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
{/* Step: Account Selection */}
{step === 'account' && (
<div className="space-y-4">
{/* Existing accounts header */}
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Select an account ({accounts.length})
</div>
<div className="grid gap-2">
{accounts.map((acc: OAuthAccount) => (
<button
key={acc.id}
type="button"
onClick={() => handleAccountSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<User className="w-5 h-5 text-muted-foreground" />
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div>
<div className="font-medium">{acc.email || acc.id}</div>
{acc.isDefault && (
@@ -253,10 +357,41 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
</button>
))}
</div>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setStep('auth')}>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
</div>
</div>
{/* Add new account button - more prominent */}
<button
type="button"
className="w-full flex items-center gap-3 p-3 border-2 border-dashed border-primary/50 rounded-lg hover:border-primary hover:bg-primary/5 transition-colors text-left"
onClick={() => {
setIsAddingNewAccount(true);
setStep('auth');
}}
>
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<ExternalLink className="w-4 h-4 text-primary" />
</div>
<div>
<div className="font-medium text-primary">Add new account</div>
<div className="text-xs text-muted-foreground">
Authenticate with a different account
</div>
</div>
</button>
<div className="flex items-center justify-between pt-2">
<Button variant="ghost" onClick={() => setStep('provider')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Add different account
Back
</Button>
</div>
</div>
@@ -286,30 +421,54 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
</div>
<div className="space-y-2">
<Label htmlFor="model-name">Model (optional)</Label>
<Input
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="e.g., gemini-2.5-pro"
/>
<Label>Model</Label>
<Select value={modelName} onValueChange={setModelName}>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{MODEL_CATALOGS[selectedProvider]?.models.map((m) => (
<SelectItem key={m.id} value={m.id}>
<div className="flex items-center gap-2">
<span>{m.name}</span>
{m.description && (
<span className="text-xs text-muted-foreground">
- {m.description}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
Default: {MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button
variant="ghost"
onClick={() => (accounts.length > 1 ? setStep('account') : setStep('auth'))}
onClick={() => (accounts.length > 0 ? setStep('account') : setStep('provider'))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button
onClick={handleCreateVariant}
disabled={!variantName || createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Variant'}
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onClose}>
Skip
</Button>
<Button
onClick={handleCreateVariant}
disabled={!variantName || createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Variant'}
</Button>
</div>
</div>
<p className="text-xs text-center text-muted-foreground">
Skip if you just wanted to add an account without creating a variant
</p>
</div>
)}
+188
View File
@@ -0,0 +1,188 @@
/**
* OAuth Auth Flow Hook for CLIProxy
* Manages popup-based OAuth authentication flows
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
interface AuthFlowState {
provider: string | null;
isAuthenticating: boolean;
error: string | null;
}
const AUTH_ENDPOINTS: Record<string, string> = {
claude: '/anthropic-auth-url',
gemini: '/gemini-cli-auth-url',
codex: '/codex-auth-url',
agy: '/antigravity-auth-url',
};
const AUTH_TIMEOUT_MS = 300000; // 5 minutes
const POLL_INTERVAL_MS = 500;
export function useCliproxyAuthFlow() {
const [state, setState] = useState<AuthFlowState>({
provider: null,
isAuthenticating: false,
error: null,
});
const popupRef = useRef<Window | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const queryClient = useQueryClient();
// Cleanup function
const cleanup = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
popupRef.current = null;
}, []);
// Cleanup on unmount
useEffect(() => {
return () => cleanup();
}, [cleanup]);
const startAuth = useCallback(
async (provider: string) => {
const endpoint = AUTH_ENDPOINTS[provider];
if (!endpoint) {
setState({
provider: null,
isAuthenticating: false,
error: `Unknown provider: ${provider}`,
});
return;
}
setState({ provider, isAuthenticating: true, error: null });
try {
// Get auth URL from API
const response = await fetch(`/api/cliproxy${endpoint}?is_webui=true`);
if (!response.ok) {
throw new Error(`Failed to get auth URL: ${response.statusText}`);
}
const data = await response.json();
const { url, state: authState } = data;
if (!url) {
throw new Error('No auth URL returned from server');
}
// Open popup
const popup = window.open(url, `${provider}_auth`, 'width=600,height=700,popup=yes');
if (!popup) {
throw new Error('Popup blocked. Please allow popups for this site.');
}
popupRef.current = popup;
// Poll for completion
pollIntervalRef.current = setInterval(async () => {
// Check if popup was closed by user
if (popup.closed) {
cleanup();
// Check final status
try {
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
const statusData = await statusRes.json();
if (statusData.status === 'ok') {
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
toast.success(`${provider} authentication successful`);
setState({ provider: null, isAuthenticating: false, error: null });
} else if (statusData.status === 'error') {
setState({
provider: null,
isAuthenticating: false,
error: statusData.error || 'Authentication failed',
});
} else {
// User closed popup before completing
setState({
provider: null,
isAuthenticating: false,
error: 'Authentication cancelled',
});
}
} catch {
setState({
provider: null,
isAuthenticating: false,
error: 'Failed to check auth status',
});
}
return;
}
// Poll status while popup is open
try {
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
const statusData = await statusRes.json();
if (statusData.status === 'ok') {
cleanup();
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
toast.success(`${provider} authentication successful`);
setState({ provider: null, isAuthenticating: false, error: null });
} else if (statusData.status === 'error') {
cleanup();
setState({
provider: null,
isAuthenticating: false,
error: statusData.error || 'Authentication failed',
});
}
// 'wait' status means keep polling
} catch {
// Silently ignore polling errors, will retry
}
}, POLL_INTERVAL_MS);
// Timeout after 5 minutes
timeoutRef.current = setTimeout(() => {
cleanup();
toast.error('Authentication timed out');
setState({
provider: null,
isAuthenticating: false,
error: 'Authentication timed out',
});
}, AUTH_TIMEOUT_MS);
} catch (error) {
cleanup();
const message = error instanceof Error ? error.message : 'Authentication failed';
toast.error(message);
setState({ provider: null, isAuthenticating: false, error: message });
}
},
[cleanup, queryClient]
);
const cancelAuth = useCallback(() => {
cleanup();
setState({ provider: null, isAuthenticating: false, error: null });
}, [cleanup]);
return {
...state,
startAuth,
cancelAuth,
};
}
+169
View File
@@ -0,0 +1,169 @@
/**
* CLIProxy Config Hook
* Manages config.yaml loading, editing, validation, and saving
*
* Uses a controlled editing pattern where edits are tracked separately
* from server state to comply with React Compiler rules.
*/
import { useCallback, useMemo, useReducer } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { parse as parseYaml } from 'yaml';
import { api } from '@/lib/api-client';
import { toast } from 'sonner';
interface ValidationResult {
valid: boolean;
error?: string;
line?: number;
column?: number;
}
type EditAction =
| { type: 'EDIT'; content: string; serverContent: string }
| { type: 'RESET'; serverContent: string }
| { type: 'SAVE_SUCCESS'; content: string };
interface EditState {
// Local edit content (empty means using server content)
localContent: string | null;
// Last known server content for dirty detection
lastServerContent: string;
}
function editReducer(state: EditState, action: EditAction): EditState {
switch (action.type) {
case 'EDIT':
// If editing back to server content, clear local state
if (action.content === action.serverContent) {
return { localContent: null, lastServerContent: action.serverContent };
}
return { localContent: action.content, lastServerContent: action.serverContent };
case 'RESET':
return { localContent: null, lastServerContent: action.serverContent };
case 'SAVE_SUCCESS':
return { localContent: null, lastServerContent: action.content };
default:
return state;
}
}
function validateYaml(code: string): ValidationResult {
if (!code.trim()) {
return { valid: true };
}
try {
parseYaml(code);
return { valid: true };
} catch (e: unknown) {
const error = e as { linePos?: Array<{ line: number; col: number }>; message: string };
const linePos = error.linePos?.[0];
return {
valid: false,
error: error.message,
line: linePos?.line,
column: linePos?.col,
};
}
}
export function useCliproxyConfig() {
const queryClient = useQueryClient();
// Fetch config.yaml - server state
const configQuery = useQuery({
queryKey: ['cliproxy-config-yaml'],
queryFn: () => api.cliproxy.getConfigYaml(),
});
// Fetch auth files list
const authFilesQuery = useQuery({
queryKey: ['cliproxy-auth-files'],
queryFn: () => api.cliproxy.getAuthFiles(),
});
// Server content
const serverContent = configQuery.data ?? '';
// Edit state - tracks local edits separate from server
const [editState, dispatch] = useReducer(editReducer, {
localContent: null,
lastServerContent: '',
});
// Derived: current content (local edit or server)
const content = editState.localContent ?? serverContent;
// Derived: dirty flag
const isDirty = editState.localContent !== null && editState.localContent !== serverContent;
// Derived: validation
const validation = useMemo(() => validateYaml(content), [content]);
// Update content
const updateContent = useCallback(
(newContent: string) => {
dispatch({ type: 'EDIT', content: newContent, serverContent });
},
[serverContent]
);
// Reset to server content
const resetContent = useCallback(() => {
dispatch({ type: 'RESET', serverContent });
}, [serverContent]);
// Save mutation
const saveMutation = useMutation({
mutationFn: (contentToSave: string) => api.cliproxy.saveConfigYaml(contentToSave),
onSuccess: (_data, variables) => {
dispatch({ type: 'SAVE_SUCCESS', content: variables });
queryClient.invalidateQueries({ queryKey: ['cliproxy-config-yaml'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
toast.success('Configuration saved successfully');
},
onError: (error: Error) => {
toast.error(`Failed to save: ${error.message}`);
},
});
// Save handler
const saveContent = useCallback(() => {
if (!validation.valid) {
toast.error('Cannot save invalid YAML');
return;
}
saveMutation.mutate(content);
}, [content, validation.valid, saveMutation]);
return {
// State
content,
originalContent: serverContent,
isDirty,
validation,
// Queries
isLoading: configQuery.isLoading,
isError: configQuery.isError,
authFiles: authFilesQuery.data?.files ?? [],
// Actions
updateContent,
resetContent,
saveContent,
isSaving: saveMutation.isPending,
// Refresh
refetch: configQuery.refetch,
};
}
export function useCliproxyAuthFile(fileName: string | null) {
return useQuery({
queryKey: ['cliproxy-auth-file', fileName],
queryFn: () => (fileName ? api.cliproxy.getAuthFile(fileName) : null),
enabled: Boolean(fileName),
});
}
+195
View File
@@ -0,0 +1,195 @@
/**
* CLIProxy Logs Hook
* Manages log streaming with buffering and throttling
*/
import { useState, useCallback, useRef, useEffect } from 'react';
export interface LogEntry {
id: string;
timestamp: string;
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
provider?: string;
requestId?: string;
}
interface LogsState {
logs: LogEntry[];
isPaused: boolean;
isConnected: boolean;
lastTimestamp: string | null;
stats: {
total: number;
errors: number;
warnings: number;
};
}
const MAX_LOGS = 500;
const FLUSH_INTERVAL = 100; // ms
const POLL_INTERVAL = 1000; // ms
export function useCliproxyLogs() {
const [state, setState] = useState<LogsState>({
logs: [],
isPaused: false,
isConnected: false,
lastTimestamp: null,
stats: { total: 0, errors: 0, warnings: 0 },
});
const bufferRef = useRef<LogEntry[]>([]);
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flushIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isPausedRef = useRef(false);
const lastTimestampRef = useRef<string | null>(null);
// Keep refs in sync
useEffect(() => {
isPausedRef.current = state.isPaused;
}, [state.isPaused]);
useEffect(() => {
lastTimestampRef.current = state.lastTimestamp;
}, [state.lastTimestamp]);
// Flush buffer to state
const flushBuffer = useCallback(() => {
if (bufferRef.current.length === 0) return;
if (isPausedRef.current) return;
const newLogs = bufferRef.current;
bufferRef.current = [];
setState((prev) => {
const combined = [...prev.logs, ...newLogs];
const trimmed = combined.slice(-MAX_LOGS);
const errorCount = trimmed.filter((l) => l.level === 'error').length;
const warnCount = trimmed.filter((l) => l.level === 'warn').length;
return {
...prev,
logs: trimmed,
stats: {
total: trimmed.length,
errors: errorCount,
warnings: warnCount,
},
};
});
}, []);
// Poll for new logs
const pollLogs = useCallback(async () => {
if (isPausedRef.current) return;
try {
const after = lastTimestampRef.current ?? new Date(Date.now() - 60000).toISOString();
const response = await fetch(`/api/cliproxy/logs?after=${encodeURIComponent(after)}`);
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
const data = await response.json();
const entries: LogEntry[] = data.logs ?? [];
if (entries.length > 0) {
bufferRef.current.push(...entries);
const lastEntry = entries[entries.length - 1];
setState((prev) => ({
...prev,
isConnected: true,
lastTimestamp: lastEntry.timestamp,
}));
} else {
setState((prev) => ({ ...prev, isConnected: true }));
}
} catch {
setState((prev) => ({ ...prev, isConnected: false }));
}
// Schedule next poll
if (!isPausedRef.current) {
pollTimeoutRef.current = setTimeout(pollLogs, POLL_INTERVAL);
}
}, []);
// Start polling on mount
useEffect(() => {
// Start flush interval
flushIntervalRef.current = setInterval(flushBuffer, FLUSH_INTERVAL);
// Start polling
pollLogs();
return () => {
if (flushIntervalRef.current) {
clearInterval(flushIntervalRef.current);
}
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
}
};
}, [flushBuffer, pollLogs]);
// Actions
const pause = useCallback(() => {
setState((prev) => ({ ...prev, isPaused: true }));
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
}, []);
const resume = useCallback(() => {
setState((prev) => ({ ...prev, isPaused: false }));
pollLogs();
}, [pollLogs]);
const clear = useCallback(() => {
bufferRef.current = [];
setState((prev) => ({
...prev,
logs: [],
stats: { total: 0, errors: 0, warnings: 0 },
}));
}, []);
return {
logs: state.logs,
isPaused: state.isPaused,
isConnected: state.isConnected,
stats: state.stats,
pause,
resume,
clear,
};
}
// Filter hook for search and level filtering
export function useLogsFilter(logs: LogEntry[]) {
const [levelFilter, setLevelFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const filteredLogs = logs.filter((log) => {
// Level filter
if (levelFilter !== 'all' && log.level !== levelFilter) {
return false;
}
// Search filter
if (searchQuery && !log.message.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
return true;
});
return {
filteredLogs,
levelFilter,
setLevelFilter,
searchQuery,
setSearchQuery,
};
}
+114
View File
@@ -0,0 +1,114 @@
/**
* React Query hook for CLIProxyAPI stats
*/
import { useQuery } from '@tanstack/react-query';
/** CLIProxy usage statistics */
export interface CliproxyStats {
totalRequests: number;
tokens: {
input: number;
output: number;
total: number;
};
requestsByModel: Record<string, number>;
requestsByProvider: Record<string, number>;
quotaExceededCount: number;
retryCount: number;
collectedAt: string;
}
/** CLIProxy running status */
export interface CliproxyStatus {
running: boolean;
}
/**
* Fetch CLIProxy stats from API
*/
async function fetchCliproxyStats(): Promise<CliproxyStats> {
const response = await fetch('/api/cliproxy/stats');
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to fetch stats');
}
return response.json();
}
/**
* Fetch CLIProxy running status
*/
async function fetchCliproxyStatus(): Promise<CliproxyStatus> {
const response = await fetch('/api/cliproxy/status');
if (!response.ok) {
throw new Error('Failed to fetch status');
}
return response.json();
}
/**
* Hook to get CLIProxy running status
*/
export function useCliproxyStatus() {
return useQuery({
queryKey: ['cliproxy-status'],
queryFn: fetchCliproxyStatus,
refetchInterval: 10000, // Check every 10 seconds
retry: 1,
});
}
/**
* Hook to get CLIProxy usage stats
*/
export function useCliproxyStats(enabled = true) {
return useQuery({
queryKey: ['cliproxy-stats'],
queryFn: fetchCliproxyStats,
enabled,
refetchInterval: 30000, // Refresh every 30 seconds
retry: 1,
staleTime: 10000, // Consider data stale after 10 seconds
});
}
/** CLIProxy model from /v1/models endpoint */
export interface CliproxyModel {
id: string;
object: string;
created: number;
owned_by: string;
}
/** Categorized models response */
export interface CliproxyModelsResponse {
models: CliproxyModel[];
byCategory: Record<string, CliproxyModel[]>;
totalCount: number;
}
/**
* Fetch CLIProxy models from API
*/
async function fetchCliproxyModels(): Promise<CliproxyModelsResponse> {
const response = await fetch('/api/cliproxy/models');
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to fetch models');
}
return response.json();
}
/**
* Hook to get available CLIProxy models (categorized by provider)
*/
export function useCliproxyModels(enabled = true) {
return useQuery({
queryKey: ['cliproxy-models'],
queryFn: fetchCliproxyModels,
enabled,
staleTime: 60000, // Models don't change often, cache for 1 minute
retry: 1,
});
}
+94 -2
View File
@@ -5,7 +5,7 @@
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, type CreateVariant, type UpdateVariant } from '@/lib/api-client';
import { api, type CreateVariant, type UpdateVariant, type CreatePreset } from '@/lib/api-client';
import { toast } from 'sonner';
export function useCliproxy() {
@@ -18,7 +18,7 @@ export function useCliproxy() {
export function useCliproxyAuth() {
return useQuery({
queryKey: ['cliproxy-auth'],
queryFn: () => api.cliproxy.auth(),
queryFn: () => api.cliproxy.getAuthStatus(),
});
}
@@ -117,3 +117,95 @@ export function useRemoveAccount() {
},
});
}
// OAuth flow hook
export function useStartAuth() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ provider, nickname }: { provider: string; nickname?: string }) =>
api.cliproxy.auth.start(provider, nickname),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
toast.success(`Account added for ${variables.provider}`);
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
// Stats and models hooks for Overview tab
export function useCliproxyStats() {
return useQuery({
queryKey: ['cliproxy-stats'],
queryFn: () => api.cliproxy.stats(),
refetchInterval: 30000, // Refresh every 30s
});
}
export function useCliproxyModels() {
return useQuery({
queryKey: ['cliproxy-models'],
queryFn: () => api.cliproxy.models(),
});
}
export function useUpdateModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ provider, model }: { provider: string; model: string }) =>
api.cliproxy.updateModel(provider, model),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
toast.success('Model updated');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
// ==================== Presets ====================
export function usePresets(profile: string) {
return useQuery({
queryKey: ['presets', profile],
queryFn: () => api.presets.list(profile),
enabled: !!profile,
});
}
export function useCreatePreset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ profile, data }: { profile: string; data: CreatePreset }) =>
api.presets.create(profile, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['presets', variables.profile] });
toast.success(`Preset "${variables.data.name}" saved`);
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useDeletePreset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ profile, name }: { profile: string; name: string }) =>
api.presets.delete(profile, name),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['presets', variables.profile] });
toast.success('Preset deleted');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
+99 -1
View File
@@ -86,6 +86,27 @@ export interface AuthStatus {
defaultAccount?: string;
}
/** Auth file info for Config tab */
export interface AuthFile {
name: string;
provider?: string;
}
/** CLIProxy model from /v1/models endpoint */
export interface CliproxyModel {
id: string;
object: string;
created: number;
owned_by: string;
}
/** Categorized models response from CLIProxyAPI */
export interface CliproxyModelsResponse {
models: CliproxyModel[];
byCategory: Record<string, CliproxyModel[]>;
totalCount: number;
}
/** Provider accounts summary */
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
@@ -116,6 +137,23 @@ export interface SecretsExists {
keys: string[];
}
/** Model preset for quick model switching */
export interface ModelPreset {
name: string;
default: string;
opus: string;
sonnet: string;
haiku: string;
}
export interface CreatePreset {
name: string;
default: string;
opus?: string;
sonnet?: string;
haiku?: string;
}
// API
export const api = {
profiles: {
@@ -134,7 +172,7 @@ export const api = {
},
cliproxy: {
list: () => request<{ variants: Variant[] }>('/cliproxy'),
auth: () => request<{ authStatus: AuthStatus[] }>('/cliproxy/auth'),
getAuthStatus: () => request<{ authStatus: AuthStatus[] }>('/cliproxy/auth'),
create: (data: CreateVariant) =>
request('/cliproxy', {
method: 'POST',
@@ -146,6 +184,44 @@ export const api = {
body: JSON.stringify(data),
}),
delete: (name: string) => request(`/cliproxy/${name}`, { method: 'DELETE' }),
// Stats and models for Overview tab
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
updateModel: (provider: string, model: string) =>
request(`/cliproxy/models/${provider}`, {
method: 'PUT',
body: JSON.stringify({ model }),
}),
// Config YAML for Config tab
getConfigYaml: async (): Promise<string> => {
const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`);
if (!res.ok) throw new Error('Failed to load config');
return res.text();
},
saveConfigYaml: async (content: string): Promise<void> => {
const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`, {
method: 'PUT',
headers: { 'Content-Type': 'application/yaml' },
body: content,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ error: 'Failed to save config' }));
throw new Error(error.error || 'Failed to save config');
}
},
// Auth files for Config tab
getAuthFiles: () => request<{ files: AuthFile[] }>('/cliproxy/auth-files'),
getAuthFile: async (name: string): Promise<string> => {
const res = await fetch(
`${BASE_URL}/cliproxy/auth-files/download?name=${encodeURIComponent(name)}`
);
if (!res.ok) throw new Error('Failed to load auth file');
return res.text();
},
// Multi-account management
accounts: {
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'),
@@ -159,6 +235,15 @@ export const api = {
remove: (provider: string, accountId: string) =>
request(`/cliproxy/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
},
// OAuth flow
auth: {
/** Start OAuth flow - opens browser for authentication */
start: (provider: string, nickname?: string) =>
request<{ success: boolean; account: OAuthAccount }>(`/cliproxy/auth/${provider}/start`, {
method: 'POST',
body: JSON.stringify({ nickname }),
}),
},
},
accounts: {
list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'),
@@ -193,4 +278,17 @@ export const api = {
}),
exists: (profile: string) => request<SecretsExists>(`/secrets/${profile}/exists`),
},
/** Model presets for quick model switching */
presets: {
list: (profile: string) => request<{ presets: ModelPreset[] }>(`/settings/${profile}/presets`),
create: (profile: string, data: CreatePreset) =>
request<{ preset: ModelPreset }>(`/settings/${profile}/presets`, {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (profile: string, name: string) =>
request<{ success: boolean }>(`/settings/${profile}/presets/${encodeURIComponent(name)}`, {
method: 'DELETE',
}),
},
};
+118
View File
@@ -0,0 +1,118 @@
/**
* Model Catalogs for CLIProxy providers
* Shared data for Quick Setup Wizard and Provider Editor
*/
import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector';
/** Model catalog data - mirrors src/cliproxy/model-catalog.ts */
export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
agy: {
provider: 'agy',
displayName: 'Antigravity',
defaultModel: 'gemini-claude-opus-4-5-thinking',
models: [
{
id: 'gemini-claude-opus-4-5-thinking',
name: 'Claude Opus 4.5 Thinking',
description: 'Most capable, extended thinking',
},
{
id: 'gemini-claude-sonnet-4-5-thinking',
name: 'Claude Sonnet 4.5 Thinking',
description: 'Balanced with extended thinking',
},
{
id: 'gemini-claude-sonnet-4-5',
name: 'Claude Sonnet 4.5',
description: 'Fast and capable',
},
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
description: 'Google latest model via Antigravity',
},
],
},
gemini: {
provider: 'gemini',
displayName: 'Gemini',
defaultModel: 'gemini-2.5-pro',
models: [
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
tier: 'paid',
description: 'Latest model, requires paid Google account',
},
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
description: 'Stable, works with free Google account',
},
],
},
codex: {
provider: 'codex',
displayName: 'Codex',
defaultModel: 'gpt-5.1-codex-max',
models: [
{
id: 'gpt-5.1-codex-max',
name: 'Codex Max (5.1)',
description: 'Most capable Codex model',
presetMapping: {
default: 'gpt-5.1-codex-max',
opus: 'gpt-5.1-codex-max-high',
sonnet: 'gpt-5.1-codex-max',
haiku: 'gpt-5.1-codex-mini-high',
},
},
{
id: 'gpt-5.2',
name: 'GPT 5.2',
description: 'Latest GPT model',
presetMapping: {
default: 'gpt-5.2',
opus: 'gpt-5.2',
sonnet: 'gpt-5.2',
haiku: 'gpt-5.2',
},
},
{
id: 'gpt-5.1-codex-mini',
name: 'Codex Mini',
description: 'Fast and efficient Codex model',
},
],
},
qwen: {
provider: 'qwen',
displayName: 'Qwen',
defaultModel: 'qwen-coder-plus',
models: [
{
id: 'qwen-coder-plus',
name: 'Qwen Coder Plus',
description: 'Alibaba code-focused model',
},
{
id: 'qwen-max',
name: 'Qwen Max',
description: 'Most capable Qwen model',
},
],
},
iflow: {
provider: 'iflow',
displayName: 'iFlow',
defaultModel: 'iflow-default',
models: [
{
id: 'iflow-default',
name: 'iFlow Default',
description: 'Default iFlow model',
},
],
},
};
+12 -18
View File
@@ -18,7 +18,7 @@ import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
import { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
import {
useUsageSummary,
@@ -26,7 +26,6 @@ import {
useModelUsage,
useRefreshUsage,
useUsageStatus,
useUsageInsights,
useSessions,
type ModelUsage,
} from '@/hooks/use-usage';
@@ -73,7 +72,6 @@ export function AnalyticsPage() {
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
const { data: insights, isLoading: isInsightsLoading } = useUsageInsights(apiOptions);
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
const { data: status } = useUsageStatus();
@@ -96,7 +94,7 @@ export function AnalyticsPage() {
}, []);
return (
<div className="flex flex-col h-[calc(100vh-5.5rem)] overflow-hidden p-4 gap-3">
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div>
@@ -114,6 +112,7 @@ export function AnalyticsPage() {
{ label: 'All Time', range: { from: undefined, to: new Date() } },
]}
/>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText}
@@ -135,22 +134,22 @@ export function AnalyticsPage() {
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0 gap-3">
<div className="flex-1 flex flex-col min-h-0 gap-4">
{/* Usage Trend Chart - Full Width */}
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
<CardHeader className="px-3 py-2">
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
<CardHeader className="px-3 py-2 shrink-0">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Usage Trends
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0 flex items-center justify-center">
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} className="h-full" />
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
</CardContent>
</Card>
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */}
<div className="grid grid-cols-1 lg:grid-cols-10 gap-3 flex-1 min-h-0">
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (2) + CLIProxy Stats (2) */}
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
{/* Cost by Model - 4/10 width with breakdown */}
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
<CardHeader className="px-3 py-2">
@@ -291,13 +290,8 @@ export function AnalyticsPage() {
className="lg:col-span-2"
/>
{/* Usage Insights - 2/10 width */}
<UsageInsightsCard
anomalies={insights?.anomalies}
summary={insights?.summary}
isLoading={isInsightsLoading}
className="lg:col-span-2"
/>
{/* CLIProxy Stats - 2/10 width */}
<CliproxyStatsCard className="lg:col-span-2" />
</div>
{/* Model Details Popover - positioned at cursor */}
+15
View File
@@ -0,0 +1,15 @@
/**
* CLIProxy Control Panel Page
*
* Dedicated page for the CLIProxy management panel.
*/
import { ControlPanelEmbed } from '@/components/cliproxy/control-panel-embed';
export function CliproxyControlPanelPage() {
return (
<div className="h-[calc(100vh-100px)] flex flex-col">
<ControlPanelEmbed />
</div>
);
}
+322 -180
View File
@@ -1,159 +1,171 @@
/**
* CLIProxy Page
* Phase 03: REST API Routes & CRUD
* Phase 06: Multi-Account Management
* CLIProxy Page - Master-Detail Layout
* Left sidebar: Provider navigation + Quick actions
* Right panel: Provider Editor with split-view (settings + code editor)
*/
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Plus, Check, X, User, ChevronDown, Star, Trash2, Sparkles } from 'lucide-react';
import { CliproxyTable } from '@/components/cliproxy-table';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react';
import { QuickSetupWizard } from '@/components/quick-setup-wizard';
import { AddAccountDialog } from '@/components/add-account-dialog';
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
import {
useCliproxy,
useCliproxyAuth,
useSetDefaultAccount,
useRemoveAccount,
useDeleteVariant,
} from '@/hooks/use-cliproxy';
import type { OAuthAccount, AuthStatus } from '@/lib/api-client';
import type { AuthStatus, Variant } from '@/lib/api-client';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
import { cn } from '@/lib/utils';
function AccountBadge({
account,
onSetDefault,
onRemove,
isRemoving,
// Sidebar provider item
function ProviderSidebarItem({
status,
isSelected,
onSelect,
}: {
account: OAuthAccount;
onSetDefault: () => void;
onRemove: () => void;
isRemoving: boolean;
status: AuthStatus;
isSelected: boolean;
onSelect: () => void;
}) {
const accountCount = status.accounts?.length || 0;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-full border transition-colors hover:bg-muted/80 ${
account.isDefault
? 'border-primary/30 bg-primary/10 text-primary font-medium'
: 'border-muted bg-muted/40'
}`}
>
<User className="w-3 h-3" />
<span className="max-w-[200px] truncate">{account.email || account.id}</span>
{account.isDefault && <Star className="w-3 h-3 fill-current" />}
<ChevronDown className="w-3 h-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<div className="px-2 py-1.5 text-xs text-muted-foreground bg-muted/30 -mx-1 -mt-1 mb-1 border-b">
<div className="font-medium text-foreground mb-0.5 truncate">
{account.email || account.id}
</div>
{account.lastUsedAt && (
<div>Last used: {new Date(account.lastUsedAt).toLocaleDateString()}</div>
<button
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors cursor-pointer text-left',
isSelected
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-muted border border-transparent'
)}
onClick={onSelect}
>
<ProviderLogo provider={status.provider} size="md" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{status.displayName}</span>
{accountCount > 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">
{accountCount}
</Badge>
)}
</div>
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/30"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-1.5 mt-0.5">
{status.authenticated ? (
<>
<Check className="w-3 h-3 text-green-600" />
<span className="text-xs text-green-600">Connected</span>
</>
) : (
<>
<X className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Not connected</span>
</>
)}
</div>
</div>
</button>
);
}
function ProviderRow({
status,
setDefaultMutation,
removeMutation,
onAddAccount,
// Sidebar variant item (user-created provider variants)
function VariantSidebarItem({
variant,
parentAuth,
isSelected,
onSelect,
onDelete,
isDeleting,
}: {
status: AuthStatus;
setDefaultMutation: ReturnType<typeof useSetDefaultAccount>;
removeMutation: ReturnType<typeof useRemoveAccount>;
onAddAccount: () => void;
variant: Variant;
parentAuth?: AuthStatus;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
isDeleting?: boolean;
}) {
const accounts = status.accounts || [];
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b last:border-0 hover:bg-muted/5 transition-colors gap-4">
<div className="flex items-center gap-4 min-w-[180px]">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full ${
status.authenticated
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-muted text-muted-foreground'
}`}
>
{status.authenticated ? <Check className="w-5 h-5" /> : <X className="w-5 h-5" />}
<button
className={cn(
'group w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left pl-6',
isSelected
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-muted border border-transparent'
)}
onClick={onSelect}
>
<div className="relative">
<ProviderLogo provider={variant.provider} size="sm" />
<GitBranch className="w-2.5 h-2.5 absolute -bottom-0.5 -right-0.5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{variant.name}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1">
variant
</Badge>
</div>
<div>
<div className="font-medium flex items-center gap-2">
{status.displayName}
{accounts.length > 0 && (
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 font-normal">
{accounts.length}
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{status.provider}</div>
<div className="flex items-center gap-1.5 mt-0.5">
{parentAuth?.authenticated ? (
<>
<Check className="w-3 h-3 text-green-600" />
<span className="text-xs text-muted-foreground truncate">via {variant.provider}</span>
</>
) : (
<>
<X className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Parent not connected</span>
</>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
disabled={isDeleting}
>
<Trash2 className="w-3 h-3" />
</Button>
</button>
);
}
<div className="flex-1 flex items-center gap-2 flex-wrap">
{status.authenticated && accounts.length > 0 ? (
accounts.map((account) => (
<AccountBadge
key={account.id}
account={account}
onSetDefault={() =>
setDefaultMutation.mutate({
provider: status.provider,
accountId: account.id,
})
}
onRemove={() =>
removeMutation.mutate({
provider: status.provider,
accountId: account.id,
})
}
isRemoving={removeMutation.isPending}
/>
))
) : (
<div className="text-sm text-muted-foreground italic">
{status.authenticated
? 'Authenticated (No specific accounts tracked)'
: 'Not authenticated'}
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Show Add Account button for all - opens dialog with instructions */}
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={onAddAccount}>
<Plus className="w-3 h-3" />
Add Account
// Empty state for right panel
function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
return (
<div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center max-w-md px-8">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-6">
<Zap className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold mb-2">CCS Profile Manager</h2>
<p className="text-muted-foreground mb-4">
Manage OAuth authentication, account preferences, and model selection for CLIProxy
providers. Configure how CCS routes requests to different AI backends.
</p>
<p className="text-xs text-muted-foreground mb-6">
For live usage stats and real-time monitoring, visit the{' '}
<a href="/cliproxy/control-panel" className="text-primary hover:underline">
Control Panel
</a>
.
</p>
<Button onClick={onSetup} className="gap-2">
<Sparkles className="w-4 h-4" />
Quick Setup
</Button>
</div>
</div>
@@ -161,86 +173,216 @@ function ProviderRow({
}
export function CliproxyPage() {
const queryClient = useQueryClient();
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
const { data: variantsData, isFetching } = useCliproxy();
const setDefaultMutation = useSetDefaultAccount();
const removeMutation = useRemoveAccount();
const deleteMutation = useDeleteVariant();
// Selection state: either a provider or a variant
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [selectedVariant, setSelectedVariant] = useState<string | null>(null);
const [wizardOpen, setWizardOpen] = useState(false);
const [addAccountProvider, setAddAccountProvider] = useState<{
provider: string;
displayName: string;
} | null>(null);
const { data, isLoading } = useCliproxy();
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
const setDefaultMutation = useSetDefaultAccount();
const removeMutation = useRemoveAccount();
const providers = authData?.authStatus || [];
const variants = variantsData?.variants || [];
// Auto-select first provider if nothing selected
const effectiveProvider = useMemo(() => {
// If a variant is selected, no provider is effective
if (selectedVariant) return null;
if (selectedProvider && providers.some((p) => p.provider === selectedProvider)) {
return selectedProvider;
}
return providers.length > 0 ? providers[0].provider : null;
}, [selectedProvider, selectedVariant, providers]);
const selectedStatus = providers.find((p) => p.provider === effectiveProvider);
const selectedVariantData = variants.find((v) => v.name === selectedVariant);
const parentAuthForVariant = selectedVariantData
? providers.find((p) => p.provider === selectedVariantData.provider)
: undefined;
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
};
const handleSelectProvider = (provider: string) => {
setSelectedProvider(provider);
setSelectedVariant(null);
};
const handleSelectVariant = (variantName: string) => {
setSelectedVariant(variantName);
setSelectedProvider(null);
};
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">CLIProxy</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage OAuth-based provider variants and multi-account configurations
</p>
</div>
<Button onClick={() => setWizardOpen(true)}>
<Sparkles className="w-4 h-4 mr-2" />
Quick Setup
</Button>
</div>
<div className="h-[calc(100vh-100px)] flex">
{/* Left Sidebar */}
<div className="w-64 border-r flex flex-col bg-muted/30">
{/* Header */}
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-primary" />
<h1 className="font-semibold">CLIProxy</h1>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleRefresh}
disabled={isFetching}
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground mb-3">CCS-level account management</p>
{/* Built-in Profiles with Account Management */}
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">Built-in Providers</h2>
<p className="text-sm text-muted-foreground">
Manage authentication status and accounts for built-in providers.
</p>
<Button
variant="outline"
size="sm"
className="w-full gap-2"
onClick={() => setWizardOpen(true)}
>
<Sparkles className="w-4 h-4" />
Quick Setup
</Button>
</div>
<Card className="py-0 gap-0">
<CardContent className="p-0">
{/* Providers List */}
<ScrollArea className="flex-1">
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
Providers
</div>
{authLoading ? (
<div className="p-8 text-center text-muted-foreground">
Loading authentication status...
<div className="space-y-2 px-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
) : (
<div className="flex flex-col">
{authData?.authStatus.map((status) => (
<ProviderRow
<div className="space-y-1">
{providers.map((status) => (
<ProviderSidebarItem
key={status.provider}
status={status}
setDefaultMutation={setDefaultMutation}
removeMutation={removeMutation}
onAddAccount={() =>
setAddAccountProvider({
provider: status.provider,
displayName: status.displayName,
})
}
isSelected={effectiveProvider === status.provider}
onSelect={() => handleSelectProvider(status.provider)}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Custom Variants */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">Custom Variants</h2>
<p className="text-sm text-muted-foreground">
Create custom aliases for providers with specific accounts.
</p>
{/* Variants Section */}
{variants.length > 0 && (
<>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
<GitBranch className="w-3 h-3" />
Variants
</div>
<div className="space-y-1">
{variants.map((variant) => (
<VariantSidebarItem
key={variant.name}
variant={variant}
parentAuth={providers.find((p) => p.provider === variant.provider)}
isSelected={selectedVariant === variant.name}
onSelect={() => handleSelectVariant(variant.name)}
onDelete={() => deleteMutation.mutate(variant.name)}
isDeleting={deleteMutation.isPending}
/>
))}
</div>
</>
)}
</div>
</ScrollArea>
{/* Footer Stats */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{providers.length} provider{providers.length !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1">
<Check className="w-3 h-3 text-green-600" />
{providers.filter((p) => p.authenticated).length} connected
</span>
</div>
</div>
</div>
{isLoading ? (
<div className="text-muted-foreground">Loading variants...</div>
{/* Right Panel */}
<div className="flex-1 flex flex-col min-w-0 bg-background">
{selectedVariantData && parentAuthForVariant ? (
// Variant selected - show ProviderEditor with variant profile name
<ProviderEditor
provider={selectedVariantData.name}
displayName={`${selectedVariantData.name} (${selectedVariantData.provider} variant)`}
authStatus={parentAuthForVariant}
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedVariantData.provider,
displayName: parentAuthForVariant.displayName,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
isRemovingAccount={removeMutation.isPending}
/>
) : selectedStatus ? (
<ProviderEditor
provider={selectedStatus.provider}
displayName={selectedStatus.displayName}
authStatus={selectedStatus}
catalog={MODEL_CATALOGS[selectedStatus.provider]}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedStatus.provider,
displayName: selectedStatus.displayName,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
isRemovingAccount={removeMutation.isPending}
/>
) : (
<CliproxyTable data={data?.variants || []} />
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
)}
</div>
{/* Dialogs */}
<QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
<AddAccountDialog
open={addAccountProvider !== null}